@junghanacs/entwurf 0.20.1 → 0.22.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 (111) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +6 -3
  3. package/CHANGELOG.md +473 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +332 -60
  6. package/README.md +100 -22
  7. package/VERIFY.md +93 -7
  8. package/docs/acp-backend-rail.md +0 -1
  9. package/docs/external-mcp-host.md +64 -33
  10. package/docs/setup-clean-host.md +151 -17
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  29. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  30. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  31. package/mcp/entwurf-bridge/src/index.ts +154 -116
  32. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  33. package/package.json +13 -4
  34. package/pi-extensions/entwurf-control.ts +71 -19
  35. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  36. package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
  37. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  38. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  39. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  41. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  42. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  43. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  44. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  46. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  47. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  48. package/pi-extensions/lib/mux-fresh-call.ts +233 -29
  49. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  50. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  51. package/pi-extensions/lib/native-push/register.ts +7 -9
  52. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  53. package/run.sh +342 -28
  54. package/scripts/check-agy-sender-identity.ts +1 -1
  55. package/scripts/check-codex-app-server-launch.ts +445 -0
  56. package/scripts/check-codex-birth-hook.ts +264 -0
  57. package/scripts/check-codex-bridge-identity.ts +179 -0
  58. package/scripts/check-codex-native-push.ts +386 -0
  59. package/scripts/check-codex-sender-identity.ts +495 -0
  60. package/scripts/check-compaction-send-guard.ts +130 -0
  61. package/scripts/check-copilot-receive-arm.ts +4 -1
  62. package/scripts/check-entwurf-fact-provider.ts +38 -0
  63. package/scripts/check-entwurf-peers-surface.ts +13 -1
  64. package/scripts/check-entwurf-self-address.ts +15 -16
  65. package/scripts/check-entwurf-v2-contract.ts +4 -3
  66. package/scripts/check-entwurf-v2-decider.ts +7 -5
  67. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  68. package/scripts/check-entwurf-v2-production.ts +245 -12
  69. package/scripts/check-entwurf-v2-runner.ts +1 -1
  70. package/scripts/check-entwurf-v2-send.ts +26 -7
  71. package/scripts/check-entwurf-v2-surface.ts +1 -1
  72. package/scripts/check-gate-qualification.ts +8 -3
  73. package/scripts/check-harness-admission-parity.ts +0 -1
  74. package/scripts/check-mux-launch-tmux.ts +345 -4
  75. package/scripts/check-native-push-adapter.ts +20 -16
  76. package/scripts/check-native-push-register.ts +5 -1
  77. package/scripts/check-release-gate-outcomes.ts +47 -1
  78. package/scripts/check-setup-qualification.sh +3 -1
  79. package/scripts/codex-app-server-launch.sh +275 -0
  80. package/scripts/codex-birth-doctor.sh +276 -0
  81. package/scripts/codex-birth-install.sh +414 -0
  82. package/scripts/codex-birth-uninstall.sh +170 -0
  83. package/scripts/codex-mcp-config.py +435 -0
  84. package/scripts/codex-socket-path.ts +33 -0
  85. package/scripts/codex-statusline-config.py +434 -0
  86. package/scripts/codex-terminal-title-config.py +500 -0
  87. package/scripts/codex_toml_io.py +653 -0
  88. package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
  89. package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
  90. package/scripts/lib/launch-receipt-windows.ts +46 -0
  91. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  92. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  93. package/scripts/mutants/codex-app-server-launch.json +157 -0
  94. package/scripts/mutants/codex-caller-seat.json +336 -0
  95. package/scripts/mutants/codex-native.json +838 -0
  96. package/scripts/mutants/compaction-send-guard.json +103 -0
  97. package/scripts/mutants/entwurf-peers.json +19 -0
  98. package/scripts/mutants/mux-fresh-call.json +93 -10
  99. package/scripts/mutants/omp-fresh.json +6 -4
  100. package/scripts/mutants/release-gate.json +13 -0
  101. package/scripts/mutants/v2-surface.json +75 -1
  102. package/scripts/raw-async-delivery/README.md +2 -1
  103. package/scripts/raw-codex-measure/README.md +114 -46
  104. package/scripts/smoke-agy-native-push-live.ts +3 -1
  105. package/scripts/smoke-codex-birth.sh +347 -0
  106. package/scripts/smoke-codex-config-state.sh +700 -0
  107. package/scripts/smoke-codex-fresh-live.ts +1426 -0
  108. package/scripts/smoke-codex-native-push-live.ts +75 -0
  109. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  110. package/scripts/smoke-setup-verdict.sh +125 -10
  111. package/scripts/tsconfig.json +1 -0
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * mux-fresh-call — open ONE visible sibling in the caller's own tmux server (its own session by
3
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
+ * the launch argv, and let it name itself back to the caller. A Codex CALLER is the one measured
5
+ * exception to "the caller's own session": it opens beside its own TUI pane, found by that
6
+ * pane's terminal title (#95 lane B), never by a TUI guess.
5
7
  *
