@junghanacs/entwurf 0.22.0 → 0.23.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 (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { parse } from "smol-toml";
5
+ import { CODEX_BIRTH_EVENT, classifyOwnedPath, entwurfDeclarationCommand, selectEntwurfDeclaration, statOwnedPath, trustReceiptKey, } from "./codex-declaration.js";
5
6
  import { checkCodexSocketFile, realCodexProtocolOpener, resolveCodexDefaultSocketPath, } from "./native-push/codex-ws-client.js";
6
7
  export const CODEX_PREFLIGHT_HINT = {
7
8
  "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`",
@@ -44,54 +45,69 @@ const CODEX_MCP_ENV_VARS = [
44
45
  "TMUX",
45
46
  "TMUX_PANE",
46
47
  ];
48
+ /**
49
+ * ONE POLICY, FOUR SURFACES. The predicate itself lives in `codex-declaration.js` (sol B3,
50
+ * 2026-09-18) because the installer, the inverse and the doctor decide about the SAME shared
51
+ * hooks.json and used to decide differently: they checked symlink-and-regular, this checked owner
52
+ * and mode as well, and the doctor checked neither. That split let an install and a doctor say yes
53
+ * about bytes every fresh call then refused. The executable bit stays HERE — it is this rail's
54
+ * question about the launcher codex is going to exec, not a fact about shared ownership.
55
+ */
47
56
  function isSafeOwnedFile(file, expectedUid, executable = false) {
48
- try {
49
- const stat = fs.lstatSync(file);
50
- return (stat.isFile() &&
51
- !stat.isSymbolicLink() &&
52
- stat.uid === expectedUid &&
53
- (stat.mode & 0o022) === 0 &&
54
- (!executable || (stat.mode & 0o111) !== 0));
55
- }
56
- catch {
57
+ const stat = statOwnedPath(fs, file);
58
+ if (classifyOwnedPath(stat, expectedUid) !== "ok")
57
59
  return false;
58
- }
60
+ return !executable || (stat.mode & 0o111) !== 0;
59
61
  }
60
62
  function isSafeOwnedDir(dir, expectedUid) {
61
- try {
62
- const stat = fs.lstatSync(dir);
63
- return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === expectedUid && (stat.mode & 0o022) === 0;
64
- }
65
- catch {
66
- return false;
67
- }
63
+ return classifyOwnedPath(statOwnedPath(fs, dir), expectedUid, { kind: "directory" }) === "ok";
68
64
  }
69
65
  /**
70
66
  * The unit's own ownership state, read as the digest inventory it is. Bytes that merely
71
67
  * EXIST prove nothing: the launcher codex is about to exec must be the one this unit
72
68
  * published, so every recorded member is compared to its recorded digest before a sibling
73
69
  * is opened. No digest is computed for the VENDOR here — that is a different axis below.
70
+ *
71
+ * WHAT THIS NO LONGER READS (#117): a WHOLE-FILE `hooksSha256`. `codex-birth-install-state/v2`
72
+ * records the normalized digest of entwurf's own declaration instead, because hooks.json is a
73
+ * file this unit SHARES — a neighbouring integration appending its own `SessionStart` group
74
+ * changes every byte of that file and none of our declaration's meaning. The declaration axis
75
+ * lives in `birthDeclaration` below, which needs the live document anyway to find where our group
76
+ * currently sits.
74
77
  */
75
- function closureDriftedFromState(paths, expectedUid) {
78
+ function certifiedState(paths, expectedUid) {
76
79
  // The directory holding the state carries the state's authority: anyone who can write it
77
80
  // can replace the inventory every digest below is compared against.
78
81
  if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid))
79
- return true;
82
+ return null;
80
83
  if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid))
81
- return true;
84
+ return null;
82
85
  if (!isSafeOwnedFile(paths.stateFile, expectedUid))
83
- return true;
86
+ return null;
84
87
  let state;
85
88
  try {
86
89
  state = JSON.parse(fs.readFileSync(paths.stateFile, "utf8"));
87
90
  }
88
91
  catch {
89
- return true;
92
+ return null;
90
93
  }
