@junghanacs/entwurf 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +179 -0
- package/README.md +3 -3
- package/VERIFY.md +1 -1
- package/docs/external-mcp-host.md +2 -2
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +18 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +76 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +54 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resolve-tmux-session.js +114 -0
- package/mcp/entwurf-bridge/src/index.ts +22 -7
- package/package.json +1 -1
- package/pi-extensions/entwurf-control.ts +27 -3
- package/pi-extensions/lib/mux-fresh-call.ts +98 -11
- package/pi-extensions/lib/mux-placement.ts +65 -10
- package/pi-extensions/lib/resolve-tmux-session.ts +137 -0
- package/scripts/check-fresh-cut-gate.sh +54 -2
- package/scripts/check-gate-qualification.ts +4 -4
- package/scripts/check-mux-launch-tmux.ts +77 -2
- package/scripts/check-mux-launch.ts +17 -0
- package/scripts/check-mux-placement-tmux.ts +61 -1
- package/scripts/check-mux-placement.ts +33 -0
- package/scripts/mutants/fresh-cut.json +26 -0
- package/scripts/mutants/mux-boundary.json +24 -0
- package/scripts/mutants/mux-fresh-call.json +81 -0
- package/scripts/mutants/omp-birth.json +16 -3
- package/scripts/omp-bridge-doctor.sh +11 -1
- package/scripts/smoke-mux-fresh-call-live.ts +175 -3
- package/scripts/smoke-omp-bridge-state.sh +5 -2
|
@@ -8,11 +8,17 @@
|
|
|
8
8
|
* session the operator is looking at. Inheriting the environment IS the addressing: this
|
|
9
9
|
* module never takes a socket path or a session name from a caller.
|
|
10
10
|
*
|
|
11
|
+
* That sentence is still exactly true of THIS leaf, and #105 is where it started to be worth
|
|
12
|
+
* saying precisely. The fresh-call composition above may now be handed one session NAME by its
|
|
13
|
+
* caller, but it resolves that name to a native `$id` through its own leaf before anything
|
|
14
|
+
* reaches here — `appendWindow` still appends only to the caller's own session, and no function
|
|
15
|
+
* in this file has ever seen or will see a session name.
|
|
16
|
+
*
|
|
11
17
|
* Three actions, deliberately not four:
|
|
12
18
|
*
|
|
13
19
|
* inspectPlacement() the caller's own $session/@window/%pane, or a named refusal
|
|
14
20
|
* appendWindow() one default-shell window at the end of that same session
|
|
15
|
-
* closeWindow() that window, by stable id,
|
|
21
|
+
* closeWindow() that window, by stable id, on the server it was opened on
|
|
16
22
|
*
|
|
17
23
|
* What this module is NOT: a delivery transport, an address, a liveness fact, a launcher.
|
|
18
24
|
* It opens a place. `entwurf_v2` still owns delivery (`V2-DELIVERY-EXCLUDES-MUX`), and
|
|
@@ -51,8 +57,20 @@
|
|
|
51
57
|
* 4. BINDING. A `Placement` is a fact about one server and one session, and the environment
|
|
52
58
|
* passed to a later call could name a different — or restarted — server where the same
|
|
53
59
|
* `$3`/`@7` mean something else entirely. Every mutation re-reads the caller's placement
|
|
54
|
-
*
|
|
55
|
-
*
|
|
60
|
+
* before it runs.
|
|
61
|
+
*
|
|
62
|
+
* The two mutations bind to DIFFERENT halves of that fact, because they are answering
|
|
63
|
+
* different questions. `appendWindow` asks "is the target the caller gave me still the
|
|
64
|
+
* caller's own seat?" and needs both halves — `isSameContext`. `closeWindow` asks "is this
|
|
65
|
+
* handle still the window it was born as?" and needs only the SERVER half — a `@id` is
|
|
66
|
+
* unique for the life of one server, so the session half adds nothing there. `[측정
|
|
67
|
+
* 2026-09-07, private server]` ids are handed out monotonically and are never recycled:
|
|
68
|
+
* after `@2` was killed the next window was `@3`, and after a whole session holding `@4`
|
|
69
|
+
* and `@5` was killed the next was `@6`. Requiring the handle's session to still exist
|
|
70
|
+
* would also break the more informative answer below — a window whose session is gone is
|
|
71
|
+
* `already-gone`, which is a fact the caller wants, not an error. Since #105 a launched
|
|
72
|
+
* window may legitimately live in a session that is not the caller's, and that is exactly
|
|
73
|
+
* when this distinction stops being academic.
|
|
56
74
|
*
|
|
57
75
|
* The machine-readable rows carry ONLY native ids and decimal numbers, joined by `|`. The
|
|
58
76
|
* free-form fields tmux could also report (`socket_path`, `session_name`) are deliberately
|
|
@@ -260,6 +278,36 @@ export function requireSameContext(label, origin, env) {
|
|
|
260
278
|
`now server ${now.placement.serverPid} session ${now.placement.sessionId})`);
|
|
261
279
|
}
|
|
262
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Same server, whatever the session. The close-side half of boundary 4, kept as its own pure
|
|
283
|
+
* predicate for the same reason `isSameContext` is one: the decision is what a deterministic
|
|
284
|
+
* gate can pin, and the re-read around it is not.
|
|
285
|
+
*/
|
|
286
|
+
export function isSameServer(origin, now) {
|
|
287
|
+
return origin.serverPid === now.serverPid;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Re-read the caller's placement and refuse unless the handle was born on the SAME SERVER. The
|
|
291
|
+
* close-side half of boundary 4, and deliberately NOT `requireSameContext`: since #105 a handle
|
|
292
|
+
* can name a window in a session that is not the caller's, and the session half would refuse a
|
|
293
|
+
* legitimate close. What it must still refuse is a handle from a DIFFERENT — or restarted —
|
|
294
|
+
* server, where `@7` names some other window entirely.
|
|
295
|
+
*
|
|
296
|
+
* The session half is not lost, it is covered better: `@id`s are unique for one server's whole
|
|
297
|
+
* life (measured — see boundary 4), so on a matching server the id alone identifies the window,
|
|
298
|
+
* and a window whose session has since been killed is proven absent by `closeWindow`'s own
|
|
299
|
+
* `list-windows -a` read and reported as `already-gone`.
|
|
300
|
+
*/
|
|
301
|
+
export function requireSameServer(label, origin, env) {
|
|
302
|
+
const now = inspectPlacement(env);
|
|
303
|
+
if (!now.ok) {
|
|
304
|
+
throw new Error(`mux-placement: ${label} refused — the caller's placement is not resolvable (${now.reason})`);
|
|
305
|
+
}
|
|
306
|
+
if (!isSameServer(origin, now.placement)) {
|
|
307
|
+
throw new Error(`mux-placement: ${label} refused — server changed (handle was born on server ${origin.serverPid}, ` +
|
|
308
|
+
`this environment names server ${now.placement.serverPid})`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
263
311
|
/** One detached default-shell window at the end of the caller's own session. */
|
|
264
312
|
export function appendWindow(placement, env = process.env) {
|
|
265
313
|
requireSameContext("appendWindow", placement, env);
|
|
@@ -268,11 +316,11 @@ export function appendWindow(placement, env = process.env) {
|
|
|
268
316
|
return { serverPid: placement.serverPid, sessionId: placement.sessionId, ...parseWindowFields(run.stdout) };
|
|
269
317
|
}
|
|
270
318
|
/**
|
|
271
|
-
* Close one window by stable id,
|
|
272
|
-
*
|
|
319
|
+
* Close one window by stable id, on the server it was opened on. Reports whether it was closed
|
|
320
|
+
* or had already gone. The binding is the server, not the session — see `requireSameServer`.
|
|
273
321
|
*/
|
|
274
322
|
export function closeWindow(handle, env = process.env) {
|
|
275
|
-
|
|
323
|
+
requireSameServer("closeWindow", handle, env);
|
|
276
324
|
const run = runTmux(buildCloseArgs(handle.windowId), env);
|
|
277
325
|
if (run.status === 0)
|
|
278
326
|
return "closed";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolve-tmux-session — the ONE resolution of a caller-supplied tmux SESSION NAME into the
|
|
3
|
+
* native `$id` a placement may target. Narrow leaf of the fresh-call composition (#105); it
|
|
4
|
+
* owns the name grammar and the name→id lookup and NOTHING else — it never runs tmux (the
|
|
5
|
+
* runner is injected), never phrases a hint (each consumer owns its own wording), never
|
|
6
|
+
* creates a session, and has no fallback session.
|
|
7
|
+
*
|
|
8
|
+
* Same shape and same discipline as `classify-tmux-cwd.ts`: this file imports nothing at all,
|
|
9
|
+
* not even a node builtin, so it stays deletable on its own and cannot acquire an opinion
|
|
10
|
+
* about mux, entwurf, identity or delivery. The injected runner is matched STRUCTURALLY to
|
|
11
|
+
* `mux-placement.TmuxRun` rather than by a type import, for the same reason.
|
|
12
|
+
*
|
|
13
|
+
* Every rule below is a MEASURED tmux 3.6a behaviour (2026-09-07, private `-S` servers; the
|
|
14
|
+
* research lane's two independent reproductions are in issue #105's thread), and each one is a
|
|
15
|
+
* way a lookup would look successful while addressing the wrong thing:
|
|
16
|
+
*
|
|
17
|
+
* 1. `-t '=NAME'` performs NO format expansion — `=a}b`, `=a|b`, `=a b`, `=a,b` all resolve
|
|
18
|
+
* exactly. The `-f '#{==:#{session_name},NAME}'` FILTER engine does the opposite: a `}`
|
|
19
|
+
* inside the name closes the comparison early, the filter becomes a truthy string, and
|
|
20
|
+
* EVERY session matches (6/6 measured). So the engine here is `-t '='`, never a filter.
|
|
21
|
+
* 2. absence is reported by EXIT CODE, not by output. `list-windows -t '=nosuch'` exits 1
|
|
22
|
+
* with `can't find session: nosuch`, while `display-message -p -t '=NAME'` exits 0 with
|
|
23
|
+
* EMPTY output for a name that exists AND for one that does not — it is unusable as a
|
|
24
|
+
* probe. Only an rc=0 answer is ever parsed here.
|
|
25
|
+
* 3. some names cannot be addressed at all, for two different measured reasons. `#` is
|
|
26
|
+
* FORMAT-EXPANDED when `new-session -s` stores it (`a#{x}` stored as `a`), so the
|
|
27
|
+
* requested name never exists; and `.`/`:` are tmux's own PANE/WINDOW separators inside a
|
|
28
|
+
* `-t` target, so the requested name is SPLIT before any session is matched. Measured
|
|
29
|
+
* with this leaf's own engine: `list-windows -t '=my.project'` → rc=1
|
|
30
|
+
* `can't find pane: project`, and `list-windows -t '=my:project'` → rc=1
|
|
31
|
+
* `can't find session: my`. The exact wording is NOT contract — it moves with which
|
|
32
|
+
* sessions happen to exist (`can't find window: project` once a session `my` is there) —
|
|
33
|
+
* the fact is that the split happens at all. Both characters are ALSO normalised to `_`
|
|
34
|
+
* when stored, so `a.b` and `a:b` are the same stored name `a_b` and whichever is created
|
|
35
|
+
* second is a `duplicate session` error rather than a second seat. The lookup half is the
|
|
36
|
+
* load-bearing one: whatever tmux stored, the REQUESTED name can never address it.
|
|
37
|
+
* NOTE the difference from `classify-tmux-cwd.ts`: there `#(…)`
|
|
38
|
+
* was observed EXECUTING inside a `-c` value; here it expands but does NOT execute (a
|
|
39
|
+
* `q#(touch …)q` name stored as `qq` and wrote no file). Do not copy that leaf's
|
|
40
|
+
* rationale into this one, or relaxing one will silently relax the other.
|
|
41
|
+
* 4. `=` protects a name against `%9`/`@1` id syntax but NOT against `$`: with a session
|
|
42
|
+
* NAMED `$0` beside one whose ID is `$0`, `-t '=$0'` resolves the ID (measured — the
|
|
43
|
+
* name-holder was `$1` and the lookup returned `$0`). And a session literally named
|
|
44
|
+
* `=foo` needs `==foo`. An escaping layer here would be a second parser to keep true.
|
|
45
|
+
*
|
|
46
|
+
* The grammar is `^[A-Za-z0-9][A-Za-z0-9_-]*$`, and it is WIDER than what rules 3-4 force. Say
|
|
47
|
+
* that plainly rather than letting the reasons above cover the whole refusal set: `a}b`, `a|b`,
|
|
48
|
+
* `a b`, `a;b`, `a,b` and `_a` are all created verbatim AND resolved exactly by `-t '=NAME'`
|
|
49
|
+
* (measured 2026-09-07, one server, six sessions, six exact ids). They are refused anyway, and
|
|
50
|
+
* the reason is a DECISION, not a tmux limit — this is the grammar entwurf would need to CREATE
|
|
51
|
+
* a session safely, kept symmetric for lookup, with the remaining foreign-name width closed
|
|
52
|
+
* until an operator need for it is actually observed. That is why the refusal is
|
|
53
|
+
* `tmux-session-name-invalid` ("this rail does not address that shape") and not
|
|
54
|
+
* `tmux-session-missing` ("no such session here"): the caller's repair differs, and telling an
|
|
55
|
+
* operator their perfectly findable session "could never be found" would be a false cause.
|
|
56
|
+
*
|
|
57
|
+
* ONE BOUNDED IMPRECISION, STATED RATHER THAN LAUNDERED: rc≠0 also covers "no server running
|
|
58
|
+
* on <socket>" (measured). This leaf reads every rc≠0 as `tmux-session-missing`, so a server
|
|
59
|
+
* that died between the caller's context proof and this lookup is reported under the narrower
|
|
60
|
+
* word. That is safe — both readings are refusals that mutate nothing, and the consumer's hint
|
|
61
|
+
* names both — and it is preferred over matching tmux's own stderr text, which would pin this
|
|
62
|
+
* leaf to one vendor version's wording.
|
|
63
|
+
*/
|
|
64
|
+
const SESSION_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
65
|
+
const SESSION_ID = /^\$[0-9]+$/;
|
|
66
|
+
/**
|
|
67
|
+
* Classify a candidate session NAME. Separate from the lookup because the operator's next move
|
|
68
|
+
* differs: an out-of-grammar name is a shape this rail does not address — some of those tmux
|
|
69
|
+
* genuinely cannot resolve (rules 3-4), others it resolves fine and this rail declines anyway —
|
|
70
|
+
* while a missing session is something the operator creates and retries.
|
|
71
|
+
*/
|
|
72
|
+
export function classifyTmuxSessionName(name) {
|
|
73
|
+
return SESSION_NAME.test(name) ? null : "tmux-session-name-invalid";
|
|
74
|
+
}
|
|
75
|
+
/** The lookup argv. `-t '=NAME'` is the exact-name selector; `list-windows` is the engine that
|
|
76
|
+
* answers with an exit code for absence. The builder re-validates rather than trusting its
|
|
77
|
+
* caller, the same way the fresh-call argv builder re-checks a cwd. */
|
|
78
|
+
export function buildTmuxSessionLookupArgs(name) {
|
|
79
|
+
if (classifyTmuxSessionName(name)) {
|
|
80
|
+
throw new Error(`resolve-tmux-session: refusing to build a lookup for an unresolvable session name: ${name}`);
|
|
81
|
+
}
|
|
82
|
+
return ["list-windows", "-t", `=${name}`, "-F", "#{session_id}"];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a session name to its native `$id` on whatever server the runner's environment names.
|
|
86
|
+
* The id is the ONLY thing a caller-supplied name is allowed to become: everything downstream
|
|
87
|
+
* targets `$id`, never the name again.
|
|
88
|
+
*
|
|
89
|
+
* A session always holds at least one window, so an rc=0 answer prints one `$id` line per
|
|
90
|
+
* window and they are all the same id. An rc=0 that cannot be read that way is an operational
|
|
91
|
+
* anomaly, not an answer about the session, and is raised rather than turned into a refusal.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveTmuxSessionId(name, run) {
|
|
94
|
+
const badName = classifyTmuxSessionName(name);
|
|
95
|
+
if (badName)
|
|
96
|
+
return { ok: false, reason: badName };
|
|
97
|
+
const result = run(buildTmuxSessionLookupArgs(name));
|
|
98
|
+
// A signalled call is not tmux answering — it carries no information about the session at
|
|
99
|
+
// all, so it must never be read as absence.
|
|
100
|
+
if (result.status === null) {
|
|
101
|
+
throw new Error(`resolve-tmux-session: the lookup for session "${name}" was killed by a signal: ${result.stderr.trim()}`);
|
|
102
|
+
}
|
|
103
|
+
if (result.status !== 0)
|
|
104
|
+
return { ok: false, reason: "tmux-session-missing" };
|
|
105
|
+
const lines = result.stdout
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((line) => line.trim())
|
|
108
|
+
.filter((line) => line.length > 0);
|
|
109
|
+
const first = lines[0];
|
|
110
|
+
if (first === undefined || !SESSION_ID.test(first) || lines.some((line) => line !== first)) {
|
|
111
|
+
throw new Error(`resolve-tmux-session: tmux answered rc=0 for session "${name}" with something that is not one native session id: ${JSON.stringify(result.stdout)}`);
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, sessionId: first };
|
|
114
|
+
}
|
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
* this read is the receipt.
|
|
27
27
|
* - entwurf_register_native — explicit/manual fallback binding an ALREADY-RUNNING native
|
|
28
28
|
* conversation (antigravity) to a garden id. Never a spawn.
|
|
29
|
-
* - entwurf_fresh_call — open ONE fresh visible sibling in the operator's
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* - entwurf_fresh_call — open ONE fresh visible sibling in the operator's tmux — the caller's own
|
|
30
|
+
* session, or ONE EXISTING named session on the same server (the project
|
|
31
|
+
* seat, #105; nothing is ever created), optionally at ONE literal requested
|
|
32
|
+
* cwd (cross-repo fresh, #73); returns a LAUNCH receipt only, and the new
|
|
33
|
+
* address arrives later as the sender envelope of the sibling's nonce callback.
|
|
33
34
|
* - entwurf_resume_call — reopen ONE DORMANT pi citizen under its OWN garden id in a visible
|
|
34
35
|
* window; target-only, runs no turn, LAUNCH and OBSERVATION receipts stay
|
|
35
36
|
* apart.
|
|
@@ -706,7 +707,7 @@ server.tool(
|
|
|
706
707
|
// against that answer would call home to a garden id nobody holds.
|
|
707
708
|
server.tool(
|
|
708
709
|
"entwurf_fresh_call",
|
|
709
|
-
"Open ONE fresh visible sibling in the operator's
|
|
710
|
+
"Open ONE fresh visible sibling in the operator's tmux and hand it a first task. Four fixed " +
|
|
710
711
|
"backends only: pi, claude-code, copilot, omp. The sibling's FIRST action is a callback to you carrying a nonce, and the " +
|
|
711
712
|
"sender envelope of that callback is its garden id — that is how you learn the address of something that " +
|
|
712
713
|
"did not exist a moment ago. This returns a LAUNCH receipt (tmux window/pane plus that nonce) and nothing " +
|
|
@@ -721,6 +722,8 @@ server.tool(
|
|
|
721
722
|
"prompt, so the sibling could not call you back at all). An optional " +
|
|
722
723
|
"cwd starts the sibling in ONE literal absolute existing directory (cross-repo fresh) — never pick resume " +
|
|
723
724
|
"for a dormant record's cwd; resume is continuity-only. Omitted/empty cwd means the caller's own directory. " +
|
|
725
|
+
"An optional placement.tmuxSession opens it in ONE EXISTING session of this agent's own tmux server; an " +
|
|
726
|
+
"absent SESSION is tmux-session-missing and NOTHING is created. Omit placement for the caller's own session. " +
|
|
724
727
|
"There are no arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to " +
|
|
725
728
|
"same-user processes on this host. Requires that this agent itself runs " +
|
|
726
729
|
"inside tmux: without a pane anchor there is no session to open a sibling beside.",
|
|
@@ -757,8 +760,20 @@ server.tool(
|
|
|
757
760
|
.describe(
|
|
758
761
|
"Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \"\" to start in this agent's own cwd. Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). The receipt echoes what was REQUESTED, never an observation.",
|
|
759
762
|
),
|
|
763
|
+
placement: z
|
|
764
|
+
.object({
|
|
765
|
+
tmuxSession: z
|
|
766
|
+
.string()
|
|
767
|
+
.describe(
|
|
768
|
+
"EXACT name of an EXISTING session on this agent's own tmux server. Nothing is created: an absent session is refused as tmux-session-missing, and a name outside [A-Za-z0-9][A-Za-z0-9_-]* as tmux-session-name-invalid.",
|
|
769
|
+
),
|
|
770
|
+
})
|
|
771
|
+
.optional()
|
|
772
|
+
.describe(
|
|
773
|
+
"Optional project seat: open the sibling in ONE EXISTING tmux session of this agent's own server instead of the caller's session. Nothing is ever created — an absent session is a refusal, not a new session. Independent of cwd; neither is inferred from the other. The receipt echoes the REQUESTED name and reports the resolved target session id.",
|
|
774
|
+
),
|
|
760
775
|
},
|
|
761
|
-
async ({ backend, model, task, cwd }) => {
|
|
776
|
+
async ({ backend, model, task, cwd, placement }) => {
|
|
762
777
|
let callerGardenId: string | null = null;
|
|
763
778
|
try {
|
|
764
779
|
const self = await buildAuthoritativeSelfEnvelope();
|
|
@@ -775,7 +790,7 @@ server.tool(
|
|
|
775
790
|
callerGardenId = null;
|
|
776
791
|
}
|
|
777
792
|
try {
|
|
778
|
-
const rendered = renderFreshCall(freshCall({ backend, model, task, cwd, callerGardenId }));
|
|
793
|
+
const rendered = renderFreshCall(freshCall({ backend, model, task, cwd, placement, callerGardenId }));
|
|
779
794
|
return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
|
|
780
795
|
} catch (err) {
|
|
781
796
|
return textErr(`entwurf_fresh_call error: ${err instanceof Error ? err.message : String(err)}`);
|
package/package.json
CHANGED
|
@@ -1504,6 +1504,7 @@ interface MuxFreshCallModule {
|
|
|
1504
1504
|
model: string;
|
|
1505
1505
|
task: string;
|
|
1506
1506
|
cwd?: string;
|
|
1507
|
+
placement?: { tmuxSession: string };
|
|
1507
1508
|
callerGardenId: string | null;
|
|
1508
1509
|
},
|
|
1509
1510
|
env?: NodeJS.ProcessEnv,
|
|
@@ -1529,7 +1530,7 @@ function registerFreshCallTool(pi: ExtensionAPI): void {
|
|
|
1529
1530
|
registerTool({
|
|
1530
1531
|
name: "entwurf_fresh_call",
|
|
1531
1532
|
label: "Open Fresh Sibling",
|
|
1532
|
-
description: `Open ONE fresh visible sibling in the operator's
|
|
1533
|
+
description: `Open ONE fresh visible sibling in the operator's tmux and hand it a first task. Four fixed
|
|
1533
1534
|
backends only: pi, claude-code, copilot, omp. The sibling's FIRST action is a callback to you carrying a nonce, and the
|
|
1534
1535
|
sender envelope of that callback is its garden id — that is how you learn the address of something that did
|
|
1535
1536
|
not exist a moment ago. This returns a LAUNCH receipt (tmux window/pane plus that nonce) and nothing else:
|
|
@@ -1543,7 +1544,9 @@ launch is refused the same way if this host lacks the OMP birth, MCP, receiver o
|
|
|
1543
1544
|
omp's tools.xdev is not false (the vendor default hides MCP tool schemas from the prompt, so the sibling
|
|
1544
1545
|
could not call you back at all). An optional cwd starts the
|
|
1545
1546
|
sibling in ONE literal absolute existing directory (cross-repo fresh) — never pick resume for a dormant
|
|
1546
|
-
record's cwd; resume is continuity-only. Omitted/empty cwd means the caller's own directory.
|
|
1547
|
+
record's cwd; resume is continuity-only. Omitted/empty cwd means the caller's own directory. An optional
|
|
1548
|
+
placement.tmuxSession opens it in ONE EXISTING session of this agent's own tmux server; an absent SESSION is
|
|
1549
|
+
tmux-session-missing and NOTHING is created. Omit placement for the caller's own session. There are no
|
|
1547
1550
|
arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to same-user
|
|
1548
1551
|
processes on this host.`,
|
|
1549
1552
|
parameters: Type.Object({
|
|
@@ -1569,10 +1572,30 @@ processes on this host.`,
|
|
|
1569
1572
|
"Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \"\" to start in this agent's own cwd. Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). The receipt echoes what was REQUESTED, never an observation.",
|
|
1570
1573
|
}),
|
|
1571
1574
|
),
|
|
1575
|
+
placement: Type.Optional(
|
|
1576
|
+
Type.Object(
|
|
1577
|
+
{
|
|
1578
|
+
tmuxSession: Type.String({
|
|
1579
|
+
description:
|
|
1580
|
+
"EXACT name of an EXISTING session on this agent's own tmux server. Nothing is created: an absent session is refused as tmux-session-missing, and a name outside [A-Za-z0-9][A-Za-z0-9_-]* as tmux-session-name-invalid.",
|
|
1581
|
+
}),
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
description:
|
|
1585
|
+
"Optional project seat: open the sibling in ONE EXISTING tmux session of this agent's own server instead of the caller's session. Nothing is ever created — an absent session is a refusal, not a new session. Independent of cwd; neither is inferred from the other. The receipt echoes the REQUESTED name and reports the resolved target session id.",
|
|
1586
|
+
},
|
|
1587
|
+
),
|
|
1588
|
+
),
|
|
1572
1589
|
}),
|
|
1573
1590
|
async execute(
|
|
1574
1591
|
_toolCallId: string,
|
|
1575
|
-
params: {
|
|
1592
|
+
params: {
|
|
1593
|
+
backend: "pi" | "claude-code" | "copilot" | "omp";
|
|
1594
|
+
model: string;
|
|
1595
|
+
task: string;
|
|
1596
|
+
cwd?: string;
|
|
1597
|
+
placement?: { tmuxSession: string };
|
|
1598
|
+
},
|
|
1576
1599
|
_signal: AbortSignal | undefined,
|
|
1577
1600
|
_onUpdate: unknown,
|
|
1578
1601
|
_ctx: ExtensionContext,
|
|
@@ -1584,6 +1607,7 @@ processes on this host.`,
|
|
|
1584
1607
|
model: params.model,
|
|
1585
1608
|
task: params.task,
|
|
1586
1609
|
cwd: params.cwd,
|
|
1610
|
+
placement: params.placement,
|
|
1587
1611
|
callerGardenId: residentGardenId,
|
|
1588
1612
|
});
|
|
1589
1613
|
const rendered = mux.renderFreshCall(result);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mux-fresh-call — open ONE visible sibling in the caller's own tmux
|
|
3
|
-
*
|
|
2
|
+
* mux-fresh-call — open ONE visible sibling in the caller's own tmux server (its own session by
|
|
3
|
+
* default, or one named existing session on that server since #105), hand it its first task in
|
|
4
|
+
* the launch argv, and let it name itself back to the caller.
|
|
4
5
|
*
|
|
5
6
|
* ── Why this is a third module and not a parameter on the leaf ──
|
|
6
7
|
*
|
|
@@ -43,6 +44,36 @@
|
|
|
43
44
|
* - the receipt echoes what was REQUESTED, exactly as `runtimePath` does. It never reports
|
|
44
45
|
* `pane_current_path`: proving where the pane actually landed belongs to acceptance, not
|
|
45
46
|
* to the launch receipt.
|
|
47
|
+
*
|
|
48
|
+
* ── The optional project seat (issue #105) ──
|
|
49
|
+
*
|
|
50
|
+
* A fresh sibling opens in the caller's own tmux session — unless the caller names ONE session
|
|
51
|
+
* on the SAME server. That input exists because the operator's seats are per-project: a sibling
|
|
52
|
+
* opened for the `org` project belongs in the `org` session, and before this the only way to
|
|
53
|
+
* put it there was for the operator to move the window by hand. The rules are as narrow as the
|
|
54
|
+
* cwd input's, and for the same reason:
|
|
55
|
+
*
|
|
56
|
+
* - `undefined` means the caller's own session, and the argv is byte-identical to the
|
|
57
|
+
* pre-#105 shape. A named session is resolved to its native `$id` by the shared
|
|
58
|
+
* `resolve-tmux-session.ts` leaf and ONLY that id ever reaches `-t`.
|
|
59
|
+
* - a session that does not exist is `tmux-session-missing` and NOTHING is created — not the
|
|
60
|
+
* window, not the session. There is no `ifMissing` axis and no `new-session` verb anywhere
|
|
61
|
+
* in this product (GLG, 2026-09-07): the operator creates the seat and calls again. A name
|
|
62
|
+
* outside the leaf's grammar is the separate `tmux-session-name-invalid` — a different
|
|
63
|
+
* repair, because that name is a shape this rail does not address rather than a session
|
|
64
|
+
* that is absent. The leaf owns which shapes and why; part of that set tmux genuinely
|
|
65
|
+
* cannot resolve and part is a narrowing this rail chose, and it says which is which.
|
|
66
|
+
* - `-d` is what makes this safe to do to a session someone is looking at. `[측정]` without
|
|
67
|
+
* it, a `new-window` into another session changes THAT session's active window and steals
|
|
68
|
+
* the operator's focus. It was already fixed in this argv; #105 is where it became
|
|
69
|
+
* load-bearing.
|
|
70
|
+
* - the seat is ORTHOGONAL to the cwd. `[측정 ×2]` with `-c` omitted, a window opened into
|
|
71
|
+
* another session lands in the cwd of the process that ran `new-window` — not the target
|
|
72
|
+
* session's `session_path` and not its active pane. Neither input is ever inferred from
|
|
73
|
+
* the other.
|
|
74
|
+
* - the receipt echoes the REQUESTED name and carries the OBSERVED target `$id`, which is
|
|
75
|
+
* the session the window is actually in. It still reports no `pane_current_path`, and
|
|
76
|
+
* there is no "session created" field because nothing here creates one.
|
|
46
77
|
*/
|
|
47
78
|
|
|
48
79
|
import { randomBytes } from "node:crypto";
|
|
@@ -63,7 +94,6 @@ import {
|
|
|
63
94
|
assertSelector,
|
|
64
95
|
assertTmuxOk,
|
|
65
96
|
inspectPlacement,
|
|
66
|
-
type Placement,
|
|
67
97
|
type PlacementRejectReason,
|
|
68
98
|
parseWindowFields,
|
|
69
99
|
requireSameContext,
|
|
@@ -71,6 +101,7 @@ import {
|
|
|
71
101
|
type WindowHandle,
|
|
72
102
|
} from "./mux-placement.ts";
|
|
73
103
|
import { OMP_PREFLIGHT_HINT, type OmpPreflightRejectReason, ompFreshPreflight } from "./omp-fresh-preflight.ts";
|
|
104
|
+
import { classifyTmuxSessionName, resolveTmuxSessionId, type TmuxSessionRejectReason } from "./resolve-tmux-session.ts";
|
|
74
105
|
|
|
75
106
|
/** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
|
|
76
107
|
* not a config entry. `copilot` was added by #82 RAIL 9 under the step 9 admission contract, and
|
|
@@ -341,6 +372,7 @@ export type FreshCallRejectReason =
|
|
|
341
372
|
| PlacementRejectReason
|
|
342
373
|
| LaunchRejectReason
|
|
343
374
|
| TmuxCwdRejectReason
|
|
375
|
+
| TmuxSessionRejectReason
|
|
344
376
|
| CopilotPreflightRejectReason
|
|
345
377
|
| OmpPreflightRejectReason
|
|
346
378
|
| "caller-identity-unavailable"
|
|
@@ -349,6 +381,14 @@ export type FreshCallRejectReason =
|
|
|
349
381
|
| "task-empty"
|
|
350
382
|
| "task-too-long";
|
|
351
383
|
|
|
384
|
+
/** The optional project seat: ONE existing session on the caller's own tmux server, named
|
|
385
|
+
* literally. An object rather than a bare string so the seat axis can never be confused with
|
|
386
|
+
* the cwd axis at a call site — and so a future seat fact, if one is ever measured to be
|
|
387
|
+
* needed, does not arrive as a second top-level parameter. */
|
|
388
|
+
export interface FreshCallPlacement {
|
|
389
|
+
tmuxSession: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
352
392
|
/** Coordinates plus what was handed to tmux. Read `runtimePath` as "what we asked to start".
|
|
353
393
|
* There is deliberately NO field here for the callback, the nonce's arrival, or the sibling's
|
|
354
394
|
* garden id — see the module header. */
|
|
@@ -359,6 +399,10 @@ export interface FreshCallReceipt extends WindowHandle {
|
|
|
359
399
|
* of fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
|
|
360
400
|
* landed. */
|
|
361
401
|
cwd?: string;
|
|
402
|
+
/** The REQUESTED session name — present only when the caller named a seat. The RESOLVED
|
|
403
|
+
* target is the inherited `sessionId`, which is the session the window is actually in; this
|
|
404
|
+
* field is the request that produced it, exactly as `cwd` is. */
|
|
405
|
+
tmuxSession?: string;
|
|
362
406
|
runtimePath: string;
|
|
363
407
|
nonce: string;
|
|
364
408
|
}
|
|
@@ -403,14 +447,21 @@ const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="] as const;
|
|
|
403
447
|
|
|
404
448
|
/** Launch argv: the leaf's detached-append shape, the identity scrub, optionally `-c` at the
|
|
405
449
|
* resume-symmetric token position (after `-t`, before `-P -F`), the runtime, then the backend's
|
|
406
|
-
* dialect. An omitted cwd adds no `-c` carrier at all.
|
|
450
|
+
* dialect. An omitted cwd adds no `-c` carrier at all.
|
|
451
|
+
*
|
|
452
|
+
* The first parameter is the TARGET session id, not the caller's placement. Since #105 those
|
|
453
|
+
* are not always the same session, and taking a `Placement` here would invite exactly the
|
|
454
|
+
* defect this signature prevents: copying the caller's own id into a cross-session launch. A
|
|
455
|
+
* name never reaches this function — the seat is resolved to a native `$id` before it is
|
|
456
|
+
* called, and `assertSelector` refuses anything that is not one. `-d` is not optional: without
|
|
457
|
+
* it a window opened into another session steals that session's focus (measured). */
|
|
407
458
|
export function buildFreshCallArgs(
|
|
408
|
-
|
|
459
|
+
targetSessionId: string,
|
|
409
460
|
runtimePath: string,
|
|
410
461
|
backendArgs: readonly string[],
|
|
411
462
|
cwd?: string,
|
|
412
463
|
): string[] {
|
|
413
|
-
assertSelector("session",
|
|
464
|
+
assertSelector("session", targetSessionId);
|
|
414
465
|
assertLaunchTarget(runtimePath);
|
|
415
466
|
if (cwd !== undefined) {
|
|
416
467
|
const bad = classifyTmuxCwd(cwd);
|
|
@@ -422,7 +473,7 @@ export function buildFreshCallArgs(
|
|
|
422
473
|
"-a",
|
|
423
474
|
...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
|
|
424
475
|
"-t",
|
|
425
|
-
`${
|
|
476
|
+
`${targetSessionId}:{end}`,
|
|
426
477
|
...(cwd === undefined ? [] : ["-c", cwd]),
|
|
427
478
|
"-P",
|
|
428
479
|
"-F",
|
|
@@ -443,7 +494,14 @@ export function buildFreshCallArgs(
|
|
|
443
494
|
* against a store, or guesses it: an empty value is a named refusal, not a lookup.
|
|
444
495
|
*/
|
|
445
496
|
export function freshCall(
|
|
446
|
-
params: {
|
|
497
|
+
params: {
|
|
498
|
+
backend: FreshCallBackend;
|
|
499
|
+
model: string;
|
|
500
|
+
task: string;
|
|
501
|
+
cwd?: string;
|
|
502
|
+
placement?: FreshCallPlacement;
|
|
503
|
+
callerGardenId: string | null;
|
|
504
|
+
},
|
|
447
505
|
env: NodeJS.ProcessEnv = process.env,
|
|
448
506
|
nonce: string = mintNonce(),
|
|
449
507
|
): FreshCallResult {
|
|
@@ -464,6 +522,16 @@ export function freshCall(
|
|
|
464
522
|
const badCwd = classifyTmuxCwd(cwd);
|
|
465
523
|
if (badCwd) return { ok: false, reason: badCwd };
|
|
466
524
|
}
|
|
525
|
+
// The seat's NAME is classified here, beside the cwd and for the same reason: it is decidable
|
|
526
|
+
// without tmux, so an unresolvable name is answered before anything else runs. Whether that
|
|
527
|
+
// session EXISTS is a tmux question and is asked below, after the caller's own context is
|
|
528
|
+
// proven — a name check that needed a live server would refuse for the wrong reason on a
|
|
529
|
+
// host with no tmux at all.
|
|
530
|
+
const seat = params.placement?.tmuxSession;
|
|
531
|
+
if (seat !== undefined) {
|
|
532
|
+
const badSeat = classifyTmuxSessionName(seat);
|
|
533
|
+
if (badSeat) return { ok: false, reason: badSeat };
|
|
534
|
+
}
|
|
467
535
|
|
|
468
536
|
let runtimePath: string;
|
|
469
537
|
try {
|
|
@@ -493,6 +561,17 @@ export function freshCall(
|
|
|
493
561
|
const placement = inspected.placement;
|
|
494
562
|
requireSameContext("freshCall", placement, env);
|
|
495
563
|
|
|
564
|
+
// The caller's own context is now proven, which is what makes the next lookup's exit code
|
|
565
|
+
// readable as "that session is not here" rather than "there is no server". Only the resolved
|
|
566
|
+
// native id continues; the name does not travel past this line. STILL PRE-MUTATION: an
|
|
567
|
+
// absent seat refuses with no window anywhere.
|
|
568
|
+
let targetSessionId = placement.sessionId;
|
|
569
|
+
if (seat !== undefined) {
|
|
570
|
+
const resolved = resolveTmuxSessionId(seat, (args) => runTmux(args, env));
|
|
571
|
+
if (!resolved.ok) return { ok: false, reason: resolved.reason };
|
|
572
|
+
targetSessionId = resolved.sessionId;
|
|
573
|
+
}
|
|
574
|
+
|
|
496
575
|
const composition: FreshCallComposition = {
|
|
497
576
|
prompt: buildFreshCallPrompt({
|
|
498
577
|
backend: params.backend,
|
|
@@ -503,7 +582,7 @@ export function freshCall(
|
|
|
503
582
|
bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
|
|
504
583
|
};
|
|
505
584
|
const run = runTmux(
|
|
506
|
-
buildFreshCallArgs(
|
|
585
|
+
buildFreshCallArgs(targetSessionId, runtimePath, buildBackendArgs(params.backend, composition, model), cwd),
|
|
507
586
|
env,
|
|
508
587
|
);
|
|
509
588
|
assertTmuxOk("new-window", run);
|
|
@@ -516,7 +595,7 @@ export function freshCall(
|
|
|
516
595
|
// to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
|
|
517
596
|
throw new Error(
|
|
518
597
|
`mux-fresh-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
|
|
519
|
-
`be open in session ${
|
|
598
|
+
`be open in session ${targetSessionId} that this call cannot identify or close: ${
|
|
520
599
|
err instanceof Error ? err.message : String(err)
|
|
521
600
|
}`,
|
|
522
601
|
);
|
|
@@ -526,11 +605,12 @@ export function freshCall(
|
|
|
526
605
|
ok: true,
|
|
527
606
|
receipt: {
|
|
528
607
|
serverPid: placement.serverPid,
|
|
529
|
-
sessionId:
|
|
608
|
+
sessionId: targetSessionId,
|
|
530
609
|
...fields,
|
|
531
610
|
backend: params.backend,
|
|
532
611
|
model,
|
|
533
612
|
...(cwd === undefined ? {} : { cwd }),
|
|
613
|
+
...(seat === undefined ? {} : { tmuxSession: seat }),
|
|
534
614
|
runtimePath,
|
|
535
615
|
nonce,
|
|
536
616
|
},
|
|
@@ -557,6 +637,10 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
|
557
637
|
"cwd-missing":
|
|
558
638
|
"the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
|
|
559
639
|
"cwd-not-directory": "the requested cwd exists but is not a directory",
|
|
640
|
+
"tmux-session-name-invalid":
|
|
641
|
+
"the requested tmux session name is outside the shape this rail addresses (start with a letter or digit, then letters, digits, '_' or '-') — some other shapes tmux cannot resolve at all ('#' is expanded when the name is stored; '.' and ':' are its own pane/window separators inside a target; a name like '$0' loses to the session id '$0'), and the rest are declined to keep one narrow grammar, so rename the session or open one whose name fits",
|
|
642
|
+
"tmux-session-missing":
|
|
643
|
+
"no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again",
|
|
560
644
|
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
561
645
|
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
562
646
|
"task-empty": "task is empty after trimming",
|
|
@@ -592,6 +676,9 @@ export function renderFreshCall(result: FreshCallResult): { text: string; isErro
|
|
|
592
676
|
` backend: ${r.backend} (${r.runtimePath})\n` +
|
|
593
677
|
` model: ${r.model} (requested on the runtime CLI)\n` +
|
|
594
678
|
(r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
|
|
679
|
+
(r.tmuxSession === undefined
|
|
680
|
+
? ""
|
|
681
|
+
: ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
|
|
595
682
|
` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
|
|
596
683
|
` pane: ${r.paneId} pid ${r.panePid}\n` +
|
|
597
684
|
` nonce: ${r.nonce}\n` +
|