@junghanacs/entwurf 0.20.0 → 0.21.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 (112) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +6 -5
  3. package/CHANGELOG.md +220 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +202 -60
  6. package/README.md +54 -26
  7. package/VERIFY.md +37 -7
  8. package/docs/acp-backend-rail.md +31 -15
  9. package/docs/external-mcp-host.md +58 -35
  10. package/docs/fresh-cut-policy.md +3 -2
  11. package/docs/setup-clean-host.md +110 -17
  12. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +101 -109
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +6 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +326 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  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 +50 -15
  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/meta-bridge-fresh-cut.js +6 -1
  30. package/mcp/entwurf-bridge/src/index.ts +115 -111
  31. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  32. package/package.json +14 -5
  33. package/pi-extensions/entwurf-control.ts +71 -19
  34. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  35. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  36. package/pi-extensions/lib/acp/backend.ts +3 -3
  37. package/pi-extensions/lib/acp/event-mapper.ts +4 -4
  38. package/pi-extensions/lib/acp/tool-surface.ts +6 -0
  39. package/pi-extensions/lib/codex-fresh-preflight.ts +363 -0
  40. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  41. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  42. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  43. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  44. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  45. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  46. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  47. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  48. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  49. package/pi-extensions/lib/mux-fresh-call.ts +64 -19
  50. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  51. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  52. package/pi-extensions/lib/native-push/register.ts +7 -9
  53. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  54. package/run.sh +251 -35
  55. package/scripts/check-acp-usage-accounting.ts +9 -9
  56. package/scripts/check-agy-sender-identity.ts +1 -1
  57. package/scripts/check-codex-birth-hook.ts +264 -0
  58. package/scripts/check-codex-bridge-identity.ts +179 -0
  59. package/scripts/check-codex-native-push.ts +386 -0
  60. package/scripts/check-codex-sender-identity.ts +495 -0
  61. package/scripts/check-compaction-send-guard.ts +130 -0
  62. package/scripts/check-copilot-receive-arm.ts +4 -1
  63. package/scripts/check-entwurf-fact-provider.ts +38 -0
  64. package/scripts/check-entwurf-peers-surface.ts +13 -1
  65. package/scripts/check-entwurf-self-address.ts +15 -16
  66. package/scripts/check-entwurf-v2-contract.ts +4 -3
  67. package/scripts/check-entwurf-v2-decider.ts +7 -5
  68. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  69. package/scripts/check-entwurf-v2-production.ts +203 -11
  70. package/scripts/check-entwurf-v2-runner.ts +1 -1
  71. package/scripts/check-entwurf-v2-surface.ts +1 -1
  72. package/scripts/check-gate-qualification.ts +7 -4
  73. package/scripts/check-harness-admission-parity.ts +0 -1
  74. package/scripts/check-install-surface.ts +23 -7
  75. package/scripts/check-mux-launch-tmux.ts +47 -2
  76. package/scripts/check-native-push-adapter.ts +20 -16
  77. package/scripts/check-native-push-register.ts +5 -1
  78. package/scripts/check-release-gate-outcomes.ts +47 -1
  79. package/scripts/check-setup-qualification.sh +3 -1
  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-statusline-config.py +434 -0
  85. package/scripts/codex_toml_io.py +532 -0
  86. package/scripts/lib/codex-fresh-live-protocol.ts +113 -0
  87. package/scripts/lib/codex-fresh-source-receipts.ts +399 -0
  88. package/scripts/lib/launch-receipt-windows.ts +46 -0
  89. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  90. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  91. package/scripts/mutants/codex-native.json +838 -0
  92. package/scripts/mutants/compaction-send-guard.json +103 -0
  93. package/scripts/mutants/entwurf-peers.json +19 -0
  94. package/scripts/mutants/mux-fresh-call.json +19 -8
  95. package/scripts/mutants/omp-fresh.json +6 -4
  96. package/scripts/mutants/pi-package-ownership.json +26 -0
  97. package/scripts/mutants/release-gate.json +13 -0
  98. package/scripts/mutants/v2-surface.json +53 -1
  99. package/scripts/pi_settings_io.py +3 -1
  100. package/scripts/raw-async-delivery/README.md +2 -1
  101. package/scripts/raw-codex-measure/README.md +114 -46
  102. package/scripts/register-pi-package.py +38 -30
  103. package/scripts/register-pi-provider.py +3 -2
  104. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  105. package/scripts/smoke-agy-native-push-live.ts +3 -1
  106. package/scripts/smoke-codex-birth.sh +347 -0
  107. package/scripts/smoke-codex-config-state.sh +511 -0
  108. package/scripts/smoke-codex-fresh-live.ts +1186 -0
  109. package/scripts/smoke-codex-native-push-live.ts +75 -0
  110. package/scripts/smoke-setup-verdict.sh +123 -10
  111. package/scripts/smoke-user-scope-citizen.sh +199 -71
  112. package/scripts/tsconfig.json +1 -0