91
- if (state.schema !== "codex-birth-install-state/v1" || state.status !== "installed")
92
- return true;
94
+ // v2 ONLY, and a v1 state is refused rather than read leniently: v1 recorded a whole-file
95
+ // digest, so a reader that tolerated it would be certifying an authority nobody holds any
96
+ // more. `entwurf install-codex-birth` supersedes it — that is the one forward path.
97
+ if (state.schema !== "codex-birth-install-state/v2" || state.status !== "installed")
98
+ return null;
93
99
  if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir)
94
- return true;
100
+ return null;
101
+ const declaration = state.declaration;
102
+ if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration))
103
+ return null;
104
+ const recorded = declaration;
105
+ if (recorded.event !== CODEX_BIRTH_EVENT)
106
+ return null;
107
+ if (typeof recorded.command !== "string" || typeof recorded.sha256 !== "string")
108
+ return null;
109
+ if (!/^[0-9a-f]{64}$/.test(recorded.sha256))
110
+ return null;
95
111
  const digest = (file) => {
96
112
  try {
97
113
  return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
@@ -100,11 +116,9 @@ function closureDriftedFromState(paths, expectedUid) {
100
116
  return null;
101
117
  }
102
118
  };
103
- if (typeof state.hooksSha256 !== "string" || digest(paths.hooksFile) !== state.hooksSha256)
104
- return true;
105
119
  const members = state.helperFiles;
106
120
  if (!Array.isArray(members))
107
- return true;
121
+ return null;
108
122
  // Exactly the closure the installer publishes: a short inventory would leave a member
109
123
  // nothing compares, which is the same hole as not checking digests at all.
110
124
  const named = members.map((raw) => raw?.path);
@@ -117,23 +131,37 @@ function closureDriftedFromState(paths, expectedUid) {
117
131
  "entwurf-capabilities.json",
118
132
  ];
119
133
  if (named.length !== expected.length)
120
- return true;
134
+ return null;
121
135
  if (expected.some((name) => !named.includes(name)))
122
- return true;
136
+ return null;
123
137
  if (named.some((name, index) => named.indexOf(name) !== index))
124
- return true;
138
+ return null;
125
139
  for (const raw of members) {
126
140
  const member = raw;
127
141
  const rel = member.path;
128
142
  if (typeof rel !== "string" || rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
129
- return true;
143
+ return null;
130
144
  }
131
145
  if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256)
132
- return true;
146
+ return null;
133
147
  }
134
- return false;
148
+ return { command: recorded.command, sha256: recorded.sha256 };
135
149
  }
136
- function birthMissing(paths, expectedUid) {
150
+ /**
151
+ * WHERE ENTWURF'S DECLARATION CURRENTLY SITS, or nothing.
152
+ *
153
+ * `null` here means the SAME reject as before — `codex-birth-unit-missing` — but the question
154
+ * it answers is narrower than it used to be (#117). It no longer asks "is hooks.json exactly the
155
+ * file we wrote"; it asks "is entwurf's own `SessionStart` group present exactly once, shaped the
156
+ * way the operator approved, and still the declaration this unit recorded". A Herdr (or any
157
+ * other) integration declaring its own group beside ours changes neither answer.
158
+ *
159
+ * The POSITION is the return value rather than a side note because the vendor keys its trust
160
+ * receipt by index: a declaration that has been renumbered by a neighbour's install needs its
161
+ * receipt read at the new index, and reading the old one would report a NEIGHBOUR's approval as
162
+ * our own.
163
+ */
164
+ function birthDeclaration(paths, expectedUid) {
137
165
  const launcher = path.join(paths.helperDir, "codex-birth-launch.sh");
138
166
  const directories = [
139
167
  path.dirname(paths.hooksFile),
@@ -142,9 +170,9 @@ function birthMissing(paths, expectedUid) {
142
170
  path.join(paths.helperDir, "lib", "native-push"),
143
171
  ];
144
172
  if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid)))
145
- return true;
173
+ return null;
146
174
  if (!isSafeOwnedFile(paths.hooksFile, expectedUid))
147
- return true;
175
+ return null;
148
176
  const closure = [
149
177
  [launcher, true],
150
178
  [path.join(paths.helperDir, "meta-bridge-hook-codex.ts"), false],
@@ -154,25 +182,28 @@ function birthMissing(paths, expectedUid) {
154
182
  [path.join(paths.helperDir, "entwurf-capabilities.json"), false],
155
183
  ];
156
184
  if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable)))
