@junghanacs/entwurf 0.35.1 → 0.36.1

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 (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +403 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +17 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +12 -10
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +69 -5
  10. package/docs/setup-clean-host.md +30 -22
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +59 -14
  15. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  16. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  17. package/package.json +21 -16
  18. package/pi/pi-durable/bootstrap.mjs +12 -8
  19. package/pi/pi-durable/carrier/runtime.js +52 -2
  20. package/pi/pi-durable/carrier/subagent.js +6 -1
  21. package/pi/pi-durable/carrier/tui.js +31 -4
  22. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  23. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  24. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  25. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  26. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  27. package/pi-extensions/meta-bridge-pi-durable.ts +77 -21
  28. package/run.sh +116 -39
  29. package/scripts/check-acp-prompt-lifecycle.ts +4 -2
  30. package/scripts/check-acp-session-reuse.ts +4 -2
  31. package/scripts/check-acp-stop-reason.ts +4 -2
  32. package/scripts/check-acp-stream-hooks.ts +4 -2
  33. package/scripts/check-acp-usage-accounting.ts +4 -2
  34. package/scripts/check-codex-app-server-launch.ts +55 -12
  35. package/scripts/check-codex-native-push.ts +231 -12
  36. package/scripts/check-gate-qualification.ts +4 -4
  37. package/scripts/check-herdr-supply.ts +32 -7
  38. package/scripts/codex-app-server-launch.sh +54 -29
  39. package/scripts/codex-socket-classify.ts +46 -0
  40. package/scripts/emit-pi-durable-carrier.ts +159 -168
  41. package/scripts/fixtures/herdr-supply.json +10 -9
  42. package/scripts/lib/acp-gate-home.ts +75 -0
  43. package/scripts/lib/codex-socket-listener.ts +429 -0
  44. package/scripts/mutants/acp-stop-reason.json +11 -0
  45. package/scripts/mutants/codex-app-server-launch.json +39 -12
  46. package/scripts/mutants/codex-native.json +67 -0
  47. package/scripts/mutants/control-send-receipt.json +1 -1
  48. package/scripts/mutants/herdr-supply.json +6 -6
  49. package/scripts/mutants/pack-install.json +3 -3
  50. package/scripts/mutants/pi-durable.json +165 -16
  51. package/scripts/mutants/pi-floor.json +5 -5
  52. package/scripts/pi-durable-contact-host.mjs +1 -1
  53. package/scripts/smoke-codex-fresh-live.ts +31 -7
  54. package/scripts/smoke-pi-durable-fresh-live.ts +22 -3
  55. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -1,64 +1,107 @@
1
1
  {
2
- "schemaVersion": 2,
3
- "comment": "The one upstream commit the pi-durable carrier is emitted from, the patch it applies, the emitted files and the published SDK set they run against. The experimental durable app is source-only upstream (published pi-coding-agent ships no dist/experimental), so Entwurf ships it as a CARRIER: the pinned app files, type-stripped, with the relocation in pi/pi-durable/carrier-relocation.mjs. scripts/emit-pi-durable-carrier.ts is the only writer of the generated fields (blob, sha256, emitter.node) and the only reader of upstream source; an installed package never reads source. Model data rides the published @earendil-works/pi-ai.",
4
- "repository": "https://github.com/earendil-works/pi",
5
- "tag": "v1.1.0",
6
- "commit": "abe508e1b89912adde45528136c3221eb69acdd7",
7
- "patches": ["runtime-contacts.patch"],
2
+ "schemaVersion": 3,
3
+ "comment": "The pi-durable carrier: what it is emitted from, what it is, and the published SDK set it runs against. The experimental durable app is source-only upstream (published pi-coding-agent ships no dist/experimental), so Entwurf owns its thin application/TUI composition as canonical source (pi/pi-durable/src) and ships it as a CARRIER: those files, type-stripped, with the relocation in pi/pi-durable/carrier-relocation.mjs. The SDK engine (execution, scheduler, queues, storage/transcript, compaction, cancellation/recovery, providers, auth) stays the published packages in `sdk`. scripts/emit-pi-durable-carrier.ts is the only writer of the generated fields (carrier blob, sha256, emitter.node); an installed package never reads source. Model data rides the published @earendil-works/pi-ai.",
4
+ "origin": {
5
+ "comment": "Frozen history, never an input: the upstream commit the owned source was first taken from, its objects as recorded then, the overlay patch that carried Entwurf's changes until it was folded into the source (#141), and the carrier bytes the last patch-built release shipped. Neither install nor emit reads this; `emit-pi-durable-carrier --origin <clone>` checks it as provenance only.",
6
+ "repository": "https://github.com/earendil-works/pi",
7
+ "tag": "v1.1.0",
8
+ "commit": "abe508e1b89912adde45528136c3221eb69acdd7",
9
+ "license": {
10
+ "path": "LICENSE",
11
+ "blob": "b0a8e9b81083294360c69b4ec45d3d39a2b28197"
12
+ },
13
+ "files": [
14
+ {
15
+ "path": "packages/coding-agent/src/experimental/durable/runtime.ts",
16
+ "blob": "fcd6e5239e999ebf17dea0335fb3dc9cd7f7ce45"
17
+ },
18
+ {
19
+ "path": "packages/coding-agent/src/experimental/durable/tui.ts",
20
+ "blob": "397b276cbeb1c90e1fbc96c3cc59e6a25f92dad7"
21
+ },
22
+ {
23
+ "path": "packages/coding-agent/src/experimental/durable/harness-setup.ts",
24
+ "blob": "4d6a4be5333ebb8e0f17eac1987f18ea35a91fc7"
25
+ },
26
+ {
27
+ "path": "packages/coding-agent/src/experimental/durable/prompt.ts",
28
+ "blob": "8e2f5d5273fba58da8b314766956ed62951c3814"
29
+ },
30
+ {
31
+ "path": "packages/coding-agent/src/experimental/durable/sessions.ts",
32
+ "blob": "34f9ceeeaeaf874f7478d56115da2e4f726c6a85"
33
+ },
34
+ {
35
+ "path": "packages/coding-agent/src/experimental/durable/subagent.ts",
36
+ "blob": "e767d54e0bf2908e0f4f41c6933b55c00562eb25"
37
+ }
38
+ ],
39
+ "retiredOverlay": {
40
+ "name": "runtime-contacts.patch",
41
+ "sha256": "5b1bd9b48d4ff58ad223006d81cc12c4e0b18959cca5cda5ab3ad89f68bbe898"
42
+ },
43
+ "baselineCarrier": {
44
+ "release": "0.35.1",
45
+ "node": "v24.18.1",
46
+ "files": {
47
+ "runtime.js": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005",
48
+ "tui.js": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4",
49
+ "harness-setup.js": "712b630145dc6f5c655a85b01b1e20299c962542e63c5494d2202bb7bf414a15",
50
+ "prompt.js": "ff1ff765fa313e5cb1c3d7537c901c23adb0562ee433bd050fd35eb3d892134c",
51
+ "sessions.js": "1bd1a04ece66f5ab3ca22860c64dba9abc848334d11a27a9afe543f1365c7254",
52
+ "subagent.js": "8a75d934ef87efbb0bb2006a2141f1ea932560851f1718b991ed947d6efdb54c",
53
+ "LICENSE": "0457f5bcec3b3b211605dfb5d1a49042fd638f3686a410fe099c24a25af13c48"
54
+ }
55
+ }
56
+ },
8
57
  "carrier": {
9
- "comment": "files: the six app files bootstrap.mjs needs (main.ts is replaced by the bootstrap). distTargets: every pi-coding-agent internal the stripped files import, as paths under the published dist/; the emitter refuses a set that differs and the resolver answers no other.",
58
+ "comment": "files: the six app files bootstrap.mjs needs (main.ts is replaced by the bootstrap), each emitted from its owned `source`. distTargets: every pi-coding-agent internal the stripped files import, as paths under the published dist/; the emitter refuses a set that differs and the resolver answers no other.",
10
59
  "emitter": {
11
60
  "tool": "node:module.stripTypeScriptTypes",
12
61
  "mode": "strip",
13
62
  "node": "v24.18.1"
14
63
  },
15
64
  "license": {
16
- "source": "LICENSE",
65
+ "source": "pi/pi-durable/src/LICENSE",
17
66
  "blob": "b0a8e9b81083294360c69b4ec45d3d39a2b28197",
18
67
  "sha256": "0457f5bcec3b3b211605dfb5d1a49042fd638f3686a410fe099c24a25af13c48"
19
68
  },
20
69
  "files": [
21
70
  {
22
71
  "name": "runtime.js",
23
- "source": "packages/coding-agent/src/experimental/durable/runtime.ts",
24
- "patched": true,
25
- "blob": "fcd6e5239e999ebf17dea0335fb3dc9cd7f7ce45",
26
- "sha256": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005"
72
+ "source": "pi/pi-durable/src/experimental/durable/runtime.ts",
73
+ "blob": "36c352291620ab86184172a1b4a0df4562ab9581",
74
+ "sha256": "60c2965eadbdbbe78fce1b7eeb89d97eb3104a30b7d58ebca87009d2e9118403"
27
75
  },
28
76
  {
29
77
  "name": "tui.js",
30
- "source": "packages/coding-agent/src/experimental/durable/tui.ts",
31
- "patched": true,
32
- "blob": "397b276cbeb1c90e1fbc96c3cc59e6a25f92dad7",
33
- "sha256": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4"
78
+ "source": "pi/pi-durable/src/experimental/durable/tui.ts",
79
+ "blob": "dab99cd4778ae1db7f062e64db2d0c068f7e342d",
80
+ "sha256": "0f9ebaf8fbb2a532ccd1a7198e1a25533a99720b5fedbf03f42d5c381db3abc5"
34
81
  },
35
82
  {
36
83
  "name": "harness-setup.js",
37
- "source": "packages/coding-agent/src/experimental/durable/harness-setup.ts",
38
- "patched": false,
84
+ "source": "pi/pi-durable/src/experimental/durable/harness-setup.ts",
39
85
  "blob": "4d6a4be5333ebb8e0f17eac1987f18ea35a91fc7",
40
86
  "sha256": "712b630145dc6f5c655a85b01b1e20299c962542e63c5494d2202bb7bf414a15"
41
87
  },
42
88
  {
43
89
  "name": "prompt.js",
44
- "source": "packages/coding-agent/src/experimental/durable/prompt.ts",
45
- "patched": false,
90
+ "source": "pi/pi-durable/src/experimental/durable/prompt.ts",
46
91
  "blob": "8e2f5d5273fba58da8b314766956ed62951c3814",
47
92
  "sha256": "ff1ff765fa313e5cb1c3d7537c901c23adb0562ee433bd050fd35eb3d892134c"
48
93
  },
49
94
  {
50
95
  "name": "sessions.js",
51
- "source": "packages/coding-agent/src/experimental/durable/sessions.ts",
52
- "patched": false,
96
+ "source": "pi/pi-durable/src/experimental/durable/sessions.ts",
53
97
  "blob": "34f9ceeeaeaf874f7478d56115da2e4f726c6a85",
54
98
  "sha256": "1bd1a04ece66f5ab3ca22860c64dba9abc848334d11a27a9afe543f1365c7254"
55
99
  },
56
100
  {
57
101
  "name": "subagent.js",
58
- "source": "packages/coding-agent/src/experimental/durable/subagent.ts",
59
- "patched": false,
60
- "blob": "e767d54e0bf2908e0f4f41c6933b55c00562eb25",
61
- "sha256": "8a75d934ef87efbb0bb2006a2141f1ea932560851f1718b991ed947d6efdb54c"
102
+ "source": "pi/pi-durable/src/experimental/durable/subagent.ts",
103
+ "blob": "3fa9aebbe93e82cfff436e7b9d4c9877e0ed0e27",
104
+ "sha256": "1cb2390b673a0d914117bc07708c39b004fb10e48b31436bf9d9d2b953a64220"
62
105
  }
63
106
  ],
64
107
  "distTargets": [
@@ -89,17 +132,17 @@
89
132
  ]
90
133
  },
91
134
  "sdk": {
92
- "comment": "The published library set the carrier runs against, derived from the 1.1.0 declarations. members: the @earendil-works packages the carrier imports and every @earendil-works package those declare. direct: a third-party package a carrier file imports itself (sessions.ts: proper-lockfile, at the version pi-coding-agent 1.1.0 pins). Each is an exact production dependency in package.json; the members' own edges are caret ranges, so the installed physical edges, not semver, are what must equal these versions.",
135
+ "comment": "The published library set the carrier runs against, derived from the 1.2.0 declarations. members: the @earendil-works packages the carrier imports and every @earendil-works package those declare. direct: a third-party package a carrier file imports itself (sessions.ts: proper-lockfile, at the version pi-coding-agent 1.2.0 pins). Each is an exact production dependency in package.json; the members' own edges are caret ranges, so the installed physical edges, not semver, are what must equal these versions.",
93
136
  "members": {
94
- "@earendil-works/chord": "1.1.0",
95
- "@earendil-works/pi-agent-core": "1.1.0",
96
- "@earendil-works/pi-ai": "1.1.0",
97
- "@earendil-works/pi-codemode": "1.1.0",
98
- "@earendil-works/pi-coding-agent": "1.1.0",
99
- "@earendil-works/pi-durable": "1.1.0",
100
- "@earendil-works/pi-mcp": "1.1.0",
101
- "@earendil-works/pi-telemetry": "1.1.0",
102
- "@earendil-works/pi-tui": "1.1.0"
137
+ "@earendil-works/chord": "1.2.0",
138
+ "@earendil-works/pi-agent-core": "1.2.0",
139
+ "@earendil-works/pi-ai": "1.2.0",
140
+ "@earendil-works/pi-codemode": "1.2.0",
141
+ "@earendil-works/pi-coding-agent": "1.2.0",
142
+ "@earendil-works/pi-durable": "1.2.0",
143
+ "@earendil-works/pi-mcp": "1.2.0",
144
+ "@earendil-works/pi-telemetry": "1.2.0",
145
+ "@earendil-works/pi-tui": "1.2.0"
103
146
  },
104
147
  "direct": {
105
148
  "proper-lockfile": "4.1.2"
@@ -624,11 +624,12 @@ export async function codexFreshPreflight(
624
624
  if (config === null || trustReceiptMissing(config, key)) return "codex-birth-trust-missing";
625
625
  if (mcpMissing(config)) return "codex-mcp-hand-missing";
626
626
  if (visibleIdentityMissing(config)) return "codex-visible-identity-missing";
627
- const socketPath = resolveCodexDefaultSocketPath(env);
628
- const socket = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
627
+ const socket = (deps.checkSocket ?? checkCodexSocketFile)(resolveCodexDefaultSocketPath(env));
629
628
  if (!socket.ok) return "codex-app-server-unavailable";
629
+ // The protocol exchange goes to the endpoint the check verified, never the advertised path
630
+ // again (on Codex 0.162.1 that path is an alias; see `checkCodexSocketFile`).
630
631
  const live = await appServerIsLive(
631
- socketPath,
632
+ socket.connectPath,
632
633
  deps.openProtocol ?? realCodexProtocolOpener,
633
634
  deps.appServerTimeoutMs ?? DEFAULT_APP_SERVER_TIMEOUT_MS,
634
635
  );
@@ -23,30 +23,135 @@ export function resolveCodexDefaultSocketPath(env: CodexSocketEnv): string {
23
23
  return path.join(resolveCodexHome(env), "app-server-control", "app-server-control.sock");
24
24
  }
25
25
 
26
- export type CodexSocketFileCheck = { ok: true } | { ok: false; status: "dead" | "indeterminate"; reason: string };
26
+ /**
27
+ * What the advertised control-socket path is, and the one endpoint a caller may connect to.
28
+ *
29
+ * `connectPath` is a transport endpoint for one exchange: the socket this check verified. It is
30
+ * not an address, a record field or a replacement for `resolveCodexDefaultSocketPath`. `dead`
31
+ * names which absence it is, because the managed launcher treats them differently: an absent
32
+ * path is a first launch, and a dangling rendezvous alias is the leftover of a crashed server
33
+ * that the vendor removes on its next start.
34
+ */
35
+ export type CodexSocketFileCheck =
36
+ | { ok: true; connectPath: string }
37
+ | { ok: false; status: "dead"; layout: "absent" | "dangling-alias"; reason: string }
38
+ | { ok: false; status: "indeterminate"; reason: string };
39
+
40
+ /** Test seam only: the gate points these at a sandbox. Production callers pass nothing. */
41
+ export interface CodexSocketFileDeps {
42
+ readonly daemonRoot?: string;
43
+ readonly euid?: number;
44
+ }
27
45
 
28
- export function checkCodexSocketFile(socketPath: string): CodexSocketFileCheck {
46
+ /**
47
+ * The advertised path is exactly one of two layouts, and anything else is refused.
48
+ *
49
+ * (A) A plain socket owned by the effective uid: Codex up to 0.153.4 binds the advertised
50
+ * path itself. `connectPath` is that path.
51
+ * (B) Codex's rendezvous alias. From 0.162.1 the advertised path "is only an alias": the
52
+ * server binds a 0600 socket under `realpath(/tmp)/codex-daemon-<euid>/` named by the
53
+ * sha256 of the canonical advertised path, then symlinks the advertised path to it
54
+ * (`[source]` rust-v0.162.1 `app-server-transport/src/transport/unix_socket.rs:43-131,
55
+ * 290-304`; `uds/src/daemon_directory.rs:1-38`). Upstream's reason is a privileged
56
+ * rendezvous root that sandboxes hide; the alias also keeps an over-long advertised path
57
+ * usable (`uds/src/lib.rs:167-174`). The alias is accepted only when every hop is the
58
+ * vendor's own: the link is owned by the effective uid; its target is BYTE-EQUAL to the
59
+ * path Codex derives (a relative, foreign, re-hashed or chained target is refused); the
60
+ * daemon directory is a real directory owned by the effective uid with `mode & 0o777`
61
+ * exactly 0700, the vendor's own rule; and the target is a socket owned by the effective
62
+ * uid that no group or other user can open. `connectPath` is the verified target, so the
63
+ * alias is never re-resolved between this check and the connect. A same-euid process can
64
+ * still replace the verified socket file itself; that residual is named, not locked.
65
+ *
66
+ * Only `lstat` and one `readlink` touch the alias; it is never followed or `realpath`ed.
67
+ * The effective uid is the vendor's (`libc::geteuid`), not the real uid.
68
+ */
69
+ export function checkCodexSocketFile(socketPath: string, deps: CodexSocketFileDeps = {}): CodexSocketFileCheck {
70
+ const euid = deps.euid ?? process.geteuid?.();
71
+ let stat: fs.Stats;
29
72
  try {
30
- const stat = fs.lstatSync(socketPath);
31
- if (stat.isSymbolicLink()) {
32
- return { ok: false, status: "indeterminate", reason: `app-server socket is a symlink: ${socketPath}` };
33
- }
34
- if (!stat.isSocket()) {
35
- return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
73
+ stat = fs.lstatSync(socketPath);
74
+ } catch (error) {
75
+ const code = (error as NodeJS.ErrnoException).code;
76
+ if (code === "ENOENT") {
77
+ return { ok: false, status: "dead", layout: "absent", reason: `app-server socket is absent: ${socketPath}` };
36
78
  }
37
- if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
79
+ return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
80
+ }
81
+ if (stat.isSymbolicLink()) return checkCodexRendezvousAlias(socketPath, stat, euid, deps);
82
+ if (!stat.isSocket()) {
83
+ return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
84
+ }
85
+ if (euid !== undefined && stat.uid !== euid) {
86
+ return {
87
+ ok: false,
88
+ status: "indeterminate",
89
+ reason: `app-server socket is owned by uid ${stat.uid}, not the effective uid ${euid}`,
90
+ };
91
+ }
92
+ return { ok: true, connectPath: socketPath };
93
+ }
94
+
95
+ function checkCodexRendezvousAlias(
96
+ socketPath: string,
97
+ link: fs.Stats,
98
+ euid: number | undefined,
99
+ deps: CodexSocketFileDeps,
100
+ ): CodexSocketFileCheck {
101
+ const refuse = (reason: string): CodexSocketFileCheck => ({ ok: false, status: "indeterminate", reason });
102
+ if (euid === undefined) {
103
+ return refuse(`app-server socket is a symlink and this platform has no effective uid to verify it: ${socketPath}`);
104
+ }
105
+ if (link.uid !== euid) {
106
+ return refuse(`app-server socket symlink is owned by uid ${link.uid}, not the effective uid ${euid}`);
107
+ }
108
+ let daemonRoot: string;
109
+ let expected: string;
110
+ let target: string;
111
+ try {
112
+ daemonRoot = deps.daemonRoot ?? path.join(fs.realpathSync("/tmp"), `codex-daemon-${euid}`);
113
+ const canonical = path.join(fs.realpathSync(path.dirname(socketPath)), path.basename(socketPath));
114
+ expected = path.join(daemonRoot, crypto.createHash("sha256").update(canonical).digest("hex"));
115
+ target = fs.readlinkSync(socketPath);
116
+ } catch (error) {
117
+ return refuse(
118
+ `could not verify the app-server socket symlink ${socketPath}: ${error instanceof Error ? error.message : String(error)}`,
119
+ );
120
+ }
121
+ if (target !== expected) {
122
+ return refuse(`app-server socket is a symlink that is not Codex's rendezvous alias: ${socketPath} -> ${target}`);
123
+ }
124
+ let directory: fs.Stats;
125
+ try {
126
+ directory = fs.lstatSync(daemonRoot);
127
+ } catch (error) {
128
+ return refuse(`Codex's socket directory cannot be read: ${error instanceof Error ? error.message : String(error)}`);
129
+ }
130
+ if (!directory.isDirectory() || directory.uid !== euid || (directory.mode & 0o777) !== 0o700) {
131
+ return refuse(
132
+ `Codex's socket directory ${daemonRoot} must be a real directory owned by uid ${euid} with mode 0700`,
133
+ );
134
+ }
135
+ let physical: fs.Stats;
136
+ try {
137
+ physical = fs.lstatSync(expected);
138
+ } catch (error) {
139
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
38
140
  return {
39
141
  ok: false,
40
- status: "indeterminate",
41
- reason: `app-server socket is owned by uid ${stat.uid}, not ${process.getuid()}`,
142
+ status: "dead",
143
+ layout: "dangling-alias",
144
+ reason: `app-server socket is a dangling rendezvous alias: ${socketPath} -> ${expected}`,
42
145
  };
43
146
  }
44
- return { ok: true };
45
- } catch (error) {
46
- const code = (error as NodeJS.ErrnoException).code;
47
- if (code === "ENOENT") return { ok: false, status: "dead", reason: `app-server socket is absent: ${socketPath}` };
48
- return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
147
+ return refuse(error instanceof Error ? error.message : String(error));
49
148
  }
149
+ if (!physical.isSocket() || physical.uid !== euid || (physical.mode & 0o077) !== 0) {
150
+ return refuse(
151
+ `Codex's physical socket ${expected} must be a socket owned by uid ${euid} that no group or other user can open`,
152
+ );
153
+ }
154
+ return { ok: true, connectPath: expected };
50
155
  }
51
156
 
52
157
  export interface CodexRpcProtocol {
@@ -315,6 +420,7 @@ async function initialize(protocol: CodexRpcProtocol): Promise<void> {
315
420
 
316
421
  export interface CodexRoute {
317
422
  readonly backend: "codex";
423
+ /** The endpoint `checkCodexSocketFile` verified for this probe, which the send also uses. Not an address. */
318
424
  readonly socketPath: string;
319
425
  }
320
426
 
@@ -359,9 +465,14 @@ export async function probeCodexThread(
359
465
  nativeSessionId: string,
360
466
  deps: CodexAdapterDeps = {},
361
467
  ): Promise<CodexProbeResult> {
362
- const socketPath = resolveCodexDefaultSocketPath(deps.env ?? process.env);
363
- const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
468
+ const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(
469
+ resolveCodexDefaultSocketPath(deps.env ?? process.env),
470
+ );
364
471
  if (!socketCheck.ok) return { status: socketCheck.status, reason: socketCheck.reason };
472
+ // The probe, and the queue send its route carries, both use the endpoint just verified. The
473
+ // advertised path is not resolved again: on 0.162.1 it is an alias that could be retargeted
474
+ // between the check and a second transport.
475
+ const socketPath = socketCheck.connectPath;
365
476
  let protocol: CodexRpcProtocol;
366
477
  try {
367
478
  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
  *
@@ -47,7 +47,7 @@ export type PiDurableRuntimeRejectReason =
47
47
  export interface PiDurablePackageLayout {
48
48
  readonly root: string;
49
49
  readonly overlayDir: string;
50
- /** The emitted upstream app files and their license. */
50
+ /** The app files emitted from the owned source, and their license. */
51
51
  readonly carrierDir: string;
52
52
  /** The `--import` resolver the launcher runs the bootstrap under. */
53
53
  readonly resolver: string;
@@ -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,16 @@
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.
60
63
  *
61
64
  * WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
62
65
  * unread body. So after a read the notices still queued for that mail would each cost a turn and an
@@ -295,9 +298,14 @@ export interface PiDurableRoots {
295
298
  // ---------------------------------------------------------------------------
296
299
  // Durable surface, typed NARROWLY and locally — read at pi-durable 1.0.2 @cd32f77 (#129,
297
300
  // `harness/types.ts`; historical coordinates, not re-anchored here). The current supply is the
298
- // carrier `pi/pi-durable/overlay/upstream-pin.json` names (1.1.0 at `abe508e`). `[read 2026-10-08]`
299
- // v1.0.4 → v1.1.0 `harness/types.ts` adds `models`, `contextRetentionMs` and a `context()` options
300
- // bag only; none of the shapes typed below moved.
301
+ // carrier emitted from Entwurf's owned source (pi/pi-durable/src, #141) against the 1.2.0 SDK set
302
+ // `pi/pi-durable/overlay/upstream-pin.json` names (origin `abe508e`). `[read 2026-10-13]` at
303
+ // v1.2.0 one shape typed below moved: `ToolExecutionResult.content` is now `output`, beside the new
304
+ // `structuredOutput` (`harness/types.ts`, its CHANGELOG's breaking changes). An omitted `output`
305
+ // gives the model the text a tool retained through `api.output()`; `content` is not read, so a
306
+ // result naming only `content` that retained no output reaches the model as nothing. The contact's
307
+ // tools retain none, so they answer in `output`.
308
+ // The submission draft, its `abort()` answers and the identity `define*` helpers did not move.
301
309
  // `defineExtension`/`defineTool` are identity functions (`harness/define.ts:6-17`), so a plain
302
310
  // object is the extension.
303
311
  // ---------------------------------------------------------------------------
@@ -314,8 +322,9 @@ export interface DurableCallContext {
314
322
  readonly abortSignal?: AbortSignal;
315
323
  }
316
324
 
325
+ /** `ToolExecutionResult`, narrowed: `output` is what the model reads. */
317
326
  export interface DurableToolResult {
318
- content: { type: "text"; text: string }[];
327
+ output: { type: "text"; text: string }[];
319
328
  }
320
329
 
321
330
  export interface DurableTool {
@@ -562,14 +571,19 @@ export interface DurableAdmittedSubmission {
562
571
  /** The overlay's `OpenDurableResult.submitToRoot`. */
563
572
  export type DurableSubmitToRoot = (draft: DurableDoorbellDraft) => Promise<DurableAdmittedSubmission>;
564
573
 
574
+ /** The overlay's `OpenDurableResult.onFailed`: one synchronous call when the session fails and closes itself. */
575
+ export type DurableOnFailed = (listener: () => void) => () => void;
576
+
565
577
  /** What `attach` needs to arm the doorbell. Without it the citizen only sends. */
566
578
  export interface DurableRootAdmission {
567
579
  submitToRoot: DurableSubmitToRoot;
580
+ onFailed: DurableOnFailed;
568
581
  }
569
582
 
570
583
  /** Why an armed doorbell was retired. Nothing re-arms it. */
571
584
  export type PiDurableReceiverRetirement =
572
585
  | "closed"
586
+ | "session-failed"
573
587
  | "watch-error"
574
588
  | "signal-vanished"
575
589
  | "doorbell-failed"
@@ -630,7 +644,7 @@ export interface PiDurableAttachment {
630
644
  /** The roots written — and the roots the bridge child was bound to. */
631
645
  roots: PiDurableRoots;
632
646
  bridgePid: number | null;
633
- /** The receiver marker this attach published, or null when no root admission was handed in. */
647
+ /** The receiver marker this attach published; null when no root admission was handed in, or the session had already failed. */
634
648
  receiverMarkerPath: string | null;
635
649
  }
636
650
 
@@ -682,6 +696,7 @@ interface ArmedDoorbell {
682
696
  markerPath: string;
683
697
  watcher: fs.FSWatcher;
684
698
  submitToRoot: DurableSubmitToRoot;
699
+ unsubscribeFailed: () => void;
685
700
  }
686
701
 
687
702
  /** One admitted notice: its native handle and the `.msg` names it announced. */
@@ -783,7 +798,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
783
798
  const call = () => client.callTool(name, (args ?? {}) as Record<string, unknown>, context?.abortSignal);
784
799
  const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
785
800
  if (result.isError) throw new Error(result.text || `${name} failed`);
786
- return { content: [{ type: "text", text: result.text }] };
801
+ return { output: [{ type: "text", text: result.text }] };
787
802
  },
788
803
  });
789
804
 
@@ -836,6 +851,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
836
851
  const armed = doorbell;
837
852
  if (armed === null) return [];
838
853
  doorbell = null;
854
+ armed.unsubscribeFailed();
839
855
  const problems: string[] = [];
840
856
  try {
841
857
  armed.watcher.close();
@@ -985,15 +1001,16 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
985
1001
  * Mailbox, signal, watch — then the check that the record and this process's sender marker
986
1002
  * still name the session — and only then the receiver marker, the one fact a sender reads.
987
1003
  * Synchronous, so nothing can close the contact between the check and the publish. Throws,
988
- * after closing whatever it opened, when any step fails.
1004
+ * after closing whatever it opened, when any step fails. Returns null when the session had
1005
+ * already failed: the doorbell was retired as `session-failed` before this returned.
989
1006
  */
990
1007
  const arm = (
991
1008
  gardenId: string,
992
1009
  nativeSessionId: string,
993
1010
  ownerPid: number,
994
1011
  roots: PiDurableRoots,
995
- submitToRoot: DurableSubmitToRoot,
996
- ): string => {
1012
+ root: DurableRootAdmission,
1013
+ ): string | null => {
997
1014
  if (ownerPid !== process.pid) {
998
1015
  throw new Error(
999
1016
  `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`,
@@ -1004,6 +1021,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1004
1021
  fs.mkdirSync(mailbox, { recursive: true });
1005
1022
  if (!fs.existsSync(signal)) fs.writeFileSync(signal, "", { mode: 0o600 });
1006
1023
  const watcher = watch(signal, () => void ring("signal"));
1024
+ let unsubscribeFailed = (): void => {};
1007
1025
  try {
1008
1026
  watcher.on("error", (error) => retire("watch-error", describeError(error)));
1009
1027
  const identity = readAddressableMetaIdentity(gardenId, roots.sessionsDir);
@@ -1020,6 +1038,21 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1020
1038
  : `the sender marker for owner ${ownerPid} names ${sender.gardenId}/${sender.nativeSessionId}, not ${gardenId}/${nativeSessionId}`,
1021
1039
  );
1022
1040
  }
1041
+ // A failed session admits nothing: retire at once, so no sender reads a marker for a host that cannot wake.
1042
+ // Subscribed before the marker is written, so a registration that throws leaves no marker. A session that
1043
+ // failed before this arm says so during the subscription, before the doorbell exists: that is noted, and
1044
+ // the retirement runs once the doorbell is published below. It is final; nothing re-arms.
1045
+ let published = false;
1046
+ let failedBeforePublished = false;
1047
+ const sessionFailed = (): void => {
1048
+ if (!published) {
1049
+ failedBeforePublished = true;
1050
+ return;
1051
+ }
1052
+ const problems = retire("session-failed", "the durable session failed and closed itself");
1053
+ if (problems.length > 0) throw new Error(`the doorbell did not retire cleanly: ${problems.join("; ")}`);
1054
+ };
1055
+ unsubscribeFailed = root.onFailed(sessionFailed);
1023
1056
  const markerPath = writeMetaReceiverMarker({
1024
1057
  gardenId,
1025
1058
  backend: PI_DURABLE_BACKEND,
@@ -1029,17 +1062,37 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1029
1062
  armProvenance: "session-start",
1030
1063
  receiversDir: roots.receiversDir,
1031
1064
  });
1032
- doorbell = { gardenId, ownerPid, mailbox, signal, markerPath, watcher, submitToRoot };
1065
+ doorbell = {
1066
+ gardenId,
1067
+ ownerPid,
1068
+ mailbox,
1069
+ signal,
1070
+ markerPath,
1071
+ watcher,
1072
+ submitToRoot: root.submitToRoot,
1073
+ unsubscribeFailed,
1074
+ };
1033
1075
  inbox = mailbox;
1034
1076
  report = { status: "armed", gardenId, markerPath, admissions };
1077
+ published = true;
1078
+ if (failedBeforePublished) {
1079
+ sessionFailed();
1080
+ return null;
1081
+ }
1035
1082
  return markerPath;
1036
1083
  } catch (error) {
1037
- try {
1038
- watcher.close();
1039
- } catch (closeError) {
1084
+ const cleanup: unknown[] = [];
1085
+ for (const step of [unsubscribeFailed, () => watcher.close()]) {
1086
+ try {
1087
+ step();
1088
+ } catch (cleanupError) {
1089
+ cleanup.push(cleanupError);
1090
+ }
1091
+ }
1092
+ if (cleanup.length > 0) {
1040
1093
  throw new AggregateError(
1041
- [error, closeError],
1042
- `${describeError(error)} (cleanup also failed: ${describeError(closeError)})`,
1094
+ [error, ...cleanup],
1095
+ `${describeError(error)} (cleanup also failed: ${cleanup.map(describeError).join("; ")})`,
1043
1096
  );
1044
1097
  }
1045
1098
  throw error;
@@ -1068,6 +1121,9 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1068
1121
  if (root !== undefined && typeof root.submitToRoot !== "function") {
1069
1122
  throw new Error("the root admission handed to attach has no submitToRoot function");
1070
1123
  }
1124
+ if (root !== undefined && typeof root.onFailed !== "function") {
1125
+ throw new Error("the root admission handed to attach has no onFailed function");
1126
+ }
1071
1127
  const ownerPid = options.ownerPid ?? process.pid;
1072
1128
  const sendersDir = path.resolve(options.sendersDir ?? defaultMetaSendersDir());
1073
1129
  const upsert = upsertMetaSession({
@@ -1119,7 +1175,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1119
1175
  let receiverMarkerPath: string | null = null;
1120
1176
  if (root !== undefined) {
1121
1177
  try {
1122
- receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root.submitToRoot);
1178
+ receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root);
1123
1179
  } catch (error) {
1124
1180
  return await failAfterCleanup(new Error(`the doorbell did not arm: ${describeError(error)}`), () =>
1125
1181
  client.close(),