@@ -137,8 +137,9 @@ export interface ProductionEntwurfV2Seams {
137
137
  export interface ProductionEntwurfV2Opts {
138
138
  /** Built at the wiring site from `buildLocalSenderEnvelope(ctx)`, decorated with its
139
139
  * HONEST pi-session replyability (SE-1 2e-a: `replyable` reflects whether the canonical
140
- * control socket actually exists, not a hardcoded true). ONE provider feeds the
141
- * control-socket RPC sender AND the meta-mailbox body sender (they share the envelope). */
140
+ * control socket actually exists, not a hardcoded true). ONE provider feeds ALL THREE
141
+ * rails — the control-socket RPC sender, the meta-mailbox body sender, and the
142
+ * native-push injected body sender — so no rail can present a different who-sent-it. */
142
143
  senderProvider: () => SenderEnvelope | undefined;
143
144
  lockDir?: string;
144
145
  sessionsDir?: string;
@@ -339,7 +340,13 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
339
340
  sendMailbox: (plan, _lock) => sendViaMailbox(plan as MetaMailboxPlan, _lock as LockClaim),
340
341
  // native-push (봉인 4): the SAME injected adapter resolver drives the executor send,
341
342
  // so the decider's probe and the delivery use one adapter. Lock-free (lock ignored).
342
- sendNativePush: makeNativePushSend({ resolveAdapter: io.resolveNativePushAdapter }),
343
+ // It also receives the SAME `opts.senderProvider` the mailbox hand got — one envelope
344
+ // source for all three rails, so a native-push citizen can name its sender exactly as
345
+ // a mailbox citizen does (#95: an envelope-less direct injection is uncorrelatable).
346
+ sendNativePush: makeNativePushSend({
347
+ resolveAdapter: io.resolveNativePushAdapter,
348
+ senderProvider: opts.senderProvider,
349
+ }),
343
350
  };
344
351
 