157
- return true;
185
+ return null;
186
+ const recorded = certifiedState(paths, expectedUid);
187
+ if (recorded === null)
188
+ return null;
189
+ // The state's own account of what it certified must be the declaration these paths produce;
190
+ // a receipt bound to a different launcher is a receipt for a different unit.
191
+ if (recorded.command !== entwurfDeclarationCommand(launcher))
192
+ return null;
193
+ let selected;
158
194
  try {
159
- const parsed = JSON.parse(fs.readFileSync(paths.hooksFile, "utf8"));
160
- const events = parsed.hooks?.SessionStart;
161
- if (!Array.isArray(events) || events.length !== 1)
162
- return true;
163
- const group = events[0];
164
- if ("matcher" in group || !Array.isArray(group.hooks) || group.hooks.length !== 1)
165
- return true;
166
- const hook = group.hooks[0];
167
- if (Object.keys(hook).sort().join(",") !== "command,timeout,type")
168
- return true;
169
- if (hook.type !== "command" || hook.command !== `'${launcher}'` || hook.timeout !== 30)
170
- return true;
195
+ selected = selectEntwurfDeclaration(JSON.parse(fs.readFileSync(paths.hooksFile, "utf8")), launcher);
171
196
  }
172
197
  catch {
173
- return true;
198
+ return null;
174
199
  }
175
- return closureDriftedFromState(paths, expectedUid);
200
+ if (!selected.ok)
201
+ return null;
202
+ // The NORMALIZED digest, so a neighbour re-serializing the document (measured: Herdr does)
203
+ // cannot make our intact declaration look edited — and an edit to our own handler still can.
204
+ if (selected.digest !== recorded.sha256)
205
+ return null;
206
+ return { groupIndex: selected.groupIndex, handlerIndex: selected.handlerIndex };
176
207
  }
177
208
  /**
178
209
  * The vendor's own receipt for THIS declaration, read and never written. The key the vendor
@@ -180,15 +211,21 @@ function birthMissing(paths, expectedUid) {
180
211
  * `trusted_hash` of the shape `sha256:<64 hex>`. What this asserts is that a receipt EXISTS
181
212
  * for our declaration identity — never that the hash is correct, which only the vendor can
182
213
  * say, and never by launching Codex to find out.
214
+ *
215
+ * THE KEY IS MEASURED, NOT ASSUMED (#117). It used to be spelled `…:session_start:0:0`, which
216
+ * was true only while entwurf was the sole declaration in the file. With a neighbour at index 0
217
+ * that constant reads THEIR receipt and calls it ours — a false green for a hook the vendor has
218
+ * never been asked to run. The caller passes the key built from the position our declaration was
219
+ * just found at.
183
220
  */
184
- function trustReceiptMissing(config, hooksFile) {
221
+ function trustReceiptMissing(config, key) {
185
222
  const hooks = config.hooks;
186
223
  if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks))
187
224
  return true;
188
225
  const state = hooks.state;
189
226
  if (state == null || typeof state !== "object" || Array.isArray(state))
190
227
  return true;
191
- const entry = state[`${hooksFile}:session_start:0:0`];
228
+ const entry = state[key];
192
229
  if (entry == null || typeof entry !== "object" || Array.isArray(entry))
193
230
  return true;
194
231
  const digest = entry.trusted_hash;
