@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
@@ -5,23 +5,21 @@
5
5
  * ROUTING (the NATIVE_PUSH_DISPATCH_TABLE did) and carries NO release seam — a native-push
6
6
  * plan is the LOCK-FREE path (봉인 4), so a lock leak is structurally impossible here.
7
7
  *
8
- * This is where the 1-shot retry lives NOT in the adapter leaf (봉인 3). The decider
9
- * probed a fresh route and planted it in the plan; the executor sends over it, and on a
10
- * failure it re-probes ONCE (the volatile LS port may have shifted between decide and
11
- * execute) and re-sends. A second failure is fail-loud (no infinite loop). This mirrors the
12
- * control-socket send-fallback pattern: the decider stays pure, the hand owns the retry.
8
+ * Retry is an adapter capability, not a rail-wide assumption. Antigravity re-probes once
9
+ * because its volatile LS port can shift before send. Codex never retries: `codex queue`
10
+ * may have accepted the message before its receipt failed, so replay could duplicate it.
11
+ * The decider stays pure; this hand enforces the adapter's declared policy.
13
12
  *
14
13
  * A native-push send has NO in-band refuse (there is no live receiver to answer
15
14
  * success:false) — like a mailbox enqueue, it either succeeds or THROWS. The hand never
16
15
  * folds a throw into {success:false}; the runner maps a thrown error to execution-failed.
17
16
  */
17
+ import { formatMetaMailboxBody } from "./meta-mailbox-body.js";
18
18
  import { resolveNativePushAdapter } from "./native-push/adapter.js";
19
19
  /**
20
- * Deliver `content` into the conversation, owning the 1-shot retry (봉인 3). Send over the
21
- * planted (decider-probed) route first; on failure re-probe ONCE and re-send over the fresh
22
- * route; a second failure PROPAGATES (fail-loud). If the re-probe finds the target no longer
23
- * alive, throw — a failed send into a now-dead conversation is an honest non-delivery, never
24
- * a silent success. The re-probe is the ONLY re-derivation of the volatile route.
20
+ * Deliver `content` through the decider-probed route. A non-retriable adapter propagates
21
+ * the first failure unchanged. A retriable adapter re-probes once and sends once more only
22
+ * when the target is still alive; no adapter gets a third attempt.
25
23
  */
