@junghanacs/entwurf 0.18.2 → 0.20.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.
Files changed (77) hide show
  1. package/AGENTS.md +2 -2
  2. package/BASELINE.md +23 -5
  3. package/CHANGELOG.md +415 -0
  4. package/DELIVERY.md +59 -6
  5. package/README.md +43 -12
  6. package/VERIFY.md +10 -2
  7. package/docs/acp-backend-rail.md +9 -1
  8. package/docs/external-mcp-host.md +8 -5
  9. package/docs/setup-clean-host.md +93 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +18 -7
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +3 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +6 -2
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +76 -9
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +54 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resolve-tmux-session.js +114 -0
  16. package/mcp/entwurf-bridge/src/index.ts +22 -7
  17. package/package.json +2 -2
  18. package/pi-extensions/acp-provider.ts +1 -1
  19. package/pi-extensions/entwurf-control.ts +27 -3
  20. package/pi-extensions/lib/acp/models.ts +3 -3
  21. package/pi-extensions/lib/acp/overlay.ts +6 -2
  22. package/pi-extensions/lib/meta-session.ts +25 -0
  23. package/pi-extensions/lib/mux-fresh-call.ts +98 -11
  24. package/pi-extensions/lib/mux-placement.ts +65 -10
  25. package/pi-extensions/lib/resolve-tmux-session.ts +137 -0
  26. package/run.sh +71 -26
  27. package/scripts/agy-bridge.sh +1 -1
  28. package/scripts/agy-imprint.sh +10 -7
  29. package/scripts/check-acp-carrier-augment.ts +6 -5
  30. package/scripts/check-acp-cortex.ts +41 -0
  31. package/scripts/check-acp-provider-surface.ts +9 -6
  32. package/scripts/check-copilot-birth-hook.ts +3 -1
  33. package/scripts/check-copilot-receive-arm.ts +169 -21
  34. package/scripts/check-copilot-statusline.ts +3 -1
  35. package/scripts/check-fresh-cut-gate.sh +54 -2
  36. package/scripts/check-gate-qualification.ts +13 -12
  37. package/scripts/check-hook-launch-topology.ts +6 -5
  38. package/scripts/check-install-surface.ts +2 -1
  39. package/scripts/check-meta-facts.ts +6 -4
  40. package/scripts/check-meta-hook-session-switch.ts +4 -3
  41. package/scripts/check-meta-identity-consumers.ts +8 -7
  42. package/scripts/check-meta-receiver-marker.ts +2 -1
  43. package/scripts/check-mux-launch-tmux.ts +77 -2
  44. package/scripts/check-mux-launch.ts +17 -0
  45. package/scripts/check-mux-placement-tmux.ts +61 -1
  46. package/scripts/check-mux-placement.ts +33 -0
  47. package/scripts/check-omp-birth-hook.ts +3 -2
  48. package/scripts/check-probe-bridge-command.ts +3 -1
  49. package/scripts/check-setup-qualification.sh +21 -0
  50. package/scripts/copilot-bridge-doctor.sh +15 -5
  51. package/scripts/copilot-bridge-install.sh +6 -2
  52. package/scripts/copilot-launch.sh +16 -2
  53. package/scripts/copilot-receive-bridge.sh +80 -13
  54. package/scripts/lib/reclaim-on-exit.ts +86 -0
  55. package/scripts/meta-bridge-doctor.sh +56 -15
  56. package/scripts/meta-bridge-install.sh +13 -7
  57. package/scripts/meta-bridge-uninstall.sh +6 -4
  58. package/scripts/mutants/acp-cortex.json +26 -2
  59. package/scripts/mutants/copilot-receive.json +26 -0
  60. package/scripts/mutants/fresh-cut.json +26 -0
  61. package/scripts/mutants/mux-boundary.json +24 -0
  62. package/scripts/mutants/mux-fresh-call.json +81 -0
  63. package/scripts/mutants/omp-birth.json +33 -3
  64. package/scripts/mutants/setup-verdict.json +13 -0
  65. package/scripts/omp-bridge-doctor.sh +110 -31
  66. package/scripts/omp-bridge-install.sh +6 -2
  67. package/scripts/omp-receive-install.sh +7 -2
  68. package/scripts/raw-async-delivery/README.md +10 -1
  69. package/scripts/raw-codex-measure/README.md +689 -0
  70. package/scripts/raw-codex-measure/source-audit.md +243 -0
  71. package/scripts/raw-macos-measure/README.md +148 -0
  72. package/scripts/raw-macos-measure/probe.sh +389 -0
  73. package/scripts/smoke-meta-async-drift.sh +7 -5
  74. package/scripts/smoke-meta-install-state.sh +70 -19
  75. package/scripts/smoke-mux-fresh-call-live.ts +175 -3
  76. package/scripts/smoke-omp-bridge-state.sh +43 -2
  77. package/scripts/smoke-setup-verdict.sh +100 -0
