@junghanacs/entwurf 0.13.1 → 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 +48 -15
- package/BASELINE.md +3 -3
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +13 -9
- package/DELIVERY.md +7 -6
- package/README.md +27 -27
- package/VERIFY.md +22 -14
- 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 +9 -4
- package/docs/external-mcp-host.md +4 -5
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
- 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 +164 -28
- 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 +22 -11
- package/pi-extensions/entwurf-control.ts +218 -40
- package/pi-extensions/lib/acp/backend.ts +71 -12
- package/pi-extensions/lib/acp/overlay.ts +8 -6
- 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/run.sh +471 -201
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-overlay.ts +13 -3
- 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 +157 -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 +98 -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 +84 -76
- package/scripts/check-release-gate-outcomes.ts +127 -7
- 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/mutation-qualify.ts +109 -3
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-overlay.json +17 -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 +0 -1037
- package/scripts/mutants/release-gate.json +35 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +58 -18
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-native-push-live.ts +6 -17
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-meta-honesty.sh +1 -1
- 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 -275
- 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 -467
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mux-fresh-call — open ONE visible sibling in the caller's own tmux session, hand it its first
|
|
3
|
+
* task in the launch argv, and let it name itself back to the caller.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this is a third module and not a parameter on the leaf ──
|
|
6
|
+
*
|
|
7
|
+
* `mux-placement` owns place and only place; `mux-launch` owns "start the fixed official pi in
|
|
8
|
+
* a window I placed" and refuses a command carrier (docs §11). Both stay that way. The carrier
|
|
9
|
+
* lives HERE, one layer up, because the thing being carried is not a command in the shell sense
|
|
10
|
+
* — it is a TASK plus the instruction that makes the new citizen announce itself. Putting it on
|
|
11
|
+
* the leaf would make every future placement caller inherit a prompt argument it has no use for.
|
|
12
|
+
*
|
|
13
|
+
* caller agent → fresh-call → fixed backend runtime + first-turn framing
|
|
14
|
+
* fresh-call → placement leaf (unchanged, carrier-free)
|
|
15
|
+
* fresh-call -X-> identity minting, delivery transport, task planning
|
|
16
|
+
*
|
|
17
|
+
* ── Invariants ──
|
|
18
|
+
*
|
|
19
|
+
* 1. The first turn does not MINT the garden id — record and socket exist from session start.
|
|
20
|
+
* It exists to SAY that id to the caller, who is the one who cannot see it. (rail §6-a)
|
|
21
|
+
* 2. Correlation is what the DELIVERY layer stamps on the callback, never what the sibling
|
|
22
|
+
* says about itself — asked directly, a fresh cell answers with a uuidv7. (rail §6-b)
|
|
23
|
+
* 3. The two receipts are separate objects and stay that way: `freshCall` returns tmux
|
|
24
|
+
* coordinates synchronously and nothing else; the correlation receipt arrives later on the
|
|
25
|
+
* caller's own inbound surface. Merging them would claim knowledge this module cannot have.
|
|
26
|
+
* 4. A launch with no callback is a REAL outcome, not an error to retry. No watcher, no poll,
|
|
27
|
+
* no timeout supervisor. The window is visible; the operator can look.
|
|
28
|
+
*/
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
30
|
+
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
|
|
31
|
+
import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
|
|
32
|
+
/** The two backends this rail can open. Fixed set, not a profile — a third one is a decision,
|
|
33
|
+
* not a config entry. */
|
|
34
|
+
export const FRESH_CALL_BACKENDS = ["pi", "claude-code"];
|
|
35
|
+
/** The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
|
|
36
|
+
* than a compiled-in location: the official binary is whatever the operator's environment gives
|
|
37
|
+
* when they type the name. */
|
|
38
|
+
export const FRESH_CALL_RUNTIME = {
|
|
39
|
+
pi: "pi",
|
|
40
|
+
"claude-code": "claude",
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
|
|
44
|
+
* capability directly (`entwurf_v2`), while a Claude Code session reaches it through the MCP
|
|
45
|
+
* bridge under its namespaced name. Naming the wrong one costs the whole first turn.
|
|
46
|
+
*/
|
|
47
|
+
export const FRESH_CALL_CALLBACK_TOOL = {
|
|
48
|
+
pi: "entwurf_v2",
|
|
49
|
+
"claude-code": "mcp__entwurf-bridge__entwurf_v2",
|
|
50
|
+
};
|
|
51
|
+
/** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
|
|
52
|
+
* delivery surface, not a claim that a task of this size was measured through tmux. An argv
|
|
53
|
+
* that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
|
|
54
|
+
export const TASK_MAX_CHARS = 16000;
|
|
55
|
+
export const MODEL_MAX_CHARS = 200;
|
|
56
|
+
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/;
|
|
57
|
+
/** A model is an explicit launch input, not ambient process state. The grammar admits canonical
|
|
58
|
+
* pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
|
|
59
|
+
* whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
|
|
60
|
+
* CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
|
|
61
|
+
export function isSafeFreshCallModel(model) {
|
|
62
|
+
return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
|
|
66
|
+
* getting them wrong first (rail §6-a):
|
|
67
|
+
*
|
|
68
|
+
* pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
|
|
69
|
+
* Flag-first submitted no message; Pi rejects the equals form for `--model`.
|
|
70
|
+
* claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
|
|
71
|
+
* for allowedTools is variadic and eats the prompt as an option value.
|
|
72
|
+
*
|
|
73
|
+
* Both failures looked identical from outside: window open, record and socket minted, no turn.
|
|
74
|
+
*
|
|
75
|
+
* The equals form is NOT a permission guarantee — on the measured host the tool was already
|
|
76
|
+
* permitted, so the option's effect was unobservable. What was observed is that it does no harm
|
|
77
|
+
* to the argv. Permission stays a documented host precondition.
|
|
78
|
+
*/
|
|
79
|
+
export function buildBackendArgs(backend, prompt, model) {
|
|
80
|
+
switch (backend) {
|
|
81
|
+
case "pi":
|
|
82
|
+
return [prompt, "--entwurf-control", "--model", model];
|
|
83
|
+
case "claude-code":
|
|
84
|
+
return [prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The first-turn framing. Order is the contract: the callback is the FIRST action and the task
|
|
89
|
+
* follows it, so a sibling that gets stuck in the task has already told the caller who it is.
|
|
90
|
+
*
|
|
91
|
+
* The three prohibitions are not politeness. Each names a detour that was measured to produce a
|
|
92
|
+
* confidently wrong answer or a wasted turn.
|
|
93
|
+
*/
|
|
94
|
+
export function buildFreshCallPrompt(params) {
|
|
95
|
+
const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
|
|
96
|
+
return [
|
|
97
|
+
"You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
|
|
98
|
+
"",
|
|
99
|
+
`FIRST ACTION, before reading files or anything else: call ${tool} with ` +
|
|
100
|
+
`target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
|
|
101
|
+
`message set to exactly ${params.nonce} — that string alone, nothing added.`,
|
|
102
|
+
"That call is how the agent that opened you learns your address. Do not skip it, do not",
|
|
103
|
+
"defer it until the task is done, and do not reword the message.",
|
|
104
|
+
"",
|
|
105
|
+
"Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
|
|
106
|
+
"server yourself. Your own report of your identity is not the address anyone needs.",
|
|
107
|
+
"",
|
|
108
|
+
"After the tool receipt, carry out this task:",
|
|
109
|
+
"",
|
|
110
|
+
params.task,
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
/** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
|
|
114
|
+
* encoded any of those would invite exactly the guessing this rail exists to refuse. */
|
|
115
|
+
export function mintNonce(randomHex = defaultRandomHex) {
|
|
116
|
+
return `mux-fresh-call-${randomHex()}`;
|
|
117
|
+
}
|
|
118
|
+
function defaultRandomHex() {
|
|
119
|
+
return randomBytes(12).toString("hex");
|
|
120
|
+
}
|
|
121
|
+
/** Launch argv: the leaf's detached-append shape, the runtime, then the backend's dialect. */
|
|
122
|
+
export function buildFreshCallArgs(placement, runtimePath, backendArgs) {
|
|
123
|
+
assertSelector("session", placement.sessionId);
|
|
124
|
+
assertLaunchTarget(runtimePath);
|
|
125
|
+
return [
|
|
126
|
+
"new-window",
|
|
127
|
+
"-d",
|
|
128
|
+
"-a",
|
|
129
|
+
"-t",
|
|
130
|
+
`${placement.sessionId}:{end}`,
|
|
131
|
+
"-P",
|
|
132
|
+
"-F",
|
|
133
|
+
APPEND_FORMAT,
|
|
134
|
+
"--",
|
|
135
|
+
runtimePath,
|
|
136
|
+
...backendArgs,
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Open the sibling. Order is the safety argument, same as `launchPi`: validate the caller's
|
|
141
|
+
* identity and task, resolve and prove the runtime, re-read the caller's placement and refuse a
|
|
142
|
+
* changed context — and only then mutate. Nothing above can leave a window behind.
|
|
143
|
+
*
|
|
144
|
+
* `callerGardenId` is supplied by the SURFACE that registered this tool, from its own
|
|
145
|
+
* record-backed context. It is not a tool parameter and this module never derives, validates
|
|
146
|
+
* against a store, or guesses it: an empty value is a named refusal, not a lookup.
|
|
147
|
+
*/
|
|
148
|
+
export function freshCall(params, env = process.env, nonce = mintNonce()) {
|
|
149
|
+
if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
|
|
150
|
+
return { ok: false, reason: "caller-identity-unavailable" };
|
|
151
|
+
}
|
|
152
|
+
const model = params.model.trim();
|
|
153
|
+
if (model.length === 0)
|
|
154
|
+
return { ok: false, reason: "model-empty" };
|
|
155
|
+
if (!isSafeFreshCallModel(model))
|
|
156
|
+
return { ok: false, reason: "model-invalid" };
|
|
157
|
+
const task = params.task.trim();
|
|
158
|
+
if (task.length === 0)
|
|
159
|
+
return { ok: false, reason: "task-empty" };
|
|
160
|
+
if (task.length > TASK_MAX_CHARS)
|
|
161
|
+
return { ok: false, reason: "task-too-long" };
|
|
162
|
+
let runtimePath;
|
|
163
|
+
try {
|
|
164
|
+
runtimePath = resolveRuntimeOnPath(FRESH_CALL_RUNTIME[params.backend], env);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (err instanceof LaunchPreconditionError)
|
|
168
|
+
return { ok: false, reason: err.reason };
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
const inspected = inspectPlacement(env);
|
|
172
|
+
if (!inspected.ok)
|
|
173
|
+
return { ok: false, reason: inspected.reason };
|
|
174
|
+
const placement = inspected.placement;
|
|
175
|
+
requireSameContext("freshCall", placement, env);
|
|
176
|
+
const prompt = buildFreshCallPrompt({
|
|
177
|
+
backend: params.backend,
|
|
178
|
+
task,
|
|
179
|
+
callerGardenId: params.callerGardenId,
|
|
180
|
+
nonce,
|
|
181
|
+
});
|
|
182
|
+
const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model)), env);
|
|
183
|
+
assertTmuxOk("new-window", run);
|
|
184
|
+
let fields;
|
|
185
|
+
try {
|
|
186
|
+
fields = parseWindowFields(run.stdout);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
// The window exists and its id is precisely what could not be read. Diffing the inventory
|
|
190
|
+
// to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
|
|
191
|
+
throw new Error(`mux-fresh-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
|
|
192
|
+
`be open in session ${placement.sessionId} that this call cannot identify or close: ${err instanceof Error ? err.message : String(err)}`);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
receipt: {
|
|
197
|
+
serverPid: placement.serverPid,
|
|
198
|
+
sessionId: placement.sessionId,
|
|
199
|
+
...fields,
|
|
200
|
+
backend: params.backend,
|
|
201
|
+
model,
|
|
202
|
+
runtimePath,
|
|
203
|
+
nonce,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
|
|
208
|
+
* they will guess about. */
|
|
209
|
+
const REJECT_HINT = {
|
|
210
|
+
"no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
|
|
211
|
+
"anchor-malformed": "TMUX_PANE is not a native pane id",
|
|
212
|
+
"anchor-unresolved": "tmux resolved no pane for this agent's anchor",
|
|
213
|
+
"anchor-mismatch": "tmux answered about a different pane than the one asked about",
|
|
214
|
+
"caller-identity-unavailable": "this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
|
|
215
|
+
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
216
|
+
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
217
|
+
"task-empty": "task is empty after trimming",
|
|
218
|
+
"task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
|
|
219
|
+
"runtime-unresolved": "the backend's runtime is not installed on PATH",
|
|
220
|
+
"runtime-not-absolute": "the resolved runtime path is not absolute",
|
|
221
|
+
"runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
|
|
222
|
+
"runtime-missing": "nothing exists at the resolved runtime path",
|
|
223
|
+
"runtime-not-regular-file": "the resolved runtime path is not a regular file",
|
|
224
|
+
"runtime-not-executable": "the resolved runtime path is not executable",
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* ONE renderer for both surfaces. Not a convenience: the two registrations are separate literals
|
|
228
|
+
* (that is this repo's shape), so a shared renderer is what keeps the operator-visible answer
|
|
229
|
+
* from drifting apart between native pi and the MCP bridge.
|
|
230
|
+
*
|
|
231
|
+
* The success text states the boundary out loud. A caller that reads "launched" and assumes
|
|
232
|
+
* "delivered" is the exact confusion the two-receipt split exists to prevent, so the text refuses
|
|
233
|
+
* to imply it.
|
|
234
|
+
*/
|
|
235
|
+
export function renderFreshCall(result) {
|
|
236
|
+
if (!result.ok) {
|
|
237
|
+
return {
|
|
238
|
+
text: `entwurf_fresh_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. No window was opened.`,
|
|
239
|
+
isError: true,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const r = result.receipt;
|
|
243
|
+
return {
|
|
244
|
+
text: `[entwurf fresh call →]\n` +
|
|
245
|
+
` backend: ${r.backend} (${r.runtimePath})\n` +
|
|
246
|
+
` model: ${r.model} (requested on the runtime CLI)\n` +
|
|
247
|
+
` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
|
|
248
|
+
` pane: ${r.paneId} pid ${r.panePid}\n` +
|
|
249
|
+
` nonce: ${r.nonce}\n` +
|
|
250
|
+
`\n` +
|
|
251
|
+
`This is a LAUNCH receipt: tmux created that window and was asked to start the runtime with the model above. It does ` +
|
|
252
|
+
`NOT mean the sibling is running, that its first turn ran, or that the task was delivered.\n` +
|
|
253
|
+
`The sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its ` +
|
|
254
|
+
`first action, and the sender envelope of THAT message is the address. Nothing is polling for it; ` +
|
|
255
|
+
`if it never comes, the window is visible and can be read directly.`,
|
|
256
|
+
isError: false,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
import { accessSync, constants as fsConstants, statSync } from "node:fs";
|
|
57
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
58
|
+
import { APPEND_FORMAT, assertSelector, assertTmuxOk, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
|
|
59
|
+
/** The official runtime this rail opens. FIXED: there is no command carrier, and adding one
|
|
60
|
+
* is a separate ownership decision (docs §11), not a parameter. */
|
|
61
|
+
export const PI_RUNTIME_COMMAND = "pi";
|
|
62
|
+
/** A named precondition failure. Callers surface `reason`; they never soften it into a
|
|
63
|
+
* fallback launch of something else. */
|
|
64
|
+
export class LaunchPreconditionError extends Error {
|
|
65
|
+
reason;
|
|
66
|
+
constructor(reason, message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "LaunchPreconditionError";
|
|
69
|
+
this.reason = reason;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Any ASCII or Unicode whitespace — the tmux re-split hazard is about the space character,
|
|
73
|
+
* but a tab or newline in an argv element is no more launchable and no less a surprise. */
|
|
74
|
+
const WHITESPACE = /\s/;
|
|
75
|
+
/**
|
|
76
|
+
* The five preconditions, in the order a failure would otherwise bite. Absolute first
|
|
77
|
+
* (everything after it is meaningless for a relative path), whitespace before the filesystem
|
|
78
|
+
* questions (a re-split path's stat is about the wrong file), then present → regular →
|
|
79
|
+
* executable, exactly as `probe-cli-target.ts` orders them.
|
|
80
|
+
*/
|
|
81
|
+
export function assertLaunchTarget(runtimePath) {
|
|
82
|
+
if (!isAbsolute(runtimePath)) {
|
|
83
|
+
throw new LaunchPreconditionError("runtime-not-absolute", `mux-launch: runtime target ${JSON.stringify(runtimePath)} is not absolute — tmux would resolve it against ` +
|
|
84
|
+
"the new pane's cwd, so which binary started would depend on where the window happened to open");
|
|
85
|
+
}
|
|
86
|
+
if (WHITESPACE.test(runtimePath)) {
|
|
87
|
+
throw new LaunchPreconditionError("runtime-path-whitespace", `mux-launch: runtime target ${JSON.stringify(runtimePath)} contains whitespace — tmux RE-SPLITS a single ` +
|
|
88
|
+
"argv element on spaces (measured, 3.6a), so this would launch a truncated path with the remainder as " +
|
|
89
|
+
"an argument and the window would die instantly");
|
|
90
|
+
}
|
|
91
|
+
let stat;
|
|
92
|
+
try {
|
|
93
|
+
stat = statSync(runtimePath);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new LaunchPreconditionError("runtime-missing", `mux-launch: runtime target ${runtimePath} does not exist — refusing before a window is opened onto a ` +
|
|
97
|
+
"runtime that cannot start");
|
|
98
|
+
}
|
|
99
|
+
if (!stat.isFile()) {
|
|
100
|
+
throw new LaunchPreconditionError("runtime-not-regular-file", `mux-launch: runtime target ${runtimePath} is not a regular file — a directory or special file cannot be ` +
|
|
101
|
+
"the official runtime");
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
accessSync(runtimePath, fsConstants.X_OK);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new LaunchPreconditionError("runtime-not-executable", `mux-launch: runtime target ${runtimePath} is not executable (X_OK) — exec would fail after the window ` +
|
|
108
|
+
"already existed, which is exactly the state this precondition exists to prevent");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Find the official runtime on the caller's PATH and prove it launchable.
|
|
113
|
+
*
|
|
114
|
+
* PATH rather than a compiled-in location because the official `pi` is whatever the operator's
|
|
115
|
+
* environment resolves — pinning a path here would silently launch a different install than
|
|
116
|
+
* the one the operator gets by typing `pi`. Resolving it to an ABSOLUTE path (rather than
|
|
117
|
+
* handing tmux the bare word) is what makes the preconditions mean anything: a bare `pi`
|
|
118
|
+
* would be re-resolved inside the pane, after the window exists, against an environment this
|
|
119
|
+
* module never checked.
|
|
120
|
+
*
|
|
121
|
+
* Empty PATH entries are skipped rather than treated as cwd — the historical POSIX reading of
|
|
122
|
+
* `::` is a cwd search, and a launch that depends on where the caller stood is the drift this
|
|
123
|
+
* rail refuses.
|
|
124
|
+
*/
|
|
125
|
+
export function resolveRuntimeOnPath(command, env = process.env) {
|
|
126
|
+
const raw = env.PATH;
|
|
127
|
+
const entries = typeof raw === "string" ? raw.split(delimiter).filter((p) => p.length > 0) : [];
|
|
128
|
+
for (const dir of entries) {
|
|
129
|
+
if (!isAbsolute(dir))
|
|
130
|
+
continue;
|
|
131
|
+
const candidate = join(dir, command);
|
|
132
|
+
try {
|
|
133
|
+
if (!statSync(candidate).isFile())
|
|
134
|
+
continue;
|
|
135
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
assertLaunchTarget(candidate);
|
|
141
|
+
return candidate;
|
|
142
|
+
}
|
|
143
|
+
throw new LaunchPreconditionError("runtime-unresolved", `mux-launch: no executable ${JSON.stringify(command)} on PATH — refusing to open a window for a ` +
|
|
144
|
+
"runtime that is not installed");
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* T1-a's own resolution: the fixed official `pi`. Kept as a named function rather than a call
|
|
148
|
+
* site with a constant because the launch contract is "this rail opens `pi`", and a caller that
|
|
149
|
+
* could pass a command would be the carrier this module refuses to have (docs §11). The
|
|
150
|
+
* generic form above exists only so a SEPARATE composition can apply the SAME preconditions to
|
|
151
|
+
* its own fixed runtime — not so this one becomes parameterised.
|
|
152
|
+
*/
|
|
153
|
+
export function resolvePiRuntime(env = process.env) {
|
|
154
|
+
return resolveRuntimeOnPath(PI_RUNTIME_COMMAND, env);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The launch argv. Identical to the leaf's append shape — detached, appended at `{end}`,
|
|
158
|
+
* addressed by session id, printing the same handle format — with exactly one addition: the
|
|
159
|
+
* fixed runtime after `--`.
|
|
160
|
+
*
|
|
161
|
+
* `-d` is kept. "Visible" in T1-a means the window is a real window in the operator's own
|
|
162
|
+
* session rather than a headless child; it does not mean stealing the caller's focus. Moving
|
|
163
|
+
* focus is a different verb with a different owner, and the leaf's acceptance already pins
|
|
164
|
+
* "focus unchanged" as a property worth having.
|
|
165
|
+
*
|
|
166
|
+
* `--` is what stops tmux from handing the target to a shell (measured: an argument of
|
|
167
|
+
* `x; touch <file>` created no file). The selector and the runtime are both validated before
|
|
168
|
+
* this returns, so nothing unvalidated ever reaches tmux's parser.
|
|
169
|
+
*/
|
|
170
|
+
export function buildLaunchArgs(sessionId, runtimePath) {
|
|
171
|
+
assertSelector("session", sessionId);
|
|
172
|
+
assertLaunchTarget(runtimePath);
|
|
173
|
+
return ["new-window", "-d", "-a", "-t", `${sessionId}:{end}`, "-P", "-F", APPEND_FORMAT, "--", runtimePath];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Open one window in the caller's own session and start the fixed official `pi` in it.
|
|
177
|
+
*
|
|
178
|
+
* Order matters and is the whole safety argument: resolve and prove the runtime FIRST, then
|
|
179
|
+
* re-read the caller's placement and refuse a changed context (the leaf's binding), and only
|
|
180
|
+
* then mutate. A precondition failure therefore never leaves a window behind, because no
|
|
181
|
+
* window has been opened yet.
|
|
182
|
+
*
|
|
183
|
+
* Responsibility ends at the returned ids. No identity, no delivery, no supervision.
|
|
184
|
+
*/
|
|
185
|
+
export function launchPi(placement, env = process.env) {
|
|
186
|
+
const runtimePath = resolvePiRuntime(env);
|
|
187
|
+
requireSameContext("launchPi", placement, env);
|
|
188
|
+
const run = runTmux(buildLaunchArgs(placement.sessionId, runtimePath), env);
|
|
189
|
+
assertTmuxOk("new-window", run);
|
|
190
|
+
let fields;
|
|
191
|
+
try {
|
|
192
|
+
fields = parseWindowFields(run.stdout);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
// The window exists and its id is exactly what we failed to read. Diffing the inventory
|
|
196
|
+
// to find "the new one" is the guess this rail forbids, so name the orphan instead of
|
|
197
|
+
// killing a window chosen by inference.
|
|
198
|
+
throw new Error(`mux-launch: launched ${runtimePath} but could not read the window handle tmux printed — a window may be ` +
|
|
199
|
+
`open in session ${placement.sessionId} that this call cannot identify or close: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
return { serverPid: placement.serverPid, sessionId: placement.sessionId, ...fields, runtimePath };
|
|
202
|
+
}
|