@junghanacs/entwurf 0.35.0 → 0.36.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 (91) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +338 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +15 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +14 -11
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +29 -3
  10. package/docs/setup-clean-host.md +31 -23
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +178 -25
  23. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  24. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  25. package/package.json +20 -15
  26. package/pi/pi-durable/bootstrap.mjs +12 -8
  27. package/pi/pi-durable/carrier/runtime.js +52 -2
  28. package/pi/pi-durable/carrier/subagent.js +6 -1
  29. package/pi/pi-durable/carrier/tui.js +31 -4
  30. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  31. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  32. package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
  33. package/pi-extensions/lib/acp/model-ids.js +77 -0
  34. package/pi-extensions/lib/acp/models.ts +15 -22
  35. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  36. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
  37. package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
  38. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  39. package/pi-extensions/lib/mux-fresh-call.ts +3 -0
  40. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  41. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  42. package/pi-extensions/lib/resume-launch-identity.ts +12 -4
  43. package/pi-extensions/meta-bridge-pi-durable.ts +230 -34
  44. package/run.sh +193 -58
  45. package/scripts/check-acp-backend-preflight.ts +10 -2
  46. package/scripts/check-acp-cortex.ts +7 -6
  47. package/scripts/check-acp-prompt-lifecycle.ts +2 -2
  48. package/scripts/check-acp-provider-surface.ts +1 -1
  49. package/scripts/check-acp-session-reuse.ts +5 -5
  50. package/scripts/check-acp-stop-reason.ts +2 -2
  51. package/scripts/check-acp-stream-hooks.ts +2 -2
  52. package/scripts/check-acp-usage-accounting.ts +2 -2
  53. package/scripts/check-codex-app-server-launch.ts +55 -12
  54. package/scripts/check-codex-native-push.ts +231 -12
  55. package/scripts/check-fresh-call-dispatch.ts +47 -4
  56. package/scripts/check-gate-qualification.ts +8 -6
  57. package/scripts/check-probe-ordering.ts +13 -6
  58. package/scripts/check-resume-launch-identity.ts +38 -0
  59. package/scripts/codex-app-server-launch.sh +54 -29
  60. package/scripts/codex-socket-classify.ts +46 -0
  61. package/scripts/emit-pi-durable-carrier.ts +159 -168
  62. package/scripts/mutants/acp-cortex.json +17 -8
  63. package/scripts/mutants/acp-set-model-readback.json +28 -0
  64. package/scripts/mutants/codex-app-server-launch.json +39 -12
  65. package/scripts/mutants/codex-native.json +15 -0
  66. package/scripts/mutants/control-send-receipt.json +1 -1
  67. package/scripts/mutants/fresh-call-dispatch.json +11 -0
  68. package/scripts/mutants/herdr-dev-checkout.json +70 -0
  69. package/scripts/mutants/pack-install.json +3 -3
  70. package/scripts/mutants/pi-durable.json +282 -16
  71. package/scripts/mutants/pi-floor.json +5 -5
  72. package/scripts/mutants/resume-launch-identity.json +15 -0
  73. package/scripts/mutants/v2-visible-resume.json +11 -0
  74. package/scripts/pi-durable-contact-host.mjs +1 -1
  75. package/scripts/resolve-acp-bridge.ts +1 -1
  76. package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
  77. package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
  78. package/scripts/smoke-acp-long-turn-live.ts +1 -1
  79. package/scripts/smoke-acp-mcp-live.ts +1 -1
  80. package/scripts/smoke-acp-memory-containment-live.ts +1 -1
  81. package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
  82. package/scripts/smoke-acp-overlay-live.ts +1 -1
  83. package/scripts/smoke-acp-provider-live.ts +1 -1
  84. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  85. package/scripts/smoke-acp-session-reuse-live.ts +1 -1
  86. package/scripts/smoke-acp-skill-live.ts +1 -1
  87. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  88. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  89. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  90. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  91. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -15,30 +15,108 @@ export function resolveCodexHome(env) {
15
15
  export function resolveCodexDefaultSocketPath(env) {
16
16
  return path.join(resolveCodexHome(env), "app-server-control", "app-server-control.sock");
17
17
  }
18
- export function checkCodexSocketFile(socketPath) {
18
+ /**
19
+ * The advertised path is exactly one of two layouts, and anything else is refused.
20
+ *
21
+ * (A) A plain socket owned by the effective uid: Codex up to 0.153.4 binds the advertised
22
+ * path itself. `connectPath` is that path.
23
+ * (B) Codex's rendezvous alias. From 0.162.1 the advertised path "is only an alias": the
24
+ * server binds a 0600 socket under `realpath(/tmp)/codex-daemon-<euid>/` named by the
25
+ * sha256 of the canonical advertised path, then symlinks the advertised path to it
26
+ * (`[source]` rust-v0.162.1 `app-server-transport/src/transport/unix_socket.rs:43-131,
27
+ * 290-304`; `uds/src/daemon_directory.rs:1-38`). Upstream's reason is a privileged
28
+ * rendezvous root that sandboxes hide; the alias also keeps an over-long advertised path
29
+ * usable (`uds/src/lib.rs:167-174`). The alias is accepted only when every hop is the
30
+ * vendor's own: the link is owned by the effective uid; its target is BYTE-EQUAL to the
31
+ * path Codex derives (a relative, foreign, re-hashed or chained target is refused); the
32
+ * daemon directory is a real directory owned by the effective uid with `mode & 0o777`
33
+ * exactly 0700, the vendor's own rule; and the target is a socket owned by the effective
34
+ * uid that no group or other user can open. `connectPath` is the verified target, so the
35
+ * alias is never re-resolved between this check and the connect. A same-euid process can
36
+ * still replace the verified socket file itself; that residual is named, not locked.
37
+ *
38
+ * Only `lstat` and one `readlink` touch the alias; it is never followed or `realpath`ed.
39
+ * The effective uid is the vendor's (`libc::geteuid`), not the real uid.
40
+ */
41
+ export function checkCodexSocketFile(socketPath, deps = {}) {
42
+ const euid = deps.euid ?? process.geteuid?.();
43
+ let stat;
19
44
  try {
20
- const stat = fs.lstatSync(socketPath);
21
- if (stat.isSymbolicLink()) {
22
- return { ok: false, status: "indeterminate", reason: `app-server socket is a symlink: ${socketPath}` };
23
- }
24
- if (!stat.isSocket()) {
25
- return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
45
+ stat = fs.lstatSync(socketPath);
46
+ }
47
+ catch (error) {
48
+ const code = error.code;
49
+ if (code === "ENOENT") {
50
+ return { ok: false, status: "dead", layout: "absent", reason: `app-server socket is absent: ${socketPath}` };
26
51
  }
27
- if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
52
+ return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
53
+ }
54
+ if (stat.isSymbolicLink())
55
+ return checkCodexRendezvousAlias(socketPath, stat, euid, deps);
56
+ if (!stat.isSocket()) {
57
+ return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
58
+ }
59
+ if (euid !== undefined && stat.uid !== euid) {
60
+ return {
61
+ ok: false,
62
+ status: "indeterminate",
63
+ reason: `app-server socket is owned by uid ${stat.uid}, not the effective uid ${euid}`,
64
+ };
65
+ }
66
+ return { ok: true, connectPath: socketPath };
67
+ }
68
+ function checkCodexRendezvousAlias(socketPath, link, euid, deps) {
69
+ const refuse = (reason) => ({ ok: false, status: "indeterminate", reason });
70
+ if (euid === undefined) {
71
+ return refuse(`app-server socket is a symlink and this platform has no effective uid to verify it: ${socketPath}`);
72
+ }
73
+ if (link.uid !== euid) {
74
+ return refuse(`app-server socket symlink is owned by uid ${link.uid}, not the effective uid ${euid}`);
75
+ }
76
+ let daemonRoot;
77
+ let expected;
78
+ let target;
79
+ try {
80
+ daemonRoot = deps.daemonRoot ?? path.join(fs.realpathSync("/tmp"), `codex-daemon-${euid}`);
81
+ const canonical = path.join(fs.realpathSync(path.dirname(socketPath)), path.basename(socketPath));
82
+ expected = path.join(daemonRoot, crypto.createHash("sha256").update(canonical).digest("hex"));
83
+ target = fs.readlinkSync(socketPath);
84
+ }
85
+ catch (error) {
86
+ return refuse(`could not verify the app-server socket symlink ${socketPath}: ${error instanceof Error ? error.message : String(error)}`);
87
+ }
88
+ if (target !== expected) {
89
+ return refuse(`app-server socket is a symlink that is not Codex's rendezvous alias: ${socketPath} -> ${target}`);
90
+ }
91
+ let directory;
92
+ try {
93
+ directory = fs.lstatSync(daemonRoot);
94
+ }
95
+ catch (error) {
96
+ return refuse(`Codex's socket directory cannot be read: ${error instanceof Error ? error.message : String(error)}`);
97
+ }
98
+ if (!directory.isDirectory() || directory.uid !== euid || (directory.mode & 0o777) !== 0o700) {
99
+ return refuse(`Codex's socket directory ${daemonRoot} must be a real directory owned by uid ${euid} with mode 0700`);
100
+ }
101
+ let physical;
102
+ try {
103
+ physical = fs.lstatSync(expected);
104
+ }
105
+ catch (error) {
106
+ if (error.code === "ENOENT") {
28
107
  return {
29
108
  ok: false,
30
- status: "indeterminate",
31
- reason: `app-server socket is owned by uid ${stat.uid}, not ${process.getuid()}`,
109
+ status: "dead",
110
+ layout: "dangling-alias",
111
+ reason: `app-server socket is a dangling rendezvous alias: ${socketPath} -> ${expected}`,
32
112
  };
33
113
  }
34
- return { ok: true };
114
+ return refuse(error instanceof Error ? error.message : String(error));
35
115
  }
36
- catch (error) {
37
- const code = error.code;
38
- if (code === "ENOENT")
39
- return { ok: false, status: "dead", reason: `app-server socket is absent: ${socketPath}` };
40
- return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
116
+ if (!physical.isSocket() || physical.uid !== euid || (physical.mode & 0o077) !== 0) {
117
+ return refuse(`Codex's physical socket ${expected} must be a socket owned by uid ${euid} that no group or other user can open`);
41
118
  }
119
+ return { ok: true, connectPath: expected };
42
120
  }
43
121
  function promiseResolvers() {
44
122
  return Promise.withResolvers();
@@ -302,10 +380,13 @@ const realCodexExecRunner = {
302
380
  },
303
381
  };
304
382
  export async function probeCodexThread(nativeSessionId, deps = {}) {
305
- const socketPath = resolveCodexDefaultSocketPath(deps.env ?? process.env);
306
- const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
383
+ const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(resolveCodexDefaultSocketPath(deps.env ?? process.env));
307
384
  if (!socketCheck.ok)
308
385
  return { status: socketCheck.status, reason: socketCheck.reason };
386
+ // The probe, and the queue send its route carries, both use the endpoint just verified. The
387
+ // advertised path is not resolved again: on 0.162.1 it is an alias that could be retargeted
388
+ // between the check and a second transport.
389
+ const socketPath = socketCheck.connectPath;
309
390
  let protocol;
310
391
  try {
311
392
  protocol = await (deps.openProtocol ?? realCodexProtocolOpener).open(socketPath);
@@ -3,9 +3,9 @@
3
3
  * under (#129 W, #130).
4
4
  *
5
5
  * The experimental durable app is source-only upstream: published `@earendil-works/pi-coding-agent`
6
- * ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — the pinned
7
- * upstream files, type-stripped and relocated (pi/pi-durable/carrier/, emitted only by the
8
- * maintainer's scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
6
+ * ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — its owned
7
+ * source (pi/pi-durable/src, #141), type-stripped and relocated (pi/pi-durable/carrier/, emitted only
8
+ * by scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
9
9
  * against as exact production dependencies. Nothing is provisioned beside the package: there is no
10
10
  * runtime directory, no source checkout and no model data to copy (it rides the published pi-ai).
11
11
  *
@@ -19,11 +19,12 @@
19
19
  */
20
20
  import { existsSync } from "node:fs";
21
21
  import path from "node:path";
22
+ import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
22
23
  import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.js";
23
24
  import { readAddressableMetaIdentity } from "./meta-session.js";
24
25
  /**
25
- * The ONE expected refusal on this leaf: the target is a citizen of a backend that has no
26
- * same-id resume at all.
26
+ * One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
27
+ * same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
27
28
  *
28
29
  * Everything else this function throws is a defect in the record, the transcript or the
29
30
  * environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
@@ -126,11 +127,19 @@ export function resolveResumeLaunchIdentity(gardenId) {
126
127
  if (!identity.cwd) {
127
128
  throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
128
129
  }
130
+ // #128: the pair checked is the one the launch will EMIT, not the recorded provider alone —
131
+ // `getEntwurfExplicitExtensions` also routes a bare Claude model id to `entwurf`. Under that
132
+ // provider Pi resolves `--model` by substring, so a retired `claude-sonnet-5` would reopen on
133
+ // `cortex-claude-sonnet-5`. The record and transcript stay as they are; the resume is refused.
134
+ const provider = explicitExtensions.provider ?? identity.provider;
135
+ const model = explicitExtensions.modelOverride ?? resumeModel;
136
+ if (provider === PROVIDER_ID && !isCuratedAcpModelId(model))
137
+ throw new AcpModelNotCuratedError(model);
129
138
  return {
130
139
  sessionFile,
131
140
  cwd: identity.cwd,
132
141
  explicitExtensionArgs: explicitExtensions.args,
133
- provider: explicitExtensions.provider ?? identity.provider,
134
- model: explicitExtensions.modelOverride ?? resumeModel,
142
+ provider,
143
+ model,
135
144
  };
136
145
  }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * WHAT THIS IS. The long-running durable app (`@earendil-works/pi-coding-agent`
5
5
  * `experimental/durable`, which upstream omits from its npm distribution — Entwurf's package
6
- * supplies it as a pinned carrier, #130, not as an operator prerequisite) owns its runtime, TUI,
7
- * models/auth, storage, prompt and recovery. This module is the whole Entwurf side of its contact:
6
+ * supplies it as a carrier emitted from its owned source, #130/#141, not as an operator
7
+ * prerequisite) owns its runtime, TUI, models/auth, storage, prompt and recovery. This module is
8
+ * the whole Entwurf side of its contact:
8
9
  *
9
10
  * createPiDurableContact({ bridgeEntry })
10
11
  * .extension tools-only durable Extension, handed to the overlay's
@@ -49,14 +50,34 @@
49
50
  * bridge whose list differs: the callable bridge schema is the truth, the copy only has to agree.
50
51
  *
51
52
  * RECEIVE (self-fetch, announce-only). When the bootstrap hands `attach` the overlay's
52
- * `submitToRoot`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
53
+ * `submitToRoot` and `onFailed`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
53
54
  * then the watch, then a check that the record and this process's sender marker still name the
54
55
  * session, and only then the receiver marker. A fresh `.msg` is stamped `.msg.delivered` and
55
56
  * announced to the ROOT conversation as input; the body stays in the mailbox until the model
56
57
  * calls `entwurf_inbox_read`, which is the read receipt. The watch and the sender marker share
57
58
  * this process's pid, so the #101 owner join applies (`pi-durable-host`). A watch error, a
58
- * vanished signal, a failed ring, a rejected admission or close retires the doorbell — watch
59
- * closed, our own marker removed, the reason kept by name — and nothing retries it.
59
+ * vanished signal, a failed ring, a rejected admission, the session failing (at once, from the
60
+ * overlay's `onFailed`, not at the next ring — and before `attach` returns when it had already
61
+ * failed, with no receiver marker left behind) or close retires the doorbell — watch closed, our own
62
+ * marker removed, the reason kept by name — and nothing retries it.
63
+ *
64
+ * WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
65
+ * unread body. So after a read the notices still queued for that mail would each cost a turn and an
66
+ * empty read. The contact keeps the native `Submission` handle of each notice it admitted, with the
67
+ * `.msg` names that notice announced, only while that mail is unread. After every
68
+ * `entwurf_inbox_read` attempt — whatever its garden id or outcome — it lists its OWN mailbox once
69
+ * and withdraws, through the native `Submission.abort` and the read's own invocation Context, each
70
+ * notice none of whose names is still `.msg.delivered`. That per-notice file predicate is the only
71
+ * authority: no garden-id guard, no requestId lookup, no queue of our own, no rewritten text. A placed
72
+ * or settled notice answers so and is let go; a withdraw that fails is let go too, named in one
73
+ * stderr line and on the receiver report, never retried and never retiring the doorbell; a mailbox
74
+ * that cannot be listed proves nothing absent, so every notice is kept and that is named the same
75
+ * way. The read's own result or error is returned unchanged — no diagnostic joins it. Ring passes
76
+ * and reads share one gate: a read waits for an admission in flight (else its drain could beat the
77
+ * handle it must withdraw), and an arrival during a read is rung once the read lets go. The count
78
+ * in a notice is its admission-time snapshot. Not covered: notices admitted before a restart (no
79
+ * handle), mail drained by another process (until this contact's next read), and operator abort,
80
+ * which natively withdraws queued notices while their mail stays unread.
60
81
  *
61
82
  * VISIBLE IDENTITY (a static projection). `withGardenIdentity` hands the native TUI a view whose
62
83
  * every conversation label carries this citizen's garden id, so the footer the TUI keeps on screen
@@ -79,7 +100,8 @@
79
100
  *
80
101
  * NOT HERE: crash-safe receive. `entwurf_inbox_read` archives a body before the durable tool
81
102
  * result commits, so a crash between the two loses that body to the model; recovery settles the
82
- * call as interrupted and no doorbell rings again. No resume.
103
+ * call as interrupted and no doorbell rings again. A drain that fails part-way has archived the
104
+ * bodies before it (the bridge returns its error), and those too never reach the model. No resume.
83
105
  */
84
106
  import * as fs from "node:fs";
85
107
  import * as path from "node:path";
@@ -461,10 +483,11 @@ export function createPiDurableContact(options) {
461
483
  throw new Error(`${name} arguments must be an object`);
462
484
  }
463
485
  const client = await waitReady(context?.abortSignal);
464
- const result = await client.callTool(name, (args ?? {}), context?.abortSignal);
486
+ const call = () => client.callTool(name, (args ?? {}), context?.abortSignal);
487
+ const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
465
488
  if (result.isError)
466
489
  throw new Error(result.text || `${name} failed`);
467
- return { content: [{ type: "text", text: result.text }] };
490
+ return { output: [{ type: "text", text: result.text }] };
468
491
  },
469
492
  });
470
493
  const extension = {
@@ -475,8 +498,33 @@ export function createPiDurableContact(options) {
475
498
  let doorbell = null;
476
499
  let report = { status: "off" };
477
500
  const admissions = [];
478
- let ringing = false;
501
+ /** The armed mailbox, kept past a retirement so notices admitted before it can still be judged. */
502
+ let inbox = null;
503
+ /** Every admitted notice still owed a withdraw judgement, oldest first. */
504
+ const notices = [];
505
+ let withdrawalFailure;
506
+ // One gate for ring passes and inbox reads; `pending` is a signal that landed while it was held.
507
+ let gated = false;
479
508
  let pending = false;
509
+ const gateWaiters = [];
510
+ const enterGate = () => {
511
+ if (!gated) {
512
+ gated = true;
513
+ return Promise.resolve();
514
+ }
515
+ return new Promise((resolve) => gateWaiters.push(resolve));
516
+ };
517
+ /** Hand the gate to the next waiting read, else open it — and ring once for a signal that landed meanwhile. */
518
+ const leaveGate = () => {
519
+ const next = gateWaiters.shift();
520
+ if (next !== undefined) {
521
+ next();
522
+ return;
523
+ }
524
+ gated = false;
525
+ if (pending)
526
+ void ring("signal");
527
+ };
480
528
  /**
481
529
  * Give the doorbell back: close the watch and remove the marker, in one step, because a
482
530
  * closed watch under a surviving marker is the false deliverability this unit refuses. Only a
@@ -489,6 +537,7 @@ export function createPiDurableContact(options) {
489
537
  if (armed === null)
490
538
  return [];
491
539
  doorbell = null;
540
+ armed.unsubscribeFailed();
492
541
  const problems = [];
493
542
  try {
494
543
  armed.watcher.close();
@@ -520,20 +569,20 @@ export function createPiDurableContact(options) {
520
569
  /**
521
570
  * One contract with the other self-fetch doorbells: a fresh `*.msg` is the trigger, it is
522
571
  * stamped `.msg.delivered` before the announce, and the announced count is every
523
- * `.msg.delivered`. Events that land while an admission is in flight coalesce into one more
524
- * pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg` rings —
525
- * the startup pass included — so a `.msg.delivered` left undrained by an earlier host is not
526
- * announced again on its own; it shows only in the count of the next fresh arrival.
572
+ * `.msg.delivered`. Events that land while an admission or an inbox read holds the gate coalesce
573
+ * into one more pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
574
+ * rings — the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
575
+ * not announced again on its own; it shows only in the count of the next fresh arrival.
527
576
  */
528
577
  const ring = async (why) => {
529
578
  const armed = doorbell;
530
579
  if (armed === null)
531
580
  return;
532
- if (ringing) {
581
+ if (gated) {
533
582
  pending = true;
534
583
  return;
535
584
  }
536
- ringing = true;
585
+ gated = true;
537
586
  try {
538
587
  do {
539
588
  pending = false;
@@ -568,22 +617,83 @@ export function createPiDurableContact(options) {
568
617
  }
569
618
  // Latest only: the diagnostic names the LAST success and never grows with the host's lifetime.
570
619
  admissions.splice(0, admissions.length, { why, requestId, submissionId: submission.id, fresh, unread });
620
+ notices.push({ requestId, fresh, submission });
571
621
  } while (pending && doorbell === armed);
572
622
  }
573
623
  catch (error) {
574
624
  retire("doorbell-failed", describeError(error));
575
625
  }
576
626
  finally {
577
- ringing = false;
627
+ leaveGate();
628
+ }
629
+ };
630
+ /**
631
+ * One named stderr line per failed withdrawal — stderr ALWAYS, as `entwurf-control` does, since
632
+ * nothing in the native app reads the receiver report — and the report keeps the latest one.
633
+ */
634
+ const noteWithdrawalFailure = (failure) => {
635
+ withdrawalFailure = failure;
636
+ const subject = failure.requestId === undefined ? "" : ` ${failure.requestId}`;
637
+ const detail = failure.detail.replace(/\s+/g, " ");
638
+ process.stderr.write(`[entwurf pi-durable] notice withdrawal ${failure.reason}${subject}: ${detail}\n`);
639
+ };
640
+ /**
641
+ * Withdraw every held notice none of whose announced `.msg` names is still `.msg.delivered` in the
642
+ * armed mailbox, listed once. A mailbox that cannot be listed proves nothing absent: every notice
643
+ * is kept and that is named. A judged notice is let go whatever the native withdraw answers — or
644
+ * when it rejects, which is named and never retried — so no handle outlives its mail by more
645
+ * than one read. Never throws: the read's own answer is what the caller gets.
646
+ */
647
+ const withdrawDrained = async (context) => {
648
+ if (inbox === null || notices.length === 0)
649
+ return;
650
+ let unread;
651
+ try {
652
+ unread = new Set(fs.readdirSync(inbox).filter((name) => name.endsWith(".msg.delivered")));
653
+ }
654
+ catch (error) {
655
+ noteWithdrawalFailure({ reason: "inspection-failed", detail: `${inbox}: ${describeError(error)}` });
656
+ return;
657
+ }
658
+ for (const notice of [...notices]) {
659
+ if (notice.fresh.some((name) => unread.has(`${name}.delivered`)))
660
+ continue;
661
+ notices.splice(notices.indexOf(notice), 1);
662
+ try {
663
+ await notice.submission.abort(context);
664
+ }
665
+ catch (error) {
666
+ noteWithdrawalFailure({ reason: "abort-failed", requestId: notice.requestId, detail: describeError(error) });
667
+ }
668
+ }
669
+ };
670
+ /**
671
+ * An inbox read under the doorbell's gate, then the withdrawal its drain made due — after a
672
+ * failed read too, whose partial drain may already have archived mail. The bridge's own answer,
673
+ * result or rejection, is returned unchanged.
674
+ */
675
+ const readInbox = async (call, context) => {
676
+ await enterGate();
677
+ try {
678
+ return await call();
679
+ }
680
+ finally {
681
+ try {
682
+ await withdrawDrained(context);
683
+ }
684
+ finally {
685
+ leaveGate();
686
+ }
578
687
  }
579
688
  };
580
689
  /**
581
690
  * Mailbox, signal, watch — then the check that the record and this process's sender marker
582
691
  * still name the session — and only then the receiver marker, the one fact a sender reads.
583
692
  * Synchronous, so nothing can close the contact between the check and the publish. Throws,
584
- * after closing whatever it opened, when any step fails.
693
+ * after closing whatever it opened, when any step fails. Returns null when the session had
694
+ * already failed: the doorbell was retired as `session-failed` before this returned.
585
695
  */
586
- const arm = (gardenId, nativeSessionId, ownerPid, roots, submitToRoot) => {
696
+ const arm = (gardenId, nativeSessionId, ownerPid, roots, root) => {
587
697
  if (ownerPid !== process.pid) {
588
698
  throw new Error(`the doorbell's watch lives in this process (${process.pid}); a receiver marker naming owner ${ownerPid} would advertise a watch that pid does not hold`);
589
699
  }
@@ -593,6 +703,7 @@ export function createPiDurableContact(options) {
593
703
  if (!fs.existsSync(signal))
594
704
  fs.writeFileSync(signal, "", { mode: 0o600 });
595
705
  const watcher = watch(signal, () => void ring("signal"));
706
+ let unsubscribeFailed = () => { };
596
707
  try {
597
708
  watcher.on("error", (error) => retire("watch-error", describeError(error)));
598
709
  const identity = readAddressableMetaIdentity(gardenId, roots.sessionsDir);
@@ -605,6 +716,22 @@ export function createPiDurableContact(options) {
605
716
  ? `no live sender marker for owner ${ownerPid}`
606
717
  : `the sender marker for owner ${ownerPid} names ${sender.gardenId}/${sender.nativeSessionId}, not ${gardenId}/${nativeSessionId}`);
607
718
  }
719
+ // A failed session admits nothing: retire at once, so no sender reads a marker for a host that cannot wake.
720
+ // Subscribed before the marker is written, so a registration that throws leaves no marker. A session that
721
+ // failed before this arm says so during the subscription, before the doorbell exists: that is noted, and
722
+ // the retirement runs once the doorbell is published below. It is final; nothing re-arms.
723
+ let published = false;
724
+ let failedBeforePublished = false;
725
+ const sessionFailed = () => {
726
+ if (!published) {
727
+ failedBeforePublished = true;
728
+ return;
729
+ }
730
+ const problems = retire("session-failed", "the durable session failed and closed itself");
731
+ if (problems.length > 0)
732
+ throw new Error(`the doorbell did not retire cleanly: ${problems.join("; ")}`);
733
+ };
734
+ unsubscribeFailed = root.onFailed(sessionFailed);
608
735
  const markerPath = writeMetaReceiverMarker({
609
736
  gardenId,
610
737
  backend: PI_DURABLE_BACKEND,
@@ -614,16 +741,37 @@ export function createPiDurableContact(options) {
614
741
  armProvenance: "session-start",
615
742
  receiversDir: roots.receiversDir,
616
743
  });
617
- doorbell = { gardenId, ownerPid, mailbox, signal, markerPath, watcher, submitToRoot };
744
+ doorbell = {
745
+ gardenId,
746
+ ownerPid,
747
+ mailbox,
748
+ signal,
749
+ markerPath,
750
+ watcher,
751
+ submitToRoot: root.submitToRoot,
752
+ unsubscribeFailed,
753
+ };
754
+ inbox = mailbox;
618
755
  report = { status: "armed", gardenId, markerPath, admissions };
756
+ published = true;
757
+ if (failedBeforePublished) {
758
+ sessionFailed();
759
+ return null;
760
+ }
619
761
  return markerPath;
620
762
  }
621
763
  catch (error) {
622
- try {
623
- watcher.close();
764
+ const cleanup = [];
765
+ for (const step of [unsubscribeFailed, () => watcher.close()]) {
766
+ try {
767
+ step();
768
+ }
769
+ catch (cleanupError) {
770
+ cleanup.push(cleanupError);
771
+ }
624
772
  }
625
- catch (closeError) {
626
- throw new AggregateError([error, closeError], `${describeError(error)} (cleanup also failed: ${describeError(closeError)})`);
773
+ if (cleanup.length > 0) {
774
+ throw new AggregateError([error, ...cleanup], `${describeError(error)} (cleanup also failed: ${cleanup.map(describeError).join("; ")})`);
627
775
  }
628
776
  throw error;
629
777
  }
@@ -651,6 +799,9 @@ export function createPiDurableContact(options) {
651
799
  if (root !== undefined && typeof root.submitToRoot !== "function") {
652
800
  throw new Error("the root admission handed to attach has no submitToRoot function");
653
801
  }
802
+ if (root !== undefined && typeof root.onFailed !== "function") {
803
+ throw new Error("the root admission handed to attach has no onFailed function");
804
+ }
654
805
  const ownerPid = options.ownerPid ?? process.pid;
655
806
  const sendersDir = path.resolve(options.sendersDir ?? defaultMetaSendersDir());
656
807
  const upsert = upsertMetaSession({
@@ -700,7 +851,7 @@ export function createPiDurableContact(options) {
700
851
  let receiverMarkerPath = null;
701
852
  if (root !== undefined) {
702
853
  try {
703
- receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root.submitToRoot);
854
+ receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root);
704
855
  }
705
856
  catch (error) {
706
857
  return await failAfterCleanup(new Error(`the doorbell did not arm: ${describeError(error)}`), () => client.close());
@@ -731,13 +882,15 @@ export function createPiDurableContact(options) {
731
882
  settleFailed(describeError(error));
732
883
  },
733
884
  receiver() {
734
- return report;
885
+ return withdrawalFailure === undefined || report.status === "off" ? report : { ...report, withdrawalFailure };
735
886
  },
736
887
  async close() {
737
888
  const previous = state;
738
889
  settleFailed("the contact closed before attach completed");
739
890
  state = { kind: "closed" };
740
891
  const client = previous.kind === "ready" ? previous.client : undefined;
892
+ // Queued notices stay native (a continued session places them); this contact judges no more.
893
+ notices.length = 0;
741
894
  await closeAll(disarmOnClose, async () => {
742
895
  await client?.close();
743
896
  });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * codex-socket-classify — classify the Codex app-server default control socket for THIS
3
+ * environment, through the one product classifier, and print the answer for a shell.
4
+ *
5
+ * WHY A LEAF. `entwurf codex-app-server` is a bash launcher, and it has to decide whether a
6
+ * launch would clobber something. From Codex 0.162.1 the advertised socket path is a
7
+ * rendezvous alias into a vendor-owned directory, and telling the vendor's exact alias from any
8
+ * other symlink takes a hash, an exact readlink comparison and ownership/mode checks. Doing that
9
+ * in the launcher's Python would be a second implementation of a security classification, and
10
+ * `codex-socket-path.ts` records what a second spelling of this socket did once (it diverged).
11
+ * So the launcher asks `checkCodexSocketFile`, the classifier delivery and preflight use, and
12
+ * only connects to what it was told.
13
+ *
14
+ * HOW IT IS REACHED. The launcher runs this file's COMPILED twin,
15
+ * `mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js`, directly with `node` — in a checkout
16
+ * and in an installed package alike. There is no `run.sh` verb and no `run_ts` crossing for it:
17
+ * `build-bridge` emits it (`pnpm install` runs that through `prepare`, packing through `prepack`),
18
+ * and a missing twin is an incomplete installation the launcher refuses by name.
19
+ *
20
+ * One line on stdout, a word then a tab-separated detail:
21
+ * socket <connectPath> a verified socket; the launcher connects there to tell live from stale
22
+ * absent nothing at the advertised path
23
+ * dangling <reason> Codex's own alias whose socket is gone; the vendor removes it on start
24
+ * indeterminate <reason> anything else; the launcher refuses
25
+ *
26
+ * Reads `process.env` and takes no argument, for the reason `codex-socket-path.ts` gives: an argv
27
+ * seam would be a way to redirect the address. It has no daemon-root input either; the gate's
28
+ * exact-alias cells drive `checkCodexSocketFile` directly with a sandbox root.
29
+ */
30
+ import { checkCodexSocketFile, resolveCodexDefaultSocketPath, } from "../pi-extensions/lib/native-push/codex-ws-client.js";
31
+ if (process.argv.length > 2) {
32
+ console.error("usage: codex-socket-classify (no arguments; the environment is the input)");
33
+ process.exit(2);
34
+ }
35
+ const oneLine = (text) => text.replace(/[\t\r\n]+/g, " ");
36
+ const check = checkCodexSocketFile(resolveCodexDefaultSocketPath(process.env));
37
+ if (check.ok)
38
+ process.stdout.write(`socket\t${check.connectPath}`);
39
+ else if (check.status === "dead" && check.layout === "absent")
40
+ process.stdout.write("absent");
41
+ else if (check.status === "dead")
42
+ process.stdout.write(`dangling\t${oneLine(check.reason)}`);
43
+ else
44
+ process.stdout.write(`indeterminate\t${oneLine(check.reason)}`);
@@ -122,6 +122,10 @@
122
122
  // twin would make the one-command launch die before its first line. Its only repo import is
123
123
  // native-push/codex-ws-client.ts, ALREADY emitted for meta-bridge-hook-codex, so this adds ONE
124
124
  // leaf JS and ZERO new deps.
125
+ // WHY codex-socket-classify rides THIS build (#141): the same launcher asks it whether a launch
126
+ // would clobber something, and runs THIS compiled twin directly with `node` in a checkout and an
127
+ // installed package alike (no run.sh verb, no run_ts crossing), so the emitted file is the only
128
+ // route. Its only repo import is that same codex-ws-client.ts: ONE leaf JS, ZERO new deps.
125
129
  // WHY meta-bridge-pi-durable rides THIS build (#129 W, L4): the packaged durable bootstrap
126
130
  // (pi/pi-durable/bootstrap.mjs) imports the contact from THIS closure — the same closure whose
127
131
  // bridge entry the contact spawns and whose meta-bridge-omp.js decodes the first-input payload —
@@ -151,6 +155,7 @@
151
155
  "../../scripts/peer-facts.ts",
152
156
  "../../scripts/omp-receive-facts.ts",
153
157
  "../../scripts/codex-socket-path.ts",
158
+ "../../scripts/codex-socket-classify.ts",
154
159
  "../../pi-extensions/meta-bridge-hook.ts",
155
160
  "../../pi-extensions/meta-bridge-hook-copilot.ts",
156
161
  "../../pi-extensions/meta-bridge-hook-codex.ts",