@@ -497,14 +534,16 @@ export async function codexFreshPreflight(env, deps = {}) {
497
534
  if (!path.isAbsolute(paths.hooksFile) || !path.isAbsolute(paths.helperDir) || !path.isAbsolute(paths.stateFile)) {
498
535
  return "codex-birth-unit-missing";
499
536
  }
500
- if (birthMissing(paths, deps.operatorUid ?? process.getuid?.() ?? -1)) {
537
+ const declaration = birthDeclaration(paths, deps.operatorUid ?? process.getuid?.() ?? -1);
538
+ if (declaration === null) {
501
539
  return "codex-birth-unit-missing";
502
540
  }
503
541
  const config = readConfig(env);
504
542
  // The vendor receipt is a SEPARATE reject from our bytes: perfect bytes the vendor will
505
543
  // not run and absent bytes are two different repairs, and folding them would send the
506
544
  // operator to the installer for something only they can answer in their own Codex.
507
- if (config === null || trustReceiptMissing(config, paths.hooksFile))
545
+ const key = trustReceiptKey(paths.hooksFile, declaration.groupIndex, declaration.handlerIndex);
546
+ if (config === null || trustReceiptMissing(config, key))
508
547
  return "codex-birth-trust-missing";
509
548
  if (mcpMissing(config))
510
549
  return "codex-mcp-hand-missing";
@@ -38,6 +38,46 @@ export function formatSenderInfoBlock(sender, wantsReply = false) {
38
38
  ...(wantsReply ? { wants_reply: true } : {}),
39
39
  })}</sender_info>`;
40
40
  }
41
+ // ============================================================================
42
+ // Accepted-connection disconnect policy (server half of the same wire)
43
+ // ============================================================================
44
+ /** The two codes that mean THE PEER WENT AWAY, and nothing else. `EPIPE` is a write to a pipe the
45
+ * far side already closed; `ECONNRESET` is the far side resetting it. Both describe the client, not
46
+ * this process's state, which is why they are absorbed rather than diagnosed. */
47
+ const PEER_DISCONNECT_CODES = new Set(["EPIPE", "ECONNRESET"]);
48
+ export function isPeerDisconnect(error) {
49
+ return typeof error.code === "string" && PEER_DISCONNECT_CODES.has(error.code);
50
+ }
51
+ /**
52
+ * Install the disconnect policy on a connection this process ACCEPTED. Must run before any data
53
+ * handler and before any response can be written back.
54
+ *
55
+ * `[측정 2026-09-14, .agent-reports/116-c4-live-blocker-20260914.md]` a resident pi DIED without
56
+ * this. A sibling's control-socket send timed out while the receiving session was mid-turn, the
57
+ * sender closed its end, and the server then wrote its late response to a socket whose peer was
58
+ * gone. That EPIPE does NOT arrive as a throw — `writeResponse`'s synchronous try/catch cannot see
59
+ * it — it arrives asynchronously as an `error` event, and an `error` event with no listener is an
60
+ * uncaught exception, so Node terminated the whole session. The citizen then simply read as `dead`
61
+ * and the stale socket it left behind was the only trace.
62
+ *
63
+ * A client that went away is a BOUNDED environment condition, not invalid state: Rule 15's "crash,
64
+ * don't warn" governs states we cannot reason about, and losing an entire resident session because
65
+ * someone hung up is itself the silent failure. Same policy the MCP probe already carries for an
66
+ * async EPIPE on a child's stdin (`scripts/probe-bridge-command.ts:88-97`).
67
+ *
68
+ * Anything that is NOT a peer hanging up is diagnosed exactly once with its code and message and
69
+ * then served on. Swallowing every error would hide the class this listener is not here to absorb;
70
+ * rethrowing from an event callback would be the crash this whole function exists to prevent.
71
+ *
72
+ * `diagnose` is injected so a gate can count diagnostics without capturing global stderr.
73
+ */
74
+ export function attachAcceptedSocketDisconnectPolicy(socket, diagnose = (line) => console.error(line)) {
75
+ socket.on("error", (error) => {
76
+ if (isPeerDisconnect(error))
77
+ return;
78
+ diagnose(`[entwurf-control] control socket error (${error.code ?? "no code"}): ${error.message}`);
79
+ });
80
+ }
41
81
  export function parseGetInfoResponseData(data) {
42
82
  const value = data;
43
83
  return {
@@ -27,7 +27,7 @@
27
27
  * one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
28
28
  */
29
29
  import { isOutOfSocketDomainGardenIdConflict, resolveFactList, UNOBSERVED_PEER, } from "./entwurf-facts.js";
30
- import { observePeerFacts } from "./entwurf-peer-observe.js";
30
+ import { makeObservePeerFacts, readHerdrPlacementIndex } 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";
33
33
  import { scanSocketProbes } from "./socket-discovery.js";
@@ -140,7 +140,11 @@ export async function listEntwurfFacts(deps) {
140
140
  // not performed becomes `unobserved`.
141
141
  const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
142
142
  const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
143
- const observe = deps.observe ?? observePeerFacts;
143
+ // The placement owner is read EXACTLY ONCE per listing, before any row is shaped, and
144
+ // never again for this call. That single read is the anti-watcher rule in code: herdr
145
+ // publishes no "the session reference landed" event, and a retry loop around that gap
146
+ // is the discovery watcher `docs/mux-launch-rail.md` §7 refuses by name.
147
+ const observe = deps.observe ?? makeObservePeerFacts((deps.readPlacementIndex ?? readHerdrPlacementIndex)());
144
148
  let selectedObserve = observe;
145
149
  if (deps.observationLimit !== undefined) {
146
150
  if (!Number.isSafeInteger(deps.observationLimit) || deps.observationLimit < 0) {
@@ -30,9 +30,14 @@
30
30
  * the surrounding identity facts around it.
31
31
  */
32
32
  import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
33
+ import { UNOBSERVED_PLACEMENT } from "./herdr-placement.js";
33
34
  /** What a caller that measured nothing must say. Explicit and greppable — a row that
34
35
  * silently defaulted to `none`/`absent` would be a fabricated fact. */
35
- export const UNOBSERVED_PEER = { receiver: "unobserved", transcript: "unobserved" };
36
+ export const UNOBSERVED_PEER = {
37
+ receiver: "unobserved",
38
+ transcript: "unobserved",
39
+ placement: UNOBSERVED_PLACEMENT,
40
+ };
36
41
  /**
37
42
  * Compose a `PeerFact` from a citizen's identity and an optional socket probe.
38
43
  *
@@ -57,6 +62,7 @@ export function resolvePeerFact(identity, socket, observations = UNOBSERVED_PEER
57
62
  liveness: factLivenessOf(identity.backend, socket),
58
63
  receiver: observations.receiver,
59
64
  transcript: observations.transcript,
65
+ placement: observations.placement,
60
66
  };
61
67
  }
62
68
  /**
@@ -13,14 +13,23 @@
13
13
  * about it: no `sendable`, no `deliverable`, no transport. The listing stays a listing —
14
14
  * dispatch still computes routing at call time from the same underlying markers.
15
15
  *
16
+ * THE PLACEMENT AXIS IS MEASURED ONCE, NOT PER CITIZEN (#116 S1). `receiver` and
17
+ * `transcript` are per-citizen filesystem questions; placement is one question asked of
18
+ * one placement owner about every pane it has. So the herdr read happens ABOVE this
19
+ * module (the provider does it once) and arrives here as an already-built index. That
20
+ * is also the anti-watcher shape: one read per listing, no retry, no wait for a pane
21
+ * whose session reference has not landed yet.
22
+ *
16
23
  * ONE MEASUREMENT, TWO WORDS. `receiver` is derived from the SAME
17
24
  * `resolveMailboxReceiverFacts` composition the v2 dispatch seam and `entwurf_self` use.
18
25
  * The surfaces are allowed to phrase it differently — a listing wants an enum, a reject
19
26
  * wants a sentence — but they may not disagree, so the enum is a projection of that atom
20
27
  * rather than a second opinion about the same markers.
21
28
  */
29
+ import { execFileSync } from "node:child_process";
22
30
  import * as fs from "node:fs";
23
31
  import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.js";
32
+ import { buildPlacementIndex, parseHerdrPaneList, resolvePlacement, } from "./herdr-placement.js";
24
33
  import { metaCapabilityFor, metaReceiverMarkerPath, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "./meta-session.js";
25
34
  /**
26
35
  * The mailbox receiver axis for one citizen.
@@ -60,7 +69,63 @@ function observeTranscript(identity) {
60
69
  return "absent";
61
70
  }
62
71
  }
63
- /** The production observer: both axes, measured for one citizen. */
72
+ /** How long the one placement read may take before we stop waiting for it. A listing
73
+ * must not hang on a placement owner: the column is a convenience and `unobserved` is
74
+ * a complete answer. */
75
+ const HERDR_READ_TIMEOUT_MS = 2000;
76
+ /**
77
+ * Read the placement owner ONCE, or decline to.
78
+ *
79
+ * TWO ENV FACTS DECIDE, AND BOTH ARE HERDR'S OWN (measured, herdr 0.9.0). `HERDR_ENV=1`
80
+ * is how herdr tells a process it is running inside herdr, and `HERDR_BIN_PATH` is the
81
+ * invocation path herdr's own plugin contract tells callers to use. Neither is
82
+ * discovered: if herdr did not put them in this process's environment we are not inside
83
+ * herdr and we make no claim. There is no path guess, no socket scan, and no PATH
84
+ * lookup — this must stay as explicit as Hard Rule 6 wants configuration to be.
85
+ *
86
+ * EVERY FAILURE IS `null`, NEVER AN EMPTY INDEX. A missing binary, a timeout, a nonzero
87
+ * exit or a payload we cannot parse all mean nobody measured. An empty index would say
88
+ * something much stronger — "herdr was read and has none of your citizens" — about a
89
+ * read that did not happen.
90
+ */
91
+ export function readHerdrPlacementIndex(env = process.env) {
92
+ if (env.HERDR_ENV !== "1")
93
+ return null;
94
+ const bin = env.HERDR_BIN_PATH;
95
+ if (typeof bin !== "string" || bin.length === 0)
96
+ return null;
97
+ let stdout;
98
+ try {
99
+ stdout = execFileSync(bin, ["pane", "list"], {
100
+ encoding: "utf8",
101
+ timeout: HERDR_READ_TIMEOUT_MS,
102
+ stdio: ["ignore", "pipe", "ignore"],
103
+ });
104
+ }
105
+ catch {
106
+ // Bounded environment probe: a placement owner that did not answer is a citizen
107
+ // column that reads `unobserved`, not a listing that fails.
108
+ return null;
109
+ }
110
+ const rows = parseHerdrPaneList(stdout);
111
+ return rows === null ? null : buildPlacementIndex(rows);
112
+ }
113
+ /**
114
+ * Build the production observer over an already-resolved placement index.
115
+ *
116
+ * `null` means no placement owner was read on this host — every citizen then reads
117
+ * `unobserved`, which is the only honest answer when nobody looked. It is NOT `none`:
118
+ * that word is reserved for a herdr that WAS read and does not have this citizen.
119
+ */
120
+ export function makeObservePeerFacts(placementIndex) {
121
+ return (identity) => ({
122
+ receiver: observeReceiver(identity),
123
+ transcript: observeTranscript(identity),
124
+ placement: resolvePlacement(placementIndex, identity),
125
+ });
126
+ }
127
+ /** The production observer with no placement owner read — the shape every caller that
128
+ * has not resolved an index gets, and the default on a host with no herdr. */
64
129
  export function observePeerFacts(identity) {
65
- return { receiver: observeReceiver(identity), transcript: observeTranscript(identity) };
130
+ return makeObservePeerFacts(null)(identity);
66
131
  }
@@ -22,6 +22,7 @@
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
+ import { renderPlacement } from "./herdr-placement.js";
25
26
  /** Human rows and expensive provider observations share this one presentation budget. */
26
27
  export const ENTWURF_PEERS_RENDER_LIMIT = 32;
27
28
  // `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
