@junghanacs/entwurf 0.20.1 → 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 (96) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +4 -3
  3. package/CHANGELOG.md +195 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +202 -60
  6. package/README.md +49 -22
  7. package/VERIFY.md +37 -7
  8. package/docs/acp-backend-rail.md +0 -1
  9. package/docs/external-mcp-host.md +54 -33
  10. package/docs/setup-clean-host.md +110 -17
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +101 -109
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +326 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +50 -15
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  26. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  27. package/mcp/entwurf-bridge/src/index.ts +115 -111
  28. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  29. package/package.json +13 -4
  30. package/pi-extensions/entwurf-control.ts +71 -19
  31. package/pi-extensions/lib/codex-fresh-preflight.ts +363 -0
  32. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  33. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  34. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  35. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  36. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  37. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  38. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  39. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  40. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  41. package/pi-extensions/lib/mux-fresh-call.ts +64 -19
  42. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  43. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  44. package/pi-extensions/lib/native-push/register.ts +7 -9
  45. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  46. package/run.sh +218 -28
  47. package/scripts/check-agy-sender-identity.ts +1 -1
  48. package/scripts/check-codex-birth-hook.ts +264 -0
  49. package/scripts/check-codex-bridge-identity.ts +179 -0
  50. package/scripts/check-codex-native-push.ts +386 -0
  51. package/scripts/check-codex-sender-identity.ts +495 -0
  52. package/scripts/check-compaction-send-guard.ts +130 -0
  53. package/scripts/check-copilot-receive-arm.ts +4 -1
  54. package/scripts/check-entwurf-fact-provider.ts +38 -0
  55. package/scripts/check-entwurf-peers-surface.ts +13 -1
  56. package/scripts/check-entwurf-self-address.ts +15 -16
  57. package/scripts/check-entwurf-v2-contract.ts +4 -3
  58. package/scripts/check-entwurf-v2-decider.ts +7 -5
  59. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  60. package/scripts/check-entwurf-v2-production.ts +203 -11
  61. package/scripts/check-entwurf-v2-runner.ts +1 -1
  62. package/scripts/check-entwurf-v2-surface.ts +1 -1
  63. package/scripts/check-gate-qualification.ts +6 -3
  64. package/scripts/check-harness-admission-parity.ts +0 -1
  65. package/scripts/check-mux-launch-tmux.ts +47 -2
  66. package/scripts/check-native-push-adapter.ts +20 -16
  67. package/scripts/check-native-push-register.ts +5 -1
  68. package/scripts/check-release-gate-outcomes.ts +47 -1
  69. package/scripts/check-setup-qualification.sh +3 -1
  70. package/scripts/codex-birth-doctor.sh +276 -0
  71. package/scripts/codex-birth-install.sh +414 -0
  72. package/scripts/codex-birth-uninstall.sh +170 -0
  73. package/scripts/codex-mcp-config.py +435 -0
  74. package/scripts/codex-statusline-config.py +434 -0
  75. package/scripts/codex_toml_io.py +532 -0
  76. package/scripts/lib/codex-fresh-live-protocol.ts +113 -0
  77. package/scripts/lib/codex-fresh-source-receipts.ts +399 -0
  78. package/scripts/lib/launch-receipt-windows.ts +46 -0
  79. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  80. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  81. package/scripts/mutants/codex-native.json +838 -0
  82. package/scripts/mutants/compaction-send-guard.json +103 -0
  83. package/scripts/mutants/entwurf-peers.json +19 -0
  84. package/scripts/mutants/mux-fresh-call.json +19 -8
  85. package/scripts/mutants/omp-fresh.json +6 -4
  86. package/scripts/mutants/release-gate.json +13 -0
  87. package/scripts/mutants/v2-surface.json +53 -1
  88. package/scripts/raw-async-delivery/README.md +2 -1
  89. package/scripts/raw-codex-measure/README.md +114 -46
  90. package/scripts/smoke-agy-native-push-live.ts +3 -1
  91. package/scripts/smoke-codex-birth.sh +347 -0
  92. package/scripts/smoke-codex-config-state.sh +511 -0
  93. package/scripts/smoke-codex-fresh-live.ts +1186 -0
  94. package/scripts/smoke-codex-native-push-live.ts +75 -0
  95. package/scripts/smoke-setup-verdict.sh +123 -10
  96. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,326 @@
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
+ import { checkCodexSocketFile, realCodexProtocolOpener, resolveCodexDefaultSocketPath, } from "./native-push/codex-ws-client.js";
6
+ export const CODEX_PREFLIGHT_HINT = {
7
+ "codex-birth-unit-missing": "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`",
8
+ "codex-birth-trust-missing": "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`",
9
+ "codex-mcp-hand-missing": "the Codex user config does not expose the managed entwurf bridge; run `entwurf install-codex-mcp`, then `entwurf doctor-codex-mcp`",
10
+ "codex-visible-identity-missing": "Codex tui.status_line does not include thread-title; run `entwurf install-codex-statusline`, then `entwurf doctor-codex-statusline`",
11
+ "codex-app-server-unavailable": "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",
12
+ };
13
+ /**
14
+ * The unit's paths are the operator's own, derived from the SAME environment the installer
15
+ * reads — never a system location and never a flag. A preflight that resolved them any other
16
+ * way could pass while the installed unit sat somewhere else.
17
+ */
18
+ function unitPaths(env) {
19
+ const home = env.HOME?.trim() ?? "";
20
+ const codexHome = env.CODEX_HOME?.trim() || path.join(home, ".codex");
21
+ const dataHome = env.XDG_DATA_HOME?.trim() || path.join(home, ".local", "share");
22
+ const unitRoot = path.join(dataHome, "entwurf", "codex-birth");
23
+ return {
24
+ hooksFile: path.join(codexHome, "hooks.json"),
25
+ helperDir: path.join(unitRoot, "helper"),
26
+ stateFile: path.join(unitRoot, "install-state.json"),
27
+ };
28
+ }
29
+ const CODEX_MCP_ENV_VARS = [
30
+ "CODEX_HOME",
31
+ "ENTWURF_DIR",
32
+ "PI_CODING_AGENT_DIR",
33
+ "ENTWURF_META_SESSIONS_DIR",
34
+ "ENTWURF_META_MAILBOX_DIR",
35
+ "ENTWURF_META_SENDERS_DIR",
36
+ "ENTWURF_META_RECEIVERS_DIR",
37
+ "TMUX",
38
+ "TMUX_PANE",
39
+ ];
40
+ function isSafeOwnedFile(file, expectedUid, executable = false) {
41
+ try {
42
+ const stat = fs.lstatSync(file);
43
+ return (stat.isFile() &&
44
+ !stat.isSymbolicLink() &&
45
+ stat.uid === expectedUid &&
46
+ (stat.mode & 0o022) === 0 &&
47
+ (!executable || (stat.mode & 0o111) !== 0));
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function isSafeOwnedDir(dir, expectedUid) {
54
+ try {
55
+ const stat = fs.lstatSync(dir);
56
+ return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === expectedUid && (stat.mode & 0o022) === 0;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * The unit's own ownership state, read as the digest inventory it is. Bytes that merely
64
+ * EXIST prove nothing: the launcher codex is about to exec must be the one this unit
65
+ * published, so every recorded member is compared to its recorded digest before a sibling
66
+ * is opened. No digest is computed for the VENDOR here — that is a different axis below.
67
+ */
68
+ function closureDriftedFromState(paths, expectedUid) {
69
+ // The directory holding the state carries the state's authority: anyone who can write it
70
+ // can replace the inventory every digest below is compared against.
71
+ if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid))
72
+ return true;
73
+ if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid))
74
+ return true;
75
+ if (!isSafeOwnedFile(paths.stateFile, expectedUid))
76
+ return true;
77
+ let state;
78
+ try {
79
+ state = JSON.parse(fs.readFileSync(paths.stateFile, "utf8"));
80
+ }
81
+ catch {
82
+ return true;
83
+ }
84
+ if (state.schema !== "codex-birth-install-state/v1" || state.status !== "installed")
85
+ return true;
86
+ if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir)
87
+ return true;
88
+ const digest = (file) => {
89
+ try {
90
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ };
96
+ if (typeof state.hooksSha256 !== "string" || digest(paths.hooksFile) !== state.hooksSha256)
97
+ return true;
98
+ const members = state.helperFiles;
99
+ if (!Array.isArray(members))
100
+ return true;
101
+ // Exactly the closure the installer publishes: a short inventory would leave a member
102
+ // nothing compares, which is the same hole as not checking digests at all.
103
+ const named = members.map((raw) => raw?.path);
104
+ const expected = [
105
+ "codex-birth-launch.sh",
106
+ "meta-bridge-hook-codex.ts",
107
+ "lib/meta-session.ts",
108
+ "lib/native-push/codex-ws-client.ts",
109
+ "lib/session-id.js",
110
+ "entwurf-capabilities.json",
111
+ ];
112
+ if (named.length !== expected.length)
113
+ return true;
114
+ if (expected.some((name) => !named.includes(name)))
115
+ return true;
116
+ if (named.some((name, index) => named.indexOf(name) !== index))
117
+ return true;
118
+ for (const raw of members) {
119
+ const member = raw;
120
+ const rel = member.path;
121
+ if (typeof rel !== "string" || rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
122
+ return true;
123
+ }
124
+ if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256)
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ function birthMissing(paths, expectedUid) {
130
+ const launcher = path.join(paths.helperDir, "codex-birth-launch.sh");
131
+ const directories = [
132
+ path.dirname(paths.hooksFile),
133
+ paths.helperDir,
134
+ path.join(paths.helperDir, "lib"),
135
+ path.join(paths.helperDir, "lib", "native-push"),
136
+ ];
137
+ if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid)))
138
+ return true;
139
+ if (!isSafeOwnedFile(paths.hooksFile, expectedUid))
140
+ return true;
141
+ const closure = [
142
+ [launcher, true],
143
+ [path.join(paths.helperDir, "meta-bridge-hook-codex.ts"), false],
144
+ [path.join(paths.helperDir, "lib", "meta-session.ts"), false],
145
+ [path.join(paths.helperDir, "lib", "native-push", "codex-ws-client.ts"), false],
146
+ [path.join(paths.helperDir, "lib", "session-id.js"), false],
147
+ [path.join(paths.helperDir, "entwurf-capabilities.json"), false],
148
+ ];
149
+ if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable)))
150
+ return true;
151
+ try {
152
+ const parsed = JSON.parse(fs.readFileSync(paths.hooksFile, "utf8"));
153
+ const events = parsed.hooks?.SessionStart;
154
+ if (!Array.isArray(events) || events.length !== 1)
155
+ return true;
156
+ const group = events[0];
157
+ if ("matcher" in group || !Array.isArray(group.hooks) || group.hooks.length !== 1)
158
+ return true;
159
+ const hook = group.hooks[0];
160
+ if (Object.keys(hook).sort().join(",") !== "command,timeout,type")
161
+ return true;
162
+ if (hook.type !== "command" || hook.command !== `'${launcher}'` || hook.timeout !== 30)
163
+ return true;
164
+ }
165
+ catch {
166
+ return true;
167
+ }
168
+ return closureDriftedFromState(paths, expectedUid);
169
+ }
170
+ /**
171
+ * The vendor's own receipt for THIS declaration, read and never written. The key the vendor
172
+ * writes is `<declaration path>:<event>:<group>:<handler>`; the value must be a
173
+ * `trusted_hash` of the shape `sha256:<64 hex>`. What this asserts is that a receipt EXISTS
174
+ * for our declaration identity — never that the hash is correct, which only the vendor can
175
+ * say, and never by launching Codex to find out.
176
+ */
177
+ function trustReceiptMissing(config, hooksFile) {
178
+ const hooks = config.hooks;
179
+ if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks))
180
+ return true;
181
+ const state = hooks.state;
182
+ if (state == null || typeof state !== "object" || Array.isArray(state))
183
+ return true;
184
+ const entry = state[`${hooksFile}:session_start:0:0`];
185
+ if (entry == null || typeof entry !== "object" || Array.isArray(entry))
186
+ return true;
187
+ const digest = entry.trusted_hash;
188
+ return typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest);
189
+ }
190
+ function readConfig(env) {
191
+ try {
192
+ const text = fs.readFileSync(path.join(env.CODEX_HOME?.trim() || path.join(env.HOME ?? "", ".codex"), "config.toml"), "utf8");
193
+ const value = parse(text);
194
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ function mcpMissing(config) {
201
+ const servers = config.mcp_servers;
202
+ if (servers == null || typeof servers !== "object" || Array.isArray(servers))
203
+ return true;
204
+ const entry = servers["entwurf-bridge"];
205
+ if (entry == null || typeof entry !== "object" || Array.isArray(entry))
206
+ return true;
207
+ const server = entry;
208
+ if (Object.keys(server).sort().join(",") !== "command,env,env_vars")
209
+ return true;
210
+ const envVars = server.env_vars;
211
+ if (!Array.isArray(envVars) ||
212
+ envVars.length !== CODEX_MCP_ENV_VARS.length ||
213
+ envVars.some((name, index) => name !== CODEX_MCP_ENV_VARS[index]))
214
+ return true;
215
+ const env = server.env;
216
+ if (env == null || typeof env !== "object" || Array.isArray(env))
217
+ return true;
218
+ const serverEnv = env;
219
+ return (server.command !== "entwurf-bridge" ||
220
+ Object.keys(serverEnv).join(",") !== "ENTWURF_BRIDGE_NATIVE_HOST" ||
221
+ serverEnv.ENTWURF_BRIDGE_NATIVE_HOST !== "codex");
222
+ }
223
+ function visibleIdentityMissing(config) {
224
+ const tui = config.tui;
225
+ if (tui == null || typeof tui !== "object" || Array.isArray(tui))
226
+ return true;
227
+ const statusLine = tui.status_line;
228
+ return !Array.isArray(statusLine) || !statusLine.includes("thread-title");
229
+ }
230
+ const DEFAULT_APP_SERVER_TIMEOUT_MS = 5_000;
231
+ function withTimeout(promise, timeoutMs) {
232
+ return new Promise((resolve, reject) => {
233
+ const timer = setTimeout(() => reject(new Error("codex app-server exchange timed out")), timeoutMs);
234
+ promise.then((value) => {
235
+ clearTimeout(timer);
236
+ resolve(value);
237
+ }, (error) => {
238
+ clearTimeout(timer);
239
+ reject(error);
240
+ });
241
+ });
242
+ }
243
+ /**
244
+ * A bounded real initialize/liveness exchange over the app-server's own JSON-RPC. The inode
245
+ * check above only proves a socket FILE with the right owner and mode exists — a dead
246
+ * app-server's stale socket a new process has not reclaimed yet passes that check identically
247
+ * to a live one. Only a real connection plus a real `initialize` round trip distinguishes them.
248
+ * Any failure — refused/hung connection, a response that never lands within the bound, or a
249
+ * reply the transport itself rejects — answers identically: not live. This is deliberately not
250
+ * `probeCodexThread`: preflight asks "is the app-server there to talk to", never "is a specific
251
+ * thread loaded", so it carries no thread id and no retry/backoff policy.
252
+ */
253
+ async function appServerIsLive(socketPath, openProtocol, timeoutMs) {
254
+ let protocol;
255
+ const opening = openProtocol.open(socketPath);
256
+ try {
257
+ protocol = await withTimeout(opening, timeoutMs);
258
+ }
259
+ catch {
260
+ // The bound expired (or the connect failed), but a slow open can still LAND afterwards.
261
+ // Nothing else holds that handle, so a late success would leave a live connection to the
262
+ // operator's app-server open for the rest of this process. Close it when it arrives —
263
+ // the answer stays "not live" either way; this only refuses to leak.
264
+ void opening.then((late) => {
265
+ try {
266
+ late.close();
267
+ }
268
+ catch {
269
+ // best-effort: the socket may already be gone.
270
+ }
271
+ }, () => {
272
+ // The open failed; there is nothing to close.
273
+ });
274
+ return false;
275
+ }
276
+ try {
277
+ await withTimeout(protocol.request("initialize", {
278
+ clientInfo: { name: "entwurf-fresh-preflight", title: "entwurf-fresh-preflight", version: "0" },
279
+ capabilities: { experimentalApi: true, requestAttestation: false },
280
+ }), timeoutMs);
281
+ return true;
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ finally {
287
+ try {
288
+ protocol.close();
289
+ }
290
+ catch {
291
+ // best-effort: the socket may already be gone.
292
+ }
293
+ }
294
+ }
295
+ /**
296
+ * Async because the app-server axis now performs a real bounded protocol exchange, not just an
297
+ * inode check (see `appServerIsLive`). Every other axis stays synchronous filesystem/config
298
+ * work; only the last `await` point can suspend.
299
+ */
300
+ export async function codexFreshPreflight(env, deps = {}) {
301
+ const paths = deps.unitPathsOverride ?? unitPaths(env);
302
+ // A relative root would make the declaration's launcher path — the very string the vendor
303
+ // keys its trust receipt to — depend on this process's cwd.
304
+ if (!path.isAbsolute(paths.hooksFile) || !path.isAbsolute(paths.helperDir) || !path.isAbsolute(paths.stateFile)) {
305
+ return "codex-birth-unit-missing";
306
+ }
307
+ if (birthMissing(paths, deps.operatorUid ?? process.getuid?.() ?? -1)) {
308
+ return "codex-birth-unit-missing";
309
+ }
310
+ const config = readConfig(env);
311
+ // The vendor receipt is a SEPARATE reject from our bytes: perfect bytes the vendor will
312
+ // not run and absent bytes are two different repairs, and folding them would send the
313
+ // operator to the installer for something only they can answer in their own Codex.
314
+ if (config === null || trustReceiptMissing(config, paths.hooksFile))
315
+ return "codex-birth-trust-missing";
316
+ if (mcpMissing(config))
317
+ return "codex-mcp-hand-missing";
318
+ if (visibleIdentityMissing(config))
319
+ return "codex-visible-identity-missing";
320
+ const socketPath = resolveCodexDefaultSocketPath(env);
321
+ const socket = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
322
+ if (!socket.ok)
323
+ return "codex-app-server-unavailable";
324
+ const live = await appServerIsLive(socketPath, deps.openProtocol ?? realCodexProtocolOpener, deps.appServerTimeoutMs ?? DEFAULT_APP_SERVER_TIMEOUT_MS);
325
+ return live ? null : "codex-app-server-unavailable";
326
+ }
@@ -26,7 +26,7 @@
26
26
  * a same-gid socket exists would be half a lie. Both leave the normal output;
27
27
  * one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
28
28
  */
29
- import { isOutOfSocketDomainGardenIdConflict, resolveFactList, } from "./entwurf-facts.js";
29
+ import { isOutOfSocketDomainGardenIdConflict, resolveFactList, UNOBSERVED_PEER, } from "./entwurf-facts.js";
30
30
  import { observePeerFacts } from "./entwurf-peer-observe.js";
31
31
  import { isLivenessSupported } from "./entwurf-v2-contract.js";
32
32
  import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, } from "./meta-session.js";
@@ -130,9 +130,29 @@ export async function listEntwurfFacts(deps) {
130
130
  // 4. resolveFactList over CLEAN inputs only. Its throws (duplicate identity /
131
131
  // unprobed in-domain citizen) are impossible wiring invariants — left to
132
132
  // fire as the last line of defense, never caught here.
133
+ //
134
+ // A peers surface may bound observation to its SAME newest rendered rows. Receiver
135
+ // observation can inspect process state and historical Claude markers, so doing it
136
+ // for every retained record made bounded text grow linearly with history. The generic
137
+ // provider stays unbounded when no limit is supplied. Either way the full clean
138
+ // identity/probe arrays reach the core: certification, duplicate detection,
139
+ // diagnostics, liveness, and the machine payload remain complete. Only an observation
140
+ // not performed becomes `unobserved`.
133
141
  const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
134
142
  const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
135
- const facts = resolveFactList(cleanIdentities, cleanProbes, deps.observe ?? observePeerFacts);
143
+ const observe = deps.observe ?? observePeerFacts;
144
+ let selectedObserve = observe;
145
+ if (deps.observationLimit !== undefined) {
146
+ if (!Number.isSafeInteger(deps.observationLimit) || deps.observationLimit < 0) {
147
+ throw new Error("listEntwurfFacts: observationLimit must be a non-negative safe integer");
148
+ }
149
+ const observedGardenIds = new Set([...cleanIdentities]
150
+ .sort((a, b) => (a.gardenId < b.gardenId ? -1 : a.gardenId > b.gardenId ? 1 : 0))
151
+ .slice(Math.max(0, cleanIdentities.length - deps.observationLimit))
152
+ .map((identity) => identity.gardenId));
153
+ selectedObserve = (identity) => (observedGardenIds.has(identity.gardenId) ? observe(identity) : UNOBSERVED_PEER);
154
+ }
155
+ const facts = resolveFactList(cleanIdentities, cleanProbes, selectedObserve);
136
156
  // 5. #50 C4 demotion: a record-less socket is a diagnostic, not a listing
137
157
  // section. One diagnostic per socket (subjects aggregate at render, F8);
138
158
  // the message is liveness-keyed so same-state sockets group into one line.
@@ -22,6 +22,8 @@
22
22
  * socketPath rows — the pre-record socket-scan worldview) is gone with the
23
23
  * `controlDir` it exposed. Socket paths are dispatch-internal transport.
24
24
  */
25
+ /** Human rows and expensive provider observations share this one presentation budget. */
26
+ export const ENTWURF_PEERS_RENDER_LIMIT = 32;
25
27
  // `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
26
28
  // found on disk for this citizen, and a caller still asks entwurf_v2 what may be done
27
29
  // about it. They earn a column because for every claude-code row `liveness` reads
@@ -102,7 +104,7 @@ function renderDiagnosticLines(diagnostics) {
102
104
  }
103
105
  return lines;
104
106
  }
105
- function compactLines(lines, max = 32) {
107
+ function compactLines(lines, max = ENTWURF_PEERS_RENDER_LIMIT) {
106
108
  if (lines.length <= max)
107
109
  return lines;
108
110
  const omitted = lines.length - max;
@@ -110,13 +110,12 @@ export function isLivenessSupported(backend) {
110
110
  }
111
111
  // ── Native-push backend domain (봉인 2/4) ───────────────────────────────────
112
112
  // A backend whose liveness is measured by the SEPARATE native-push adapter rail (a
113
- // live app-server conversation probe — antigravity's LS gRPC), NOT a control
114
- // socket. This domain is DISJOINT from LIVENESS_DOMAIN_BACKENDS: an agy session
115
- // is `unsupported` on the socket FACT axis (entwurf_peers) yet fully
116
- // measured + deliverable on the native-push axis. The two are separate rails on
117
- // purpose check-entwurf-facts pins both sets and asserts their intersection is
118
- // (a backend can never be in both domains).
119
- export const NATIVE_PUSH_BACKENDS = ["antigravity"];
113
+ // live vendor app-server target probe), NOT a control socket. This domain is DISJOINT
114
+ // from LIVENESS_DOMAIN_BACKENDS: an Antigravity or Codex session is `unsupported` on
115
+ // the socket FACT axis (entwurf_peers) yet independently measured on the native-push
116
+ // axis. The two are separate rails on purpose — check-entwurf-facts pins both sets
117
+ // and asserts their intersection is (a backend can never be in both domains).
118
+ export const NATIVE_PUSH_BACKENDS = ["antigravity", "codex"];
120
119
  export function nativePushSupported(backend) {
121
120
  return NATIVE_PUSH_BACKENDS.includes(backend);
122
121
  }
@@ -5,23 +5,21 @@
5
5
  * ROUTING (the NATIVE_PUSH_DISPATCH_TABLE did) and carries NO release seam — a native-push
6
6
  * plan is the LOCK-FREE path (봉인 4), so a lock leak is structurally impossible here.
7
7
  *
8
- * This is where the 1-shot retry lives NOT in the adapter leaf (봉인 3). The decider
9
- * probed a fresh route and planted it in the plan; the executor sends over it, and on a
10
- * failure it re-probes ONCE (the volatile LS port may have shifted between decide and
11
- * execute) and re-sends. A second failure is fail-loud (no infinite loop). This mirrors the
12
- * control-socket send-fallback pattern: the decider stays pure, the hand owns the retry.
8
+ * Retry is an adapter capability, not a rail-wide assumption. Antigravity re-probes once
9
+ * because its volatile LS port can shift before send. Codex never retries: `codex queue`
10
+ * may have accepted the message before its receipt failed, so replay could duplicate it.
11
+ * The decider stays pure; this hand enforces the adapter's declared policy.
13
12
  *
14
13
  * A native-push send has NO in-band refuse (there is no live receiver to answer
15
14
  * success:false) — like a mailbox enqueue, it either succeeds or THROWS. The hand never
16
15
  * folds a throw into {success:false}; the runner maps a thrown error to execution-failed.
17
16
  */
17
+ import { formatMetaMailboxBody } from "./meta-mailbox-body.js";
18
18
  import { resolveNativePushAdapter } from "./native-push/adapter.js";
19
19
  /**
20
- * Deliver `content` into the conversation, owning the 1-shot retry (봉인 3). Send over the
21
- * planted (decider-probed) route first; on failure re-probe ONCE and re-send over the fresh
22
- * route; a second failure PROPAGATES (fail-loud). If the re-probe finds the target no longer
23
- * alive, throw — a failed send into a now-dead conversation is an honest non-delivery, never
24
- * a silent success. The re-probe is the ONLY re-derivation of the volatile route.
20
+ * Deliver `content` through the decider-probed route. A non-retriable adapter propagates
21
+ * the first failure unchanged. A retriable adapter re-probes once and sends once more only
22
+ * when the target is still alive; no adapter gets a third attempt.
25
23
  */
26
24
  export async function deliverViaNativePush(adapter, route, nativeSessionId, content) {
27
25
  try {
@@ -29,8 +27,9 @@ export async function deliverViaNativePush(adapter, route, nativeSessionId, cont
29
27
  return { success: true, retried: false };
30
28
  }
31
29
  catch (firstErr) {
32
- // 1-shot re-probe → re-send: the volatile LS route may have shifted since the decider
33
- // probed it. Re-discover it fresh and retry exactly once.
30
+ if (!adapter.retriable)
31
+ throw firstErr;
32
+ // One re-probe and one retry for adapters whose send boundary is safe to replay.
34
33
  const reprobe = await adapter.probe(nativeSessionId);
35
34
  if (reprobe.status !== "alive") {
36
35
  throw new Error(`native-push deliver: first send failed and re-probe found target ${reprobe.status} (${reprobe.reason}) — not retried`, { cause: firstErr });
@@ -42,16 +41,30 @@ export async function deliverViaNativePush(adapter, route, nativeSessionId, cont
42
41
  }
43
42
  /**
44
43
  * Build the production `sendNativePush(plan, lock)` adapter the runner consumes. It IGNORES
45
- * `lock` entirely (a native-push plan is lock-free, 봉인 4) the field exists only to match
46
- * the DispatchExecutorDeps hand signature. It resolves the adapter from the plan's backend
47
- * and delivers with the 1-shot retry. A delivery throw surfaces as a REJECTED promise (the
48
- * runner's try/catch maps it to execution-failed).
44
+ * `lock` entirely (a native-push plan is lock-free, 봉인 4), resolves the adapter, then applies
45
+ * that adapter's retry policy. A delivery throw surfaces as a REJECTED promise (the runner's
46
+ * try/catch maps it to execution-failed).
47
+ *
48
+ * SENDER ENVELOPE (measured #95). Direct injection used to hand the adapter the raw
49
+ * `plan.message`, so a native-push citizen received a body with no sender. The #95 A LIVE run
50
+ * measured what that costs: a fresh Pi's nonce-only callback landed in the Codex thread as
51
+ * bare text, and Codex could not name who called it — the callback was uncorrelatable. The
52
+ * control-socket rail carries the envelope in its RPC framing and the mailbox rail serializes
53
+ * it into the body; this rail has neither, so it renders the SAME `formatMetaMailboxBody` SSOT
54
+ * the mailbox uses. Rendered ONCE, before `deliverViaNativePush`, so an Antigravity re-probe
55
+ * retry replays byte-identical content (including its timestamp) instead of a second render.
56
+ * The adapter stays a dumb pipe — its signature is unchanged and it never learns about senders.
49
57
  */
50
58
  export function makeNativePushSend(deps = {}) {
51
59
  const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
60
+ const senderProvider = deps.senderProvider;
52
61
  // `_lock` is named for the hand contract but NEVER read — native-push owns/releases no lock.
53
62
  return async (plan, _lock) => {
63
+ // Resolve the sender BEFORE the adapter is resolved or touched: a provider throw must
64
+ // reject this send with nothing injected, not leave a half-delivered conversation.
65
+ const sender = senderProvider?.();
66
+ const content = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
54
67
  const adapter = resolveAdapter(plan.backend);
55
- return deliverViaNativePush(adapter, plan.route, plan.nativeSessionId, plan.message);
68
+ return deliverViaNativePush(adapter, plan.route, plan.nativeSessionId, content);
56
69
  };
57
70
  }
@@ -210,7 +210,13 @@ export function makeProductionEntwurfV2Deps(opts) {
210
210
  sendMailbox: (plan, _lock) => sendViaMailbox(plan, _lock),
211
211
  // native-push (봉인 4): the SAME injected adapter resolver drives the executor send,
212
212
  // so the decider's probe and the delivery use one adapter. Lock-free (lock ignored).
213
- sendNativePush: makeNativePushSend({ resolveAdapter: io.resolveNativePushAdapter }),
213
+ // It also receives the SAME `opts.senderProvider` the mailbox hand got — one envelope
214
+ // source for all three rails, so a native-push citizen can name its sender exactly as
215
+ // a mailbox citizen does (#95: an envelope-less direct injection is uncorrelatable).
216
+ sendNativePush: makeNativePushSend({
217
+ resolveAdapter: io.resolveNativePushAdapter,
218
+ senderProvider: opts.senderProvider,
219
+ }),
214
220
  };
215
221
  return {
216
222
  decide: (input) => decideDispatch(input, deciderDeps),
@@ -67,11 +67,14 @@ export function actionableRejectHint(reason) {
67
67
  "Re-run entwurf_peers to re-probe; if it stays indeterminate, check for a stale socket file left at " +
68
68
  "that garden id by a session that died without cleaning up.");
69
69
  case "native-push-target-dead":
70
- // The adapter probe found no live host process for the conversation.
71
- return "native-push conversation is not live (no host process found). Re-open the conversation, then retry there is nothing to inject into.";
70
+ // The adapter probe found no live native host serving this conversation/thread. Stays
71
+ // backend-neutral: agy speaks conversations over an LS port, Codex threads over an
72
+ // app-server socket, and this hint is read by callers of both.
73
+ return "the native-push conversation/thread is not live — no native host (harness process or app-server) answered the probe for it. Re-open that conversation/thread in its own harness, then retry; there is nothing to inject into.";
72
74
  case "native-push-probe-indeterminate":
73
- // Host up, but no LS port served the conversation — inconclusive, not a hard dead.
74
- return "native-push host is up but no port served this conversation (probe inconclusive). Retry once the conversation is loaded, or verify the conversation id.";
75
+ // Native host up, but nothing served this conversation/thread — inconclusive, not a
76
+ // hard dead. Backend-neutral for the same reason as the dead cell above.
77
+ return "the native host (harness process or app-server) is up, but nothing served this conversation/thread, so the probe is INCONCLUSIVE — not a measured death. Retry once it is loaded, or verify the conversation/thread id.";
75
78
  case "record-less-socket":
76
79
  // #50 C4: name the true cause AND the fix — a bare socket is a diagnostic
77
80
  // state, not an addressable citizen (the record is the address).
@@ -1,22 +1,29 @@
1
1
  /**
2
2
  * meta-mailbox-body — the SINGLE source for rendering an entwurf message as a
3
- * meta-bridge mailbox body. Both transports that can deliver to a garden citizen
4
- * with no live control socket use this:
3
+ * meta-bridge mailbox body. Every transport that can deliver to a garden citizen
4
+ * with no live control socket uses this:
5
5
  * - the MCP bridge entwurf_v2 (mcp/entwurf-bridge) — external/Claude-host sends
6
6
  * - the pi-native entwurf_v2 (pi-extensions/entwurf-control.ts) — pi-session sends
7
+ * - the native-push rail (pi-extensions/lib/entwurf-v2-native-push.ts) — direct
8
+ * injection into an Antigravity conversation or a loaded Codex thread. Its
9
+ * THIRD consumer, added after the #95 A LIVE run measured what an envelope-less
10
+ * injection costs: a nonce-only callback arrived in a Codex thread as bare text
11
+ * and the receiving citizen could not name who had called it.
7
12
  *
8
13
  * The control-socket path carries the sender envelope inside its RPC framing; the
9
- * mailbox path is just a file, so the envelope must be SERIALIZED INTO the body
10
- * else a receiver reading entwurf_inbox_read would not know who sent it, whether
11
- * the sender is replyable (and at which sessionId), or whether a reply was wanted.
12
- * The render mirrors the live "[entwurf received ⟵]" header so a transcript reads
13
- * the same whether the message arrived over a socket or a mailbox.
14
+ * mailbox path is just a file and direct injection is just a string, so for both the
15
+ * envelope must be SERIALIZED INTO the body
16
+ * else a receiver reading entwurf_inbox_read, or reading an injected turn in its own
17
+ * conversation would not know who sent it, whether the sender is replyable (and at
18
+ * which sessionId), or whether a reply was wanted. The render mirrors the live
19
+ * "[entwurf received ⟵]" header so a transcript reads the same whether the message
20
+ * arrived over a socket, a mailbox, or direct injection.
14
21
  *
15
22
  * No filesystem/network IO and no mutation — the only ambient read is
16
23
  * process.env.HOME for display abbreviation (so not strictly referentially pure,
17
- * but deterministic per environment). Extracted so the two senders cannot drift
18
- * in how a mailbox message presents who-sent-it — the field that round-trips
19
- * garden-id replies.
24
+ * but deterministic per environment). Extracted so the three consumers cannot
25
+ * drift in how a delivered message presents who-sent-it — the field that
26
+ * round-trips garden-id replies.
20
27
  */
21
28
  /** `~`-abbreviate a home-relative cwd for display. Reads process.env.HOME. */
22
29
  function abbreviateHome(cwd) {
@@ -41,7 +48,9 @@ function formatKstTimestamp(iso) {
41
48
  `${pad(kst.getUTCHours())}:${pad(kst.getUTCMinutes())}:${pad(kst.getUTCSeconds())} KST`);
42
49
  }
43
50
  /**
44
- * Render the full mailbox body: header envelope + separator + message. A
51
+ * Render the full body: header envelope + separator + message. The mailbox writes this
52
+ * into its message file and the native-push rail injects it directly into a live
53
+ * conversation; both present the sender identically. A
45
54
  * replyable sender (pi-session, or a trusted meta-session) advertises its
46
55
  * sessionId as the reply address; a non-replyable sender says so WITHOUT
47
56
  * losing its origin — a record-backed meta-session that is currently inactive