26
24
  export async function deliverViaNativePush(adapter, route, nativeSessionId, content) {
27
25
  try {
@@ -29,8 +27,9 @@ export async function deliverViaNativePush(adapter, route, nativeSessionId, cont
29
27
  return { success: true, retried: false };
30
28
  }
31
29
  catch (firstErr) {
32
- // 1-shot re-probe → re-send: the volatile LS route may have shifted since the decider
33
- // probed it. Re-discover it fresh and retry exactly once.
30
+ if (!adapter.retriable)
31
+ throw firstErr;
32
+ // One re-probe and one retry for adapters whose send boundary is safe to replay.
34
33
  const reprobe = await adapter.probe(nativeSessionId);
35
34
  if (reprobe.status !== "alive") {
36
35
  throw new Error(`native-push deliver: first send failed and re-probe found target ${reprobe.status} (${reprobe.reason}) — not retried`, { cause: firstErr });
@@ -42,16 +41,30 @@ export async function deliverViaNativePush(adapter, route, nativeSessionId, cont
42
41
  }
43
42
  /**
44
43
  * Build the production `sendNativePush(plan, lock)` adapter the runner consumes. It IGNORES
45
- * `lock` entirely (a native-push plan is lock-free, 봉인 4) the field exists only to match
46
- * the DispatchExecutorDeps hand signature. It resolves the adapter from the plan's backend
47
- * and delivers with the 1-shot retry. A delivery throw surfaces as a REJECTED promise (the
48
- * runner's try/catch maps it to execution-failed).
44
+ * `lock` entirely (a native-push plan is lock-free, 봉인 4), resolves the adapter, then applies
45
+ * that adapter's retry policy. A delivery throw surfaces as a REJECTED promise (the runner's
46
+ * try/catch maps it to execution-failed).
47
+ *
48
+ * SENDER ENVELOPE (measured #95). Direct injection used to hand the adapter the raw
49
+ * `plan.message`, so a native-push citizen received a body with no sender. The #95 A LIVE run
50
+ * measured what that costs: a fresh Pi's nonce-only callback landed in the Codex thread as
51
+ * bare text, and Codex could not name who called it — the callback was uncorrelatable. The
52
+ * control-socket rail carries the envelope in its RPC framing and the mailbox rail serializes
53
+ * it into the body; this rail has neither, so it renders the SAME `formatMetaMailboxBody` SSOT
54
+ * the mailbox uses. Rendered ONCE, before `deliverViaNativePush`, so an Antigravity re-probe
55
+ * retry replays byte-identical content (including its timestamp) instead of a second render.
56
+ * The adapter stays a dumb pipe — its signature is unchanged and it never learns about senders.
49
57
  */
50
58
  export function makeNativePushSend(deps = {}) {
51
59
  const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
60
+ const senderProvider = deps.senderProvider;
52
61
  // `_lock` is named for the hand contract but NEVER read — native-push owns/releases no lock.
53
62
  return async (plan, _lock) => {
63
+ // Resolve the sender BEFORE the adapter is resolved or touched: a provider throw must
64
+ // reject this send with nothing injected, not leave a half-delivered conversation.
65
+ const sender = senderProvider?.();
66
+ const content = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
54
67
  const adapter = resolveAdapter(plan.backend);
55
- return deliverViaNativePush(adapter, plan.route, plan.nativeSessionId, plan.message);
68
+ return deliverViaNativePush(adapter, plan.route, plan.nativeSessionId, content);
56
69
  };
57
70
  }
@@ -210,7 +210,13 @@ export function makeProductionEntwurfV2Deps(opts) {
210
210
  sendMailbox: (plan, _lock) => sendViaMailbox(plan, _lock),
211
211
  // native-push (봉인 4): the SAME injected adapter resolver drives the executor send,
212
212
  // so the decider's probe and the delivery use one adapter. Lock-free (lock ignored).
213
- sendNativePush: makeNativePushSend({ resolveAdapter: io.resolveNativePushAdapter }),
213
+ // It also receives the SAME `opts.senderProvider` the mailbox hand got — one envelope
214
+ // source for all three rails, so a native-push citizen can name its sender exactly as
215
+ // a mailbox citizen does (#95: an envelope-less direct injection is uncorrelatable).
216
+ sendNativePush: makeNativePushSend({
217
+ resolveAdapter: io.resolveNativePushAdapter,
218
+ senderProvider: opts.senderProvider,
219
+ }),
214
220
  };
215
221
  return {
216
222
  decide: (input) => decideDispatch(input, deciderDeps),
@@ -12,8 +12,9 @@
12
12
  *
13
13
  * Why a result type richer than the receipt: the carry-over contracts from 5c demand it.
14
14
  * - N3 (5c-2b): a dead-path re-resolve `rejected` carries the resolver's `rejectReason`
15
- * (dormant-fire-forget-unsupported / mailbox-undeliverable / …). The runner carries it
16
- * verbatim so the surface distinguishes "in-band refusal" from "no live route".
15
+ * (dormant-fire-forget-unsupported / mailbox-undeliverable / …), while an in-band
16
+ * refusal carries its supplied receiver error. The runner carries either verbatim;
17
+ * receiver evidence is not reclassified as resolver taxonomy.
17
18
  * - N1 (5c-2a): a non-`failed` outcome whose `releaseLock` then threw is a
18
19
  * `SendDeliveredReleaseFailedError` — the delivery HAPPENED, the lock is dirty, a
19
20
  * re-send would double-deliver. The runner surfaces this as `execution-failed` with
@@ -76,8 +76,9 @@ async function driveSend(plan, lock, deps) {
76
76
  // dead ⇒ proven non-delivery ⇒ same-lock one-shot re-resolve (lock still held).
77
77
  return await driveDeadFallback(plan, lock, deps);
78
78
  }
79
- // A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback.
80
- return { outcome: result.success ? "sent" : "rejected" };
79
+ // A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback. Preserve
80
+ // its receiver error verbatim when supplied; do not make a reason up when absent.
81
+ return result.success ? { outcome: "sent" } : inBandRejected(result);
81
82
  }
82
83
  /**
83
84
  * The dead-path fallback: re-resolve ONCE under the held lock, then run the resolver's
@@ -85,6 +86,9 @@ async function driveSend(plan, lock, deps) {
85
86
  * re-enter the fallback, it finalizes as failed). The hand only executes; the resolver
86
87
  * decided.
87
88
  */
89
+ function inBandRejected(result) {
90
+ return { outcome: "rejected", rejectReason: result.error === "" ? undefined : result.error };
91
+ }
88
92
  async function driveDeadFallback(plan, lock, deps) {
89
93
  let resolution;
90
94
  try {
@@ -112,7 +116,7 @@ async function driveDeadFallback(plan, lock, deps) {
112
116
  // connect failure finalizes as failed (no further fallback).
113
117
  try {
114
118
  const r = await deps.sendOverSocket(rePlan);
115
- return { outcome: r.success ? "fallback-sent" : "rejected" };
119
+ return r.success ? { outcome: "fallback-sent" } : inBandRejected(r);
116
120
  }
117
121
  catch (err) {
118
122
  return { outcome: "failed", error: err };
@@ -125,7 +129,7 @@ async function driveDeadFallback(plan, lock, deps) {
125
129
  // #98 R: this leg writes a `.msg` exactly like the primary mailbox rail, so it
126
130
  // owes the sender the same per-message receipt. Carried only on success — a
127
131
  // `rejected` enqueue wrote no file to name.
128
- return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : { outcome: "rejected" };
132
+ return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : inBandRejected(r);
129
133
  }
130
134
  catch (err) {
131
135
  return { outcome: "failed", error: err };
@@ -67,11 +67,14 @@ export function actionableRejectHint(reason) {
67
67
  "Re-run entwurf_peers to re-probe; if it stays indeterminate, check for a stale socket file left at " +
68
68
  "that garden id by a session that died without cleaning up.");
69
69
  case "native-push-target-dead":
70
- // The adapter probe found no live host process for the conversation.
71
- return "native-push conversation is not live (no host process found). Re-open the conversation, then retry there is nothing to inject into.";
70
+ // The adapter probe found no live native host serving this conversation/thread. Stays
71
+ // backend-neutral: agy speaks conversations over an LS port, Codex threads over an
72
+ // app-server socket, and this hint is read by callers of both.
73
+ 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.";
72
74
  case "native-push-probe-indeterminate":
73
- // Host up, but no LS port served the conversation — inconclusive, not a hard dead.
74
- 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.";
75
+ // Native host up, but nothing served this conversation/thread — inconclusive, not a
76
+ // hard dead. Backend-neutral for the same reason as the dead cell above.
77
+ 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.";
75
78
  case "record-less-socket":
76
79
  // #50 C4: name the true cause AND the fix — a bare socket is a diagnostic
77
80
  // state, not an addressable citizen (the record is the address).
@@ -1,22 +1,29 @@
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
  /** `~`-abbreviate a home-relative cwd for display. Reads process.env.HOME. */
22
29
  function abbreviateHome(cwd) {
@@ -41,7 +48,9 @@ function formatKstTimestamp(iso) {
41
48
  `${pad(kst.getUTCHours())}:${pad(kst.getUTCMinutes())}:${pad(kst.getUTCSeconds())} KST`);
42
49
  }
43
50
  /**
44
- * Render the full mailbox body: header envelope + separator + message. A
51
+ * Render the full body: header envelope + separator + message. The mailbox writes this
52
+ * into its message file and the native-push rail injects it directly into a live
53
+ * conversation; both present the sender identically. A
45
54
  * replyable sender (pi-session, or a trusted meta-session) advertises its
46
55
  * sessionId as the reply address; a non-replyable sender says so WITHOUT
47
56
  * losing its origin — a record-backed meta-session that is currently inactive
@@ -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
+ }