6
8
  * ── Why this is a third module and not a parameter on the leaf ──
7
9
  *
@@ -38,6 +40,14 @@
38
40
  * byte-identical to the pre-#73 shape. Anything else is taken LITERALLY — no trim, no
39
41
  * realpath, no project-name resolution, no store/peers/record lookup. The caller is the
40
42
  * only cwd authority this module knows.
43
+ * - when a CODEX caller omits it, the surface supplies that citizen's own record cwd and it
44
+ * becomes an explicit `-c` (#95 lane C). This is not a second authority: it is the same
45
+ * caller, named more precisely, because a codex caller's PROCESS directory is the
46
+ * operator-owned app-server's rather than its own. Every other caller keeps the inherited
47
+ * directory and an unchanged argv.
48
+ * - the chosen value reaches codex TWICE, and that is one value with two carriers rather than
49
+ * two inputs: tmux `-c` places the pane, and codex `-C` places the THREAD, which a
50
+ * `--remote` attachment would otherwise take from the app-server (see `buildBackendArgs`).
41
51
  * - the value is classified by the shared `classify-tmux-cwd.ts` leaf BEFORE any mutation
42
52
  * (same four stable reasons as resume; the measured tmux 3.6a facts live on that leaf).
43
53
  * This module's hints phrase them as the REQUESTED cwd; resume's say RECORDED.
@@ -77,17 +87,20 @@
77
87
  */
78
88
  import { randomBytes } from "node:crypto";
79
89
  import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
90
+ import { CODEX_CALLER_SEAT_HINT, resolveCodexCallerSeat, } from "./codex-caller-seat.js";
91
+ import { CODEX_CALLER_PREFLIGHT_HINT, CODEX_LAUNCH_CWD_PREFLIGHT_HINT, CODEX_PREFLIGHT_HINT, codexLaunchCwdFreshPreflight, } from "./codex-fresh-preflight.js";
80
92
  import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-preflight.js";
81
93
  import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
82
94
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
95
+ import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.js";
83
96
  import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
84
97
  import { classifyTmuxSessionName, resolveTmuxSessionId } from "./resolve-tmux-session.js";
85
98
  /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
86
- * not a config entry. `copilot` was added by #82 RAIL 9 under the step 9 admission contract, and
87
- * `omp` by #87 Bundle C under the same one. The set is joined to the citizen backends by
88
- * `check-harness-admission-parity`: a harness that mints records but is missing HERE is not an
89
- * unwired convenience, it is a release blocker. */
90
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp"];
99
+ * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
100
+ * by #95 after its system birth and app-server rails were measured. The set is joined to the
101
+ * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
102
+ * missing HERE is not an unwired convenience, it is a release blocker. */
103
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"];
91
104
  /**
92
105
  * The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
93
106
  * than a compiled-in location: the official binary is whatever the operator's environment gives
@@ -115,6 +128,7 @@ export const FRESH_CALL_RUNTIME = {
115
128
  "claude-code": "claude",
116
129
  copilot: "entwurf",
117
130
  omp: "omp",
131
+ codex: "codex",
118
132
  };
119
133
  /**
120
134
  * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
@@ -142,6 +156,7 @@ export const FRESH_CALL_CALLBACK_TOOL = {
142
156
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
143
157
  copilot: "entwurf-bridge-entwurf_v2",
144
158
  omp: "mcp__entwurf_bridge_entwurf_v",
159
+ codex: "mcp__entwurf_bridge__entwurf_v2",
145
160
  };
146
161
  /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
147
162
  * delivery surface, not a claim that a task of this size was measured through tmux. An argv
@@ -244,8 +259,33 @@ export function isSafeFreshCallModel(model) {
244
259
  * have needed its own quoting, its own lifetime and its own refusal rules; argv already owns
245
260
  * all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
246
261
  * carrier would hand callers the launch-shaping power this rail exists to refuse.
262
+ *
263
+ * CODEX ALONE CARRIES A DIRECTORY IN ITS ARGV, AND OMITTING IT IS A WRONG ANSWER RATHER THAN A
264
+ * NEUTRAL ONE. `[source rust-v0.153.4]` an explicit `--remote <endpoint>` — which this argv
265
+ * always passes — resolves to `AppServerTarget::Remote` (`codex-rs/tui/src/lib.rs:875-876`;
266
+ * `LocalDaemon` is only the IMPLICIT no-flag reuse path), and a Remote target takes its new
267
+ * thread's directory from `remote_cwd_override` ALONE: `thread_cwd_from_config`
268
+ * (`codex-rs/tui/src/app_server_session.rs:2022-2033`) answers `None` without it, and the
269
+ * app-server then opens the thread in ITS OWN directory. That override is exactly `-C/--cd`,
270
+ * retained only for a remote target (`codex-rs/tui/src/startup_orchestration.rs:191-194`), and
271
+ * the flag lives on the shared interactive options this argv already uses for
272
+ * `--dangerously-bypass-approvals-and-sandbox` (`codex-rs/utils/cli/src/shared_options.rs:53-68`)
273
+ * — not only on the `agents` subcommand that also spells it (`codex-rs/cli/src/main.rs:334`).
274
+ *
275
+ * `[측정 2026-09-16]` with the flag absent, three citizens of one chain (pi → Codex → Claude
276
+ * Code) all recorded the app-server's `~/repos/gh/entwurf` while the panes themselves sat in
277
+ * `~/repos/gh/agent-config`, and the birth hook wrote that vendor-supplied cwd into each record
278
+ * honestly (#95 lane C §1). So the token is ALWAYS present for codex: ONE directory, TWO
279
+ * CARRIERS — tmux `-c` places the PANE, codex `-C` places the THREAD, and `launchCwd` is the
280
+ * single value both receive. It is not a new input axis: the value is chosen by the cwd rules
281
+ * this module already has, one layer up.
247
282
  */
248
- export function buildBackendArgs(backend, composition, model) {
283
+ export function buildBackendArgs(backend, composition, model, env = process.env,
284
+ /** Where the sibling will actually start: the directory `freshCall` chose when it chose one,
285
+ * and otherwise THIS process's own — which is what tmux gives a window opened with no `-c`
286
+ * (`[측정 ×2]`, module header). The default is that inherited fact rather than a
287
+ * convenience, so a caller that omits it still names the truth to codex. */
288
+ launchCwd = process.cwd()) {
249
289
  switch (backend) {
250
290
  case "pi":
251
291
  return [composition.prompt, "--entwurf-control", "--model", model];
@@ -255,6 +295,17 @@ export function buildBackendArgs(backend, composition, model) {
255
295
  return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
256
296
  case "omp":
257
297
  return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
298
+ case "codex":
299
+ return [
300
+ "--remote",
301
+ `unix://${resolveCodexDefaultSocketPath(env)}`,
302
+ "-C",
303
+ launchCwd,
304
+ "--model",
305
+ model,
306
+ "--dangerously-bypass-approvals-and-sandbox",
307
+ composition.prompt,
308
+ ];
258
309
  }
259
310
  }
260
311
  /**
@@ -313,6 +364,32 @@ export function buildFreshCallPrompt(params) {
313
364
  params.task,
314
365
  ].join("\n");
315
366
  }
367
+ /**
368
+ * WHICH RULE PICKS THE TARGET SESSION. Three in strict order, and the order is the contract:
369
+ *
370
+ * 1. an explicit `placement` — the expert override, and it wins over everything below.
371
+ * 2. a CODEX CALLER with placement omitted — the sibling opens beside the caller's own TUI
372
+ * pane, found by the title anchor (#95 lane B). This one is NOT a named seat and is
373
+ * therefore not decided here: it resolves to a native `$id` with no name in between, so
374
+ * `freshCall` owns it directly (see the anchor step below) and this function answers
375
+ * `null` so the name grammar and the name→id lookup stay out of a path that has neither.
376
+ * 3. anything else — the caller's own session, with no named seat at all.
377
+ *
378
+ * THE SEAT FOLLOWS THE CALLER, NEVER THE BACKEND BEING OPENED. #95 first shipped a fourth rule —
379
+ * an omitted-placement Codex TARGET selected a fixed existing session named `codex`, where the
380
+ * operator seated the app-server and their TUIs — and #95 D1 retired it (GLG, 2026-09-16). It
381
+ * was a workaround for a mapping that did not exist yet: nothing could find the pane a Codex
382
+ * caller was sitting in, so the operator was asked to keep every Codex in one known room. Rule 2
383
+ * is that mapping, so the room is no longer load-bearing, and keeping it would have meant Codex
384
+ * alone answering "where does a sibling open?" differently from every other backend. The
385
+ * operator-owned app-server keeps its own seat wherever the operator puts it; Entwurf still
386
+ * never creates, moves or supervises it.
387
+ */
388
+ export function selectFreshCallSeat(placement) {
389
+ if (placement !== undefined)
390
+ return { tmuxSession: placement.tmuxSession, source: "requested" };
391
+ return null;
392
+ }
316
393
  /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
317
394
  * encoded any of those would invite exactly the guessing this rail exists to refuse. */
318
395
  export function mintNonce(randomHex = defaultRandomHex) {
@@ -337,9 +414,9 @@ function defaultRandomHex() {
337
414
  * for. That is sufficient and not a compromise: every reader of the carrier trims and tests
338
415
  * truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
339
416
  *
340
- * It is applied to all four backends because the leak is a property of tmux, not of a vendor. A
417
+ * It is applied to all five backends because the leak is a property of tmux, not of a vendor. A
341
418
  * scrub only on the backend whose measurement surfaced it would encode the claim that the other
342
- * three are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
419
+ * four are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
343
420
  * authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
344
421
  * exactly that after this argv has run. This is a fixed two-variable seam and deliberately NOT a
345
422
  * general env carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping
@@ -388,6 +465,20 @@ export function buildFreshCallArgs(targetSessionId, runtimePath, backendArgs, cw
388
465
  * `callerGardenId` is supplied by the SURFACE that registered this tool, from its own
389
466
  * record-backed context. It is not a tool parameter and this module never derives, validates
390
467
  * against a store, or guesses it: an empty value is a named refusal, not a lookup.
468
+ *
469
+ * `callerNativeSessionId` is supplied by the same surface under the same rule, and its PRESENCE
470
+ * is the whole signal: it is set exactly when the reconciled sender is a record-backed codex
471
+ * citizen, and it carries that citizen's `nativeSessionId` (the `_meta.threadId` the vendor put
472
+ * on this very request). This module never resolves it, never reads `_meta`, and never asks a
473
+ * store who is calling — it only turns a thread id into a pane, and only for placement.
474
+ *
475
+ * `callerCwd` rides the same surface rule and the same condition: it is that codex citizen's
476
+ * RECORD cwd, and it exists because a codex caller's process directory is NOT its own (the
477
+ * bridge runs as a child of the operator-owned app-server, so the directory that process
478
+ * reports is the app-server's — #95 lane C §2). It is consulted only when the caller requested
479
+ * no cwd, so an
480
+ * explicit request always wins, and this module never looks a cwd up, resolves it, or infers it
481
+ * from a seat, a workspace map or a project name.
391
482
  */
392
483
  export function freshCall(params, env = process.env, nonce = mintNonce()) {
393
484
  if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
@@ -405,8 +496,26 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
405
496
  return { ok: false, reason: "task-too-long" };
406
497
  // ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
407
498
  // value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
408
- // classification below instead of being silently repaired into a different directory.
409
- const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
499
+ // classification below instead of being silently repaired into a different directory. The
500
+ // caller's own record directory answers the SAME two-value emptiness rule and is consulted
501
+ // ONLY second: an explicit request always wins, and a caller that supplies neither leaves the
502
+ // pane to inherit this process's directory exactly as before — a pi caller's argv is
503
+ // byte-identical, because its process directory IS its own and a `-c` token would change
504
+ // nothing about where that window lands.
505
+ const requestedCwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
506
+ const callerCwd = params.callerCwd === undefined || params.callerCwd === "" ? undefined : params.callerCwd;
507
+ const chosenCwd = requestedCwd !== undefined
508
+ ? { value: requestedCwd, source: "requested" }
509
+ : callerCwd !== undefined
510
+ ? { value: callerCwd, source: "codex-caller-record" }
511
+ : undefined;
512
+ const cwd = chosenCwd?.value;
513
+ // SEAM (#95 lane C): a caller-record directory is classified by the SAME shared leaf and
514
+ // answers the same four `cwd-*` reasons, whose hint text says REQUESTED. When a codex
515
+ // caller's recorded directory has since been deleted, the repair that hint points at is
516
+ // still the right one — that directory does not exist — but the noun belongs to the caller
517
+ // rather than to the request. Kept shared on purpose: doubling the reason set for a wording
518
+ // difference would double the refusal contract two surfaces and one leaf already agree on.
410
519
  if (cwd !== undefined) {
411
520
  const badCwd = classifyTmuxCwd(cwd);
412
521
  if (badCwd)
@@ -416,8 +525,11 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
416
525
  // without tmux, so an unresolvable name is answered before anything else runs. Whether that
417
526
  // session EXISTS is a tmux question and is asked below, after the caller's own context is
418
527
  // 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;
528
+ // host with no tmux at all. An explicit seat is an expert override and is reported as such; a
529
+ // CODEX CALLER's own pane is deliberately not a name at all, so it is absent here and
530
+ // resolved after the context proof below — see `selectFreshCallSeat` for the three-rule order.
531
+ const selectedSeat = selectFreshCallSeat(params.placement);
532
+ const seat = selectedSeat?.tmuxSession;
421
533
  if (seat !== undefined) {
422
534
  const badSeat = classifyTmuxSessionName(seat);
423
535
  if (badSeat)
@@ -458,12 +570,28 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
458
570
  // native id continues; the name does not travel past this line. STILL PRE-MUTATION: an
459
571
  // absent seat refuses with no window anywhere.
460
572
  let targetSessionId = placement.sessionId;
573
+ let anchoredSeat = false;
461
574
  if (seat !== undefined) {
462
575
  const resolved = resolveTmuxSessionId(seat, (args) => runTmux(args, env));
463
576
  if (!resolved.ok)
464
577
  return { ok: false, reason: resolved.reason };
465
578
  targetSessionId = resolved.sessionId;
466
579
  }
580
+ else if (params.placement === undefined && params.callerNativeSessionId !== undefined) {
581
+ // Rule 2: the caller is a codex citizen and named no seat, so the sibling belongs beside
582
+ // the caller's own TUI. The pane is found by the title anchor and ONLY its `$session`
583
+ // continues — a pane title is forgeable, so it may never become an address, a liveness
584
+ // claim or a delivery input (Hard Rule 16). `params.placement` is re-read here rather
585
+ // than inferred from `seat === undefined`: "an explicit seat always wins" is the one
586
+ // invariant a later edit must not be able to lose by accident.
587
+ // STILL PRE-MUTATION: 0 or 2+ matching panes refuse with no window anywhere and no
588
+ // fallback to any other session.
589
+ const anchor = resolveCodexCallerSeat(params.callerNativeSessionId, (args) => runTmux(args, env));
590
+ if (!anchor.ok)
591
+ return { ok: false, reason: anchor.reason };
592
+ targetSessionId = anchor.seat.sessionId;
593
+ anchoredSeat = true;
594
+ }
467
595
  const composition = {
468
596
  prompt: buildFreshCallPrompt({
469
597
  backend: params.backend,
@@ -473,7 +601,36 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
473
601
  }),
474
602
  bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
475
603
  };
476
- const run = runTmux(buildFreshCallArgs(targetSessionId, runtimePath, buildBackendArgs(params.backend, composition, model), cwd), env);
604
+ const backendArgs = buildBackendArgs(params.backend, composition, model, env, cwd);
605
+ // THE LAUNCH-DIRECTORY NOTE, AND IT IS A DIAGNOSTIC RATHER THAN A GATE. `[측정 2026-09-16]` a
606
+ // Codex sibling opened into a directory this Codex has no answer for stops on the vendor's
607
+ // folder-consent screen: no first turn, no rollout, no callback. It is tempting to refuse
608
+ // that, and refusing is the wrong product. The consent screen is SELF-REPAIRING when a human
609
+ // is there — one answer and the vendor records the directory, so every later launch runs —
610
+ // and an operator at the keyboard is exactly who a visible-first rail is built for. A refusal
611
+ // would replace that one answer with "no window, go run codex yourself, then call again", and
612
+ // it would have to be right about a decision this process cannot fully see (the vendor merges
613
+ // system, managed and cloud layers around the file this leaf reads). So the launch proceeds
614
+ // and says what it saw.
615
+ //
616
+ // The UNATTENDED case is not answered here and must not be: a gate with nobody at the keyboard
617
+ // needs its precondition named before it spends a model turn, which is its own oracle's job —
618
+ // `smoke-codex-fresh-live` asserts this same leaf up front, so a missing answer reads as a
619
+ // named precondition instead of a callback timeout.
620
+ //
621
+ // The directory asked about is READ BACK off codex's own `-C` token rather than recomputed:
622
+ // one resolution, one authority, and no way for the note to name a directory the thread will
623
+ // not start in (this module is deliberately not allowed to resolve the inherited default a
624
+ // second time — `FRESHCALL-CWD-CALLER-ONLY`).
625
+ if (params.backend === "codex") {
626
+ const at = backendArgs.indexOf("-C");
627
+ const launchCwd = backendArgs[at + 1] ?? "";
628
+ const unanswered = codexLaunchCwdFreshPreflight(env, launchCwd);
629
+ if (unanswered) {
630
+ console.error(`[fresh-call] ${unanswered}: ${launchCwd}\n` + ` ${CODEX_LAUNCH_CWD_PREFLIGHT_HINT[unanswered]}`);
631
+ }
632
+ }
633
+ const run = runTmux(buildFreshCallArgs(targetSessionId, runtimePath, backendArgs, cwd), env);
477
634
  assertTmuxOk("new-window", run);
478
635
  let fields;
479
636
  try {
@@ -493,8 +650,12 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
493
650
  ...fields,
494
651
  backend: params.backend,
495
652
  model,
496
- ...(cwd === undefined ? {} : { cwd }),
497
- ...(seat === undefined ? {} : { tmuxSession: seat }),
653
+ ...(chosenCwd === undefined ? {} : { cwd: chosenCwd.value, cwdSource: chosenCwd.source }),
654
+ ...(selectedSeat === null
655
+ ? anchoredSeat
656
+ ? { tmuxSessionSource: "codex-title-anchor" }
657
+ : {}
658
+ : { tmuxSession: selectedSeat.tmuxSession, tmuxSessionSource: selectedSeat.source }),
498
659
  runtimePath,
499
660
  nonce,
500
661
  },
@@ -507,6 +668,9 @@ const REJECT_HINT = {
507
668
  // the sentence an operator reads cannot drift away from the predicate that produced it.
508
669
  ...COPILOT_PREFLIGHT_HINT,
509
670
  ...OMP_PREFLIGHT_HINT,
671
+ ...CODEX_PREFLIGHT_HINT,
672
+ ...CODEX_CALLER_PREFLIGHT_HINT,
673
+ ...CODEX_CALLER_SEAT_HINT,
510
674
  "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
511
675
  "anchor-malformed": "TMUX_PANE is not a native pane id",
512
676
  "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
@@ -550,10 +714,16 @@ export function renderFreshCall(result) {
550
714
  text: `[entwurf fresh call →]\n` +
551
715
  ` backend: ${r.backend} (${r.runtimePath})\n` +
552
716
  ` model: ${r.model} (requested on the runtime CLI)\n` +
553
- (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
554
- (r.tmuxSession === undefined
717
+ (r.cwd === undefined
718
+ ? ""
719
+ : r.cwdSource === "codex-caller-record"
720
+ ? ` cwd: ${r.cwd} (the Codex caller's own record directory, used because no cwd was requested — not an observation)\n`
721
+ : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
722
+ (r.tmuxSessionSource === undefined
555
723
  ? ""
556
- : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
724
+ : r.tmuxSessionSource === "codex-title-anchor"
725
+ ? ` seat: ${r.sessionId} (the Codex caller's own pane, found by its thread-id terminal title — an OBSERVED session, not a requested name)\n`
726
+ : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
557
727
  ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
558
728
  ` pane: ${r.paneId} pid ${r.panePid}\n` +
559
729
  ` nonce: ${r.nonce}\n` +
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * native-push adapter rail — the transport LEAF by which a native-push backend
3
- * (antigravity, the first) is (1) probed for a LIVE conversation and (2) direct-injected
4
- * with a message. Mirrors the ACP backend-adapter rail (acp/backend-adapter.ts §ADAPTERS
3
+ * is (1) probed for a LIVE native target and (2) direct-injected with a message.
4
+ * Mirrors the ACP backend-adapter rail (acp/backend-adapter.ts §ADAPTERS
5
5
  * /resolveAcpBackendAdapter): one interface, one registry, a fail-fast resolver.
6
6
  *
7
7
  * Purity contract (봉인 3):
@@ -23,6 +23,7 @@
23
23
  import { execFile } from "node:child_process";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
+ import { codexNativePushAdapter } from "./codex-ws-client.js";
26
27
  // The agy agentapi calls are bounded so a dead/stalled LS route cannot hang a dispatch
27
28
  // (raw-agy-send.sh used `timeout 8` — production had lost that; Q12 restores it). pgrep/ss
28
29
  // are fast local scans and stay unbounded.
@@ -120,6 +121,7 @@ export function createAntigravityAdapter(deps) {
120
121
  }
121
122
  return {
122
123
  id: "antigravity",
124
+ retriable: true,
123
125
  async probe(nativeSessionId) {
124
126
  let pids;
125
127
  try {
@@ -146,7 +148,7 @@ export function createAntigravityAdapter(deps) {
146
148
  for (const pid of pids) {
147
149
  for (const lsAddress of portsByPid.get(pid) ?? []) {
148
150
  if (await servesConversation(lsAddress, nativeSessionId)) {
149
- return { status: "alive", route: { lsAddress } };
151
+ return { status: "alive", route: { backend: "antigravity", lsAddress } };
150
152
  }
151
153
  }
152
154
  }
@@ -159,22 +161,23 @@ export function createAntigravityAdapter(deps) {
159
161
  };
160
162
  },
161
163
  async send(route, nativeSessionId, content) {
164
+ if (route.backend !== "antigravity") {
165
+ throw new Error(`antigravity adapter received ${route.backend} route`);
166
+ }
162
167
  const r = await runner.exec([binary, "agentapi", "send-message", nativeSessionId, content], {
163
168
  env: { ANTIGRAVITY_LS_ADDRESS: route.lsAddress },
164
169
  timeoutMs: AGY_SEND_TIMEOUT_MS,
165
170
  });
166
- // A non-zero code — including a timeout kill (124) on a stalled route — THROWS
167
- // (fail-loud); the executor hand owns the 1-shot re-probe→re-send on that throw.
168
171
  if (r.code !== 0) {
169
172
  throw new Error(`native-push send failed (agentapi send-message exit ${r.code}) via ${route.lsAddress}: ${r.stderr.trim() || "(no stderr)"}`);
170
173
  }
171
174
  },
172
175
  };
173
176
  }
174
- /** The production antigravity adapter (real runner + env-resolved binary). */
177
+ /** Production adapters. */
175
178
  export const antigravityAdapter = createAntigravityAdapter({ runner: realNativePushRunner });
176
179
  // ── registry + fail-fast resolver (mirror resolveAcpBackendAdapter) ──────────
177
- const ADAPTERS = [antigravityAdapter];
180
+ const ADAPTERS = [antigravityAdapter, codexNativePushAdapter];
178
181
  /**
179
182
  * Resolve the native-push adapter that owns backend `id`. Fail-fast, like
180
183
  * resolveAcpBackendAdapter: 0 matches → throw (unknown backend, no silent default);