@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,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mux-launch — T1-a: open ONE visible window in the caller's own tmux session and start the
|
|
3
|
+
* fixed official `pi` in it. Nothing after that.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately a SECOND module rather than a fourth verb on `mux-placement`. The
|
|
6
|
+
* placement leaf owns place and only place (`docs/mux-launch-rail.md` §11): a command
|
|
7
|
+
* parameter there would be the carrier that leaf refuses to have, and the deterministic gate
|
|
8
|
+
* pins `appendWindow`'s argv as exactly the no-carrier shape. So the composition lives here,
|
|
9
|
+
* on top of the leaf's grammar, binding and failure discipline, and the leaf stays deletable.
|
|
10
|
+
*
|
|
11
|
+
* The import lines this module must never cross (docs §11, asserted by check-mux-launch):
|
|
12
|
+
*
|
|
13
|
+
* entwurf-v2 contract/decider/runner/production -X-> mux-launch
|
|
14
|
+
* mux-launch -X-> entwurf core
|
|
15
|
+
*
|
|
16
|
+
* T1-a is launch, NOT delegation. This module does not wait for a garden identity, does not
|
|
17
|
+
* read the meta-record store, does not send a task, and has no idea what `entwurf_v2` is.
|
|
18
|
+
* The separate fresh-call composition reuses its precondition helpers but owns first-turn
|
|
19
|
+
* framing and callback correlation itself — see `docs/mux-launch-rail.md` §6-a and §11.
|
|
20
|
+
*
|
|
21
|
+
* ── What the receipt means, and what it deliberately does NOT ──
|
|
22
|
+
*
|
|
23
|
+
* A `PiLaunch` states two things: the ids tmux reported when it created the window, and the
|
|
24
|
+
* absolute runtime path we handed it. It does NOT claim pi is running.
|
|
25
|
+
*
|
|
26
|
+
* That restraint is measured, not modest. `new-window … -- /nonexistent/bin/nope` exits 0,
|
|
27
|
+
* prints a perfectly well-formed handle, and the window is STILL LISTED on an immediate
|
|
28
|
+
* re-read — 10/10 trials on tmux 3.6a, indistinguishable from a good launch at that instant.
|
|
29
|
+
* The window dies milliseconds later, through the same `remain-on-exit off` path
|
|
30
|
+
* `CloseOutcome.already-gone` already describes. So a post-launch presence check would prove
|
|
31
|
+
* nothing while making the receipt LOOK verified, which is worse than not checking: it is the
|
|
32
|
+
* rc=0 trap of T0-a wearing a different hat. This module refuses to perform one.
|
|
33
|
+
*
|
|
34
|
+
* What can honestly be done is to delete the failure class BEFORE a window exists, and that
|
|
35
|
+
* is what `assertLaunchTarget` does — the same precondition discipline
|
|
36
|
+
* `scripts/lib/probe-cli-target.ts` applies to the ACP CLI target: absolute, present, regular
|
|
37
|
+
* file, executable. A refusal is a NAMED reason on an unopened window, never a fallback.
|
|
38
|
+
*
|
|
39
|
+
* ── Cleanup, and why the contract is thin by construction ──
|
|
40
|
+
*
|
|
41
|
+
* There is exactly ONE mutation in this module: a single `new-window`. Everything after it is
|
|
42
|
+
* pure parsing. That is the cleanup design — not a compensating transaction, but a shape with
|
|
43
|
+
* almost nothing to compensate. The one residue: if tmux reports a handle this module cannot
|
|
44
|
+
* parse, the window exists and its id is precisely the thing we failed to read, so there is no
|
|
45
|
+
* honest target to close. That case fails loud and NAMES the orphan rather than killing a
|
|
46
|
+
* window picked by inventory diffing, which is the guess this rail forbids everywhere else.
|
|
47
|
+
*
|
|
48
|
+
* ── The whitespace precondition is not hygiene ──
|
|
49
|
+
*
|
|
50
|
+
* Measured on tmux 3.6a: a single argv element containing a space is RE-SPLIT by tmux, so a
|
|
51
|
+
* runtime at `/tmp/x y/pi` is launched as `/tmp/x` with the argument `y/pi` and the window
|
|
52
|
+
* dies instantly. `--` stops tmux from running the target through a shell (verified: an
|
|
53
|
+
* argument of `x; touch <file>` created no file), but it does not stop that re-split. So the
|
|
54
|
+
* path is checked for whitespace before it is handed over.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { accessSync, constants as fsConstants, statSync } from "node:fs";
|
|
58
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
59
|
+
import {
|
|
60
|
+
APPEND_FORMAT,
|
|
61
|
+
assertSelector,
|
|
62
|
+
assertTmuxOk,
|
|
63
|
+
type Placement,
|
|
64
|
+
parseWindowFields,
|
|
65
|
+
requireSameContext,
|
|
66
|
+
runTmux,
|
|
67
|
+
type WindowHandle,
|
|
68
|
+
} from "./mux-placement.ts";
|
|
69
|
+
|
|
70
|
+
/** The official runtime this rail opens. FIXED: there is no command carrier, and adding one
|
|
71
|
+
* is a separate ownership decision (docs §11), not a parameter. */
|
|
72
|
+
export const PI_RUNTIME_COMMAND = "pi";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Why a launch was refused BEFORE any window was opened. Each is a named refusal, never a
|
|
76
|
+
* fallback — a caller that cannot start the official runtime must not be handed a window.
|
|
77
|
+
*
|
|
78
|
+
* `runtime-unresolved` no `PI_RUNTIME_COMMAND` on PATH (or no usable PATH)
|
|
79
|
+
* `runtime-not-absolute` a relative target would resolve against the pane's cwd
|
|
80
|
+
* `runtime-path-whitespace` tmux re-splits a spaced argv element (measured)
|
|
81
|
+
* `runtime-missing` nothing at that path
|
|
82
|
+
* `runtime-not-regular-file` a directory/special file cannot be the runtime
|
|
83
|
+
* `runtime-not-executable` exec would fail after the window already existed
|
|
84
|
+
*/
|
|
85
|
+
export type LaunchRejectReason =
|
|
86
|
+
| "runtime-unresolved"
|
|
87
|
+
| "runtime-not-absolute"
|
|
88
|
+
| "runtime-path-whitespace"
|
|
89
|
+
| "runtime-missing"
|
|
90
|
+
| "runtime-not-regular-file"
|
|
91
|
+
| "runtime-not-executable";
|
|
92
|
+
|
|
93
|
+
/** A named precondition failure. Callers surface `reason`; they never soften it into a
|
|
94
|
+
* fallback launch of something else. */
|
|
95
|
+
export class LaunchPreconditionError extends Error {
|
|
96
|
+
readonly reason: LaunchRejectReason;
|
|
97
|
+
constructor(reason: LaunchRejectReason, message: string) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.name = "LaunchPreconditionError";
|
|
100
|
+
this.reason = reason;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Any ASCII or Unicode whitespace — the tmux re-split hazard is about the space character,
|
|
105
|
+
* but a tab or newline in an argv element is no more launchable and no less a surprise. */
|
|
106
|
+
const WHITESPACE = /\s/;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The five preconditions, in the order a failure would otherwise bite. Absolute first
|
|
110
|
+
* (everything after it is meaningless for a relative path), whitespace before the filesystem
|
|
111
|
+
* questions (a re-split path's stat is about the wrong file), then present → regular →
|
|
112
|
+
* executable, exactly as `probe-cli-target.ts` orders them.
|
|
113
|
+
*/
|
|
114
|
+
export function assertLaunchTarget(runtimePath: string): void {
|
|
115
|
+
if (!isAbsolute(runtimePath)) {
|
|
116
|
+
throw new LaunchPreconditionError(
|
|
117
|
+
"runtime-not-absolute",
|
|
118
|
+
`mux-launch: runtime target ${JSON.stringify(runtimePath)} is not absolute — tmux would resolve it against ` +
|
|
119
|
+
"the new pane's cwd, so which binary started would depend on where the window happened to open",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (WHITESPACE.test(runtimePath)) {
|
|
123
|
+
throw new LaunchPreconditionError(
|
|
124
|
+
"runtime-path-whitespace",
|
|
125
|
+
`mux-launch: runtime target ${JSON.stringify(runtimePath)} contains whitespace — tmux RE-SPLITS a single ` +
|
|
126
|
+
"argv element on spaces (measured, 3.6a), so this would launch a truncated path with the remainder as " +
|
|
127
|
+
"an argument and the window would die instantly",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
let stat: ReturnType<typeof statSync>;
|
|
131
|
+
try {
|
|
132
|
+
stat = statSync(runtimePath);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new LaunchPreconditionError(
|
|
135
|
+
"runtime-missing",
|
|
136
|
+
`mux-launch: runtime target ${runtimePath} does not exist — refusing before a window is opened onto a ` +
|
|
137
|
+
"runtime that cannot start",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!stat.isFile()) {
|
|
141
|
+
throw new LaunchPreconditionError(
|
|
142
|
+
"runtime-not-regular-file",
|
|
143
|
+
`mux-launch: runtime target ${runtimePath} is not a regular file — a directory or special file cannot be ` +
|
|
144
|
+
"the official runtime",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
accessSync(runtimePath, fsConstants.X_OK);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new LaunchPreconditionError(
|
|
151
|
+
"runtime-not-executable",
|
|
152
|
+
`mux-launch: runtime target ${runtimePath} is not executable (X_OK) — exec would fail after the window ` +
|
|
153
|
+
"already existed, which is exactly the state this precondition exists to prevent",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Find the official runtime on the caller's PATH and prove it launchable.
|
|
160
|
+
*
|
|
161
|
+
* PATH rather than a compiled-in location because the official `pi` is whatever the operator's
|
|
162
|
+
* environment resolves — pinning a path here would silently launch a different install than
|
|
163
|
+
* the one the operator gets by typing `pi`. Resolving it to an ABSOLUTE path (rather than
|
|
164
|
+
* handing tmux the bare word) is what makes the preconditions mean anything: a bare `pi`
|
|
165
|
+
* would be re-resolved inside the pane, after the window exists, against an environment this
|
|
166
|
+
* module never checked.
|
|
167
|
+
*
|
|
168
|
+
* Empty PATH entries are skipped rather than treated as cwd — the historical POSIX reading of
|
|
169
|
+
* `::` is a cwd search, and a launch that depends on where the caller stood is the drift this
|
|
170
|
+
* rail refuses.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveRuntimeOnPath(command: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
173
|
+
const raw = env.PATH;
|
|
174
|
+
const entries = typeof raw === "string" ? raw.split(delimiter).filter((p) => p.length > 0) : [];
|
|
175
|
+
for (const dir of entries) {
|
|
176
|
+
if (!isAbsolute(dir)) continue;
|
|
177
|
+
const candidate = join(dir, command);
|
|
178
|
+
try {
|
|
179
|
+
if (!statSync(candidate).isFile()) continue;
|
|
180
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
181
|
+
} catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
assertLaunchTarget(candidate);
|
|
185
|
+
return candidate;
|
|
186
|
+
}
|
|
187
|
+
throw new LaunchPreconditionError(
|
|
188
|
+
"runtime-unresolved",
|
|
189
|
+
`mux-launch: no executable ${JSON.stringify(command)} on PATH — refusing to open a window for a ` +
|
|
190
|
+
"runtime that is not installed",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* T1-a's own resolution: the fixed official `pi`. Kept as a named function rather than a call
|
|
196
|
+
* site with a constant because the launch contract is "this rail opens `pi`", and a caller that
|
|
197
|
+
* could pass a command would be the carrier this module refuses to have (docs §11). The
|
|
198
|
+
* generic form above exists only so a SEPARATE composition can apply the SAME preconditions to
|
|
199
|
+
* its own fixed runtime — not so this one becomes parameterised.
|
|
200
|
+
*/
|
|
201
|
+
export function resolvePiRuntime(env: NodeJS.ProcessEnv = process.env): string {
|
|
202
|
+
return resolveRuntimeOnPath(PI_RUNTIME_COMMAND, env);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The launch argv. Identical to the leaf's append shape — detached, appended at `{end}`,
|
|
207
|
+
* addressed by session id, printing the same handle format — with exactly one addition: the
|
|
208
|
+
* fixed runtime after `--`.
|
|
209
|
+
*
|
|
210
|
+
* `-d` is kept. "Visible" in T1-a means the window is a real window in the operator's own
|
|
211
|
+
* session rather than a headless child; it does not mean stealing the caller's focus. Moving
|
|
212
|
+
* focus is a different verb with a different owner, and the leaf's acceptance already pins
|
|
213
|
+
* "focus unchanged" as a property worth having.
|
|
214
|
+
*
|
|
215
|
+
* `--` is what stops tmux from handing the target to a shell (measured: an argument of
|
|
216
|
+
* `x; touch <file>` created no file). The selector and the runtime are both validated before
|
|
217
|
+
* this returns, so nothing unvalidated ever reaches tmux's parser.
|
|
218
|
+
*/
|
|
219
|
+
export function buildLaunchArgs(sessionId: string, runtimePath: string): string[] {
|
|
220
|
+
assertSelector("session", sessionId);
|
|
221
|
+
assertLaunchTarget(runtimePath);
|
|
222
|
+
return ["new-window", "-d", "-a", "-t", `${sessionId}:{end}`, "-P", "-F", APPEND_FORMAT, "--", runtimePath];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* What T1-a returns: the window handle tmux reported, plus the runtime path that was handed
|
|
227
|
+
* to it. `panePid` is the launched process itself — measured: with `--`, tmux execs the target
|
|
228
|
+
* directly and no shell wrapper survives in the pane.
|
|
229
|
+
*
|
|
230
|
+
* Read `runtimePath` as "what we asked tmux to start", not "what is running". See the module
|
|
231
|
+
* header: no synchronous check can tell those apart, so this type does not pretend to.
|
|
232
|
+
*/
|
|
233
|
+
export interface PiLaunch extends WindowHandle {
|
|
234
|
+
runtimePath: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Open one window in the caller's own session and start the fixed official `pi` in it.
|
|
239
|
+
*
|
|
240
|
+
* Order matters and is the whole safety argument: resolve and prove the runtime FIRST, then
|
|
241
|
+
* re-read the caller's placement and refuse a changed context (the leaf's binding), and only
|
|
242
|
+
* then mutate. A precondition failure therefore never leaves a window behind, because no
|
|
243
|
+
* window has been opened yet.
|
|
244
|
+
*
|
|
245
|
+
* Responsibility ends at the returned ids. No identity, no delivery, no supervision.
|
|
246
|
+
*/
|
|
247
|
+
export function launchPi(placement: Placement, env: NodeJS.ProcessEnv = process.env): PiLaunch {
|
|
248
|
+
const runtimePath = resolvePiRuntime(env);
|
|
249
|
+
requireSameContext("launchPi", placement, env);
|
|
250
|
+
const run = runTmux(buildLaunchArgs(placement.sessionId, runtimePath), env);
|
|
251
|
+
assertTmuxOk("new-window", run);
|
|
252
|
+
let fields: ReturnType<typeof parseWindowFields>;
|
|
253
|
+
try {
|
|
254
|
+
fields = parseWindowFields(run.stdout);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// The window exists and its id is exactly what we failed to read. Diffing the inventory
|
|
257
|
+
// to find "the new one" is the guess this rail forbids, so name the orphan instead of
|
|
258
|
+
// killing a window chosen by inference.
|
|
259
|
+
throw new Error(
|
|
260
|
+
`mux-launch: launched ${runtimePath} but could not read the window handle tmux printed — a window may be ` +
|
|
261
|
+
`open in session ${placement.sessionId} that this call cannot identify or close: ${
|
|
262
|
+
err instanceof Error ? err.message : String(err)
|
|
263
|
+
}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return { serverPid: placement.serverPid, sessionId: placement.sessionId, ...fields, runtimePath };
|
|
267
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mux-placement — where a new sibling runtime is OPENED, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The operating contract (2026-08-04, GLG): a caller that asks entwurf to open a new
|
|
5
|
+
* runtime is ALWAYS inside tmux. Outside tmux there is no fallback session, no
|
|
6
|
+
* `new-session`, no guess — the request is refused. The window is appended to exactly the
|
|
7
|
+
* server/session the caller's own inherited `TMUX`/`TMUX_PANE` names, because that is the
|
|
8
|
+
* session the operator is looking at. Inheriting the environment IS the addressing: this
|
|
9
|
+
* module never takes a socket path or a session name from a caller.
|
|
10
|
+
*
|
|
11
|
+
* Three actions, deliberately not four:
|
|
12
|
+
*
|
|
13
|
+
* inspectPlacement() the caller's own $session/@window/%pane, or a named refusal
|
|
14
|
+
* appendWindow() one default-shell window at the end of that same session
|
|
15
|
+
* closeWindow() that window, by stable id, in the context it was opened in
|
|
16
|
+
*
|
|
17
|
+
* What this module is NOT: a delivery transport, an address, a liveness fact, a launcher.
|
|
18
|
+
* It opens a place. `entwurf_v2` still owns delivery (`V2-DELIVERY-EXCLUDES-MUX`), and
|
|
19
|
+
* nothing here mints a garden id.
|
|
20
|
+
*
|
|
21
|
+
* Beside those three actions, `runTmux` and `requireSameContext` are exported as a NARROW
|
|
22
|
+
* INTERNAL SEAM for the T1-a launch composition to build on — not a fourth action and not a
|
|
23
|
+
* public operator surface. Say the risk plainly: `runTmux` takes argv this module did not
|
|
24
|
+
* author, so boundary 2 below does not protect it. The grammar is enforced by the `build*Args`
|
|
25
|
+
* builders, and anything reaching for `runTmux` owes its own argv the same validation. Its two
|
|
26
|
+
* production consumers are the launch and fresh-call compositions (`docs/mux-launch-rail.md`
|
|
27
|
+
* §11); the shipped delivery path calls neither.
|
|
28
|
+
*
|
|
29
|
+
* ── Four boundaries, each of which was a real way to touch the wrong thing ──
|
|
30
|
+
*
|
|
31
|
+
* 1. CONTEXT. Both `TMUX` and `TMUX_PANE` must be present and non-empty before anything
|
|
32
|
+
* runs. `TMUX_PANE` alone is not enough: tmux invoked without `TMUX` in the environment
|
|
33
|
+
* resolves to the DEFAULT server, so a caller carrying a stale pane id from a dead
|
|
34
|
+
* server would have mutated the operator's live one. `TMUX` is checked for presence
|
|
35
|
+
* only — never parsed. The socket and session inside that string are tmux's to report,
|
|
36
|
+
* not ours to extract.
|
|
37
|
+
*
|
|
38
|
+
* 2. GRAMMAR. Every selector handed to `-t` is a native id and is validated against its
|
|
39
|
+
* grammar BEFORE tmux is invoked. T0-a measured that tmux's own parser reinterprets
|
|
40
|
+
* values (a trailing `;` can split a command; `#{…}`/`#(…)` expand), so "we used argv,
|
|
41
|
+
* not a shell" is not by itself a safety argument. `%12`, `$3`, `@7` and decimal
|
|
42
|
+
* pids/indices are the entire accepted vocabulary; anything else is refused unrun.
|
|
43
|
+
*
|
|
44
|
+
* 3. FAILURE. `display-message` returning rc=0 is not proof that a pane exists — it answers
|
|
45
|
+
* rc=0 for a nonexistent target (filling only the SERVER fields) and rc=0 for an EMPTY
|
|
46
|
+
* target while silently describing the current pane. That is why the echo-back exists.
|
|
47
|
+
* But the converse does not follow: a NONZERO exit (no server, EACCES, a usage error) is
|
|
48
|
+
* an operational failure and is raised, never laundered into "the anchor did not
|
|
49
|
+
* resolve". Only an rc=0 row is ever parsed.
|
|
50
|
+
*
|
|
51
|
+
* 4. BINDING. A `Placement` is a fact about one server and one session, and the environment
|
|
52
|
+
* passed to a later call could name a different — or restarted — server where the same
|
|
53
|
+
* `$3`/`@7` mean something else entirely. Every mutation re-reads the caller's placement
|
|
54
|
+
* and refuses unless the server pid and session id still match the ones the handle was
|
|
55
|
+
* born in.
|
|
56
|
+
*
|
|
57
|
+
* The machine-readable rows carry ONLY native ids and decimal numbers, joined by `|`. The
|
|
58
|
+
* free-form fields tmux could also report (`socket_path`, `session_name`) are deliberately
|
|
59
|
+
* absent: a session legitimately named `a|b` would otherwise split the row. A display name
|
|
60
|
+
* is a later, separate concern, and nothing here needs one.
|
|
61
|
+
*
|
|
62
|
+
* ── Two more facts measured in T0-a (private tmux 3.6a) ──
|
|
63
|
+
*
|
|
64
|
+
* `renumber-windows on` (GLG's shipped setting) renumbers surviving windows when one in the
|
|
65
|
+
* middle disappears, so the human index is a keybinding affordance and never a handle.
|
|
66
|
+
* `remain-on-exit off` (the default) destroys a window when its pane process ends, so a
|
|
67
|
+
* window can vanish through a path this module never called; see `CloseOutcome`.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import { spawnSync } from "node:child_process";
|
|
71
|
+
|
|
72
|
+
// ── Native id grammar (boundary 2) ─────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const PANE_ID = /^%[0-9]+$/;
|
|
75
|
+
const SESSION_ID = /^\$[0-9]+$/;
|
|
76
|
+
const WINDOW_ID = /^@[0-9]+$/;
|
|
77
|
+
const DECIMAL = /^[0-9]+$/;
|
|
78
|
+
|
|
79
|
+
/** True only for tmux's own native pane id. */
|
|
80
|
+
export function isPaneId(value: string): boolean {
|
|
81
|
+
return PANE_ID.test(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True only for tmux's own native session id. */
|
|
85
|
+
export function isSessionId(value: string): boolean {
|
|
86
|
+
return SESSION_ID.test(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True only for tmux's own native window id. */
|
|
90
|
+
export function isWindowId(value: string): boolean {
|
|
91
|
+
return WINDOW_ID.test(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True only for a bare decimal (pids, window indices). */
|
|
95
|
+
export function isDecimal(value: string): boolean {
|
|
96
|
+
return DECIMAL.test(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Refuse a selector before it reaches tmux. The throw is deliberate and unconditional: a
|
|
101
|
+
* selector that is not a native id is either a bug or an injection attempt, and neither is
|
|
102
|
+
* something to route into tmux's parser to find out.
|
|
103
|
+
*/
|
|
104
|
+
export function assertSelector(kind: "pane" | "session" | "window", value: string): void {
|
|
105
|
+
const okShape = kind === "pane" ? isPaneId(value) : kind === "session" ? isSessionId(value) : isWindowId(value);
|
|
106
|
+
if (!okShape) {
|
|
107
|
+
throw new Error(`mux-placement: refusing a non-native ${kind} selector ${JSON.stringify(value)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Facts ──────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The caller's own coordinates. Native ids and decimals only — see the delimiter note in the
|
|
115
|
+
* module header for why `socket_path`/`session_name` are not here.
|
|
116
|
+
*/
|
|
117
|
+
export interface Placement {
|
|
118
|
+
serverPid: string;
|
|
119
|
+
sessionId: string;
|
|
120
|
+
windowId: string;
|
|
121
|
+
windowIndex: string;
|
|
122
|
+
paneId: string;
|
|
123
|
+
panePid: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A window this module opened, carrying the context it was born in. `serverPid`/`sessionId`
|
|
128
|
+
* are what let a later close prove it is acting on the same server and session rather than a
|
|
129
|
+
* restarted one that happens to reuse the id. Index is reported for the human, never used as
|
|
130
|
+
* a handle.
|
|
131
|
+
*/
|
|
132
|
+
export interface WindowHandle {
|
|
133
|
+
serverPid: string;
|
|
134
|
+
sessionId: string;
|
|
135
|
+
windowId: string;
|
|
136
|
+
windowIndex: string;
|
|
137
|
+
paneId: string;
|
|
138
|
+
panePid: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Why a placement could not be established. Each one is a refusal, never a fallback:
|
|
143
|
+
* - `no-tmux-context` `TMUX` and/or `TMUX_PANE` missing or empty — not inside tmux
|
|
144
|
+
* - `anchor-malformed` `TMUX_PANE` is present but is not a native `%N` pane id
|
|
145
|
+
* - `anchor-unresolved` tmux resolved no pane for that anchor
|
|
146
|
+
* - `anchor-mismatch` tmux answered about a DIFFERENT pane than the one asked about
|
|
147
|
+
*/
|
|
148
|
+
export type PlacementRejectReason = "no-tmux-context" | "anchor-malformed" | "anchor-unresolved" | "anchor-mismatch";
|
|
149
|
+
|
|
150
|
+
export type InspectResult = { ok: true; placement: Placement } | { ok: false; reason: PlacementRejectReason };
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What a close actually did.
|
|
154
|
+
*
|
|
155
|
+
* `already-gone` is NOT a blanket "any error is fine". It is issued only when the window is
|
|
156
|
+
* verifiably absent from a fresh `list-windows` — positive proof of absence, the same
|
|
157
|
+
* discipline socket-probe.ts applies to liveness. Any other failure throws, because the
|
|
158
|
+
* failure we must never swallow is "the window is still there and we did not close it".
|
|
159
|
+
*
|
|
160
|
+
* Why report it at all instead of returning void: `remain-on-exit off` makes disappearance a
|
|
161
|
+
* normal race, not an error, so failing loud on it would be wrong; but a caller that asked to
|
|
162
|
+
* close a window and got silence cannot tell whether its runtime exited on its own. Both
|
|
163
|
+
* readings matter later (T1 will want to know whether a launched runtime died before we tore
|
|
164
|
+
* it down), so the outcome is evidence, not a swallowed exception.
|
|
165
|
+
*/
|
|
166
|
+
export type CloseOutcome = "closed" | "already-gone";
|
|
167
|
+
|
|
168
|
+
// ── Pure argv / parsing (the half a deterministic gate can pin) ─────────────
|
|
169
|
+
|
|
170
|
+
/** Field order of `INSPECT_FORMAT`. Parsing and formatting share this list. */
|
|
171
|
+
export const PLACEMENT_FIELDS = ["pid", "session_id", "window_id", "window_index", "pane_id", "pane_pid"] as const;
|
|
172
|
+
|
|
173
|
+
const SEP = "|";
|
|
174
|
+
|
|
175
|
+
/** tmux format string for a placement query — tmux's own facts, never string surgery on `$TMUX`. */
|
|
176
|
+
export const INSPECT_FORMAT = PLACEMENT_FIELDS.map((f) => `#{${f}}`).join(SEP);
|
|
177
|
+
|
|
178
|
+
/** Field order of `APPEND_FORMAT` — what `new-window -P -F` prints for the window it created. */
|
|
179
|
+
export const WINDOW_FIELDS = ["window_id", "window_index", "pane_id", "pane_pid"] as const;
|
|
180
|
+
|
|
181
|
+
export const APPEND_FORMAT = WINDOW_FIELDS.map((f) => `#{${f}}`).join(SEP);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read the caller's anchor. BOTH `TMUX` and `TMUX_PANE` must be present and non-empty:
|
|
185
|
+
* `TMUX_PANE` names which pane, `TMUX` is what makes a bare `tmux` resolve to the caller's
|
|
186
|
+
* server instead of the default one. `TMUX` is checked for presence only — parsing the
|
|
187
|
+
* socket/session out of it is the guess this rail forbids.
|
|
188
|
+
*/
|
|
189
|
+
export function readAnchor(
|
|
190
|
+
env: NodeJS.ProcessEnv,
|
|
191
|
+
): { ok: true; anchor: string } | { ok: false; reason: "no-tmux-context" | "anchor-malformed" } {
|
|
192
|
+
const server = env.TMUX;
|
|
193
|
+
const anchor = env.TMUX_PANE;
|
|
194
|
+
if (typeof server !== "string" || server.length === 0) return { ok: false, reason: "no-tmux-context" };
|
|
195
|
+
if (typeof anchor !== "string" || anchor.length === 0) return { ok: false, reason: "no-tmux-context" };
|
|
196
|
+
if (!isPaneId(anchor)) return { ok: false, reason: "anchor-malformed" };
|
|
197
|
+
return { ok: true, anchor };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function buildInspectArgs(anchor: string): string[] {
|
|
201
|
+
assertSelector("pane", anchor);
|
|
202
|
+
return ["display-message", "-p", "-t", anchor, INSPECT_FORMAT];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Append at the END of the caller's own session. `{end}` is the append semantics we want
|
|
207
|
+
* literally; the lowest-free-index behaviour of a bare `new-window` is a different verb and
|
|
208
|
+
* is not this contract. Targeting by `session_id` (`$0`) rather than name keeps a renamed or
|
|
209
|
+
* duplicate-named session from redirecting the append.
|
|
210
|
+
*
|
|
211
|
+
* `-d` keeps the caller's focus. No `-n`, no `-c`, no shell-command: default shell only.
|
|
212
|
+
*/
|
|
213
|
+
export function buildAppendArgs(sessionId: string): string[] {
|
|
214
|
+
assertSelector("session", sessionId);
|
|
215
|
+
return ["new-window", "-d", "-a", "-t", `${sessionId}:{end}`, "-P", "-F", APPEND_FORMAT];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function buildCloseArgs(windowId: string): string[] {
|
|
219
|
+
assertSelector("window", windowId);
|
|
220
|
+
return ["kill-window", "-t", windowId];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Every window on the server, for the positive absence proof an `already-gone` needs. */
|
|
224
|
+
export function buildListWindowIdsArgs(): string[] {
|
|
225
|
+
return ["list-windows", "-a", "-F", "#{window_id}"];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Turn an rc=0 placement row into facts — with the echo-back check that tmux's exit status
|
|
230
|
+
* cannot provide, and a grammar check on every field. A nonexistent target still fills the
|
|
231
|
+
* server fields and leaves the rest empty, so "the row was non-empty" proves nothing; an
|
|
232
|
+
* empty pane id means no pane resolved at all, which is unresolved rather than an answer
|
|
233
|
+
* about some other pane.
|
|
234
|
+
*/
|
|
235
|
+
export function parsePlacement(anchor: string, stdout: string): InspectResult {
|
|
236
|
+
const line = stdout.split("\n", 1)[0]?.trim() ?? "";
|
|
237
|
+
if (line.length === 0) return { ok: false, reason: "anchor-unresolved" };
|
|
238
|
+
const parts = line.split(SEP);
|
|
239
|
+
if (parts.length !== PLACEMENT_FIELDS.length) return { ok: false, reason: "anchor-unresolved" };
|
|
240
|
+
const [serverPid, sessionId, windowId, windowIndex, paneId, panePid] = parts as string[];
|
|
241
|
+
if (paneId.length === 0) return { ok: false, reason: "anchor-unresolved" };
|
|
242
|
+
if (paneId !== anchor) return { ok: false, reason: "anchor-mismatch" };
|
|
243
|
+
const wellFormed =
|
|
244
|
+
isDecimal(serverPid) &&
|
|
245
|
+
isSessionId(sessionId) &&
|
|
246
|
+
isWindowId(windowId) &&
|
|
247
|
+
isDecimal(windowIndex) &&
|
|
248
|
+
isPaneId(paneId) &&
|
|
249
|
+
isDecimal(panePid);
|
|
250
|
+
if (!wellFormed) return { ok: false, reason: "anchor-unresolved" };
|
|
251
|
+
return { ok: true, placement: { serverPid, sessionId, windowId, windowIndex, paneId, panePid } };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function parseWindowFields(stdout: string): {
|
|
255
|
+
windowId: string;
|
|
256
|
+
windowIndex: string;
|
|
257
|
+
paneId: string;
|
|
258
|
+
panePid: string;
|
|
259
|
+
} {
|
|
260
|
+
const line = stdout.split("\n", 1)[0]?.trim() ?? "";
|
|
261
|
+
const parts = line.split(SEP);
|
|
262
|
+
if (line.length === 0 || parts.length !== WINDOW_FIELDS.length) {
|
|
263
|
+
throw new Error(`mux-placement: new-window did not report a window handle (got ${JSON.stringify(line)})`);
|
|
264
|
+
}
|
|
265
|
+
const [windowId, windowIndex, paneId, panePid] = parts as string[];
|
|
266
|
+
if (!isWindowId(windowId) || !isDecimal(windowIndex) || !isPaneId(paneId) || !isDecimal(panePid)) {
|
|
267
|
+
throw new Error(`mux-placement: new-window reported a non-native handle (got ${JSON.stringify(line)})`);
|
|
268
|
+
}
|
|
269
|
+
return { windowId, windowIndex, paneId, panePid };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Classify a FAILED `kill-window` against a fresh window inventory. Absent → `already-gone`
|
|
274
|
+
* (positive proof). Present → `null`, and the caller must fail loud: the window is still
|
|
275
|
+
* there and we did not close it.
|
|
276
|
+
*/
|
|
277
|
+
export function classifyCloseFailure(windowId: string, listStdout: string): CloseOutcome | null {
|
|
278
|
+
const ids = listStdout
|
|
279
|
+
.split("\n")
|
|
280
|
+
.map((l) => l.trim())
|
|
281
|
+
.filter((l) => l.length > 0);
|
|
282
|
+
return ids.includes(windowId) ? null : "already-gone";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── The three actions ──────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
export interface TmuxRun {
|
|
288
|
+
status: number | null;
|
|
289
|
+
stdout: string;
|
|
290
|
+
stderr: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* An rc=0 run is the ONLY kind whose stdout may be parsed. A nonzero status (no server,
|
|
295
|
+
* EACCES, a usage error) or a null status (killed by a signal) is an operational failure of
|
|
296
|
+
* the tmux call itself — laundering it into "the anchor did not resolve" would report a
|
|
297
|
+
* missing pane when the truth is that tmux never answered.
|
|
298
|
+
*/
|
|
299
|
+
export function assertTmuxOk(label: string, run: TmuxRun): void {
|
|
300
|
+
if (run.status === 0) return;
|
|
301
|
+
const how = run.status === null ? "terminated by signal" : `exit ${run.status}`;
|
|
302
|
+
throw new Error(`mux-placement: tmux ${label} failed (${how})${run.stderr.trim() ? `: ${run.stderr.trim()}` : ""}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Run tmux with the caller's environment INHERITED. No `-S`, no `-L`: the server is whichever
|
|
307
|
+
* one `TMUX` already names, which is the only server this module is allowed to touch — and
|
|
308
|
+
* `readAnchor` has already refused an environment where `TMUX` is absent, which is what would
|
|
309
|
+
* otherwise silently resolve to the default server.
|
|
310
|
+
*/
|
|
311
|
+
export function runTmux(args: readonly string[], env: NodeJS.ProcessEnv): TmuxRun {
|
|
312
|
+
const res = spawnSync("tmux", args as string[], { env, encoding: "utf8" });
|
|
313
|
+
if (res.error) throw res.error;
|
|
314
|
+
return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The caller's own placement, or a named refusal. Never guesses, never creates a session. */
|
|
318
|
+
export function inspectPlacement(env: NodeJS.ProcessEnv = process.env): InspectResult {
|
|
319
|
+
const anchor = readAnchor(env);
|
|
320
|
+
if (!anchor.ok) return anchor;
|
|
321
|
+
const run = runTmux(buildInspectArgs(anchor.anchor), env);
|
|
322
|
+
assertTmuxOk("display-message", run);
|
|
323
|
+
return parsePlacement(anchor.anchor, run.stdout);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The context a handle or placement belongs to. BOTH halves identify it. */
|
|
327
|
+
export interface PlacementContext {
|
|
328
|
+
serverPid: string;
|
|
329
|
+
sessionId: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Same server AND same session. Either half alone is insufficient: a restarted tmux server
|
|
334
|
+
* hands out `$0` again, and one server holds many sessions — so matching only the session id
|
|
335
|
+
* would accept a different server, and matching only the server pid would accept a different
|
|
336
|
+
* session on the right one.
|
|
337
|
+
*/
|
|
338
|
+
export function isSameContext(origin: PlacementContext, now: PlacementContext): boolean {
|
|
339
|
+
return origin.serverPid === now.serverPid && origin.sessionId === now.sessionId;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Re-read the caller's placement and refuse unless it is still the server and session the
|
|
344
|
+
* handle/placement was born in. Called before every mutation: `$3` on a restarted server is
|
|
345
|
+
* a different session, and an env naming another server would otherwise redirect the whole
|
|
346
|
+
* operation somewhere the caller never looked.
|
|
347
|
+
*/
|
|
348
|
+
export function requireSameContext(label: string, origin: PlacementContext, env: NodeJS.ProcessEnv): void {
|
|
349
|
+
const now = inspectPlacement(env);
|
|
350
|
+
if (!now.ok) {
|
|
351
|
+
throw new Error(`mux-placement: ${label} refused — the caller's placement is not resolvable (${now.reason})`);
|
|
352
|
+
}
|
|
353
|
+
if (!isSameContext(origin, now.placement)) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`mux-placement: ${label} refused — context changed (expected server ${origin.serverPid} session ${origin.sessionId}, ` +
|
|
356
|
+
`now server ${now.placement.serverPid} session ${now.placement.sessionId})`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** One detached default-shell window at the end of the caller's own session. */
|
|
362
|
+
export function appendWindow(placement: Placement, env: NodeJS.ProcessEnv = process.env): WindowHandle {
|
|
363
|
+
requireSameContext("appendWindow", placement, env);
|
|
364
|
+
const run = runTmux(buildAppendArgs(placement.sessionId), env);
|
|
365
|
+
assertTmuxOk("new-window", run);
|
|
366
|
+
return { serverPid: placement.serverPid, sessionId: placement.sessionId, ...parseWindowFields(run.stdout) };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Close one window by stable id, in the context it was opened in. Reports whether it was
|
|
371
|
+
* closed or had already gone.
|
|
372
|
+
*/
|
|
373
|
+
export function closeWindow(handle: WindowHandle, env: NodeJS.ProcessEnv = process.env): CloseOutcome {
|
|
374
|
+
requireSameContext("closeWindow", handle, env);
|
|
375
|
+
const run = runTmux(buildCloseArgs(handle.windowId), env);
|
|
376
|
+
if (run.status === 0) return "closed";
|
|
377
|
+
// A signal kill is not a "tmux said no" — it is the call failing, and it carries no
|
|
378
|
+
// information about the window at all.
|
|
379
|
+
if (run.status === null) assertTmuxOk("kill-window", run);
|
|
380
|
+
const list = runTmux(buildListWindowIdsArgs(), env);
|
|
381
|
+
assertTmuxOk("list-windows", list);
|
|
382
|
+
const verdict = classifyCloseFailure(handle.windowId, list.stdout);
|
|
383
|
+
if (verdict) return verdict;
|
|
384
|
+
throw new Error(
|
|
385
|
+
`mux-placement: kill-window ${handle.windowId} failed (exit ${run.status}) and the window is still listed: ${run.stderr.trim()}`,
|
|
386
|
+
);
|
|
387
|
+
}
|