@@ -32,10 +33,17 @@ export const ENTWURF_PEERS_RENDER_LIMIT = 32;
32
33
  // be indistinguishable here. `receiver=active|inactive|none|n/a` is a projection of the
33
34
  // same measurement dispatch uses; `transcript=exists|absent` is the record's transcript,
34
35
  // existence only, never the path.
36
+ //
37
+ // `placement` (#116 S1) is the third observed fact and the one most likely to be
38
+ // MISREAD, so it is placed at the END of the row — away from `liveness` — and printed
39
+ // as `herdr <pane>` rather than a bare pane id. A pane says where a citizen was last
40
+ // SEEN by a placement owner; it says nothing about whether that citizen is running, and
41
+ // nothing about how to reach it. `unobserved` = nobody looked (no herdr here), `none` =
42
+ // herdr was read and does not have this one, `ambiguous` = more than one pane claimed it.
35
43
  function renderPeerLine(p) {
36
44
  const model = p.model ?? "(unknown)";
37
45
  return (`- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} receiver=${p.receiver} ` +
38
- `transcript=${p.transcript} cwd=${p.cwd} model=${model}`);
46
+ `transcript=${p.transcript} cwd=${p.cwd} model=${model} placement=${renderPlacement(p.placement)}`);
39
47
  }