345
352
  return {
@@ -100,11 +100,14 @@ export function actionableRejectHint(reason: string): string | undefined {
100
100
  "that garden id by a session that died without cleaning up."
101
101
  );
102
102
  case "native-push-target-dead":
103
- // The adapter probe found no live host process for the conversation.
104
- return "native-push conversation is not live (no host process found). Re-open the conversation, then retry there is nothing to inject into.";
103
+ // The adapter probe found no live native host serving this conversation/thread. Stays
104
+ // backend-neutral: agy speaks conversations over an LS port, Codex threads over an
105
+ // app-server socket, and this hint is read by callers of both.
106
+ return "the native-push conversation/thread is not live — no native host (harness process or app-server) answered the probe for it. Re-open that conversation/thread in its own harness, then retry; there is nothing to inject into.";
105
107
  case "native-push-probe-indeterminate":
106
- // Host up, but no LS port served the conversation — inconclusive, not a hard dead.
107
- return "native-push host is up but no port served this conversation (probe inconclusive). Retry once the conversation is loaded, or verify the conversation id.";
108
+ // Native host up, but nothing served this conversation/thread — inconclusive, not a
109
+ // hard dead. Backend-neutral for the same reason as the dead cell above.
110
+ return "the native host (harness process or app-server) is up, but nothing served this conversation/thread, so the probe is INCONCLUSIVE — not a measured death. Retry once it is loaded, or verify the conversation/thread id.";
108
111
  case "record-less-socket":
109
112
  // #50 C4: name the true cause AND the fix — a bare socket is a diagnostic
110
113
  // state, not an addressable citizen (the record is the address).
@@ -1,26 +1,33 @@
1
1
  /**
2
2
  * meta-mailbox-body — the SINGLE source for rendering an entwurf message as a
3
- * meta-bridge mailbox body. Both transports that can deliver to a garden citizen
4
- * with no live control socket use this:
3
+ * meta-bridge mailbox body. Every transport that can deliver to a garden citizen
4
+ * with no live control socket uses this:
5
5
  * - the MCP bridge entwurf_v2 (mcp/entwurf-bridge) — external/Claude-host sends
6
6
  * - the pi-native entwurf_v2 (pi-extensions/entwurf-control.ts) — pi-session sends
7
+ * - the native-push rail (pi-extensions/lib/entwurf-v2-native-push.ts) — direct
8
+ * injection into an Antigravity conversation or a loaded Codex thread. Its
9
+ * THIRD consumer, added after the #95 A LIVE run measured what an envelope-less
10
+ * injection costs: a nonce-only callback arrived in a Codex thread as bare text
11
+ * and the receiving citizen could not name who had called it.
7
12
  *
8
13
  * The control-socket path carries the sender envelope inside its RPC framing; the
9
- * mailbox path is just a file, so the envelope must be SERIALIZED INTO the body
10
- * else a receiver reading entwurf_inbox_read would not know who sent it, whether
11
- * the sender is replyable (and at which sessionId), or whether a reply was wanted.
12
- * The render mirrors the live "[entwurf received ⟵]" header so a transcript reads
13
- * the same whether the message arrived over a socket or a mailbox.
14
+ * mailbox path is just a file and direct injection is just a string, so for both the
15
+ * envelope must be SERIALIZED INTO the body
16
+ * else a receiver reading entwurf_inbox_read, or reading an injected turn in its own
17
+ * conversation would not know who sent it, whether the sender is replyable (and at
18
+ * which sessionId), or whether a reply was wanted. The render mirrors the live
19
+ * "[entwurf received ⟵]" header so a transcript reads the same whether the message
20
+ * arrived over a socket, a mailbox, or direct injection.
14
21
  *
15
22
  * No filesystem/network IO and no mutation — the only ambient read is
16
23
  * process.env.HOME for display abbreviation (so not strictly referentially pure,
17
- * but deterministic per environment). Extracted so the two senders cannot drift
18
- * in how a mailbox message presents who-sent-it — the field that round-trips
19
- * garden-id replies.
24
+ * but deterministic per environment). Extracted so the three consumers cannot
25
+ * drift in how a delivered message presents who-sent-it — the field that
26
+ * round-trips garden-id replies.
20
27
  */
21
28
 
22
- /** The fields a mailbox body needs from a sender. Structurally compatible with
23
- * the SenderEnvelope of both entwurf_v2 surfaces. */
29
+ /** The fields a sender-envelope presentation needs from a sender. Structurally
30
+ * compatible with the SenderEnvelope of both entwurf_v2 surfaces. */
24
31
  export interface MailboxSenderEnvelope {
25
32
  sessionId: string;
26
33
  agentId: string;
@@ -53,7 +60,9 @@ function formatKstTimestamp(iso: string): string {
53
60
  }
54
61
 
55
62
  /**
56
- * Render the full mailbox body: header envelope + separator + message. A
63
+ * Render the full body: header envelope + separator + message. The mailbox writes this
64
+ * into its message file and the native-push rail injects it directly into a live
65
+ * conversation; both present the sender identically. A
57
66
  * replyable sender (pi-session, or a trusted meta-session) advertises its
58
67
  * sessionId as the reply address; a non-replyable sender says so WITHOUT
59
68
  * losing its origin — a record-backed meta-session that is currently inactive
@@ -34,15 +34,31 @@
34
34
  * Every candidate is collected and validated BEFORE one is chosen. A first-match loop would make
35
35
  * the answer depend on which pid or backend happened to be read first; here lookup order carries
36
36
  * no meaning, and two live identities are a refusal rather than a race.
37
+ *
38
+ * A SECOND RAIL LIVES BELOW THE MARKER RAIL, and it does not share this one's join key.
39
+ * Codex names its caller on EVERY `tools/call` (`_meta.threadId` plus an
40
+ * `x-codex-turn-metadata.{session_id,thread_id}` block), and its parent pid cannot separate
41
+ * citizens at all: in the delivery-capable launch mode every hook and every MCP child of N
42
+ * live threads resolves to the ONE app-server pid, so a pid marker would be one marker for N
43
+ * citizens — exactly what the store's `nativeSessionId` uniqueness forbids (measured
44
+ * 2026-09-08, `scripts/raw-codex-measure/README.md` S1b-C/S1b-D). So the codex resolver is
45
+ * REQUEST-scoped, not process-scoped: see {@link resolveCodexRequestSenderIdentity}. The two
46
+ * rails never merge — `codex` is deliberately absent from {@link META_SENDER_BACKENDS}, since
47
+ * a backend listed there is one whose hook writes a pid marker, and codex birth writes none.
37
48
  */
38
49
 
39
50
  import {
51
+ defaultMetaSessionsDir,
52
+ describe,
40
53
  isPlausibleOwnerPid,
54
+ listAllMetaIdentities,
41
55
  type MetaBackend,
42
56
  type MetaIdentity,
43
57
  type MetaSenderMarker,
58
+ makeStoreRecordReader,
44
59
  metaRecordExistsByGardenId,
45
60
  parentPid,
61
+ readActiveStoreEntries,
46
62
  readMetaIdentityByGardenId,
47
63
  readMetaSenderMarker,
48
64
  } from "./meta-session.ts";
@@ -211,3 +227,292 @@ export async function probeNativeSenderAlive(
211
227
  const probe = await resolveAdapter(identity.backend).probe(identity.nativeSessionId);
212
228
  return probe.status === "alive";
213
229
  }
230
+
231
+ // ─────────────────────────────────────────────────────────────────────────────
232
+ // REQUEST-SCOPED SENDER IDENTITY — codex
233
+ // ─────────────────────────────────────────────────────────────────────────────
234
+
235
+ /**
236
+ * The provenance label the MANAGED codex MCP entry carries (`ENTWURF_BRIDGE_NATIVE_HOST=codex`
237
+ * under `[mcp_servers.entwurf-bridge]` in the codex config the entwurf config writer owns).
238
+ *
239
+ * It is an OWNERSHIP/DRIFT atom, not authentication: any process able to write that config, or
240
+ * to set that env on a child, can spell it. What it buys is the thing a codex bridge child
241
+ * genuinely cannot observe otherwise — that THIS child was launched from an entwurf-managed
242
+ * entry, so the request-scoped rail below is admitted deliberately rather than by sniffing.
243
+ * The value travels to this resolver as an EXPLICIT ARGUMENT: nothing here reads the
244
+ * environment, and in particular nothing here reads `ENTWURF_BRIDGE_EXTERNAL_AGENT_ID`, which
245
+ * is the omp root-policy label with its own policy attached (`applyOmpBridgeChildRootPolicy`)
246
+ * and must never double as a codex admission.
247
+ */
248
+ export const CODEX_BRIDGE_PROVENANCE_LABEL = "codex";
249
+
250
+ /**
251
+ * `clientInfo.name` every codex MCP client sends, and it is a FIXED VENDOR LITERAL —
252
+ * `Implementation::new("codex-mcp-client", CARGO_PKG_VERSION).with_title("Codex")`
253
+ * (`codex-mcp/src/rmcp_client.rs:1035-1039`, read at 0.153.4). `initialize` carries no
254
+ * identity at all and is byte-identical between two children of two different threads, so this
255
+ * name is a HOST-KIND gate and never an identity: it says "the peer on this stdio is a codex
256
+ * client", which is precisely as much as startup can honestly say.
257
+ */
258
+ export const CODEX_MCP_CLIENT_NAME = "codex-mcp-client";
259
+
260
+ /** The `_meta` member carrying the vendor turn-metadata block (`core/src/mcp_tool_call.rs`
261
+ * `build_mcp_tool_call_request_meta`, :1238-1263). A dash-cased key, hence the string form. */
262
+ const CODEX_TURN_METADATA_KEY = "x-codex-turn-metadata";
263
+
264
+ /**
265
+ * The codex caller's identity metadata is PRESENT but does not hold together: a `threadId` that
266
+ * is not a nonempty string, a missing/foreign turn-metadata block, or three ids that disagree.
267
+ *
268
+ * WHY THIS IS LOUD AND NOT `null`. `null` on this rail means "no identity claim reached me" —
269
+ * the bridge then refuses the send as anonymous, and the prescription is "open the session
270
+ * through the installed codex birth hook". A HALF claim is a different fact with a different
271
+ * cause (a vendor `_meta` shape that moved, or something imitating the codex client), and both
272
+ * ids exist precisely so neither has to be trusted alone: `threadId` and
273
+ * `x-codex-turn-metadata.session_id` are built by two different vendor call sites, and the
274
+ * hook's `session_id` is byte-identical to both (measured 2026-09-08, S1b-D/D3). Selecting a
275
+ * record from one field while its corroborator is absent or disagrees would address a citizen on
276
+ * an unverified id — the SE-1 shape — so the disagreement is reported instead.
277
+ */
278
+ export class EntwurfCodexIdentityMetadataError extends Error {
279
+ constructor(reason: string) {
280
+ super(
281
+ "entwurf-bridge refused: this codex tool call carries identity metadata that does not hold " +
282
+ `together — ${reason} A codex caller names itself on every call with THREE ids that must be one ` +
283
+ `string: \`_meta.threadId\`, \`_meta.${CODEX_TURN_METADATA_KEY}.session_id\` and \`.thread_id\`. ` +
284
+ "Selecting a garden citizen from a partial or self-contradicting claim would send under an " +
285
+ "identity nothing vouched for. Report it; do not work around it — if the vendor `_meta` shape " +
286
+ "moved, the measurement in scripts/raw-codex-measure/README.md (S1b-D) must be re-taken.",
287
+ );
288
+ this.name = "EntwurfCodexIdentityMetadataError";
289
+ }
290
+ }
291
+
292
+ /**
293
+ * The codex `threadId` selected exactly one V3 record — and that record is NOT a codex citizen.
294
+ *
295
+ * This is corruption of the native→garden mapping, never a miss: the store says some other
296
+ * backend's session carries this exact native id. Returning `null` would report "no record for
297
+ * this thread" and prescribe re-opening the session, which would mint a SECOND record for the
298
+ * same `nativeSessionId` and turn a readable defect into the duplicate the certification
299
+ * refuses. Sending under the foreign citizen is worse still — it would attribute a codex
300
+ * caller's message to a claude-code/agy/pi session.
301
+ */
302
+ export class EntwurfCodexIdentityBackendError extends Error {
303
+ readonly gardenId: string;
304
+ constructor(identity: MetaIdentity) {
305
+ super(
306
+ `entwurf-bridge refused: codex thread ${identity.nativeSessionId} selected meta-record ` +
307
+ `${identity.gardenId}, but that citizen's backend is "${identity.backend}", not "codex". One ` +
308
+ "native id is bound to one backend for the life of the record, so this store cannot say who the " +
309
+ "caller is — and neither re-opening the codex session nor sending under the foreign citizen is a " +
310
+ "fix. The record must be corrected (a fresh generation) before this thread can send.",
311
+ );
312
+ this.name = "EntwurfCodexIdentityBackendError";
313
+ this.gardenId = identity.gardenId;
314
+ }
315
+ }
316
+
317
+ /** A JSON OBJECT, as a narrowing guard — `typeof value === "object"` alone admits `null` and
318
+ * arrays, and every codex identity field is read through a member access that both would
319
+ * survive silently (`[]._meta` is `undefined`, not a type error). */
320
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
321
+ return typeof value === "object" && value !== null && !Array.isArray(value);
322
+ }
323
+
324
+ /** What the resolver is handed. Every field is UNKNOWN on purpose: three of them come off the
325
+ * wire (an MCP peer may send any JSON) and the fourth is a config-written label, so none of
326
+ * them may be typed as trusted at the boundary. */
327
+ export interface ResolveCodexRequestSenderOptions {
328
+ /** The managed-entry provenance, supplied EXPLICITLY by the bridge (never read here). */
329
+ provenance?: unknown;
330
+ /** The peer's `initialize` `clientInfo` — a host-kind gate, never an identity. */
331
+ clientInfo?: unknown;
332
+ /** THIS request's `params._meta`. Per-call, because identity on codex is per-call. */
333
+ requestMeta?: unknown;
334
+ /** Record store override (gates isolate it; production reads the SSOT default). */
335
+ sessionsDir?: string;
336
+ }
337
+
338
+ /**
339
+ * The one codex caller a request names, as the RECORD describes it.
340
+ *
341
+ * `identity` is the whole answer: gardenId, cwd, model, transcriptPath and backend all come from
342
+ * the record, never from the wire. `_meta` carries a `workspaces` map and a `model` field, and
343
+ * both are IGNORED here — a caller that can name its own cwd/model can relabel a citizen by
344
+ * asking, which is a write disguised as a read. `threadId` is retained only because it is the
345
+ * SELECTOR that was proven equal to `identity.nativeSessionId`; it adds no new authority.
346
+ */
347
+ export interface CodexRequestSender {
348
+ identity: MetaIdentity;
349
+ /** The wire id that selected the record; `=== identity.nativeSessionId` by construction. */
350
+ threadId: string;
351
+ /** The provenance that admitted this rail — always the codex label past the gate. */
352
+ provenance: typeof CODEX_BRIDGE_PROVENANCE_LABEL;
353
+ }
354
+
355
+ /**
356
+ * Read the caller's thread id out of ONE request, or refuse.
357
+ *
358
+ * `null` — this is not an admitted codex identity claim at all: no managed provenance, a peer
359
+ * that is not the codex client, no `_meta` object, or an `_meta` with no `threadId` member
360
+ * (`tools/list`, `initialize`, a non-turn call). All four are ordinary and carry no defect.
361
+ *
362
+ * THROWS {@link EntwurfCodexIdentityMetadataError} — a claim exists and is broken. The line
363
+ * between the two is the PRESENCE of `threadId`: absent is a call that never claimed identity,
364
+ * present-but-unusable is a claim that failed, and collapsing them would report "no identity"
365
+ * for a caller that named itself.
366
+ *
367
+ * Pure: no fs, no env, no clock. The store lookup is the next function's job on purpose — this
368
+ * half is the wire contract and can be pinned without a store at all.
369
+ */
370
+ export function readCodexRequestThreadId(
371
+ opts: Pick<ResolveCodexRequestSenderOptions, "provenance" | "clientInfo" | "requestMeta">,
372
+ ): string | null {
373
+ if (opts.provenance !== CODEX_BRIDGE_PROVENANCE_LABEL) return null;
374
+ if (!isJsonObject(opts.clientInfo) || opts.clientInfo.name !== CODEX_MCP_CLIENT_NAME) return null;
375
+ const meta = opts.requestMeta;
376
+ if (!isJsonObject(meta)) return null;
377
+ if (!("threadId" in meta)) return null;
378
+
379
+ const threadId = meta.threadId;
380
+ if (typeof threadId !== "string" || threadId.length === 0) {
381
+ throw new EntwurfCodexIdentityMetadataError(
382
+ `\`_meta.threadId\` is present but is not a nonempty string (got ${describe(threadId)}).`,
383
+ );
384
+ }
385
+ const turn = meta[CODEX_TURN_METADATA_KEY];
386
+ if (!isJsonObject(turn)) {
387
+ throw new EntwurfCodexIdentityMetadataError(
388
+ `\`_meta.threadId\` names ${threadId} but \`_meta.${CODEX_TURN_METADATA_KEY}\` is ${describe(turn)}, not an ` +
389
+ "object, so the second, independently-built id that corroborates it is missing.",
390
+ );
391
+ }
392
+ const sessionId = turn.session_id;
393
+ const turnThreadId = turn.thread_id;
394
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
395
+ throw new EntwurfCodexIdentityMetadataError(
396
+ `\`_meta.${CODEX_TURN_METADATA_KEY}.session_id\` is not a nonempty string (got ${describe(sessionId)}) — ` +
397
+ "that field is the one the birth hook also sees, so without it nothing joins the wire to a record.",
398
+ );
399
+ }
400
+ if (typeof turnThreadId !== "string" || turnThreadId.length === 0) {
401
+ throw new EntwurfCodexIdentityMetadataError(
402
+ `\`_meta.${CODEX_TURN_METADATA_KEY}.thread_id\` is not a nonempty string (got ${describe(turnThreadId)}).`,
403
+ );
404
+ }
405
+ if (sessionId !== threadId || turnThreadId !== threadId) {
406
+ throw new EntwurfCodexIdentityMetadataError(
407
+ `the three ids disagree (threadId=${threadId}, session_id=${sessionId}, thread_id=${turnThreadId}); ` +
408
+ "on a healthy codex turn they are one byte-identical string.",
409
+ );
410
+ }
411
+ return threadId;
412
+ }
413
+
414
+ /**
415
+ * Resolve the ONE codex citizen a request names, or refuse.
416
+ *
417
+ * `null` — no admitted claim ({@link readCodexRequestThreadId}), or no record holds that
418
+ * `nativeSessionId`. The second case is the ordinary pre-birth window: codex birth is
419
+ * first-turn, not window-open, so a thread whose hook has not minted a record yet simply has no
420
+ * citizen, and the bridge's default anonymous refusal already names the fix.
421
+ *
422
+ * THROWS — every knowable defect, unfolded: a broken claim
423
+ * ({@link EntwurfCodexIdentityMetadataError}), a foreign-backend record
424
+ * ({@link EntwurfCodexIdentityBackendError}), or a store that cannot be certified
425
+ * (`MetaRecordError` from the strict listing — an unreadable/previous-generation record
426
+ * anywhere in the store, a duplicated `nativeSessionId`, or a store this process cannot read).
427
+ *
428
+ * THE LOOKUP IS THE STRICT STORE-WIDE LISTING, NOT A NARROW SCAN. `nativeSessionId` → garden id
429
+ * is the store's own authority (there is no index, by design), and the same reason
430
+ * `upsertMetaSession` certifies the WHOLE store before writing applies to a read that turns an
431
+ * id into an ADDRESS: a narrow "find my thread" pass would answer happily beside a duplicate,
432
+ * drifted or symlinked neighbour that the doctor refuses, so the runtime would hold the weaker
433
+ * contract exactly where a message gets attributed. `mode: "strict"` is what makes an
434
+ * unreadable record a refusal instead of an invisible one — a duplicate of THIS thread's id
435
+ * would otherwise be dropped from `identities` and read as "no record".
436
+ */
437
+ export function resolveCodexRequestSenderIdentity(
438
+ opts: ResolveCodexRequestSenderOptions = {},
439
+ ): CodexRequestSender | null {
440
+ const threadId = readCodexRequestThreadId(opts);
441
+ if (threadId === null) return null;
442
+
443
+ const dir = opts.sessionsDir ?? defaultMetaSessionsDir();
444
+ const { identities } = listAllMetaIdentities(readActiveStoreEntries(dir), makeStoreRecordReader(dir), {
445
+ mode: "strict",
446
+ });
447
+ const identity = identities.find((record) => record.nativeSessionId === threadId);
448
+ if (!identity) return null;
449
+ if (identity.backend !== "codex") throw new EntwurfCodexIdentityBackendError(identity);
450
+ return { identity, threadId, provenance: CODEX_BRIDGE_PROVENANCE_LABEL };
451
+ }
452
+
453
+ /**
454
+ * The three rails an entwurf-bridge child can learn WHO is calling from. Named as a type so a
455
+ * reconciliation can report which rails spoke without any of them being privileged by position.
456
+ */
457
+ export type SenderIdentityRail = "pi-session" | "meta-sender-marker" | "codex-request";
458
+
459
+ /** One rail's answer: the address it names (a pi session id, or a garden id). */
460
+ export interface SenderIdentityClaim {
461
+ rail: SenderIdentityRail;
462
+ id: string;
463
+ }
464
+
465
+ /** One address, plus every rail that named it. Rails are sorted and de-duplicated, so this
466
+ * answer never depends on the order the caller collected its claims in. */
467
+ export interface ReconciledSenderIdentity {
468
+ id: string;
469
+ rails: SenderIdentityRail[];
470
+ }
471
+
472
+ /**
473
+ * Two rails claim this MCP process for DIFFERENT addresses. Sibling of
474
+ * {@link EntwurfSenderIdentityAmbiguityError} and deliberately not the same error: that one is
475
+ * about two pid MARKERS on one owner pid, while this is about rails that do not even share a
476
+ * join key — a planted `PI_SESSION_ID` in a native host's environment, a pid marker inherited
477
+ * from a host whose child we are not, a codex `_meta` naming a thread while a marker names
478
+ * someone else. Which is right is unknowable here, and both are visible, so neither is used.
479
+ */
480
+ export class EntwurfSenderIdentityConflictError extends Error {
481
+ readonly claims: SenderIdentityClaim[];
482
+ constructor(claims: readonly SenderIdentityClaim[]) {
483
+ super(
484
+ "entwurf-bridge refused: conflicting sender identity. This MCP process holds authoritative " +
485
+ `identity claims from more than one rail, naming DIFFERENT addresses (${claims
486
+ .map((claim) => `${claim.rail}=${claim.id}`)
487
+ .join(", ")}), so which one is calling cannot be determined. A send under the wrong identity ` +
488
+ "is worse than no send. Report it; do not work around it.",
489
+ );
490
+ this.name = "EntwurfSenderIdentityConflictError";
491
+ this.claims = [...claims];
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Fold every rail's answer into ONE address, or refuse.
497
+ *
498
+ * 0 claims → `null` (anonymous; the bridge's default refusal owns that outcome).
499
+ * 1+ claims that AGREE on the id → that address, carrying every rail that named it. Agreement is
500
+ * the normal shape of an overlap, not a conflict: a codex citizen's request-scoped claim and a
501
+ * pid marker for the same garden id are two views of one identity.
502
+ * claims that DISAGREE → throw {@link EntwurfSenderIdentityConflictError}.
503
+ *
504
+ * Every claim is collected and compared BEFORE one is chosen — the same rule the marker rail
505
+ * holds one layer up. A rail-priority order would answer even when the truth is unknown, which
506
+ * is the failure this exists to prevent, so callers must pass ALL rails they resolved rather
507
+ * than short-circuiting on the first one that answered.
508
+ */
509
+ export function reconcileSenderIdentityClaims(claims: readonly SenderIdentityClaim[]): ReconciledSenderIdentity | null {
510
+ if (claims.length === 0) return null;
511
+ const ids = new Set(claims.map((claim) => claim.id));
512
+ if (ids.size > 1) throw new EntwurfSenderIdentityConflictError(claims);
513
+ const [id] = ids;
514
+ return {
515
+ id,
516
+ rails: [...new Set(claims.map((claim) => claim.rail))].sort(),
517
+ };
518
+ }
@@ -1,7 +1,8 @@
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. Codex is the one measured topology
5
+ * exception: an omitted seat selects the existing `codex` home session (#95), never a TUI guess.
5
6
  *
6
7
  * ── Why this is a third module and not a parameter on the leaf ──
7
8
  *
@@ -78,6 +79,7 @@
78
79
 
79
80
  import { randomBytes } from "node:crypto";
80
81
  import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
82
+ import { CODEX_PREFLIGHT_HINT, type CodexPreflightRejectReason } from "./codex-fresh-preflight.ts";
81
83
  import {
82
84
  COPILOT_PREFLIGHT_HINT,
83
85
  type CopilotPreflightRejectReason,
@@ -100,15 +102,16 @@ import {
100
102
  runTmux,
101
103
  type WindowHandle,
102
104
  } from "./mux-placement.ts";
105
+ import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.ts";
103
106
  import { OMP_PREFLIGHT_HINT, type OmpPreflightRejectReason, ompFreshPreflight } from "./omp-fresh-preflight.ts";
104
107
  import { classifyTmuxSessionName, resolveTmuxSessionId, type TmuxSessionRejectReason } from "./resolve-tmux-session.ts";
105
108
 
106
109
  /** 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;
110
+ * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
111
+ * by #95 after its system birth and app-server rails were measured. The set is joined to the
112
+ * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
113
+ * missing HERE is not an unwired convenience, it is a release blocker. */
114
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"] as const;
112
115
  export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
113
116
 
114
117
  /**
@@ -138,6 +141,7 @@ export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
138
141
  "claude-code": "claude",
139
142
  copilot: "entwurf",
140
143
  omp: "omp",
144
+ codex: "codex",
141
145
  };
142
146
 
143
147
  /**
@@ -166,6 +170,7 @@ export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
166
170
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
167
171
  copilot: "entwurf-bridge-entwurf_v2",
168
172
  omp: "mcp__entwurf_bridge_entwurf_v",
173
+ codex: "mcp__entwurf_bridge__entwurf_v2",
169
174
  };
170
175
 
171
176
  /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
@@ -276,6 +281,7 @@ export function buildBackendArgs(
276
281
  backend: FreshCallBackend,
277
282
  composition: FreshCallComposition,
278
283
  model: string,
284
+ env: NodeJS.ProcessEnv = process.env,
279
285
  ): string[] {
280
286
  switch (backend) {
281
287
  case "pi":
@@ -286,11 +292,20 @@ export function buildBackendArgs(
286
292
  return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
287
293
  case "omp":
288
294
  return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
295
+ case "codex":
296
+ return [
297
+ "--remote",
298
+ `unix://${resolveCodexDefaultSocketPath(env)}`,
299
+ "--model",
300
+ model,
301
+ "--dangerously-bypass-approvals-and-sandbox",
302
+ composition.prompt,
303
+ ];
289
304
  }
290
305
  }
291
306
 
292
307
  /**
293
- * What a launch has to say, in the two shapes the four backends need. Three of them are
308
+ * What a launch has to say, in the two shapes the five backends need. Four of them are
294
309
  * handed a first-turn PROMPT; omp is handed a bootstrap PAYLOAD its own installed extension
295
310
  * unpacks. Both are always built, because building one is cheap and a backend switch must
296
311
  * never be able to reach a field that was not composed.
@@ -375,6 +390,7 @@ export type FreshCallRejectReason =
375
390
  | TmuxSessionRejectReason
376
391
  | CopilotPreflightRejectReason
377
392
  | OmpPreflightRejectReason
393
+ | CodexPreflightRejectReason
378
394
  | "caller-identity-unavailable"
379
395
  | "model-empty"
380
396
  | "model-invalid"
@@ -389,6 +405,28 @@ export interface FreshCallPlacement {
389
405
  tmuxSession: string;
390
406
  }
391
407
 
408
+ /**
409
+ * Codex's explicit home topology (#95): the operator provides this one existing tmux session
410
+ * and seats the operator-owned app-server plus supported Codex TUIs there. This is a placement
411
+ * convention, not an address axis — `threadId` remains the native delivery address.
412
+ */
413
+ export const CODEX_HOME_TMUX_SESSION = "codex" as const;
414
+ export type FreshCallSeatSource = "requested" | "codex-home";
415
+ export interface FreshCallSeat {
416
+ tmuxSession: string;
417
+ source: FreshCallSeatSource;
418
+ }
419
+
420
+ /** An explicit seat always wins. Only omitted Codex placement gets the fixed home. */
421
+ export function selectFreshCallSeat(
422
+ backend: FreshCallBackend,
423
+ placement: FreshCallPlacement | undefined,
424
+ ): FreshCallSeat | null {
425
+ if (placement !== undefined) return { tmuxSession: placement.tmuxSession, source: "requested" };
426
+ if (backend === "codex") return { tmuxSession: CODEX_HOME_TMUX_SESSION, source: "codex-home" };
427
+ return null;
428
+ }
429
+
392
430
  /** Coordinates plus what was handed to tmux. Read `runtimePath` as "what we asked to start".
393
431
  * There is deliberately NO field here for the callback, the nonce's arrival, or the sibling's
394
432
  * garden id — see the module header. */
@@ -399,10 +437,11 @@ export interface FreshCallReceipt extends WindowHandle {
399
437
  * of fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
400
438
  * landed. */
401
439
  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. */
440
+ /** The selected session name — either caller-requested or Codex's fixed home. The RESOLVED
441
+ * target is the inherited `sessionId`, which is the session the window is actually in. */
405
442
  tmuxSession?: string;
443
+ /** Why `tmuxSession` was selected. Absent exactly when no named seat was used. */
444
+ tmuxSessionSource?: FreshCallSeatSource;
406
445
  runtimePath: string;
407
446
  nonce: string;
408
447
  }
@@ -435,9 +474,9 @@ function defaultRandomHex(): string {
435
474
  * for. That is sufficient and not a compromise: every reader of the carrier trims and tests
436
475
  * truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
437
476
  *
438
- * It is applied to all four backends because the leak is a property of tmux, not of a vendor. A
477
+ * It is applied to all five backends because the leak is a property of tmux, not of a vendor. A
439
478
  * 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
479
+ * four are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
441
480
  * authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
442
481
  * exactly that after this argv has run. This is a fixed two-variable seam and deliberately NOT a
443
482
  * general env carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping
@@ -526,8 +565,10 @@ export function freshCall(
526
565
  // without tmux, so an unresolvable name is answered before anything else runs. Whether that
527
566
  // session EXISTS is a tmux question and is asked below, after the caller's own context is
528
567
  // 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;
568
+ // host with no tmux at all. Codex alone selects its fixed home when the caller omitted a seat;
569
+ // an explicit seat remains an expert override and is reported as such.
570
+ const selectedSeat = selectFreshCallSeat(params.backend, params.placement);
571
+ const seat = selectedSeat?.tmuxSession;
531
572
  if (seat !== undefined) {
532
573
  const badSeat = classifyTmuxSessionName(seat);
533
574
  if (badSeat) return { ok: false, reason: badSeat };
@@ -555,7 +596,6 @@ export function freshCall(
555
596
  const missing = ompFreshPreflight(env);
556
597
  if (missing) return { ok: false, reason: missing };
557
598
  }
558
-
559
599
  const inspected = inspectPlacement(env);
560
600
  if (!inspected.ok) return { ok: false, reason: inspected.reason };
561
601
  const placement = inspected.placement;
@@ -582,7 +622,7 @@ export function freshCall(
582
622
  bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
583
623
  };
584
624
  const run = runTmux(
585
- buildFreshCallArgs(targetSessionId, runtimePath, buildBackendArgs(params.backend, composition, model), cwd),
625
+ buildFreshCallArgs(targetSessionId, runtimePath, buildBackendArgs(params.backend, composition, model, env), cwd),
586
626
  env,
587
627
  );
588
628
  assertTmuxOk("new-window", run);
@@ -610,7 +650,9 @@ export function freshCall(
610
650
  backend: params.backend,
611
651
  model,
612
652
  ...(cwd === undefined ? {} : { cwd }),
613
- ...(seat === undefined ? {} : { tmuxSession: seat }),
653
+ ...(selectedSeat === null
654
+ ? {}
655
+ : { tmuxSession: selectedSeat.tmuxSession, tmuxSessionSource: selectedSeat.source }),
614
656
  runtimePath,
615
657
  nonce,
616
658
  },
@@ -624,6 +666,7 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
624
666
  // the sentence an operator reads cannot drift away from the predicate that produced it.
625
667
  ...COPILOT_PREFLIGHT_HINT,
626
668
  ...OMP_PREFLIGHT_HINT,
669
+ ...CODEX_PREFLIGHT_HINT,
627
670
  "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
628
671
  "anchor-malformed": "TMUX_PANE is not a native pane id",
629
672
  "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
@@ -640,7 +683,7 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
640
683
  "tmux-session-name-invalid":
641
684
  "the requested tmux session name is outside the shape this rail addresses (start with a letter or digit, then letters, digits, '_' or '-') — some other shapes tmux cannot resolve at all ('#' is expanded when the name is stored; '.' and ':' are its own pane/window separators inside a target; a name like '$0' loses to the session id '$0'), and the rest are declined to keep one narrow grammar, so rename the session or open one whose name fits",
642
685
  "tmux-session-missing":
643
- "no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again",
686
+ "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; Codex's omitted-placement home is the exact session name 'codex'",
644
687
  "model-empty": "model is empty after trimming; fresh calls require an explicit model",
645
688
  "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
646
689
  "task-empty": "task is empty after trimming",
@@ -678,7 +721,9 @@ export function renderFreshCall(result: FreshCallResult): { text: string; isErro
678
721
  (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
679
722
  (r.tmuxSession === undefined
680
723
  ? ""
681
- : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
724
+ : r.tmuxSessionSource === "codex-home"
725
+ ? ` seat: ${r.tmuxSession} (Codex home tmux session, resolved to ${r.sessionId})\n`
726
+ : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
682
727
  ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
683
728
  ` pane: ${r.paneId} pid ${r.panePid}\n` +
684
729
  ` nonce: ${r.nonce}\n` +