@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.
Files changed (112) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +6 -5
  3. package/CHANGELOG.md +220 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +202 -60
  6. package/README.md +54 -26
  7. package/VERIFY.md +37 -7
  8. package/docs/acp-backend-rail.md +31 -15
  9. package/docs/external-mcp-host.md +58 -35
  10. package/docs/fresh-cut-policy.md +3 -2
  11. package/docs/setup-clean-host.md +110 -17
  12. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +101 -109
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +6 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +326 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +50 -15
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  29. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  30. package/mcp/entwurf-bridge/src/index.ts +115 -111
  31. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  32. package/package.json +14 -5
  33. package/pi-extensions/entwurf-control.ts +71 -19
  34. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  35. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  36. package/pi-extensions/lib/acp/backend.ts +3 -3
  37. package/pi-extensions/lib/acp/event-mapper.ts +4 -4
  38. package/pi-extensions/lib/acp/tool-surface.ts +6 -0
  39. package/pi-extensions/lib/codex-fresh-preflight.ts +363 -0
  40. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  41. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  42. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  43. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  44. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  45. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  46. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  47. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  48. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  49. package/pi-extensions/lib/mux-fresh-call.ts +64 -19
  50. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  51. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  52. package/pi-extensions/lib/native-push/register.ts +7 -9
  53. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  54. package/run.sh +251 -35
  55. package/scripts/check-acp-usage-accounting.ts +9 -9
  56. package/scripts/check-agy-sender-identity.ts +1 -1
  57. package/scripts/check-codex-birth-hook.ts +264 -0
  58. package/scripts/check-codex-bridge-identity.ts +179 -0
  59. package/scripts/check-codex-native-push.ts +386 -0
  60. package/scripts/check-codex-sender-identity.ts +495 -0
  61. package/scripts/check-compaction-send-guard.ts +130 -0
  62. package/scripts/check-copilot-receive-arm.ts +4 -1
  63. package/scripts/check-entwurf-fact-provider.ts +38 -0
  64. package/scripts/check-entwurf-peers-surface.ts +13 -1
  65. package/scripts/check-entwurf-self-address.ts +15 -16
  66. package/scripts/check-entwurf-v2-contract.ts +4 -3
  67. package/scripts/check-entwurf-v2-decider.ts +7 -5
  68. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  69. package/scripts/check-entwurf-v2-production.ts +203 -11
  70. package/scripts/check-entwurf-v2-runner.ts +1 -1
  71. package/scripts/check-entwurf-v2-surface.ts +1 -1
  72. package/scripts/check-gate-qualification.ts +7 -4
  73. package/scripts/check-harness-admission-parity.ts +0 -1
  74. package/scripts/check-install-surface.ts +23 -7
  75. package/scripts/check-mux-launch-tmux.ts +47 -2
  76. package/scripts/check-native-push-adapter.ts +20 -16
  77. package/scripts/check-native-push-register.ts +5 -1
  78. package/scripts/check-release-gate-outcomes.ts +47 -1
  79. package/scripts/check-setup-qualification.sh +3 -1
  80. package/scripts/codex-birth-doctor.sh +276 -0
  81. package/scripts/codex-birth-install.sh +414 -0
  82. package/scripts/codex-birth-uninstall.sh +170 -0
  83. package/scripts/codex-mcp-config.py +435 -0
  84. package/scripts/codex-statusline-config.py +434 -0
  85. package/scripts/codex_toml_io.py +532 -0
  86. package/scripts/lib/codex-fresh-live-protocol.ts +113 -0
  87. package/scripts/lib/codex-fresh-source-receipts.ts +399 -0
  88. package/scripts/lib/launch-receipt-windows.ts +46 -0
  89. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  90. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  91. package/scripts/mutants/codex-native.json +838 -0
  92. package/scripts/mutants/compaction-send-guard.json +103 -0
  93. package/scripts/mutants/entwurf-peers.json +19 -0
  94. package/scripts/mutants/mux-fresh-call.json +19 -8
  95. package/scripts/mutants/omp-fresh.json +6 -4
  96. package/scripts/mutants/pi-package-ownership.json +26 -0
  97. package/scripts/mutants/release-gate.json +13 -0
  98. package/scripts/mutants/v2-surface.json +53 -1
  99. package/scripts/pi_settings_io.py +3 -1
  100. package/scripts/raw-async-delivery/README.md +2 -1
  101. package/scripts/raw-codex-measure/README.md +114 -46
  102. package/scripts/register-pi-package.py +38 -30
  103. package/scripts/register-pi-provider.py +3 -2
  104. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  105. package/scripts/smoke-agy-native-push-live.ts +3 -1
  106. package/scripts/smoke-codex-birth.sh +347 -0
  107. package/scripts/smoke-codex-config-state.sh +511 -0
  108. package/scripts/smoke-codex-fresh-live.ts +1186 -0
  109. package/scripts/smoke-codex-native-push-live.ts +75 -0
  110. package/scripts/smoke-setup-verdict.sh +123 -10
  111. package/scripts/smoke-user-scope-citizen.sh +199 -71
  112. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,363 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { parse } from "smol-toml";