40
48
  function renderDiagnosticLine(d) {
41
49
  switch (d.kind) {
@@ -125,3 +125,27 @@ export function computeSelfAddressability(facts) {
125
125
  }
126
126
  }
127
127
  }
128
+ /**
129
+ * The mirror of `computeSelfAddressability`, as a decision rather than a claim.
130
+ *
131
+ * That function exists so a surface never CLAIMS an addressability it lacks. This one exists so a
132
+ * surface does not stay SILENT about lacking one: `[관측: GLG, 날것 PC, 2026-09-17]` a herdr plugin
133
+ * install wires this extension at user scope, so it loads in every pi on the host while citizenship
134
+ * stays argv-gated on purpose — and a plain `pi` after a green install is therefore silent,
135
+ * tool-less and indistinguishable from an install that did nothing.
136
+ *
137
+ * WHY THIS IS A FUNCTION AND NOT THREE `if`s AT THE CALL SITE. It was three `if`s, and its gate was
138
+ * a source regex asserting the call existed. `[측정 2026-09-17, 독립 검수 claude-opus-5 + 재현]` two
139
+ * mutants walked straight through that green: moving the call into the CITIZEN branch (so citizens
140
+ * are told they are not citizens and plain pi stays silent), and deleting the once-latch (so the
141
+ * line repeats on every session switch). A regex can see that a call exists; it cannot see which
142
+ * branch it is in or how often it fires. Those are properties of FACTS, so the facts are now
143
+ * arguments and the answer is a value a truth table can exhaust.
144
+ */
145
+ export function decideUncitizenedNotice(facts) {
146
+ if (facts.controlEnabled)
147
+ return false;
148
+ if (!facts.hasUI)
149
+ return false;
150
+ return !facts.alreadyShown;
151
+ }
@@ -102,7 +102,7 @@ export const DISPATCH_LIVENESSES = ["live", "dormant", "indeterminate"];
102
102
  // unreachable (the native-push rail measures it — entwurf-v2-decider.ts). Widening