@@ -1,6 +1,7 @@
1
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.
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
  import { randomBytes } from "node:crypto";
48
79
  import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
@@ -50,6 +81,7 @@ import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-
50
81
  import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
51
82
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
52
83
  import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
84
+ import { classifyTmuxSessionName, resolveTmuxSessionId } from "./resolve-tmux-session.js";
53
85
  /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
54
86
  * not a config entry. `copilot` was added by #82 RAIL 9 under the step 9 admission contract, and
55
87
  * `omp` by #87 Bundle C under the same one. The set is joined to the citizen backends by
@@ -316,9 +348,16 @@ function defaultRandomHex() {
316
348
  const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="];
317
349
  /** Launch argv: the leaf's detached-append shape, the identity scrub, optionally `-c` at the
318
350
  * resume-symmetric token position (after `-t`, before `-P -F`), the runtime, then the backend's
319
- * dialect. An omitted cwd adds no `-c` carrier at all. */
320
- export function buildFreshCallArgs(placement, runtimePath, backendArgs, cwd) {
321
- assertSelector("session", placement.sessionId);
351
+ * dialect. An omitted cwd adds no `-c` carrier at all.
352
+ *
353
+ * The first parameter is the TARGET session id, not the caller's placement. Since #105 those
354
+ * are not always the same session, and taking a `Placement` here would invite exactly the
355
+ * defect this signature prevents: copying the caller's own id into a cross-session launch. A
356
+ * name never reaches this function — the seat is resolved to a native `$id` before it is
357
+ * called, and `assertSelector` refuses anything that is not one. `-d` is not optional: without
358
+ * it a window opened into another session steals that session's focus (measured). */
359
+ export function buildFreshCallArgs(targetSessionId, runtimePath, backendArgs, cwd) {
360
+ assertSelector("session", targetSessionId);
322
361
  assertLaunchTarget(runtimePath);
323
362
  if (cwd !== undefined) {
324
363
  const bad = classifyTmuxCwd(cwd);
@@ -331,7 +370,7 @@ export function buildFreshCallArgs(placement, runtimePath, backendArgs, cwd) {
331
370
  "-a",
332
371
  ...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
333
372
  "-t",
334
- `${placement.sessionId}:{end}`,
373
+ `${targetSessionId}:{end}`,
335
374
  ...(cwd === undefined ? [] : ["-c", cwd]),
336
375
  "-P",
337
376
  "-F",
@@ -373,6 +412,17 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
373
412
  if (badCwd)
374
413
  return { ok: false, reason: badCwd };
375
414
  }
415
+ // The seat's NAME is classified here, beside the cwd and for the same reason: it is decidable
416
+ // without tmux, so an unresolvable name is answered before anything else runs. Whether that
417
+ // session EXISTS is a tmux question and is asked below, after the caller's own context is
418
+ // proven — a name check that needed a live server would refuse for the wrong reason on a
419
+ // host with no tmux at all.
420
+ const seat = params.placement?.tmuxSession;
421
+ if (seat !== undefined) {
422
+ const badSeat = classifyTmuxSessionName(seat);
423
+ if (badSeat)
424
+ return { ok: false, reason: badSeat };
425
+ }
376
426
  let runtimePath;
377
427
  try {
378
428
  runtimePath = resolveRuntimeOnPath(FRESH_CALL_RUNTIME[params.backend], env);
@@ -403,6 +453,17 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
403
453
  return { ok: false, reason: inspected.reason };
404
454
  const placement = inspected.placement;
405
455
  requireSameContext("freshCall", placement, env);
456
+ // The caller's own context is now proven, which is what makes the next lookup's exit code
457
+ // readable as "that session is not here" rather than "there is no server". Only the resolved
458
+ // native id continues; the name does not travel past this line. STILL PRE-MUTATION: an
459
+ // absent seat refuses with no window anywhere.
460
+ let targetSessionId = placement.sessionId;
461
+ if (seat !== undefined) {
462
+ const resolved = resolveTmuxSessionId(seat, (args) => runTmux(args, env));
463
+ if (!resolved.ok)
464
+ return { ok: false, reason: resolved.reason };
465
+ targetSessionId = resolved.sessionId;
466
+ }
406
467
  const composition = {
407
468
  prompt: buildFreshCallPrompt({
408
469
  backend: params.backend,
@@ -412,7 +473,7 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
412
473
  }),
413
474
  bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
414
475
  };
415
- const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, composition, model), cwd), env);
476
+ const run = runTmux(buildFreshCallArgs(targetSessionId, runtimePath, buildBackendArgs(params.backend, composition, model), cwd), env);
416
477
  assertTmuxOk("new-window", run);
417
478
  let fields;
418
479
  try {
@@ -422,17 +483,18 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
422
483
  // The window exists and its id is precisely what could not be read. Diffing the inventory
423
484
  // to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
424
485
  throw new Error(`mux-fresh-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
425
- `be open in session ${placement.sessionId} that this call cannot identify or close: ${err instanceof Error ? err.message : String(err)}`);
486
+ `be open in session ${targetSessionId} that this call cannot identify or close: ${err instanceof Error ? err.message : String(err)}`);
426
487
  }
427
488
  return {
428
489
  ok: true,
429
490
  receipt: {
430
491
  serverPid: placement.serverPid,
431
- sessionId: placement.sessionId,
492
+ sessionId: targetSessionId,
432
493
  ...fields,
433
494
  backend: params.backend,
434
495
  model,
435
496
  ...(cwd === undefined ? {} : { cwd }),
497
+ ...(seat === undefined ? {} : { tmuxSession: seat }),
436
498
  runtimePath,
437
499
  nonce,
438
500
  },
@@ -454,6 +516,8 @@ const REJECT_HINT = {
454
516
  "cwd-format-token": "the requested cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
455
517
  "cwd-missing": "the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
456
518
  "cwd-not-directory": "the requested cwd exists but is not a directory",
519
+ "tmux-session-name-invalid": "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",
520
+ "tmux-session-missing": "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",
457
521
  "model-empty": "model is empty after trimming; fresh calls require an explicit model",
458
522
  "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
459
523
  "task-empty": "task is empty after trimming",
@@ -487,6 +551,9 @@ export function renderFreshCall(result) {
487
551
  ` backend: ${r.backend} (${r.runtimePath})\n` +
488
552
  ` model: ${r.model} (requested on the runtime CLI)\n` +
489
553
  (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
554
+ (r.tmuxSession === undefined
555
+ ? ""
556
+ : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
490
557
  ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
491
558
  ` pane: ${r.paneId} pid ${r.panePid}\n` +
492
559
  ` nonce: ${r.nonce}\n` +
@@ -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, in the context it was opened in
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
- * and refuses unless the server pid and session id still match the ones the handle was
55
- * born in.
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, in the context it was opened in. Reports whether it was
272
- * closed or had already gone.
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
- requireSameContext("closeWindow", handle, env);
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 own tmux session,
30
- * optionally at ONE literal requested cwd (cross-repo fresh, #73);
31
- * returns a LAUNCH receipt only, and the new address arrives later as the
32
- * sender envelope of the sibling's nonce callback.
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 own tmux session and hand it a first task. Four fixed " +
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.18.2",
4
- "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
3
+ "version": "0.20.0",
4
+ "description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, and Antigravity; Claude and Snowflake Cortex Code ACP backends. Codex has verified delivery-probe evidence, not a managed native-citizen lane.",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Junghan Kim",
@@ -14,7 +14,7 @@
14
14
  // registry (backend-adapter.ts), NOT a per-backend list spelled here. Adding a
15
15
  // backend therefore never edits this file. check-acp-provider-surface pins that
16
16
  // the compiled entry really registers the EXACT union
17
- // ([QK:CORTEX-PROVIDER-SIX-ROW-SURFACE]).
17
+ // ([QK:CORTEX-PROVIDER-EXACT-ROW-SURFACE]).
18
18
  //
19
19
  // Fence: this entry rides the emit-capable root tsconfig (it is not in the root
20
20
  // `exclude` list); its lib modules are imported with `.js` suffixes (the root
@@ -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 own tmux session and hand it a first task. Four fixed
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. There are no
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: { backend: "pi" | "claude-code" | "copilot" | "omp"; model: string; task: string; cwd?: string },
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);
@@ -53,13 +53,13 @@ export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
53
53
  // both axes (protocol smoke + agent interview) — do not extend casually.
54
54
  // Exported so the claude backend adapter (backend-adapter.ts) can answer
55
55
  // `routeModel` without re-deriving the set from curatedClaudeModels().
56
- export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5"] as const;
56
+ export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1"] as const;
57
57
 
58
58
  /** The anchor model whose absence is a hard registry regression, not a soft skip. */
59
59
  export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
60
60
 
61
- // Anthropic's registry reports 1M for both Sonnet 5 and Opus 5, and the
62
- // entwurf surface exposes the full 1M for BOTH. Sonnet 5's 1M window is the
61
+ // Anthropic's registry reports 1M for Sonnet 5, Opus 5 and Fable 5.1, and the
62
+ // entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window is the
63
63
  // whole point of the 0.12.3 bump — it is the compact-free long-context floor the
64
64
  // earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
65
65
  // future registry value can't silently inflate the surface past what we verify.
@@ -46,7 +46,7 @@ import {
46
46
  writeFileSync,
47
47
  } from "node:fs";
48
48
  import { homedir } from "node:os";
49
- import { join } from "node:path";
49
+ import { isAbsolute, join, win32 } from "node:path";
50
50
 
51
51
  import type { AcpMcpServer } from "./config.js";
52
52
 
@@ -416,7 +416,11 @@ function cortexLinkIfExists(realPath: string, overlayPath: string): void {
416
416
  * Returns the isolated HOME/SNOWFLAKE_HOME the spawn env must carry.
417
417
  */
418
418
  export function ensureCortexDualHomeOverlay(params: CortexOverlayParams): CortexOverlayResult {
419
- if (!params.realHome || !params.realHome.startsWith("/")) {
419
+ // Absoluteness is judged in BOTH path flavors so the guard states the contract
420
+ // ("the parent captured an absolute HOME") instead of the host it happens to run
421
+ // on. This asserts nothing about native-Windows support; it only stops a POSIX
422
+ // host from silently accepting a drive/UNC path as "relative".
423
+ if (!params.realHome || !(isAbsolute(params.realHome) || win32.isAbsolute(params.realHome))) {
420
424
  throw new Error(
421
425
  `entwurf: cortex dual-HOME overlay requires an absolute realHome captured by the parent (got ${JSON.stringify(params.realHome)})`,
422
426
  );
@@ -1622,6 +1622,31 @@ export type MarkerOwnerVerdict = "live" | "dead" | "uncertain";
1622
1622
  * comparable to each other: `linux:<starttime ticks since boot>` and `ps:<lstart
1623
1623
  * wall-clock text>` describe the same process with different numbers, so a
1624
1624
  * mismatch ACROSS schemes says nothing about whether the process changed.
1625
+ *
1626
+ * They are also NOT equally resolved. `linux:` carries `/proc` clock ticks at
1627
+ * 10 ms granularity; `ps:` carries `lstart` wall-clock text at ONE-SECOND
1628
+ * granularity [measured, oracle 2026-09-09: two children spawned 50 ms apart
1629
+ * returned identical `lstart` strings, and widening to `lstart=,command=`
1630
+ * still collided when argv was identical]. Darwin has no `/proc`, so it ALWAYS
1631
+ * takes the `ps:` branch — there the pid-reuse defense window is 100x wider
1632
+ * than on Linux. 0.20.0 ships no key change anyway, and the numbers are why.
1633
+ * A same-second pid reuse on Darwin requires pid N to die, the whole
1634
+ * sequential pid space (PID_MAX 99999, wrapping after ~99,899 spawns and
1635
+ * skipping live pids) to wrap back around to N, and the replacement to start
1636
+ * in the SAME wall-clock second — a sustained ~99,900 spawns/second for a full
1637
+ * second. Measured on the reference host: 19.9 forks/second average under real
1638
+ * load, ~14,873 forks/second pathological synthetic peak (6.7x short even at
1639
+ * the synthetic peak), with Darwin's default `kern.maxproc` (~2,500) capping
1640
+ * churn in flight besides. The error direction is one-sided: a coarser clock
1641
+ * can only make more keys compare EQUAL, and {@link classifyMarkerOwner} grants
1642
+ * `live` on equal keys — coarsening can mint a false `live` (identity grant
1643
+ * fails OPEN) and can never mint a false `dead` (the generation cut stays
1644
+ * fail-CLOSED). The migration axis is empty by measurement, not argument: a
1645
+ * read-only scan of this host's real marker store found 1,248 marker files —
1646
+ * all `linux:`, zero `ps:` keys — because on Linux `/proc` always wins, so no
1647
+ * Linux install ever minted a `ps:` key; only a Darwin install can. A
1648
+ * Darwin-side hardening decision, if one is ever wanted, belongs with the
1649
+ * physical-Mac receipts (#78), not ahead of them.
1625
1650
  */
1626
1651
  export type StartKeyScheme = "linux" | "ps";
1627
1652