@junghanacs/entwurf 0.20.1 → 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 (96) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +4 -3
  3. package/CHANGELOG.md +195 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +202 -60
  6. package/README.md +49 -22
  7. package/VERIFY.md +37 -7
  8. package/docs/acp-backend-rail.md +0 -1
  9. package/docs/external-mcp-host.md +54 -33
  10. package/docs/setup-clean-host.md +110 -17
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +101 -109
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +326 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +50 -15
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  26. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  27. package/mcp/entwurf-bridge/src/index.ts +115 -111
  28. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  29. package/package.json +13 -4
  30. package/pi-extensions/entwurf-control.ts +71 -19
  31. package/pi-extensions/lib/codex-fresh-preflight.ts +363 -0
  32. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  33. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  34. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  35. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  36. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  37. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  38. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  39. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  40. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  41. package/pi-extensions/lib/mux-fresh-call.ts +64 -19
  42. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  43. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  44. package/pi-extensions/lib/native-push/register.ts +7 -9
  45. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  46. package/run.sh +218 -28
  47. package/scripts/check-agy-sender-identity.ts +1 -1
  48. package/scripts/check-codex-birth-hook.ts +264 -0
  49. package/scripts/check-codex-bridge-identity.ts +179 -0
  50. package/scripts/check-codex-native-push.ts +386 -0
  51. package/scripts/check-codex-sender-identity.ts +495 -0
  52. package/scripts/check-compaction-send-guard.ts +130 -0
  53. package/scripts/check-copilot-receive-arm.ts +4 -1
  54. package/scripts/check-entwurf-fact-provider.ts +38 -0
  55. package/scripts/check-entwurf-peers-surface.ts +13 -1
  56. package/scripts/check-entwurf-self-address.ts +15 -16
  57. package/scripts/check-entwurf-v2-contract.ts +4 -3
  58. package/scripts/check-entwurf-v2-decider.ts +7 -5
  59. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  60. package/scripts/check-entwurf-v2-production.ts +203 -11
  61. package/scripts/check-entwurf-v2-runner.ts +1 -1
  62. package/scripts/check-entwurf-v2-surface.ts +1 -1
  63. package/scripts/check-gate-qualification.ts +6 -3
  64. package/scripts/check-harness-admission-parity.ts +0 -1
  65. package/scripts/check-mux-launch-tmux.ts +47 -2
  66. package/scripts/check-native-push-adapter.ts +20 -16
  67. package/scripts/check-native-push-register.ts +5 -1
  68. package/scripts/check-release-gate-outcomes.ts +47 -1
  69. package/scripts/check-setup-qualification.sh +3 -1
  70. package/scripts/codex-birth-doctor.sh +276 -0
  71. package/scripts/codex-birth-install.sh +414 -0
  72. package/scripts/codex-birth-uninstall.sh +170 -0
  73. package/scripts/codex-mcp-config.py +435 -0
  74. package/scripts/codex-statusline-config.py +434 -0
  75. package/scripts/codex_toml_io.py +532 -0
  76. package/scripts/lib/codex-fresh-live-protocol.ts +113 -0
  77. package/scripts/lib/codex-fresh-source-receipts.ts +399 -0
  78. package/scripts/lib/launch-receipt-windows.ts +46 -0
  79. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  80. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  81. package/scripts/mutants/codex-native.json +838 -0
  82. package/scripts/mutants/compaction-send-guard.json +103 -0
  83. package/scripts/mutants/entwurf-peers.json +19 -0
  84. package/scripts/mutants/mux-fresh-call.json +19 -8
  85. package/scripts/mutants/omp-fresh.json +6 -4
  86. package/scripts/mutants/release-gate.json +13 -0
  87. package/scripts/mutants/v2-surface.json +53 -1
  88. package/scripts/raw-async-delivery/README.md +2 -1
  89. package/scripts/raw-codex-measure/README.md +114 -46
  90. package/scripts/smoke-agy-native-push-live.ts +3 -1
  91. package/scripts/smoke-codex-birth.sh +347 -0
  92. package/scripts/smoke-codex-config-state.sh +511 -0
  93. package/scripts/smoke-codex-fresh-live.ts +1186 -0
  94. package/scripts/smoke-codex-native-push-live.ts +75 -0
  95. package/scripts/smoke-setup-verdict.sh +123 -10
  96. package/scripts/tsconfig.json +1 -0