5
+
6
+ import {
7
+ type CodexProtocolOpener,
8
+ type CodexRpcProtocol,
9
+ type CodexSocketFileCheck,
10
+ checkCodexSocketFile,
11
+ realCodexProtocolOpener,
12
+ resolveCodexDefaultSocketPath,
13
+ } from "./native-push/codex-ws-client.ts";
14
+
15
+ export type CodexPreflightRejectReason =
16
+ | "codex-birth-unit-missing"
17
+ | "codex-mcp-hand-missing"
18
+ | "codex-visible-identity-missing"
19
+ | "codex-birth-trust-missing"
20
+ | "codex-app-server-unavailable";
21
+
22
+ export const CODEX_PREFLIGHT_HINT: Record<CodexPreflightRejectReason, string> = {
23
+ "codex-birth-unit-missing":
24
+ "the Codex SessionStart birth unit is absent, drifted from its recorded digests, or unsafely owned; run `entwurf install-codex-birth`, then `entwurf doctor-codex-birth`",
25
+ "codex-birth-trust-missing":
26
+ "the vendor has recorded no trust receipt for this birth declaration, so the hook is declared and never runs; open a visible plain Codex, answer its prompt with `Trust all and continue`, send one first turn, then `entwurf doctor-codex-birth`",
27
+ "codex-mcp-hand-missing":
28
+ "the Codex user config does not expose the managed entwurf bridge; run `entwurf install-codex-mcp`, then `entwurf doctor-codex-mcp`",
29
+ "codex-visible-identity-missing":
30
+ "Codex tui.status_line does not include thread-title; run `entwurf install-codex-statusline`, then `entwurf doctor-codex-statusline`",
31
+ "codex-app-server-unavailable":
32
+ "the operator-owned Codex app-server default socket is absent or unsafe; start `codex app-server --listen unix://$CODEX_HOME/app-server-control/app-server-control.sock` and retry",
33
+ };
34
+
35
+ export interface CodexUnitPaths {
36
+ hooksFile: string;
37
+ helperDir: string;
38
+ stateFile: string;
39
+ }
40
+
41
+ export interface CodexPreflightDeps {
42
+ unitPathsOverride?: CodexUnitPaths;
43
+ operatorUid?: number;
44
+ checkSocket?: (socketPath: string) => CodexSocketFileCheck;
45
+ openProtocol?: CodexProtocolOpener;
46
+ appServerTimeoutMs?: number;
47
+ }
48
+
49
+ /**
50
+ * The unit's paths are the operator's own, derived from the SAME environment the installer
51
+ * reads — never a system location and never a flag. A preflight that resolved them any other
52
+ * way could pass while the installed unit sat somewhere else.
53
+ */
54
+ function unitPaths(env: NodeJS.ProcessEnv): CodexUnitPaths {
55
+ const home = env.HOME?.trim() ?? "";
56
+ const codexHome = env.CODEX_HOME?.trim() || path.join(home, ".codex");
57
+ const dataHome = env.XDG_DATA_HOME?.trim() || path.join(home, ".local", "share");
58
+ const unitRoot = path.join(dataHome, "entwurf", "codex-birth");
59
+ return {
60
+ hooksFile: path.join(codexHome, "hooks.json"),
61
+ helperDir: path.join(unitRoot, "helper"),
62
+ stateFile: path.join(unitRoot, "install-state.json"),
63
+ };
64
+ }
65
+
66
+ const CODEX_MCP_ENV_VARS = [
67
+ "CODEX_HOME",
68
+ "ENTWURF_DIR",
69
+ "PI_CODING_AGENT_DIR",
70
+ "ENTWURF_META_SESSIONS_DIR",
71
+ "ENTWURF_META_MAILBOX_DIR",
72
+ "ENTWURF_META_SENDERS_DIR",
73
+ "ENTWURF_META_RECEIVERS_DIR",
74
+ "TMUX",
75
+ "TMUX_PANE",
76
+ ] as const;
77
+
78
+ function isSafeOwnedFile(file: string, expectedUid: number, executable = false): boolean {
79
+ try {
80
+ const stat = fs.lstatSync(file);
81
+ return (
82
+ stat.isFile() &&
83
+ !stat.isSymbolicLink() &&
84
+ stat.uid === expectedUid &&
85
+ (stat.mode & 0o022) === 0 &&
86
+ (!executable || (stat.mode & 0o111) !== 0)
87
+ );
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function isSafeOwnedDir(dir: string, expectedUid: number): boolean {
94
+ try {
95
+ const stat = fs.lstatSync(dir);
96
+ return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === expectedUid && (stat.mode & 0o022) === 0;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The unit's own ownership state, read as the digest inventory it is. Bytes that merely
104
+ * EXIST prove nothing: the launcher codex is about to exec must be the one this unit
105
+ * published, so every recorded member is compared to its recorded digest before a sibling
106
+ * is opened. No digest is computed for the VENDOR here — that is a different axis below.
107
+ */
108
+ function closureDriftedFromState(paths: CodexUnitPaths, expectedUid: number): boolean {
109
+ // The directory holding the state carries the state's authority: anyone who can write it
110
+ // can replace the inventory every digest below is compared against.
111
+ if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid)) return true;
112
+ if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid)) return true;
113
+ if (!isSafeOwnedFile(paths.stateFile, expectedUid)) return true;
114
+ let state: Record<string, unknown>;
115
+ try {
116
+ state = JSON.parse(fs.readFileSync(paths.stateFile, "utf8")) as Record<string, unknown>;
117
+ } catch {
118
+ return true;
119
+ }
120
+ if (state.schema !== "codex-birth-install-state/v1" || state.status !== "installed") return true;
121
+ if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir) return true;
122
+ const digest = (file: string): string | null => {
123
+ try {
124
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
125
+ } catch {
126
+ return null;
127
+ }
128
+ };
129
+ if (typeof state.hooksSha256 !== "string" || digest(paths.hooksFile) !== state.hooksSha256) return true;
130
+ const members = state.helperFiles;
131
+ if (!Array.isArray(members)) return true;
132
+ // Exactly the closure the installer publishes: a short inventory would leave a member
133
+ // nothing compares, which is the same hole as not checking digests at all.
134
+ const named = members.map((raw) => (raw as Record<string, unknown>)?.path);
135
+ const expected = [
136
+ "codex-birth-launch.sh",
137
+ "meta-bridge-hook-codex.ts",
138
+ "lib/meta-session.ts",
139
+ "lib/native-push/codex-ws-client.ts",
140
+ "lib/session-id.js",
141
+ "entwurf-capabilities.json",
142
+ ];
143
+ if (named.length !== expected.length) return true;
144
+ if (expected.some((name) => !named.includes(name))) return true;
145
+ if (named.some((name, index) => named.indexOf(name) !== index)) return true;
146
+ for (const raw of members) {
147
+ const member = raw as Record<string, unknown>;
148
+ const rel = member.path;
149
+ if (typeof rel !== "string" || rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
150
+ return true;
151
+ }
152
+ if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256) return true;
153
+ }
154
+ return false;
155
+ }
156
+
157
+ function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
158
+ const launcher = path.join(paths.helperDir, "codex-birth-launch.sh");
159
+ const directories = [
160
+ path.dirname(paths.hooksFile),
161
+ paths.helperDir,
162
+ path.join(paths.helperDir, "lib"),
163
+ path.join(paths.helperDir, "lib", "native-push"),
164
+ ];
165
+ if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid))) return true;
166
+ if (!isSafeOwnedFile(paths.hooksFile, expectedUid)) return true;
167
+ const closure = [
168
+ [launcher, true],
169
+ [path.join(paths.helperDir, "meta-bridge-hook-codex.ts"), false],
170
+ [path.join(paths.helperDir, "lib", "meta-session.ts"), false],
171
+ [path.join(paths.helperDir, "lib", "native-push", "codex-ws-client.ts"), false],
172
+ [path.join(paths.helperDir, "lib", "session-id.js"), false],
173
+ [path.join(paths.helperDir, "entwurf-capabilities.json"), false],
174
+ ] as const;
175
+ if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable))) return true;
176
+ try {
177
+ const parsed = JSON.parse(fs.readFileSync(paths.hooksFile, "utf8")) as Record<string, unknown>;
178
+ const events = (parsed.hooks as Record<string, unknown> | undefined)?.SessionStart;
179
+ if (!Array.isArray(events) || events.length !== 1) return true;
180
+ const group = events[0] as Record<string, unknown>;
181
+ if ("matcher" in group || !Array.isArray(group.hooks) || group.hooks.length !== 1) return true;
182
+ const hook = group.hooks[0] as Record<string, unknown>;
183
+ if (Object.keys(hook).sort().join(",") !== "command,timeout,type") return true;
184
+ if (hook.type !== "command" || hook.command !== `'${launcher}'` || hook.timeout !== 30) return true;
185
+ } catch {
186
+ return true;
187
+ }
188
+ return closureDriftedFromState(paths, expectedUid);
189
+ }
190
+
191
+ /**
192
+ * The vendor's own receipt for THIS declaration, read and never written. The key the vendor
193
+ * writes is `<declaration path>:<event>:<group>:<handler>`; the value must be a
194
+ * `trusted_hash` of the shape `sha256:<64 hex>`. What this asserts is that a receipt EXISTS
195
+ * for our declaration identity — never that the hash is correct, which only the vendor can
196
+ * say, and never by launching Codex to find out.
197
+ */
198
+ function trustReceiptMissing(config: Record<string, unknown>, hooksFile: string): boolean {
199
+ const hooks = config.hooks;
200
+ if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks)) return true;
201
+ const state = (hooks as Record<string, unknown>).state;
202
+ if (state == null || typeof state !== "object" || Array.isArray(state)) return true;
203
+ const entry = (state as Record<string, unknown>)[`${hooksFile}:session_start:0:0`];
204
+ if (entry == null || typeof entry !== "object" || Array.isArray(entry)) return true;
205
+ const digest = (entry as Record<string, unknown>).trusted_hash;
206
+ return typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest);
207
+ }
208
+
209
+ function readConfig(env: NodeJS.ProcessEnv): Record<string, unknown> | null {
210
+ try {
211
+ const text = fs.readFileSync(
212
+ path.join(env.CODEX_HOME?.trim() || path.join(env.HOME ?? "", ".codex"), "config.toml"),
213
+ "utf8",
214
+ );
215
+ const value = parse(text);
216
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function mcpMissing(config: Record<string, unknown>): boolean {
223
+ const servers = config.mcp_servers;
224
+ if (servers == null || typeof servers !== "object" || Array.isArray(servers)) return true;
225
+ const entry = (servers as Record<string, unknown>)["entwurf-bridge"];
226
+ if (entry == null || typeof entry !== "object" || Array.isArray(entry)) return true;
227
+ const server = entry as Record<string, unknown>;
228
+ if (Object.keys(server).sort().join(",") !== "command,env,env_vars") return true;
229
+ const envVars = server.env_vars;
230
+ if (
231
+ !Array.isArray(envVars) ||
232
+ envVars.length !== CODEX_MCP_ENV_VARS.length ||
233
+ envVars.some((name, index) => name !== CODEX_MCP_ENV_VARS[index])
234
+ )
235
+ return true;
236
+ const env = server.env;
237
+ if (env == null || typeof env !== "object" || Array.isArray(env)) return true;
238
+ const serverEnv = env as Record<string, unknown>;
239
+ return (
240
+ server.command !== "entwurf-bridge" ||
241
+ Object.keys(serverEnv).join(",") !== "ENTWURF_BRIDGE_NATIVE_HOST" ||
242
+ serverEnv.ENTWURF_BRIDGE_NATIVE_HOST !== "codex"
243
+ );
244
+ }
245
+
246
+ function visibleIdentityMissing(config: Record<string, unknown>): boolean {
247
+ const tui = config.tui;
248
+ if (tui == null || typeof tui !== "object" || Array.isArray(tui)) return true;
249
+ const statusLine = (tui as Record<string, unknown>).status_line;
250
+ return !Array.isArray(statusLine) || !statusLine.includes("thread-title");
251
+ }
252
+
253
+ const DEFAULT_APP_SERVER_TIMEOUT_MS = 5_000;
254
+
255
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
256
+ return new Promise((resolve, reject) => {
257
+ const timer = setTimeout(() => reject(new Error("codex app-server exchange timed out")), timeoutMs);
258
+ promise.then(
259
+ (value) => {
260
+ clearTimeout(timer);
261
+ resolve(value);
262
+ },
263
+ (error) => {
264
+ clearTimeout(timer);
265
+ reject(error);
266
+ },
267
+ );
268
+ });
269
+ }
270
+
271
+ /**
272
+ * A bounded real initialize/liveness exchange over the app-server's own JSON-RPC. The inode
273
+ * check above only proves a socket FILE with the right owner and mode exists — a dead
274
+ * app-server's stale socket a new process has not reclaimed yet passes that check identically
275
+ * to a live one. Only a real connection plus a real `initialize` round trip distinguishes them.
276
+ * Any failure — refused/hung connection, a response that never lands within the bound, or a
277
+ * reply the transport itself rejects — answers identically: not live. This is deliberately not
278
+ * `probeCodexThread`: preflight asks "is the app-server there to talk to", never "is a specific
279
+ * thread loaded", so it carries no thread id and no retry/backoff policy.
280
+ */
281
+ async function appServerIsLive(
282
+ socketPath: string,
283
+ openProtocol: CodexProtocolOpener,
284
+ timeoutMs: number,
285
+ ): Promise<boolean> {
286
+ let protocol: CodexRpcProtocol;
287
+ const opening = openProtocol.open(socketPath);
288
+ try {
289
+ protocol = await withTimeout(opening, timeoutMs);
290
+ } catch {
291
+ // The bound expired (or the connect failed), but a slow open can still LAND afterwards.
292
+ // Nothing else holds that handle, so a late success would leave a live connection to the
293
+ // operator's app-server open for the rest of this process. Close it when it arrives —
294
+ // the answer stays "not live" either way; this only refuses to leak.
295
+ void opening.then(
296
+ (late) => {
297
+ try {
298
+ late.close();
299
+ } catch {
300
+ // best-effort: the socket may already be gone.
301
+ }
302
+ },
303
+ () => {
304
+ // The open failed; there is nothing to close.
305
+ },
306
+ );
307
+ return false;
308
+ }
309
+ try {
310
+ await withTimeout(
311
+ protocol.request("initialize", {
312
+ clientInfo: { name: "entwurf-fresh-preflight", title: "entwurf-fresh-preflight", version: "0" },
313
+ capabilities: { experimentalApi: true, requestAttestation: false },
314
+ }),
315
+ timeoutMs,
316
+ );
317
+ return true;
318
+ } catch {
319
+ return false;
320
+ } finally {
321
+ try {
322
+ protocol.close();
323
+ } catch {
324
+ // best-effort: the socket may already be gone.
325
+ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Async because the app-server axis now performs a real bounded protocol exchange, not just an
331
+ * inode check (see `appServerIsLive`). Every other axis stays synchronous filesystem/config
332
+ * work; only the last `await` point can suspend.
333
+ */
334
+ export async function codexFreshPreflight(
335
+ env: NodeJS.ProcessEnv,
336
+ deps: CodexPreflightDeps = {},
337
+ ): Promise<CodexPreflightRejectReason | null> {
338
+ const paths = deps.unitPathsOverride ?? unitPaths(env);
339
+ // A relative root would make the declaration's launcher path — the very string the vendor
340
+ // keys its trust receipt to — depend on this process's cwd.
341
+ if (!path.isAbsolute(paths.hooksFile) || !path.isAbsolute(paths.helperDir) || !path.isAbsolute(paths.stateFile)) {
342
+ return "codex-birth-unit-missing";
343
+ }
344
+ if (birthMissing(paths, deps.operatorUid ?? process.getuid?.() ?? -1)) {
345
+ return "codex-birth-unit-missing";
346
+ }
347
+ const config = readConfig(env);
348
+ // The vendor receipt is a SEPARATE reject from our bytes: perfect bytes the vendor will
349
+ // not run and absent bytes are two different repairs, and folding them would send the
350
+ // operator to the installer for something only they can answer in their own Codex.
351
+ if (config === null || trustReceiptMissing(config, paths.hooksFile)) return "codex-birth-trust-missing";
352
+ if (mcpMissing(config)) return "codex-mcp-hand-missing";
353
+ if (visibleIdentityMissing(config)) return "codex-visible-identity-missing";
354
+ const socketPath = resolveCodexDefaultSocketPath(env);
355
+ const socket = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
356
+ if (!socket.ok) return "codex-app-server-unavailable";
357
+ const live = await appServerIsLive(
358
+ socketPath,
359
+ deps.openProtocol ?? realCodexProtocolOpener,
360
+ deps.appServerTimeoutMs ?? DEFAULT_APP_SERVER_TIMEOUT_MS,
361
+ );
362
+ return live ? null : "codex-app-server-unavailable";
363
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * compaction-send-guard — whether a control-socket `send` may call `pi.sendMessage`.
3
+ *
4
+ * Pi 0.85.1 has no public `ExtensionContext.isCompacting()`. Compaction is non-idle
5
+ * (`AgentSession.isIdle` = no agent run AND not compacting) but `sendCustomMessage`
6
+ * does not refuse it. A non-streaming `triggerTurn:true` send starts `_runAgentPrompt`
7
+ * while `compact()` is rewriting the session tree — the field path that can make
8
+ * compaction "풀린다" and still return `delivered:true` (#111).
9
+ *
10
+ * Public facts the resident can see, without reaching into `AgentSession`:
11
+ * - `ctx.isIdle()`
12
+ * - `ctx.signal` — `agent.activeRun?.abortController.signal`, undefined when
13
+ * there is no low-level agent run. This is NOT `AgentSession.isStreaming`
14
+ * (`_isAgentRunActive`). After `agent.prompt()` returns, `_handlePostAgentRun`
15
+ * retry / auto-compaction / continuation keeps `_isAgentRunActive=true` while
16
+ * `ctx.signal` is already undefined.
17
+ * - `session_before_compact` / `session_compact` / `session_compact_failed`
18
+ *
19
+ * Terminal semantics, read from installed `agent-session.js`:
20
+ * - `session_before_compact` fires AFTER the abort controller is set (manual
21
+ * compact then awaits summarization auth — a real start-race window).
22
+ * - `session_compact` is NOT guaranteed (only if the saved compaction entry is
23
+ * found by summary-text match).
24
+ * - `session_compact_failed` fires on cancel / abort / fail.
25
+ * - `isIdle()` becoming true is the honest recovery for a missed success event.
26
+ *
27
+ * Auto-compaction can run inside `_handlePostAgentRun` while `_isAgentRunActive`
28
+ * stays true, so only the event flag can name that window `compacting`.
29
+ *
30
+ * Quiet unknown (`!idle && !hasAgentSignal && !armed`) is fail-closed as `busy`,
31
+ * not `compacting`: the public pair cannot tell manual-compact start race from
32
+ * branch summary, post-run retry, or continuation. Calling those `compacting`
33
+ * is a false claim. The start-race guarantee is still "do not call sendMessage".
34
+ *
35
+ * The predicate takes no send mode. Every mode is refused the same way.
36
+ */
37
+
38
+ export const COMPACTION_SEND_REJECT = "compacting" as const;
39
+ export const BUSY_SEND_REJECT = "busy" as const;
40
+ export type CompactionSendReject = typeof COMPACTION_SEND_REJECT | typeof BUSY_SEND_REJECT;
41
+
42
+ export type CompactionGuard = {
43
+ /** Armed by `session_before_compact`; disarmed by either terminal event or idle recovery. */
44
+ armed: boolean;
45
+ };
46
+
47
+ export type CompactionSendFacts = {
48
+ idle: boolean;
49
+ /** `ctx.signal !== undefined`. Not `AgentSession.isStreaming`. */
50
+ hasAgentSignal: boolean;
51
+ };
52
+
53
+ export function createCompactionGuard(): CompactionGuard {
54
+ return { armed: false };
55
+ }
56
+
57
+ export function noteCompactionBefore(guard: CompactionGuard): void {
58
+ guard.armed = true;
59
+ }
60
+
61
+ export function noteCompactionTerminal(guard: CompactionGuard): void {
62
+ guard.armed = false;
63
+ }
64
+
65
+ /**
66
+ * `null` = admit (caller may `pi.sendMessage`).
67
+ * `"compacting"` = event-armed compaction; `"busy"` = quiet unknown non-idle.
68
+ * Either way: do not call `pi.sendMessage`; do not emit `delivered:true`.
69
+ */
70
+ export function compactionSendReject(guard: CompactionGuard, facts: CompactionSendFacts): CompactionSendReject | null {
71
+ if (facts.idle) {
72
+ // Idle means Pi is neither in an agent run nor compacting. Also recovers a
73
+ // missed `session_compact` so a later ordinary stream is not stuck refused.
74
+ guard.armed = false;
75
+ return null;
76
+ }
77
+ if (guard.armed) return COMPACTION_SEND_REJECT;
78
+ if (!facts.hasAgentSignal) return BUSY_SEND_REJECT;
79
+ return null; // live agent run — steer/followUp unchanged
80
+ }
@@ -32,6 +32,7 @@ import {
32
32
  isOutOfSocketDomainGardenIdConflict,
33
33
  type PeerObserver,
34
34
  resolveFactList,
35
+ UNOBSERVED_PEER,
35
36
  } from "./entwurf-facts.ts";
36
37
  import { observePeerFacts } from "./entwurf-peer-observe.ts";
37
38
  import { isLivenessSupported } from "./entwurf-v2-contract.ts";
@@ -102,9 +103,12 @@ export interface EntwurfFactsDeps {
102
103
  /** Observation axis (#101): per-citizen receiver + transcript facts. Defaults to the
103
104
  * REAL measurement — the same seam shape `makeProductionEntwurfV2Deps` uses, so the two
104
105
  * wiring sites (MCP + pi-native) cannot drift by each passing their own observer, and a
105
- * gate still drives the assembly with a fake and no filesystem. A caller that injects
106
- * nothing gets facts; a caller that injects `() => UNOBSERVED_PEER` says so on every row. */
106
+ * gate still drives the assembly with a fake and no filesystem. */
107
107
  observe?: PeerObserver;
108
+ /** Optional presentation budget. Undefined preserves the generic fact provider's full
109
+ * observation contract; peers surfaces pass their shared render limit. Older machine
110
+ * payload rows then say `unobserved` rather than fabricating `active` / `exists`. */
111
+ observationLimit?: number;
108
112
  }
109
113
 
110
114
  function diagnosticSortKey(d: EntwurfDiagnostic): string {
@@ -195,9 +199,31 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
195
199
  // 4. resolveFactList over CLEAN inputs only. Its throws (duplicate identity /
196
200
  // unprobed in-domain citizen) are impossible wiring invariants — left to
197
201
  // fire as the last line of defense, never caught here.
202
+ //
203
+ // A peers surface may bound observation to its SAME newest rendered rows. Receiver
204
+ // observation can inspect process state and historical Claude markers, so doing it
205
+ // for every retained record made bounded text grow linearly with history. The generic
206
+ // provider stays unbounded when no limit is supplied. Either way the full clean
207
+ // identity/probe arrays reach the core: certification, duplicate detection,
208
+ // diagnostics, liveness, and the machine payload remain complete. Only an observation
209
+ // not performed becomes `unobserved`.
198
210
  const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
199
211
  const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
200
- const facts: FactList = resolveFactList(cleanIdentities, cleanProbes, deps.observe ?? observePeerFacts);
212
+ const observe = deps.observe ?? observePeerFacts;
213
+ let selectedObserve = observe;
214
+ if (deps.observationLimit !== undefined) {
215
+ if (!Number.isSafeInteger(deps.observationLimit) || deps.observationLimit < 0) {
216
+ throw new Error("listEntwurfFacts: observationLimit must be a non-negative safe integer");
217
+ }
218
+ const observedGardenIds = new Set(
219
+ [...cleanIdentities]
220
+ .sort((a, b) => (a.gardenId < b.gardenId ? -1 : a.gardenId > b.gardenId ? 1 : 0))
221
+ .slice(Math.max(0, cleanIdentities.length - deps.observationLimit))
222
+ .map((identity) => identity.gardenId),
223
+ );
224
+ selectedObserve = (identity) => (observedGardenIds.has(identity.gardenId) ? observe(identity) : UNOBSERVED_PEER);
225
+ }
226
+ const facts: FactList = resolveFactList(cleanIdentities, cleanProbes, selectedObserve);
201
227
 
202
228
  // 5. #50 C4 demotion: a record-less socket is a diagnostic, not a listing
203
229
  // section. One diagnostic per socket (subjects aggregate at render, F8);
@@ -39,6 +39,9 @@ export interface EntwurfPeersRender {
39
39
  payload: EntwurfPeersPayload;
40
40
  }
41
41
 
42
+ /** Human rows and expensive provider observations share this one presentation budget. */
43
+ export const ENTWURF_PEERS_RENDER_LIMIT = 32;
44
+
42
45
  // `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
43
46
  // found on disk for this citizen, and a caller still asks entwurf_v2 what may be done
44
47
  // about it. They earn a column because for every claude-code row `liveness` reads
@@ -124,7 +127,7 @@ function renderDiagnosticLines(diagnostics: EntwurfDiagnostic[]): string[] {
124
127
  return lines;
125
128
  }
126
129
 
127
- function compactLines(lines: string[], max: number = 32): string[] {
130
+ function compactLines(lines: string[], max: number = ENTWURF_PEERS_RENDER_LIMIT): string[] {
128
131
  if (lines.length <= max) return lines;
129
132
  const omitted = lines.length - max;
130
133
  return [` … (${omitted} older entries omitted; showing latest ${max})`, ...lines.slice(-max)];
@@ -122,13 +122,12 @@ export function isLivenessSupported(backend: string): boolean {
122
122
 
123
123
  // ── Native-push backend domain (봉인 2/4) ───────────────────────────────────
124
124
  // A backend whose liveness is measured by the SEPARATE native-push adapter rail (a
125
- // live app-server conversation probe — antigravity's LS gRPC), NOT a control
126
- // socket. This domain is DISJOINT from LIVENESS_DOMAIN_BACKENDS: an agy session
127
- // is `unsupported` on the socket FACT axis (entwurf_peers) yet fully
128
- // measured + deliverable on the native-push axis. The two are separate rails on
129
- // purpose check-entwurf-facts pins both sets and asserts their intersection is
130
- // (a backend can never be in both domains).
131
- export const NATIVE_PUSH_BACKENDS = ["antigravity"] as const;
125
+ // live vendor app-server target probe), NOT a control socket. This domain is DISJOINT
126
+ // from LIVENESS_DOMAIN_BACKENDS: an Antigravity or Codex session is `unsupported` on
127
+ // the socket FACT axis (entwurf_peers) yet independently measured on the native-push
128
+ // axis. The two are separate rails on purpose — check-entwurf-facts pins both sets
129
+ // and asserts their intersection is (a backend can never be in both domains).
130
+ export const NATIVE_PUSH_BACKENDS = ["antigravity", "codex"] as const;
132
131
  export type NativePushBackend = (typeof NATIVE_PUSH_BACKENDS)[number];
133
132
 
134
133
  export function nativePushSupported(backend: string): backend is NativePushBackend {
@@ -5,11 +5,10 @@
5
5
  * ROUTING (the NATIVE_PUSH_DISPATCH_TABLE did) and carries NO release seam — a native-push
6
6
  * plan is the LOCK-FREE path (봉인 4), so a lock leak is structurally impossible here.
7
7
  *
8
- * This is where the 1-shot retry lives NOT in the adapter leaf (봉인 3). The decider
9
- * probed a fresh route and planted it in the plan; the executor sends over it, and on a
10
- * failure it re-probes ONCE (the volatile LS port may have shifted between decide and
11
- * execute) and re-sends. A second failure is fail-loud (no infinite loop). This mirrors the
12
- * control-socket send-fallback pattern: the decider stays pure, the hand owns the retry.
8
+ * Retry is an adapter capability, not a rail-wide assumption. Antigravity re-probes once
9
+ * because its volatile LS port can shift before send. Codex never retries: `codex queue`
10
+ * may have accepted the message before its receipt failed, so replay could duplicate it.
11
+ * The decider stays pure; this hand enforces the adapter's declared policy.
13
12
  *
14
13
  * A native-push send has NO in-band refuse (there is no live receiver to answer
15
14
  * success:false) — like a mailbox enqueue, it either succeeds or THROWS. The hand never
@@ -18,6 +17,7 @@
18
17
 
19
18
  import type { ExecutionPlan } from "./entwurf-v2-decider.ts";
20
19
  import type { LockClaim } from "./entwurf-v2-lock.ts";
20
+ import { formatMetaMailboxBody, type MailboxSenderEnvelope } from "./meta-mailbox-body.ts";
21
21
  import { type NativePushAdapter, type NativePushRoute, resolveNativePushAdapter } from "./native-push/adapter.ts";
22
22
 
23
23
  /** The native-push slice of the ExecutionPlan union (the decider plants it, this consumes it). */
@@ -25,16 +25,14 @@ export type NativePushPlan = Extract<ExecutionPlan, { transport: "native-push" }
25
25
 
26
26
  export interface NativePushSendResult {
27
27
  success: true;
28
- /** Whether the 1-shot re-probe→re-send fired (the first send over the planted route failed). */
28
+ /** Whether the adapter's one permitted re-probe→re-send fired. */
29
29
  retried: boolean;
30
30
  }
31
31
 
32
32
  /**
33
- * Deliver `content` into the conversation, owning the 1-shot retry (봉인 3). Send over the
34
- * planted (decider-probed) route first; on failure re-probe ONCE and re-send over the fresh
35
- * route; a second failure PROPAGATES (fail-loud). If the re-probe finds the target no longer
36
- * alive, throw — a failed send into a now-dead conversation is an honest non-delivery, never
37
- * a silent success. The re-probe is the ONLY re-derivation of the volatile route.
33
+ * Deliver `content` through the decider-probed route. A non-retriable adapter propagates
34
+ * the first failure unchanged. A retriable adapter re-probes once and sends once more only
35
+ * when the target is still alive; no adapter gets a third attempt.
38
36
  */
39
37
  export async function deliverViaNativePush(
40
38
  adapter: NativePushAdapter,
@@ -46,8 +44,8 @@ export async function deliverViaNativePush(
46
44
  await adapter.send(route, nativeSessionId, content);
47
45
  return { success: true, retried: false };
48
46
  } catch (firstErr) {
49
- // 1-shot re-probe → re-send: the volatile LS route may have shifted since the decider
50
- // probed it. Re-discover it fresh and retry exactly once.
47
+ if (!adapter.retriable) throw firstErr;
48
+ // One re-probe and one retry for adapters whose send boundary is safe to replay.
51
49
  const reprobe = await adapter.probe(nativeSessionId);
52
50
  if (reprobe.status !== "alive") {
53
51
  throw new Error(
@@ -65,22 +63,41 @@ export async function deliverViaNativePush(
65
63
  * registry). Injected so the 5d gate proves the wiring with a fake adapter. */
66
64
  export interface NativePushSendDeps {
67
65
  resolveAdapter?: (backend: string) => NativePushAdapter;
66
+ /** The SAME provider the mailbox hand receives (`makeProductionEntwurfV2Deps` passes one
67
+ * `opts.senderProvider` to both). When it yields an envelope, the injected content is the
68
+ * mailbox-serialized body — see the render note on `makeNativePushSend`. Absent/undefined
69
+ * preserves the raw `plan.message`. */
70
+ senderProvider?: () => MailboxSenderEnvelope | undefined;
68
71
  }
69
72
 
70
73
  /**
71
74
  * Build the production `sendNativePush(plan, lock)` adapter the runner consumes. It IGNORES
72
- * `lock` entirely (a native-push plan is lock-free, 봉인 4) the field exists only to match
73
- * the DispatchExecutorDeps hand signature. It resolves the adapter from the plan's backend
74
- * and delivers with the 1-shot retry. A delivery throw surfaces as a REJECTED promise (the
75
- * runner's try/catch maps it to execution-failed).
75
+ * `lock` entirely (a native-push plan is lock-free, 봉인 4), resolves the adapter, then applies
76
+ * that adapter's retry policy. A delivery throw surfaces as a REJECTED promise (the runner's
77
+ * try/catch maps it to execution-failed).
78
+ *
79
+ * SENDER ENVELOPE (measured #95). Direct injection used to hand the adapter the raw
80
+ * `plan.message`, so a native-push citizen received a body with no sender. The #95 A LIVE run
81
+ * measured what that costs: a fresh Pi's nonce-only callback landed in the Codex thread as
82
+ * bare text, and Codex could not name who called it — the callback was uncorrelatable. The
83
+ * control-socket rail carries the envelope in its RPC framing and the mailbox rail serializes
84
+ * it into the body; this rail has neither, so it renders the SAME `formatMetaMailboxBody` SSOT
85
+ * the mailbox uses. Rendered ONCE, before `deliverViaNativePush`, so an Antigravity re-probe
86
+ * retry replays byte-identical content (including its timestamp) instead of a second render.
87
+ * The adapter stays a dumb pipe — its signature is unchanged and it never learns about senders.
76
88
  */
77
89
  export function makeNativePushSend(
78
90
  deps: NativePushSendDeps = {},
79
91
  ): (plan: NativePushPlan, lock: LockClaim | null) => Promise<NativePushSendResult> {
80
92
  const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
93
+ const senderProvider = deps.senderProvider;
81
94
  // `_lock` is named for the hand contract but NEVER read — native-push owns/releases no lock.
82
95
  return async (plan: NativePushPlan, _lock: LockClaim | null): Promise<NativePushSendResult> => {
96
+ // Resolve the sender BEFORE the adapter is resolved or touched: a provider throw must
97
+ // reject this send with nothing injected, not leave a half-delivered conversation.
98
+ const sender = senderProvider?.();
99
+ const content = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
83
100
  const adapter = resolveAdapter(plan.backend);
84
- return deliverViaNativePush(adapter, plan.route, plan.nativeSessionId, plan.message);
101
+ return deliverViaNativePush(adapter, plan.route, plan.nativeSessionId, content);
85
102
  };
86
103
  }