@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.
@@ -78,6 +88,19 @@
78
88
 
79
89
  import { randomBytes } from "node:crypto";
80
90
  import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
91
+ import {
92
+ CODEX_CALLER_SEAT_HINT,
93
+ type CodexCallerSeatRejectReason,
94
+ resolveCodexCallerSeat,
95
+ } from "./codex-caller-seat.ts";
96
+ import {
97
+ CODEX_CALLER_PREFLIGHT_HINT,
98
+ CODEX_LAUNCH_CWD_PREFLIGHT_HINT,
99
+ CODEX_PREFLIGHT_HINT,
100
+ type CodexCallerPreflightRejectReason,
101
+ type CodexPreflightRejectReason,
102
+ codexLaunchCwdFreshPreflight,
103
+ } from "./codex-fresh-preflight.ts";
81
104
  import {
82
105
  COPILOT_PREFLIGHT_HINT,
83
106
  type CopilotPreflightRejectReason,
@@ -100,15 +123,16 @@ import {
100
123
  runTmux,
101
124
  type WindowHandle,
102
125
  } from "./mux-placement.ts";
126
+ import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.ts";
103
127
  import { OMP_PREFLIGHT_HINT, type OmpPreflightRejectReason, ompFreshPreflight } from "./omp-fresh-preflight.ts";
104
128
  import { classifyTmuxSessionName, resolveTmuxSessionId, type TmuxSessionRejectReason } from "./resolve-tmux-session.ts";
105
129
 
106
130
  /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
107
- * not a config entry. `copilot` was added by #82 RAIL 9 under the step 9 admission contract, and
108
- * `omp` by #87 Bundle C under the same one. The set is joined to the citizen backends by
109
- * `check-harness-admission-parity`: a harness that mints records but is missing HERE is not an
110
- * unwired convenience, it is a release blocker. */
111
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp"] as const;
131
+ * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
132
+ * by #95 after its system birth and app-server rails were measured. The set is joined to the
133
+ * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
134
+ * missing HERE is not an unwired convenience, it is a release blocker. */
135
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"] as const;
112
136
  export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
113
137
 
114
138
  /**
@@ -138,6 +162,7 @@ export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
138
162
  "claude-code": "claude",
139
163
  copilot: "entwurf",
140
164
  omp: "omp",
165
+ codex: "codex",
141
166
  };
142
167
 
143
168
  /**
@@ -166,6 +191,7 @@ export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
166
191
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
167
192
  copilot: "entwurf-bridge-entwurf_v2",
168
193
  omp: "mcp__entwurf_bridge_entwurf_v",
194
+ codex: "mcp__entwurf_bridge__entwurf_v2",
169
195
  };
170
196
 
171
197
  /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
@@ -271,11 +297,37 @@ export function isSafeFreshCallModel(model: string): boolean {
271
297
  * have needed its own quoting, its own lifetime and its own refusal rules; argv already owns
272
298
  * all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
273
299
  * carrier would hand callers the launch-shaping power this rail exists to refuse.
300
+ *
301
+ * CODEX ALONE CARRIES A DIRECTORY IN ITS ARGV, AND OMITTING IT IS A WRONG ANSWER RATHER THAN A
302
+ * NEUTRAL ONE. `[source rust-v0.153.4]` an explicit `--remote <endpoint>` — which this argv
303
+ * always passes — resolves to `AppServerTarget::Remote` (`codex-rs/tui/src/lib.rs:875-876`;
304
+ * `LocalDaemon` is only the IMPLICIT no-flag reuse path), and a Remote target takes its new
305
+ * thread's directory from `remote_cwd_override` ALONE: `thread_cwd_from_config`
306
+ * (`codex-rs/tui/src/app_server_session.rs:2022-2033`) answers `None` without it, and the
307
+ * app-server then opens the thread in ITS OWN directory. That override is exactly `-C/--cd`,
308
+ * retained only for a remote target (`codex-rs/tui/src/startup_orchestration.rs:191-194`), and
309
+ * the flag lives on the shared interactive options this argv already uses for
310
+ * `--dangerously-bypass-approvals-and-sandbox` (`codex-rs/utils/cli/src/shared_options.rs:53-68`)
311
+ * — not only on the `agents` subcommand that also spells it (`codex-rs/cli/src/main.rs:334`).
312
+ *
313
+ * `[측정 2026-09-16]` with the flag absent, three citizens of one chain (pi → Codex → Claude
314
+ * Code) all recorded the app-server's `~/repos/gh/entwurf` while the panes themselves sat in
315
+ * `~/repos/gh/agent-config`, and the birth hook wrote that vendor-supplied cwd into each record
316
+ * honestly (#95 lane C §1). So the token is ALWAYS present for codex: ONE directory, TWO
317
+ * CARRIERS — tmux `-c` places the PANE, codex `-C` places the THREAD, and `launchCwd` is the
318
+ * single value both receive. It is not a new input axis: the value is chosen by the cwd rules
319
+ * this module already has, one layer up.
274
320
  */
275
321
  export function buildBackendArgs(
276
322
  backend: FreshCallBackend,
277
323
  composition: FreshCallComposition,
278
324
  model: string,
325
+ env: NodeJS.ProcessEnv = process.env,
326
+ /** Where the sibling will actually start: the directory `freshCall` chose when it chose one,
327
+ * and otherwise THIS process's own — which is what tmux gives a window opened with no `-c`
328
+ * (`[측정 ×2]`, module header). The default is that inherited fact rather than a
329
+ * convenience, so a caller that omits it still names the truth to codex. */
330
+ launchCwd: string = process.cwd(),
279
331
  ): string[] {
280
332
  switch (backend) {
281
333
  case "pi":
@@ -286,11 +338,22 @@ export function buildBackendArgs(
286
338
  return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
287
339
  case "omp":
288
340
  return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
341
+ case "codex":
342
+ return [
343
+ "--remote",
344
+ `unix://${resolveCodexDefaultSocketPath(env)}`,
345
+ "-C",
346
+ launchCwd,
347
+ "--model",
348
+ model,
349
+ "--dangerously-bypass-approvals-and-sandbox",
350
+ composition.prompt,
351
+ ];
289
352
  }
290
353
  }
291
354
 
292
355
  /**
293
- * What a launch has to say, in the two shapes the four backends need. Three of them are
356
+ * What a launch has to say, in the two shapes the five backends need. Four of them are
294
357
  * handed a first-turn PROMPT; omp is handed a bootstrap PAYLOAD its own installed extension
295
358
  * unpacks. Both are always built, because building one is cheap and a backend switch must
296
359
  * never be able to reach a field that was not composed.
@@ -375,6 +438,9 @@ export type FreshCallRejectReason =
375
438
  | TmuxSessionRejectReason
376
439
  | CopilotPreflightRejectReason
377
440
  | OmpPreflightRejectReason
441
+ | CodexPreflightRejectReason
442
+ | CodexCallerPreflightRejectReason
443
+ | CodexCallerSeatRejectReason
378
444
  | "caller-identity-unavailable"
379
445
  | "model-empty"
380
446
  | "model-invalid"
@@ -389,20 +455,66 @@ export interface FreshCallPlacement {
389
455
  tmuxSession: string;
390
456
  }
391
457
 
458
+ /** Which rule chose the start directory. Mirrors `FreshCallSeatSource` deliberately: one axis,
459
+ * one NAMED source, and no third value that means "we worked it out". `codex-caller-record` is
460
+ * the codex caller's own record cwd, consulted only when the caller requested none (#95 lane C).
461
+ */
462
+ export type FreshCallCwdSource = "requested" | "codex-caller-record";
463
+
464
+ export type FreshCallSeatSource = "requested" | "codex-title-anchor";
465
+ export interface FreshCallSeat {
466
+ tmuxSession: string;
467
+ source: FreshCallSeatSource;
468
+ }
469
+
470
+ /**
471
+ * WHICH RULE PICKS THE TARGET SESSION. Three in strict order, and the order is the contract:
472
+ *
473
+ * 1. an explicit `placement` — the expert override, and it wins over everything below.
474
+ * 2. a CODEX CALLER with placement omitted — the sibling opens beside the caller's own TUI
475
+ * pane, found by the title anchor (#95 lane B). This one is NOT a named seat and is
476
+ * therefore not decided here: it resolves to a native `$id` with no name in between, so
477
+ * `freshCall` owns it directly (see the anchor step below) and this function answers
478
+ * `null` so the name grammar and the name→id lookup stay out of a path that has neither.
479
+ * 3. anything else — the caller's own session, with no named seat at all.
480
+ *
481
+ * THE SEAT FOLLOWS THE CALLER, NEVER THE BACKEND BEING OPENED. #95 first shipped a fourth rule —
482
+ * an omitted-placement Codex TARGET selected a fixed existing session named `codex`, where the
483
+ * operator seated the app-server and their TUIs — and #95 D1 retired it (GLG, 2026-09-16). It
484
+ * was a workaround for a mapping that did not exist yet: nothing could find the pane a Codex
485
+ * caller was sitting in, so the operator was asked to keep every Codex in one known room. Rule 2
486
+ * is that mapping, so the room is no longer load-bearing, and keeping it would have meant Codex
487
+ * alone answering "where does a sibling open?" differently from every other backend. The
488
+ * operator-owned app-server keeps its own seat wherever the operator puts it; Entwurf still
489
+ * never creates, moves or supervises it.
490
+ */
491
+ export function selectFreshCallSeat(placement: FreshCallPlacement | undefined): FreshCallSeat | null {
492
+ if (placement !== undefined) return { tmuxSession: placement.tmuxSession, source: "requested" };
493
+ return null;
494
+ }
495
+
392
496
  /** Coordinates plus what was handed to tmux. Read `runtimePath` as "what we asked to start".
393
497
  * There is deliberately NO field here for the callback, the nonce's arrival, or the sibling's
394
498
  * garden id — see the module header. */
395
499
  export interface FreshCallReceipt extends WindowHandle {
396
500
  backend: FreshCallBackend;
397
501
  model: string;
398
- /** The REQUESTED start directory — present only when the caller supplied one. The same kind
399
- * of fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
400
- * landed. */
502
+ /** The start directory this launch CHOSE — present only when one was chosen: the caller's
503
+ * requested cwd, or a codex caller's own record cwd when it requested none. The same kind of
504
+ * fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
505
+ * landed. ABSENT means no directory was named at all and the pane inherits this process's —
506
+ * that inheritance is not invented into a receipt field here. */
401
507
  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. */
508
+ /** Which rule chose `cwd`. Present exactly when `cwd` is. */
509
+ cwdSource?: FreshCallCwdSource;
510
+ /** The caller-REQUESTED session name. The RESOLVED target is the inherited `sessionId`, which
511
+ * is the session the window is actually in. Absent for `codex-title-anchor`, which has no
512
+ * name to echo: a caller's own pane was OBSERVED, not requested by name, and inventing one
513
+ * here would report a seat the caller never named. */
405
514
  tmuxSession?: string;
515
+ /** Which rule selected the target session. Absent exactly when the caller's own session was
516
+ * used with no seat rule at all. */
517
+ tmuxSessionSource?: FreshCallSeatSource;
406
518
  runtimePath: string;
407
519
  nonce: string;
408
520
  }
@@ -435,9 +547,9 @@ function defaultRandomHex(): string {
435
547
  * for. That is sufficient and not a compromise: every reader of the carrier trims and tests
436
548
  * truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
437
549
  *
438
- * It is applied to all four backends because the leak is a property of tmux, not of a vendor. A
550
+ * It is applied to all five backends because the leak is a property of tmux, not of a vendor. A
439
551
  * scrub only on the backend whose measurement surfaced it would encode the claim that the other
440
- * three are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
552
+ * four are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
441
553
  * authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
442
554
  * exactly that after this argv has run. This is a fixed two-variable seam and deliberately NOT a
443
555
  * general env carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping
@@ -492,6 +604,20 @@ export function buildFreshCallArgs(
492
604
  * `callerGardenId` is supplied by the SURFACE that registered this tool, from its own
493
605
  * record-backed context. It is not a tool parameter and this module never derives, validates
494
606
  * against a store, or guesses it: an empty value is a named refusal, not a lookup.
607
+ *
608
+ * `callerNativeSessionId` is supplied by the same surface under the same rule, and its PRESENCE
609
+ * is the whole signal: it is set exactly when the reconciled sender is a record-backed codex
610
+ * citizen, and it carries that citizen's `nativeSessionId` (the `_meta.threadId` the vendor put
611
+ * on this very request). This module never resolves it, never reads `_meta`, and never asks a
612
+ * store who is calling — it only turns a thread id into a pane, and only for placement.
613
+ *
614
+ * `callerCwd` rides the same surface rule and the same condition: it is that codex citizen's
615
+ * RECORD cwd, and it exists because a codex caller's process directory is NOT its own (the
616
+ * bridge runs as a child of the operator-owned app-server, so the directory that process
617
+ * reports is the app-server's — #95 lane C §2). It is consulted only when the caller requested
618
+ * no cwd, so an
619
+ * explicit request always wins, and this module never looks a cwd up, resolves it, or infers it
620
+ * from a seat, a workspace map or a project name.
495
621
  */
496
622
  export function freshCall(
497
623
  params: {
@@ -501,6 +627,8 @@ export function freshCall(
501
627
  cwd?: string;
502
628
  placement?: FreshCallPlacement;
503
629
  callerGardenId: string | null;
630
+ callerNativeSessionId?: string;
631
+ callerCwd?: string;
504
632
  },
505
633
  env: NodeJS.ProcessEnv = process.env,
506
634
  nonce: string = mintNonce(),
@@ -516,8 +644,27 @@ export function freshCall(
516
644
  if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
517
645
  // ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
518
646
  // value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
519
- // classification below instead of being silently repaired into a different directory.
520
- const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
647
+ // classification below instead of being silently repaired into a different directory. The
648
+ // caller's own record directory answers the SAME two-value emptiness rule and is consulted
649
+ // ONLY second: an explicit request always wins, and a caller that supplies neither leaves the
650
+ // pane to inherit this process's directory exactly as before — a pi caller's argv is
651
+ // byte-identical, because its process directory IS its own and a `-c` token would change
652
+ // nothing about where that window lands.
653
+ const requestedCwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
654
+ const callerCwd = params.callerCwd === undefined || params.callerCwd === "" ? undefined : params.callerCwd;
655
+ const chosenCwd: { value: string; source: FreshCallCwdSource } | undefined =
656
+ requestedCwd !== undefined
657
+ ? { value: requestedCwd, source: "requested" }
658
+ : callerCwd !== undefined
659
+ ? { value: callerCwd, source: "codex-caller-record" }
660
+ : undefined;
661
+ const cwd = chosenCwd?.value;
662
+ // SEAM (#95 lane C): a caller-record directory is classified by the SAME shared leaf and
663
+ // answers the same four `cwd-*` reasons, whose hint text says REQUESTED. When a codex
664
+ // caller's recorded directory has since been deleted, the repair that hint points at is
665
+ // still the right one — that directory does not exist — but the noun belongs to the caller
666
+ // rather than to the request. Kept shared on purpose: doubling the reason set for a wording
667
+ // difference would double the refusal contract two surfaces and one leaf already agree on.
521
668
  if (cwd !== undefined) {
522
669
  const badCwd = classifyTmuxCwd(cwd);
523
670
  if (badCwd) return { ok: false, reason: badCwd };
@@ -526,8 +673,11 @@ export function freshCall(
526
673
  // without tmux, so an unresolvable name is answered before anything else runs. Whether that
527
674
  // session EXISTS is a tmux question and is asked below, after the caller's own context is
528
675
  // 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;
676
+ // host with no tmux at all. An explicit seat is an expert override and is reported as such; a
677
+ // CODEX CALLER's own pane is deliberately not a name at all, so it is absent here and
678
+ // resolved after the context proof below — see `selectFreshCallSeat` for the three-rule order.
679
+ const selectedSeat = selectFreshCallSeat(params.placement);
680
+ const seat = selectedSeat?.tmuxSession;
531
681
  if (seat !== undefined) {
532
682
  const badSeat = classifyTmuxSessionName(seat);
533
683
  if (badSeat) return { ok: false, reason: badSeat };
@@ -555,7 +705,6 @@ export function freshCall(
555
705
  const missing = ompFreshPreflight(env);
556
706
  if (missing) return { ok: false, reason: missing };
557
707
  }
558
-
559
708
  const inspected = inspectPlacement(env);
560
709
  if (!inspected.ok) return { ok: false, reason: inspected.reason };
561
710
  const placement = inspected.placement;
@@ -566,10 +715,24 @@ export function freshCall(
566
715
  // native id continues; the name does not travel past this line. STILL PRE-MUTATION: an
567
716
  // absent seat refuses with no window anywhere.
568
717
  let targetSessionId = placement.sessionId;
718
+ let anchoredSeat = false;
569
719
  if (seat !== undefined) {
570
720
  const resolved = resolveTmuxSessionId(seat, (args) => runTmux(args, env));
571
721
  if (!resolved.ok) return { ok: false, reason: resolved.reason };
572
722
  targetSessionId = resolved.sessionId;
723
+ } else if (params.placement === undefined && params.callerNativeSessionId !== undefined) {
724
+ // Rule 2: the caller is a codex citizen and named no seat, so the sibling belongs beside
725
+ // the caller's own TUI. The pane is found by the title anchor and ONLY its `$session`
726
+ // continues — a pane title is forgeable, so it may never become an address, a liveness
727
+ // claim or a delivery input (Hard Rule 16). `params.placement` is re-read here rather
728
+ // than inferred from `seat === undefined`: "an explicit seat always wins" is the one
729
+ // invariant a later edit must not be able to lose by accident.
730
+ // STILL PRE-MUTATION: 0 or 2+ matching panes refuse with no window anywhere and no
731
+ // fallback to any other session.
732
+ const anchor = resolveCodexCallerSeat(params.callerNativeSessionId, (args) => runTmux(args, env));
733
+ if (!anchor.ok) return { ok: false, reason: anchor.reason };
734
+ targetSessionId = anchor.seat.sessionId;
735
+ anchoredSeat = true;
573
736
  }
574
737
 
575
738
  const composition: FreshCallComposition = {
@@ -581,10 +744,38 @@ export function freshCall(
581
744
  }),
582
745
  bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
583
746
  };
584
- const run = runTmux(
585
- buildFreshCallArgs(targetSessionId, runtimePath, buildBackendArgs(params.backend, composition, model), cwd),
586
- env,
587
- );
747
+ const backendArgs = buildBackendArgs(params.backend, composition, model, env, cwd);
748
+ // THE LAUNCH-DIRECTORY NOTE, AND IT IS A DIAGNOSTIC RATHER THAN A GATE. `[측정 2026-09-16]` a
749
+ // Codex sibling opened into a directory this Codex has no answer for stops on the vendor's
750
+ // folder-consent screen: no first turn, no rollout, no callback. It is tempting to refuse
751
+ // that, and refusing is the wrong product. The consent screen is SELF-REPAIRING when a human
752
+ // is there — one answer and the vendor records the directory, so every later launch runs —
753
+ // and an operator at the keyboard is exactly who a visible-first rail is built for. A refusal
754
+ // would replace that one answer with "no window, go run codex yourself, then call again", and
755
+ // it would have to be right about a decision this process cannot fully see (the vendor merges
756
+ // system, managed and cloud layers around the file this leaf reads). So the launch proceeds
757
+ // and says what it saw.
758
+ //
759
+ // The UNATTENDED case is not answered here and must not be: a gate with nobody at the keyboard
760
+ // needs its precondition named before it spends a model turn, which is its own oracle's job —
761
+ // `smoke-codex-fresh-live` asserts this same leaf up front, so a missing answer reads as a
762
+ // named precondition instead of a callback timeout.
763
+ //
764
+ // The directory asked about is READ BACK off codex's own `-C` token rather than recomputed:
765
+ // one resolution, one authority, and no way for the note to name a directory the thread will
766
+ // not start in (this module is deliberately not allowed to resolve the inherited default a
767
+ // second time — `FRESHCALL-CWD-CALLER-ONLY`).
768
+ if (params.backend === "codex") {
769
+ const at = backendArgs.indexOf("-C");
770
+ const launchCwd = backendArgs[at + 1] ?? "";
771
+ const unanswered = codexLaunchCwdFreshPreflight(env, launchCwd);
772
+ if (unanswered) {
773
+ console.error(
774
+ `[fresh-call] ${unanswered}: ${launchCwd}\n` + ` ${CODEX_LAUNCH_CWD_PREFLIGHT_HINT[unanswered]}`,
775
+ );
776
+ }
777
+ }
778
+ const run = runTmux(buildFreshCallArgs(targetSessionId, runtimePath, backendArgs, cwd), env);
588
779
  assertTmuxOk("new-window", run);
589
780
 
590
781
  let fields: ReturnType<typeof parseWindowFields>;
@@ -609,8 +800,12 @@ export function freshCall(
609
800
  ...fields,
610
801
  backend: params.backend,
611
802
  model,
612
- ...(cwd === undefined ? {} : { cwd }),
613
- ...(seat === undefined ? {} : { tmuxSession: seat }),
803
+ ...(chosenCwd === undefined ? {} : { cwd: chosenCwd.value, cwdSource: chosenCwd.source }),
804
+ ...(selectedSeat === null
805
+ ? anchoredSeat
806
+ ? { tmuxSessionSource: "codex-title-anchor" as const }
807
+ : {}
808
+ : { tmuxSession: selectedSeat.tmuxSession, tmuxSessionSource: selectedSeat.source }),
614
809
  runtimePath,
615
810
  nonce,
616
811
  },
@@ -624,6 +819,9 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
624
819
  // the sentence an operator reads cannot drift away from the predicate that produced it.
625
820
  ...COPILOT_PREFLIGHT_HINT,
626
821
  ...OMP_PREFLIGHT_HINT,
822
+ ...CODEX_PREFLIGHT_HINT,
823
+ ...CODEX_CALLER_PREFLIGHT_HINT,
824
+ ...CODEX_CALLER_SEAT_HINT,
627
825
  "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
628
826
  "anchor-malformed": "TMUX_PANE is not a native pane id",
629
827
  "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
@@ -675,10 +873,16 @@ export function renderFreshCall(result: FreshCallResult): { text: string; isErro
675
873
  `[entwurf fresh call →]\n` +
676
874
  ` backend: ${r.backend} (${r.runtimePath})\n` +
677
875
  ` model: ${r.model} (requested on the runtime CLI)\n` +
678
- (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
679
- (r.tmuxSession === undefined
876
+ (r.cwd === undefined
877
+ ? ""
878
+ : r.cwdSource === "codex-caller-record"
879
+ ? ` cwd: ${r.cwd} (the Codex caller's own record directory, used because no cwd was requested — not an observation)\n`
880
+ : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
881
+ (r.tmuxSessionSource === undefined
680
882
  ? ""
681
- : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
883
+ : r.tmuxSessionSource === "codex-title-anchor"
884
+ ? ` seat: ${r.sessionId} (the Codex caller's own pane, found by its thread-id terminal title — an OBSERVED session, not a requested name)\n`
885
+ : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
682
886
  ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
683
887
  ` pane: ${r.paneId} pid ${r.panePid}\n` +
684
888
  ` 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):
@@ -26,6 +26,7 @@ import * as os from "node:os";
26
26
  import * as path from "node:path";
27
27
 
28
28
  import type { NativePushBackend } from "../entwurf-v2-contract.ts";
29
+ import { codexNativePushAdapter } from "./codex-ws-client.ts";
29
30
 
30
31
  // ── runner seam (injectable process exec) ───────────────────────────────────
31
32
 
@@ -85,14 +86,13 @@ export const realNativePushRunner: NativePushRunner = {
85
86
  // ── route + probe result ────────────────────────────────────────────────────
86
87
 
87
88
  /**
88
- * A VOLATILE native-push route the live LS address (`127.0.0.1:PORT`) a probe found
89
- * serving the conversation. MUST NOT be stored (봉인 3): it is re-derived every dispatch
90
- * by a fresh probe (the LS port is per-process and shifts). Carried only within a single
91
- * `send` call, handed straight from a fresh `probe`.
89
+ * A VOLATILE native-push route. Antigravity carries its live LS address; Codex carries
90
+ * the one app-server UDS that proved the target thread loaded. Routes are never stored:
91
+ * every dispatch derives one from a fresh probe and hands it straight to `send`.
92
92
  */
93
- export interface NativePushRoute {
94
- readonly lsAddress: string;
95
- }
93
+ export type NativePushRoute =
94
+ | { readonly backend: "antigravity"; readonly lsAddress: string }
95
+ | { readonly backend: "codex"; readonly socketPath: string };
96
96
 
97
97
  /**
98
98
  * A probe outcome. `alive` carries the volatile route; `dead`/`indeterminate` carry a
@@ -108,17 +108,12 @@ export type NativePushProbeResult =
108
108
  export interface NativePushAdapter {
109
109
  /** Backend discriminator (a member of NATIVE_PUSH_BACKENDS). */
110
110
  readonly id: NativePushBackend;
111
- /**
112
- * Full-scan probe: find a LIVE route serving `nativeSessionId`, else report
113
- * dead/indeterminate. Scans EVERY host pid (never `head -1`) and re-discovers the
114
- * route on every call (no cache — volatile-route discipline, 봉인 3).
115
- */
111
+ /** Whether the executor may re-probe and retry once after an ambiguous send failure.
112
+ * Codex is false: `codex queue` may accept before its receipt fails, so retry can duplicate. */
113
+ readonly retriable: boolean;
114
+ /** Probe the backend's current native route for `nativeSessionId`. */
116
115
  probe(nativeSessionId: string): Promise<NativePushProbeResult>;
117
- /**
118
- * Direct-inject `content` into the conversation over `route`. Single attempt: throws
119
- * on failure (fail-loud). Does NOT probe and does NOT retry — the executor hand owns
120
- * re-probe/retry (봉인 3), so the adapter can never silently paper over a dead route.
121
- */
116
+ /** Direct-inject once through the just-probed route. Throws on failure. */
122
117
  send(route: NativePushRoute, nativeSessionId: string, content: string): Promise<void>;
123
118
  }
124
119
 
@@ -211,6 +206,7 @@ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePu
211
206
 
212
207
  return {
213
208
  id: "antigravity",
209
+ retriable: true,
214
210
 
215
211
  async probe(nativeSessionId) {
216
212
  let pids: number[];
@@ -237,7 +233,7 @@ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePu
237
233
  for (const pid of pids) {
238
234
  for (const lsAddress of portsByPid.get(pid) ?? []) {
239
235
  if (await servesConversation(lsAddress, nativeSessionId)) {
240
- return { status: "alive", route: { lsAddress } };
236
+ return { status: "alive", route: { backend: "antigravity", lsAddress } };
241
237
  }
242
238
  }
243
239
  }
@@ -251,12 +247,13 @@ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePu
251
247
  },
252
248
 
253
249
  async send(route, nativeSessionId, content) {
250
+ if (route.backend !== "antigravity") {
251
+ throw new Error(`antigravity adapter received ${route.backend} route`);
252
+ }
254
253
  const r = await runner.exec([binary, "agentapi", "send-message", nativeSessionId, content], {
255
254
  env: { ANTIGRAVITY_LS_ADDRESS: route.lsAddress },
256
255
  timeoutMs: AGY_SEND_TIMEOUT_MS,
257
256
  });
258
- // A non-zero code — including a timeout kill (124) on a stalled route — THROWS
259
- // (fail-loud); the executor hand owns the 1-shot re-probe→re-send on that throw.
260
257
  if (r.code !== 0) {
261
258
  throw new Error(
262
259
  `native-push send failed (agentapi send-message exit ${r.code}) via ${route.lsAddress}: ${
@@ -268,12 +265,12 @@ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePu
268
265
  };
269
266
  }
270
267
 
271
- /** The production antigravity adapter (real runner + env-resolved binary). */
268
+ /** Production adapters. */
272
269
  export const antigravityAdapter: NativePushAdapter = createAntigravityAdapter({ runner: realNativePushRunner });
273
270
 
274
271
  // ── registry + fail-fast resolver (mirror resolveAcpBackendAdapter) ──────────
275
272
 
276
- const ADAPTERS: readonly NativePushAdapter[] = [antigravityAdapter];
273
+ const ADAPTERS: readonly NativePushAdapter[] = [antigravityAdapter, codexNativePushAdapter];
277
274
 
278
275
  /**
279
276
  * Resolve the native-push adapter that owns backend `id`. Fail-fast, like