@@ -34,8 +34,19 @@
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
- import { isPlausibleOwnerPid, metaRecordExistsByGardenId, parentPid, readMetaIdentityByGardenId, readMetaSenderMarker, } from "./meta-session.js";
49
+ import { defaultMetaSessionsDir, describe, isPlausibleOwnerPid, listAllMetaIdentities, makeStoreRecordReader, metaRecordExistsByGardenId, parentPid, readActiveStoreEntries, readMetaIdentityByGardenId, readMetaSenderMarker, } from "./meta-session.js";
39
50
  import { resolveNativePushAdapter } from "./native-push/adapter.js";
40
51
  /** Every native backend that mints a garden-id from its own hook and writes a sender marker.
41
52
  *
@@ -180,3 +191,218 @@ export async function probeNativeSenderAlive(identity, deps = {}) {
180
191
  const probe = await resolveAdapter(identity.backend).probe(identity.nativeSessionId);
181
192
  return probe.status === "alive";
182
193
  }
194
+ // ─────────────────────────────────────────────────────────────────────────────
195
+ // REQUEST-SCOPED SENDER IDENTITY — codex
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+ /**
198
+ * The provenance label the MANAGED codex MCP entry carries (`ENTWURF_BRIDGE_NATIVE_HOST=codex`
199
+ * under `[mcp_servers.entwurf-bridge]` in the codex config the entwurf config writer owns).
200
+ *
201
+ * It is an OWNERSHIP/DRIFT atom, not authentication: any process able to write that config, or
202
+ * to set that env on a child, can spell it. What it buys is the thing a codex bridge child
203
+ * genuinely cannot observe otherwise — that THIS child was launched from an entwurf-managed
204
+ * entry, so the request-scoped rail below is admitted deliberately rather than by sniffing.
205
+ * The value travels to this resolver as an EXPLICIT ARGUMENT: nothing here reads the
206
+ * environment, and in particular nothing here reads `ENTWURF_BRIDGE_EXTERNAL_AGENT_ID`, which
207
+ * is the omp root-policy label with its own policy attached (`applyOmpBridgeChildRootPolicy`)
208
+ * and must never double as a codex admission.
209
+ */
210
+ export const CODEX_BRIDGE_PROVENANCE_LABEL = "codex";
211
+ /**
212
+ * `clientInfo.name` every codex MCP client sends, and it is a FIXED VENDOR LITERAL —
213
+ * `Implementation::new("codex-mcp-client", CARGO_PKG_VERSION).with_title("Codex")`
214
+ * (`codex-mcp/src/rmcp_client.rs:1035-1039`, read at 0.153.4). `initialize` carries no
215
+ * identity at all and is byte-identical between two children of two different threads, so this
216
+ * name is a HOST-KIND gate and never an identity: it says "the peer on this stdio is a codex
217
+ * client", which is precisely as much as startup can honestly say.
218
+ */
219
+ export const CODEX_MCP_CLIENT_NAME = "codex-mcp-client";
220
+ /** The `_meta` member carrying the vendor turn-metadata block (`core/src/mcp_tool_call.rs`
221
+ * `build_mcp_tool_call_request_meta`, :1238-1263). A dash-cased key, hence the string form. */
222
+ const CODEX_TURN_METADATA_KEY = "x-codex-turn-metadata";
223
+ /**
224
+ * The codex caller's identity metadata is PRESENT but does not hold together: a `threadId` that
225
+ * is not a nonempty string, a missing/foreign turn-metadata block, or three ids that disagree.
226
+ *
227
+ * WHY THIS IS LOUD AND NOT `null`. `null` on this rail means "no identity claim reached me" —
228
+ * the bridge then refuses the send as anonymous, and the prescription is "open the session
229
+ * through the installed codex birth hook". A HALF claim is a different fact with a different
230
+ * cause (a vendor `_meta` shape that moved, or something imitating the codex client), and both
231
+ * ids exist precisely so neither has to be trusted alone: `threadId` and
232
+ * `x-codex-turn-metadata.session_id` are built by two different vendor call sites, and the
233
+ * hook's `session_id` is byte-identical to both (measured 2026-09-08, S1b-D/D3). Selecting a
234
+ * record from one field while its corroborator is absent or disagrees would address a citizen on
235
+ * an unverified id — the SE-1 shape — so the disagreement is reported instead.
236
+ */
237
+ export class EntwurfCodexIdentityMetadataError extends Error {
238
+ constructor(reason) {
239
+ super("entwurf-bridge refused: this codex tool call carries identity metadata that does not hold " +
240
+ `together — ${reason} A codex caller names itself on every call with THREE ids that must be one ` +
241
+ `string: \`_meta.threadId\`, \`_meta.${CODEX_TURN_METADATA_KEY}.session_id\` and \`.thread_id\`. ` +
242
+ "Selecting a garden citizen from a partial or self-contradicting claim would send under an " +
243
+ "identity nothing vouched for. Report it; do not work around it — if the vendor `_meta` shape " +
244
+ "moved, the measurement in scripts/raw-codex-measure/README.md (S1b-D) must be re-taken.");
245
+ this.name = "EntwurfCodexIdentityMetadataError";
246
+ }
247
+ }
248
+ /**
249
+ * The codex `threadId` selected exactly one V3 record — and that record is NOT a codex citizen.
250
+ *
251
+ * This is corruption of the native→garden mapping, never a miss: the store says some other
252
+ * backend's session carries this exact native id. Returning `null` would report "no record for
253
+ * this thread" and prescribe re-opening the session, which would mint a SECOND record for the
254
+ * same `nativeSessionId` and turn a readable defect into the duplicate the certification
255
+ * refuses. Sending under the foreign citizen is worse still — it would attribute a codex
256
+ * caller's message to a claude-code/agy/pi session.
257
+ */
258
+ export class EntwurfCodexIdentityBackendError extends Error {
259
+ gardenId;
260
+ constructor(identity) {
261
+ super(`entwurf-bridge refused: codex thread ${identity.nativeSessionId} selected meta-record ` +
262
+ `${identity.gardenId}, but that citizen's backend is "${identity.backend}", not "codex". One ` +
263
+ "native id is bound to one backend for the life of the record, so this store cannot say who the " +
264
+ "caller is — and neither re-opening the codex session nor sending under the foreign citizen is a " +
265
+ "fix. The record must be corrected (a fresh generation) before this thread can send.");
266
+ this.name = "EntwurfCodexIdentityBackendError";
267
+ this.gardenId = identity.gardenId;
268
+ }
269
+ }
270
+ /** A JSON OBJECT, as a narrowing guard — `typeof value === "object"` alone admits `null` and
271
+ * arrays, and every codex identity field is read through a member access that both would
272
+ * survive silently (`[]._meta` is `undefined`, not a type error). */
273
+ function isJsonObject(value) {
274
+ return typeof value === "object" && value !== null && !Array.isArray(value);
275
+ }
276
+ /**
277
+ * Read the caller's thread id out of ONE request, or refuse.
278
+ *
279
+ * `null` — this is not an admitted codex identity claim at all: no managed provenance, a peer
280
+ * that is not the codex client, no `_meta` object, or an `_meta` with no `threadId` member
281
+ * (`tools/list`, `initialize`, a non-turn call). All four are ordinary and carry no defect.
282
+ *
283
+ * THROWS {@link EntwurfCodexIdentityMetadataError} — a claim exists and is broken. The line
284
+ * between the two is the PRESENCE of `threadId`: absent is a call that never claimed identity,
285
+ * present-but-unusable is a claim that failed, and collapsing them would report "no identity"
286
+ * for a caller that named itself.
287
+ *
288
+ * Pure: no fs, no env, no clock. The store lookup is the next function's job on purpose — this
289
+ * half is the wire contract and can be pinned without a store at all.
290
+ */
291
+ export function readCodexRequestThreadId(opts) {
292
+ if (opts.provenance !== CODEX_BRIDGE_PROVENANCE_LABEL)
293
+ return null;
294
+ if (!isJsonObject(opts.clientInfo) || opts.clientInfo.name !== CODEX_MCP_CLIENT_NAME)
295
+ return null;
296
+ const meta = opts.requestMeta;
297
+ if (!isJsonObject(meta))
298
+ return null;
299
+ if (!("threadId" in meta))
300
+ return null;
301
+ const threadId = meta.threadId;
302
+ if (typeof threadId !== "string" || threadId.length === 0) {
303
+ throw new EntwurfCodexIdentityMetadataError(`\`_meta.threadId\` is present but is not a nonempty string (got ${describe(threadId)}).`);
304
+ }
305
+ const turn = meta[CODEX_TURN_METADATA_KEY];
306
+ if (!isJsonObject(turn)) {
307
+ throw new EntwurfCodexIdentityMetadataError(`\`_meta.threadId\` names ${threadId} but \`_meta.${CODEX_TURN_METADATA_KEY}\` is ${describe(turn)}, not an ` +
308
+ "object, so the second, independently-built id that corroborates it is missing.");
309
+ }
310
+ const sessionId = turn.session_id;
311
+ const turnThreadId = turn.thread_id;
312
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
313
+ throw new EntwurfCodexIdentityMetadataError(`\`_meta.${CODEX_TURN_METADATA_KEY}.session_id\` is not a nonempty string (got ${describe(sessionId)}) — ` +
314
+ "that field is the one the birth hook also sees, so without it nothing joins the wire to a record.");
315
+ }
316
+ if (typeof turnThreadId !== "string" || turnThreadId.length === 0) {
317
+ throw new EntwurfCodexIdentityMetadataError(`\`_meta.${CODEX_TURN_METADATA_KEY}.thread_id\` is not a nonempty string (got ${describe(turnThreadId)}).`);
318
+ }
319
+ if (sessionId !== threadId || turnThreadId !== threadId) {
320
+ throw new EntwurfCodexIdentityMetadataError(`the three ids disagree (threadId=${threadId}, session_id=${sessionId}, thread_id=${turnThreadId}); ` +
321
+ "on a healthy codex turn they are one byte-identical string.");
322
+ }
323
+ return threadId;
324
+ }
325
+ /**
326
+ * Resolve the ONE codex citizen a request names, or refuse.
327
+ *
328
+ * `null` — no admitted claim ({@link readCodexRequestThreadId}), or no record holds that
329
+ * `nativeSessionId`. The second case is the ordinary pre-birth window: codex birth is
330
+ * first-turn, not window-open, so a thread whose hook has not minted a record yet simply has no
331
+ * citizen, and the bridge's default anonymous refusal already names the fix.
332
+ *
333
+ * THROWS — every knowable defect, unfolded: a broken claim
334
+ * ({@link EntwurfCodexIdentityMetadataError}), a foreign-backend record
335
+ * ({@link EntwurfCodexIdentityBackendError}), or a store that cannot be certified
336
+ * (`MetaRecordError` from the strict listing — an unreadable/previous-generation record
337
+ * anywhere in the store, a duplicated `nativeSessionId`, or a store this process cannot read).
338
+ *
339
+ * THE LOOKUP IS THE STRICT STORE-WIDE LISTING, NOT A NARROW SCAN. `nativeSessionId` → garden id
340
+ * is the store's own authority (there is no index, by design), and the same reason
341
+ * `upsertMetaSession` certifies the WHOLE store before writing applies to a read that turns an
342
+ * id into an ADDRESS: a narrow "find my thread" pass would answer happily beside a duplicate,
343
+ * drifted or symlinked neighbour that the doctor refuses, so the runtime would hold the weaker
344
+ * contract exactly where a message gets attributed. `mode: "strict"` is what makes an
345
+ * unreadable record a refusal instead of an invisible one — a duplicate of THIS thread's id
346
+ * would otherwise be dropped from `identities` and read as "no record".
347
+ */
348
+ export function resolveCodexRequestSenderIdentity(opts = {}) {
349
+ const threadId = readCodexRequestThreadId(opts);
350
+ if (threadId === null)
351
+ return null;
352
+ const dir = opts.sessionsDir ?? defaultMetaSessionsDir();
353
+ const { identities } = listAllMetaIdentities(readActiveStoreEntries(dir), makeStoreRecordReader(dir), {
354
+ mode: "strict",
355
+ });
356
+ const identity = identities.find((record) => record.nativeSessionId === threadId);
357
+ if (!identity)
358
+ return null;
359
+ if (identity.backend !== "codex")
360
+ throw new EntwurfCodexIdentityBackendError(identity);
361
+ return { identity, threadId, provenance: CODEX_BRIDGE_PROVENANCE_LABEL };
362
+ }
363
+ /**
364
+ * Two rails claim this MCP process for DIFFERENT addresses. Sibling of
365
+ * {@link EntwurfSenderIdentityAmbiguityError} and deliberately not the same error: that one is
366
+ * about two pid MARKERS on one owner pid, while this is about rails that do not even share a
367
+ * join key — a planted `PI_SESSION_ID` in a native host's environment, a pid marker inherited
368
+ * from a host whose child we are not, a codex `_meta` naming a thread while a marker names
369
+ * someone else. Which is right is unknowable here, and both are visible, so neither is used.
370
+ */
371
+ export class EntwurfSenderIdentityConflictError extends Error {
372
+ claims;
373
+ constructor(claims) {
374
+ super("entwurf-bridge refused: conflicting sender identity. This MCP process holds authoritative " +
375
+ `identity claims from more than one rail, naming DIFFERENT addresses (${claims
376
+ .map((claim) => `${claim.rail}=${claim.id}`)
377
+ .join(", ")}), so which one is calling cannot be determined. A send under the wrong identity ` +
378
+ "is worse than no send. Report it; do not work around it.");
379
+ this.name = "EntwurfSenderIdentityConflictError";
380
+ this.claims = [...claims];
381
+ }
382
+ }
383
+ /**
384
+ * Fold every rail's answer into ONE address, or refuse.
385
+ *
386
+ * 0 claims → `null` (anonymous; the bridge's default refusal owns that outcome).
387
+ * 1+ claims that AGREE on the id → that address, carrying every rail that named it. Agreement is
388
+ * the normal shape of an overlap, not a conflict: a codex citizen's request-scoped claim and a
389
+ * pid marker for the same garden id are two views of one identity.
390
+ * claims that DISAGREE → throw {@link EntwurfSenderIdentityConflictError}.
391
+ *
392
+ * Every claim is collected and compared BEFORE one is chosen — the same rule the marker rail
393
+ * holds one layer up. A rail-priority order would answer even when the truth is unknown, which
394
+ * is the failure this exists to prevent, so callers must pass ALL rails they resolved rather
395
+ * than short-circuiting on the first one that answered.
396
+ */
397
+ export function reconcileSenderIdentityClaims(claims) {
398
+ if (claims.length === 0)
399
+ return null;
400
+ const ids = new Set(claims.map((claim) => claim.id));
401
+ if (ids.size > 1)
402
+ throw new EntwurfSenderIdentityConflictError(claims);
403
+ const [id] = ids;
404
+ return {
405
+ id,
406
+ rails: [...new Set(claims.map((claim) => claim.rail))].sort(),
407
+ };
408
+ }
@@ -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
  *