103
103
  // THIS set is a deliberate future decision, gated by a real compatible
104
104
  // control-socket predicate — never by silently mapping sessions to dead/indeterminate
105
- // (R1 핵심). check-entwurf-facts pins this == ["pi"] and asserts the native-push
105
+ // (R1 핵심). pi-extensions/lib/entwurf-facts.test.ts pins this == ["pi"] and asserts the native-push
106
106
  // domain is disjoint from it.
107
107
  export const LIVENESS_DOMAIN_BACKENDS = ["pi"];
108
108
  export function isLivenessSupported(backend) {
@@ -113,7 +113,7 @@ export function isLivenessSupported(backend) {
113
113
  // live vendor app-server target probe), NOT a control socket. This domain is DISJOINT
114
114
  // from LIVENESS_DOMAIN_BACKENDS: an Antigravity or Codex session is `unsupported` on
115
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
116
+ // axis. The two are separate rails on purpose — pi-extensions/lib/entwurf-facts.test.ts pins both sets
117
117
  // and asserts their intersection is ∅ (a backend can never be in both domains).
118
118
  export const NATIVE_PUSH_BACKENDS = ["antigravity", "codex"];
119
119
  export function nativePushSupported(backend) {
@@ -79,7 +79,7 @@ export const ENTWURF_V2_LOCK_DIR = path.join(os.homedir(), ".pi", "entwurf-v2-lo
79
79
  export const LOCK_SUFFIX = ".lock";
80
80
  export const LOCK_OWNER = "entwurf_v2";
81
81
  /** The reject reason a lock conflict maps to. Kept as a literal here (the lock
82
- * primitive stays decoupled from the full contract); `check-entwurf-v2-lock`
82
+ * primitive stays decoupled from the full contract); `pi-extensions/lib/entwurf-v2-lock.test.ts`
83
83
  * cross-checks it against the contract's ENTWURF_V2_REJECT_REASONS so the two
84
84
  * cannot drift. */
85
85
  export const LOCK_CONFLICT_REASON = "target-locked";