@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.
- package/AGENTS.md +85 -190
- package/BASELINE.md +6 -5
- package/CHANGELOG.md +220 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +202 -60
- package/README.md +54 -26
- package/VERIFY.md +37 -7
- package/docs/acp-backend-rail.md +31 -15
- package/docs/external-mcp-host.md +58 -35
- package/docs/fresh-cut-policy.md +3 -2
- package/docs/setup-clean-host.md +110 -17
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +101 -109
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +6 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +326 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +50 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
- package/mcp/entwurf-bridge/src/index.ts +115 -111
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +14 -5
- package/pi-extensions/entwurf-control.ts +71 -19
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +3 -3
- package/pi-extensions/lib/acp/event-mapper.ts +4 -4
- package/pi-extensions/lib/acp/tool-surface.ts +6 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +363 -0
- package/pi-extensions/lib/compaction-send-guard.ts +80 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
- package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
- package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
- package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
- package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
- package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
- package/pi-extensions/lib/meta-sender-identity.ts +305 -0
- package/pi-extensions/lib/mux-fresh-call.ts +64 -19
- package/pi-extensions/lib/native-push/adapter.ts +21 -24
- package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
- package/pi-extensions/lib/native-push/register.ts +7 -9
- package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
- package/run.sh +251 -35
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-agy-sender-identity.ts +1 -1
- package/scripts/check-codex-birth-hook.ts +264 -0
- package/scripts/check-codex-bridge-identity.ts +179 -0
- package/scripts/check-codex-native-push.ts +386 -0
- package/scripts/check-codex-sender-identity.ts +495 -0
- package/scripts/check-compaction-send-guard.ts +130 -0
- package/scripts/check-copilot-receive-arm.ts +4 -1
- package/scripts/check-entwurf-fact-provider.ts +38 -0
- package/scripts/check-entwurf-peers-surface.ts +13 -1
- package/scripts/check-entwurf-self-address.ts +15 -16
- package/scripts/check-entwurf-v2-contract.ts +4 -3
- package/scripts/check-entwurf-v2-decider.ts +7 -5
- package/scripts/check-entwurf-v2-native-push.ts +35 -7
- package/scripts/check-entwurf-v2-production.ts +203 -11
- package/scripts/check-entwurf-v2-runner.ts +1 -1
- package/scripts/check-entwurf-v2-surface.ts +1 -1
- package/scripts/check-gate-qualification.ts +7 -4
- package/scripts/check-harness-admission-parity.ts +0 -1
- package/scripts/check-install-surface.ts +23 -7
- package/scripts/check-mux-launch-tmux.ts +47 -2
- package/scripts/check-native-push-adapter.ts +20 -16
- package/scripts/check-native-push-register.ts +5 -1
- package/scripts/check-release-gate-outcomes.ts +47 -1
- package/scripts/check-setup-qualification.sh +3 -1
- package/scripts/codex-birth-doctor.sh +276 -0
- package/scripts/codex-birth-install.sh +414 -0
- package/scripts/codex-birth-uninstall.sh +170 -0
- package/scripts/codex-mcp-config.py +435 -0
- package/scripts/codex-statusline-config.py +434 -0
- package/scripts/codex_toml_io.py +532 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +113 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +399 -0
- package/scripts/lib/launch-receipt-windows.ts +46 -0
- package/scripts/lib/tmux-coordinate-row.ts +58 -0
- package/scripts/meta-bridge-fresh-cut.ts +6 -1
- package/scripts/mutants/codex-native.json +838 -0
- package/scripts/mutants/compaction-send-guard.json +103 -0
- package/scripts/mutants/entwurf-peers.json +19 -0
- package/scripts/mutants/mux-fresh-call.json +19 -8
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/pi-package-ownership.json +26 -0
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +53 -1
- package/scripts/pi_settings_io.py +3 -1
- package/scripts/raw-async-delivery/README.md +2 -1
- package/scripts/raw-codex-measure/README.md +114 -46
- package/scripts/register-pi-package.py +38 -30
- package/scripts/register-pi-provider.py +3 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-native-push-live.ts +3 -1
- package/scripts/smoke-codex-birth.sh +347 -0
- package/scripts/smoke-codex-config-state.sh +511 -0
- package/scripts/smoke-codex-fresh-live.ts +1186 -0
- package/scripts/smoke-codex-native-push-live.ts +75 -0
- package/scripts/smoke-setup-verdict.sh +123 -10
- package/scripts/smoke-user-scope-citizen.sh +199 -71
- 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
|
|
87
|
-
*
|
|
88
|
-
* `check-harness-admission-parity`: a harness that mints records but is
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
:
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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);
|