@@ -77,17 +78,19 @@
77
78
  */
78
79
  import { randomBytes } from "node:crypto";
79
80
  import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
81
+ import { CODEX_PREFLIGHT_HINT } from "./codex-fresh-preflight.js";
80
82
  import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-preflight.js";
81
83
  import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
82
84
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
85
+ import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.js";
83
86
  import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
84
87
  import { classifyTmuxSessionName, resolveTmuxSessionId } from "./resolve-tmux-session.js";
85
88
  /** 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"];
89
+ * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
90
+ * by #95 after its system birth and app-server rails were measured. The set is joined to the
91
+ * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
92
+ * missing HERE is not an unwired convenience, it is a release blocker. */
93
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"];
91
94
  /**
92
95
  * The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
93
96
  * than a compiled-in location: the official binary is whatever the operator's environment gives
@@ -115,6 +118,7 @@ export const FRESH_CALL_RUNTIME = {
115
118
  "claude-code": "claude",
116
119
  copilot: "entwurf",
117
120
  omp: "omp",
121
+ codex: "codex",
118
122
  };
119
123
  /**
120
124
  * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
@@ -142,6 +146,7 @@ export const FRESH_CALL_CALLBACK_TOOL = {
142
146
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
143
147
  copilot: "entwurf-bridge-entwurf_v2",
144
148
  omp: "mcp__entwurf_bridge_entwurf_v",
149
+ codex: "mcp__entwurf_bridge__entwurf_v2",
145
150
  };
146
151
  /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
147
152
  * delivery surface, not a claim that a task of this size was measured through tmux. An argv
@@ -245,7 +250,7 @@ export function isSafeFreshCallModel(model) {
245
250
  * all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
246
251
  * carrier would hand callers the launch-shaping power this rail exists to refuse.
247
252
  */
248
- export function buildBackendArgs(backend, composition, model) {
253
+ export function buildBackendArgs(backend, composition, model, env = process.env) {
249
254
  switch (backend) {
250
255
  case "pi":
251
256
  return [composition.prompt, "--entwurf-control", "--model", model];
@@ -255,6 +260,15 @@ export function buildBackendArgs(backend, composition, model) {
255
260
  return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
256
261
  case "omp":
257
262
  return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
263
+ case "codex":
264
+ return [
265
+ "--remote",
266
+ `unix://${resolveCodexDefaultSocketPath(env)}`,
267
+ "--model",
268
+ model,
269
+ "--dangerously-bypass-approvals-and-sandbox",
270
+ composition.prompt,
271
+ ];
258
272
  }
259
273
  }
260
274
  /**
@@ -313,6 +327,20 @@ export function buildFreshCallPrompt(params) {
313
327
  params.task,
314
328
  ].join("\n");
315
329
  }
330
+ /**
331
+ * Codex's explicit home topology (#95): the operator provides this one existing tmux session
332
+ * and seats the operator-owned app-server plus supported Codex TUIs there. This is a placement
333
+ * convention, not an address axis — `threadId` remains the native delivery address.
334
+ */
335
+ export const CODEX_HOME_TMUX_SESSION = "codex";
336
+ /** An explicit seat always wins. Only omitted Codex placement gets the fixed home. */
337
+ export function selectFreshCallSeat(backend, placement) {
338
+ if (placement !== undefined)
339
+ return { tmuxSession: placement.tmuxSession, source: "requested" };
340
+ if (backend === "codex")
341
+ return { tmuxSession: CODEX_HOME_TMUX_SESSION, source: "codex-home" };
342
+ return null;
343
+ }
316
344
  /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
317
345
  * encoded any of those would invite exactly the guessing this rail exists to refuse. */
318
346
  export function mintNonce(randomHex = defaultRandomHex) {
@@ -337,9 +365,9 @@ function defaultRandomHex() {
337
365
  * for. That is sufficient and not a compromise: every reader of the carrier trims and tests
338
366
  * truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
339
367
  *
340
- * It is applied to all four backends because the leak is a property of tmux, not of a vendor. A
368
+ * It is applied to all five backends because the leak is a property of tmux, not of a vendor. A
341
369
  * 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
370
+ * four are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
343
371
  * authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
344
372
  * exactly that after this argv has run. This is a fixed two-variable seam and deliberately NOT a
345
373
  * general env carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping
@@ -416,8 +444,10 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
416
444
  // without tmux, so an unresolvable name is answered before anything else runs. Whether that
417
445
  // session EXISTS is a tmux question and is asked below, after the caller's own context is
418
446
  // 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;
447
+ // host with no tmux at all. Codex alone selects its fixed home when the caller omitted a seat;
448
+ // an explicit seat remains an expert override and is reported as such.
449
+ const selectedSeat = selectFreshCallSeat(params.backend, params.placement);
450
+ const seat = selectedSeat?.tmuxSession;
421
451
  if (seat !== undefined) {
422
452
  const badSeat = classifyTmuxSessionName(seat);
423
453
  if (badSeat)
@@ -473,7 +503,7 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
473
503
  }),
474
504
  bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
475
505
  };
476
- const run = runTmux(buildFreshCallArgs(targetSessionId, runtimePath, buildBackendArgs(params.backend, composition, model), cwd), env);
506
+ const run = runTmux(buildFreshCallArgs(targetSessionId, runtimePath, buildBackendArgs(params.backend, composition, model, env), cwd), env);
477
507
  assertTmuxOk("new-window", run);
478
508
  let fields;
479
509
  try {
@@ -494,7 +524,9 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
494
524
  backend: params.backend,
495
525
  model,
496
526
  ...(cwd === undefined ? {} : { cwd }),
497
- ...(seat === undefined ? {} : { tmuxSession: seat }),
527
+ ...(selectedSeat === null
528
+ ? {}
529
+ : { tmuxSession: selectedSeat.tmuxSession, tmuxSessionSource: selectedSeat.source }),
498
530
  runtimePath,
499
531
  nonce,
500
532
  },
@@ -507,6 +539,7 @@ const REJECT_HINT = {
507
539
  // the sentence an operator reads cannot drift away from the predicate that produced it.
508
540
  ...COPILOT_PREFLIGHT_HINT,
509
541
  ...OMP_PREFLIGHT_HINT,
542
+ ...CODEX_PREFLIGHT_HINT,
510
543
  "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
511
544
  "anchor-malformed": "TMUX_PANE is not a native pane id",
512
545
  "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
@@ -517,7 +550,7 @@ const REJECT_HINT = {
517
550
  "cwd-missing": "the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
518
551
  "cwd-not-directory": "the requested cwd exists but is not a directory",
519
552
  "tmux-session-name-invalid": "the requested tmux session name is outside the shape this rail addresses (start with a letter or digit, then letters, digits, '_' or '-') — some other shapes tmux cannot resolve at all ('#' is expanded when the name is stored; '.' and ':' are its own pane/window separators inside a target; a name like '$0' loses to the session id '$0'), and the rest are declined to keep one narrow grammar, so rename the session or open one whose name fits",
520
- "tmux-session-missing": "no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again",
553
+ "tmux-session-missing": "no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again; Codex's omitted-placement home is the exact session name 'codex'",
521
554
  "model-empty": "model is empty after trimming; fresh calls require an explicit model",
522
555
  "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
523
556
  "task-empty": "task is empty after trimming",
@@ -553,7 +586,9 @@ export function renderFreshCall(result) {
553
586
  (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
554
587
  (r.tmuxSession === undefined
555
588
  ? ""
556
- : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
589
+ : r.tmuxSessionSource === "codex-home"
590
+ ? ` seat: ${r.tmuxSession} (Codex home tmux session, resolved to ${r.sessionId})\n`
591
+ : ` seat: ${r.tmuxSession} (requested tmux session, resolved to ${r.sessionId})\n`) +
557
592
  ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
558
593
  ` pane: ${r.paneId} pid ${r.panePid}\n` +
559
594
  ` 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);