@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
@@ -0,0 +1,213 @@
1
+ /**
2
+ * herdr-placement — the PURE half of the placement evidence axis (#116 S1).
3
+ *
4
+ * WHAT THIS IS FOR. A citizen opened inside herdr is visible in one of herdr's panes.
5
+ * That pane is an EPHEMERAL VIEW, never an address and never a liveness receipt
6
+ * (AGENTS Hard Rule 16, `docs/mux-launch-rail.md` §7). It earns a column in
7
+ * `entwurf_peers` for one reason: on a herdr host the operator's own question is
8
+ * "which pane is that one in", and answering it by hand means reading two listings
9
+ * side by side.
10
+ *
11
+ * WHY THIS JOIN IS EXACT EVIDENCE AND NOT A GUESS. §7 admitted exactly two forms of
12
+ * exact placement evidence (our own launch receipt, and a peer's verifiable
13
+ * self-report) and named the guesses it refuses: window title, cwd match, and time
14
+ * proximity. This is a THIRD form and it belongs to neither the receipts nor the
15
+ * guesses: herdr reports the native session id its OWN OFFICIAL integration told it,
16
+ * and `nativeSessionId` is unique across the whole meta-store (Hard Rule 7). Two
17
+ * independently-owned facts meet on one unique key. Nothing here narrows candidates
18
+ * by cwd, reads a title, or prefers the nearest timestamp. §7 was amended in the same
19
+ * change that added this module; if the two ever disagree, §7 is the owning document.
20
+ *
21
+ * THE TWO AXES ARE NOT EQUALLY EXACT, AND THE DIFFERENCE IS NAMED. On the claude axis
22
+ * the reported value IS the key, byte for byte. On the pi axis it is a session FILE
23
+ * PATH, and the key is recovered from that filename by a strict conversion measured on
24
+ * pi 0.85.1 — a VENDOR FLOOR, not a key equality. Calling both "a unique-key join"
25
+ * would hide which one can drift when a vendor renames a file. The conversion is
26
+ * deliberately strict at both ends so that drift lands as a missed join, never a wrong
27
+ * one, and a listing that declined to read anything says so (see `declinedReports`).
28
+ *
29
+ * ONE READ, NO WATCHER. The observer performs exactly ONE `pane list` read for a
30
+ * whole listing and never retries. herdr publishes no event that says "this pane's
31
+ * `agent_session` is now settled" — measured on herdr 0.9.0: `pane.updated` is the
32
+ * only event whose payload carries `agent_session` and plugin `[[events]]` refuses
33
+ * that name, `events.subscribe` offers three pane kinds and none of them carry it,
34
+ * and `pane.agent_detected` arrives BEFORE the session reference exists. A retry loop
35
+ * around that gap is a discovery watcher, which §7 forbids by name. So a citizen
36
+ * whose pane has not reported yet reads `none` on this pass and is simply asked again
37
+ * next time.
38
+ *
39
+ * FACTS, NOT VERBS. Nothing here returns a transport, a target, or a suggestion. A
40
+ * pane id must never reach dispatch: `entwurf_v2` resolves its rail from the record
41
+ * and a live probe, and a placement that is one server restart from being false has
42
+ * no business in that decision.
43
+ */
44
+ export const UNOBSERVED_PLACEMENT = { kind: "unobserved" };
45
+ export const NO_PLACEMENT = { kind: "none" };
46
+ export const AMBIGUOUS_PLACEMENT = { kind: "ambiguous" };
47
+ /**
48
+ * The OFFICIAL integration reports this join accepts, and nothing else.
49
+ *
50
+ * WHY THIS TABLE EXISTS. herdr accepts a session reference from any integration,
51
+ * including one a user wrote (`integrations.mdx:65-90 @ c77af189`), and the row
52
+ * carries who reported it in `agent_session.agent` / `.source`. Reading only
53
+ * `kind`/`value` would have let a third party's report stand in for the placement
54
+ * owner's own — and "the placement owner reported it" is the entire reason
55
+ * `docs/mux-launch-rail.md` §7 admits this as exact evidence. So the triple is
56
+ * pinned: source, agent, AND the shape that source is measured to emit. A row that
57
+ * misses any leg carries no key.
58
+ *
59
+ * `backend` is the meta-record backend this report may speak for. It is checked at
60
+ * resolve time so a claude-reported pane can never be handed to a pi citizen whose
61
+ * native id happens to collide.
62
+ */
63
+ const OFFICIAL_REPORTS = {
64
+ "herdr:claude": { agent: "claude", kind: "id", backend: "claude-code" },
65
+ "herdr:pi": { agent: "pi", kind: "path", backend: "pi" },
66
+ };
67
+ /**
68
+ * A pi session filename, measured 2026-09-14 on pi 0.85.1:
69
+ *
70
+ * 2026-09-14T05-17-03-979Z_01a09e58-f06a-70e8-b14a-1f0f0c7f7c7d.jsonl
71
+ * └────────── start stamp ─────────┘ └──────── nativeSessionId ────────┘
72
+ *
73
+ * WHY THIS IS PINNED HERE AND NOT INFERRED. The uuid is the join key and it lives in
74
+ * a VENDOR filename, so this rule depends on pi's naming and would break silently if
75
+ * pi changed it. **Vendor floor: pi 0.85.1, measured 2026-09-14** — whether that layout
76
+ * is a vendor contract or a convention is NOT measured, so this is the one place in the
77
+ * axis that can drift under us. It is therefore strict on both ends — the name must end
78
+ * in `.jsonl` and the tail after the last `_` must be a well-formed uuid — and a name
79
+ * that fails either test yields no key at all rather than a substring that happens to
80
+ * look plausible. Drift then lands as a DECLINED report (the citizen reads `unobserved`,
81
+ * not `none`), never as a wrong join onto someone else's pane.
82
+ */
83
+ const PI_SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
84
+ const PI_SESSION_SUFFIX = ".jsonl";
85
+ export function piNativeSessionIdFromPath(value) {
86
+ if (!value.endsWith(PI_SESSION_SUFFIX))
87
+ return null;
88
+ const stem = value.slice(0, -PI_SESSION_SUFFIX.length);
89
+ const base = stem.slice(stem.lastIndexOf("/") + 1);
90
+ const cut = base.lastIndexOf("_");
91
+ if (cut < 0)
92
+ return null;
93
+ const candidate = base.slice(cut + 1);
94
+ return PI_SESSION_UUID.test(candidate) ? candidate : null;
95
+ }
96
+ export function joinKeyOf(row) {
97
+ if (row.sessionSource === null)
98
+ return null;
99
+ const official = OFFICIAL_REPORTS[row.sessionSource];
100
+ // An unmeasured source — a herdr version we have not run, or a third-party
101
+ // integration. Declined on purpose: coercing it would invent a rule, and the
102
+ // whole exact-evidence argument rests on WHO reported.
103
+ if (official === undefined)
104
+ return null;
105
+ if (row.agent !== official.agent)
106
+ return null;
107
+ if (row.sessionKind !== official.kind)
108
+ return null;
109
+ if (row.sessionValue === null || row.sessionValue.length === 0)
110
+ return null;
111
+ const nativeSessionId = official.kind === "id" ? row.sessionValue : piNativeSessionIdFromPath(row.sessionValue);
112
+ return nativeSessionId === null ? null : { nativeSessionId, backend: official.backend };
113
+ }
114
+ export function buildPlacementIndex(rows) {
115
+ const byNativeSessionId = new Map();
116
+ const ambiguous = new Set();
117
+ let declinedReports = 0;
118
+ for (const row of rows) {
119
+ const entry = joinKeyOf(row);
120
+ if (entry === null) {
121
+ // Only a row that actually CARRIED a report counts as declined.
122
+ if (row.sessionSource !== null || row.sessionValue !== null)
123
+ declinedReports++;
124
+ continue;
125
+ }
126
+ const key = entry.nativeSessionId;
127
+ if (ambiguous.has(key))
128
+ continue;
129
+ const seen = byNativeSessionId.get(key);
130
+ if (seen !== undefined && seen.paneId !== row.paneId) {
131
+ byNativeSessionId.delete(key);
132
+ ambiguous.add(key);
133
+ continue;
134
+ }
135
+ byNativeSessionId.set(key, { ...entry, paneId: row.paneId });
136
+ }
137
+ return { byNativeSessionId, ambiguous, declinedReports };
138
+ }
139
+ /**
140
+ * Parse what `herdr pane list` printed. Returns `null` — "nobody looked" — for any
141
+ * payload this does not recognise, because a herdr that answered in a shape we cannot
142
+ * read has told us nothing, and reporting `none` for every citizen on that basis
143
+ * would be a fabricated measurement. A row missing `pane_id` is the one hard error:
144
+ * the whole payload is unreadable rather than partially trusted.
145
+ */
146
+ export function parseHerdrPaneList(stdout) {
147
+ let raw;
148
+ try {
149
+ raw = JSON.parse(stdout);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ if (typeof raw !== "object" || raw === null)
155
+ return null;
156
+ const result = raw.result;
157
+ if (typeof result !== "object" || result === null)
158
+ return null;
159
+ const panes = result.panes;
160
+ if (!Array.isArray(panes))
161
+ return null;
162
+ const rows = [];
163
+ for (const pane of panes) {
164
+ if (typeof pane !== "object" || pane === null)
165
+ return null;
166
+ const paneId = pane.pane_id;
167
+ if (typeof paneId !== "string" || paneId.length === 0)
168
+ return null;
169
+ const session = pane.agent_session;
170
+ if (typeof session !== "object" || session === null) {
171
+ rows.push({ paneId, agent: null, sessionSource: null, sessionKind: null, sessionValue: null });
172
+ continue;
173
+ }
174
+ const agent = session.agent;
175
+ const source = session.source;
176
+ const kind = session.kind;
177
+ const value = session.value;
178
+ rows.push({
179
+ paneId,
180
+ agent: typeof agent === "string" ? agent : null,
181
+ sessionSource: typeof source === "string" ? source : null,
182
+ sessionKind: kind === "id" || kind === "path" ? kind : null,
183
+ sessionValue: typeof value === "string" ? value : null,
184
+ });
185
+ }
186
+ return rows;
187
+ }
188
+ /**
189
+ * The whole decision, for one citizen. A `null` index is the no-herdr host and every
190
+ * citizen on it reads `unobserved` — which is why this takes the index rather than a
191
+ * reader: the read happened once, above, and this stays pure.
192
+ */
193
+ export function resolvePlacement(index, identity) {
194
+ if (index === null)
195
+ return UNOBSERVED_PLACEMENT;
196
+ if (index.ambiguous.has(identity.nativeSessionId))
197
+ return AMBIGUOUS_PLACEMENT;
198
+ const entry = index.byNativeSessionId.get(identity.nativeSessionId);
199
+ if (entry !== undefined) {
200
+ // The reporting integration must speak for THIS citizen's backend. A pi
201
+ // integration reporting an id that collides with a claude-code record is a
202
+ // contradiction, not a placement, and we decline to claim either way.
203
+ return entry.backend === identity.backend ? { kind: "herdr-pane", paneId: entry.paneId } : UNOBSERVED_PLACEMENT;
204
+ }
205
+ // No join. `none` is only honest when the read was COMPLETE — if any report was
206
+ // declined, the thing we did not read might have been this one.
207
+ return index.declinedReports > 0 ? UNOBSERVED_PLACEMENT : NO_PLACEMENT;
208
+ }
209
+ /** Render one observation as the listing's column value. `herdr <pane>` says WHERE it
210
+ * was seen and by WHOM, so a reader never mistakes the pane id for one of ours. */
211
+ export function renderPlacement(placement) {
212
+ return placement.kind === "herdr-pane" ? `herdr ${placement.paneId}` : placement.kind;
213
+ }
@@ -85,22 +85,46 @@
85
85
  * the session the window is actually in. It still reports no `pane_current_path`, and
86
86
  * there is no "session created" field because nothing here creates one.
87
87
  */
88
- import { randomBytes } from "node:crypto";
89
88
  import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
90
89
  import { CODEX_CALLER_SEAT_HINT, resolveCodexCallerSeat, } from "./codex-caller-seat.js";
91
90
  import { CODEX_CALLER_PREFLIGHT_HINT, CODEX_LAUNCH_CWD_PREFLIGHT_HINT, CODEX_PREFLIGHT_HINT, codexLaunchCwdFreshPreflight, } from "./codex-fresh-preflight.js";
92
91
  import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-preflight.js";
92
+ import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallPrompt, FRESH_CALL_BACKENDS, FRESH_CALL_CALLBACK_TOOL, isSafeFreshCallModel, MODEL_MAX_CHARS, mintNonce, normalizeFreshCallInputs, OMP_BOOTSTRAP_FLAG, OMP_BOOTSTRAP_VERSION, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
93
93
  import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
94
94
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
95
95
  import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.js";
96
96
  import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
97
97
  import { classifyTmuxSessionName, resolveTmuxSessionId } from "./resolve-tmux-session.js";
98
- /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
99
- * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
100
- * by #95 after its system birth and app-server rails were measured. The set is joined to the
101
- * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
102
- * missing HERE is not an unwired convenience, it is a release blocker. */
103
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"];
98
+ /**
99
+ * THE COMPOSITION MOVED, THE RAIL DID NOT (#116 S2-a). Backend argv, the first-turn framing and
100
+ * the nonce now live in `fresh-call-composition.ts`, which knows nothing about tmux, so the herdr
101
+ * rail can reach them without importing this file and dragging placement behind it. They are
102
+ * re-exported here unchanged: every existing caller, gate and mutant anchor that reads them from
103
+ * this module keeps reading the same names.
104
+ *
105
+ * The two wrappers below are where this rail states what only it can state — the sentence that
106
+ * says a sibling was opened in the operator's tmux session, and the Codex socket path. Both are
107
+ * arguments to the leaf rather than knowledge inside it.
108
+ */
109
+ export { buildOmpBootstrapPayload, FRESH_CALL_BACKENDS, FRESH_CALL_CALLBACK_TOOL, isSafeFreshCallModel, MODEL_MAX_CHARS, mintNonce, OMP_BOOTSTRAP_FLAG, OMP_BOOTSTRAP_VERSION, TASK_MAX_CHARS, };
110
+ /** What the tmux rail tells a sibling about where it woke up. The resume verb has no equivalent
111
+ * because it composes no first turn at all; a herdr rail will pass its own sentence here. */
112
+ export const TMUX_FRESH_CALL_OPENING_LINE = "You are a fresh visible citizen that entwurf opened in the operator's tmux session.";
113
+ /** Backend argv for THIS rail: the neutral dialect, with the two Codex facts only a rail can
114
+ * state — the socket resolved from the caller's env, and the directory this launch chose.
115
+ *
116
+ * `launchCwd` defaults to THIS process's own directory, which is what tmux gives a window opened
117
+ * with no `-c` (`[측정 ×2]`, module header). The default is that inherited fact rather than a
118
+ * convenience, so a caller that omits it still names the truth to codex. Both facts reach the
119
+ * leaf lazily, because a host with no Codex home must still be able to open pi and claude
120
+ * siblings — see `composeBackendArgs`. */
121
+ export function buildBackendArgs(backend, composition, model, env = process.env, launchCwd = process.cwd()) {
122
+ return composeBackendArgs(backend, composition, model, () => resolveCodexDefaultSocketPath(env), () => launchCwd);
123
+ }
124
+ /** The first turn for THIS rail: the neutral framing under this rail's placement sentence. */
125
+ export function buildFreshCallPrompt(params) {
126
+ return composeFreshCallPrompt({ ...params, openingLine: TMUX_FRESH_CALL_OPENING_LINE });
127
+ }
104
128
  /**
105
129
  * The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
106
130
  * than a compiled-in location: the official binary is whatever the operator's environment gives
@@ -130,240 +154,6 @@ export const FRESH_CALL_RUNTIME = {
130
154
  omp: "omp",
131
155
  codex: "codex",
132
156
  };
133
- /**
134
- * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
135
- * capability directly (`entwurf_v2`), while an MCP-hosted session reaches it under whatever
136
- * name that harness composes. Naming the wrong one costs the whole first turn.
137
- *
138
- * `[측정]` Copilot CLI 1.0.80 composes `<mcpServerName>-<mcpToolName>` — NOT Claude Code's
139
- * `mcp__<server>__<tool>`. Read from two independent sessions' own event logs
140
- * (`~/.copilot/session-state/<id>/events.jsonl`): `assistant.message.toolRequests[].name` and
141
- * `tool.execution_start.toolName` both carry `entwurf-bridge-entwurf_v2`, with
142
- * `mcpServerName`/`mcpToolName` beside them as the parts. Derive-and-measure, never copy a
143
- * sibling's spelling (`docs/adding-a-harness.md` step 5).
144
- *
145
- * `[측정]` omp 18.0.0 is the sharpest case for that rule: it mints
146
- * `mcp__${sanitizedServerName}_${normalizedToolName}` with a sanitizer whose charset is
147
- * `[a-z_]` (`mcp/tool-bridge.ts:351-357`, `:396`), so the DIGIT IN `entwurf_v2` IS EATEN and the
148
- * hyphen in the server key becomes an underscore — the model-facing name is
149
- * `mcp__entwurf_bridge_entwurf_v`, not `..._entwurf_v2` and not Claude's double-underscore form.
150
- * Confirmed against a live tool dump of all seven bridge tools and a real session transcript
151
- * (`scripts/raw-omp-measure/README.md` "Tool-name dialect"). Unlike Copilot there is no second
152
- * permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
153
- */
154
- export const FRESH_CALL_CALLBACK_TOOL = {
155
- pi: "entwurf_v2",
156
- "claude-code": "mcp__entwurf-bridge__entwurf_v2",
157
- copilot: "entwurf-bridge-entwurf_v2",
158
- omp: "mcp__entwurf_bridge_entwurf_v",
159
- codex: "mcp__entwurf_bridge__entwurf_v2",
160
- };
161
- /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
162
- * delivery surface, not a claim that a task of this size was measured through tmux. An argv
163
- * that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
164
- export const TASK_MAX_CHARS = 16000;
165
- export const MODEL_MAX_CHARS = 200;
166
- const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:[\]-]*$/;
167
- /** A model is an explicit launch input, not ambient process state. The grammar admits canonical
168
- * pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
169
- * whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
170
- * CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
171
- export function isSafeFreshCallModel(model) {
172
- return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
173
- }
174
- /**
175
- * Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
176
- * getting them wrong first (rail §6-a):
177
- *
178
- * pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
179
- * Flag-first submitted no message; Pi rejects the equals form for `--model`.
180
- * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
181
- * for allowedTools is variadic and eats the prompt as an option value.
182
- * copilot — the managed VERB first, then the prompt as the value of `-i/--interactive`,
183
- * `--model`, value as two tokens, and the policy as the explicit `--yolo`
184
- * token. Measured from `copilot --help` (1.0.80).
185
- * omp — NO positional prompt at all. The task rides `--entwurf-bootstrap`, a fixed
186
- * flag the installed birth extension registers, then `--model`, value as two
187
- * tokens, then the policy as `--approval-mode`, `yolo`. `-p/--print` remains
188
- * the flag this argv must never carry — it processes a prompt and EXITS,
189
- * closing the window on a sibling that has to stay open to be delivered to.
190
- *
191
- * Both pi/claude failures looked identical from outside: window open, record and socket minted,
192
- * no turn.
193
- *
194
- * For pi and claude-code the equals form is NOT a permission guarantee — on the measured host
195
- * the tool was already permitted, so the option's effect was unobservable. What was observed is
196
- * that it does no harm to the argv. Permission stays a documented host precondition there.
197
- *
198
- * Copilot is the backend where the policy IS carried explicitly (step 9 clause 2), and three of
199
- * its argv facts are load-bearing:
200
- *
201
- * - `copilot` is argv[0] of the RUNTIME `entwurf`, i.e. the managed verb — see
202
- * `FRESH_CALL_RUNTIME`. Everything after it is forwarded byte-identical by
203
- * `scripts/copilot-launch.sh`.
204
- * - the prompt rides `--interactive`, never `-p/--prompt`: `-p` runs the prompt and EXITS,
205
- * which would close the window on a sibling that is supposed to stay open and be delivered
206
- * to. `--interactive <prompt>` is non-variadic, so the space form is safe here.
207
- * - the policy token is `--yolo`, STATED HERE rather than left to the launcher: the launcher
208
- * injects `--yolo` only when the argv names no policy, and step 9 clause 2 requires the
209
- * fresh composition to state its model and permission policy explicitly, never to rely
210
- * invisibly on someone else's default.
211
- *
212
- * `--yolo` is a GLG operator decision, not a drifted default. The first cut passed a
213
- * callback-only `--allow-tool=entwurf-bridge(entwurf_v2)` grant, and GLG's 2026-08-25 operator
214
- * LIVE measured the consequence: the fresh sibling's footer showed no `YOLO`, and every tool
215
- * its task needed stopped on a confirmation prompt, which made the sibling impractical to work
216
- * with. GLG then set the policy explicitly: a fresh Copilot sibling carries the same managed
217
- * `--yolo` profile a human-typed `entwurf copilot` gets. (Copilot 1.0.80 help: `--yolo` = all
218
- * tools + all paths + all URLs.) The permission GRAMMAR lesson from that first cut — Copilot's
219
- * `--allow-tool` takes `<mcp-server-name>(tool-name?)`, a different dialect from the
220
- * model-facing tool name — stays recorded in `docs/adding-a-harness.md` step 9's worked
221
- * example; it is a measured vendor fact even though this argv no longer uses it.
222
- *
223
- * OMP'S POLICY TOKEN IS THE ONE MOST EASILY ARGUED AWAY, SO READ THIS BEFORE DELETING IT.
224
- * `[측정]` omp 18.0.0's schema default for `tools.approvalMode` IS ALREADY `yolo`
225
- * (vendor doc `omp://approval-mode.md`; `omp config get tools.approvalMode` → `yolo` on the
226
- * acceptance host). So dropping `--approval-mode yolo` changes NOTHING observable: the callback
227
- * still fires, the LIVE smoke still passes, and the argv silently starts depending on a vendor
228
- * default and on whatever the operator's config happens to say. That is exactly the drift step 9
229
- * clause 2 forbids — "carry the chosen width as an explicit argv token rather than relying on a
230
- * launcher's injected default" — and the reason the width is stated here even though the host
231
- * would have granted it anyway. The width itself (task-wide, not callback-only) is a GLG
232
- * operator decision of 2026-08-30, taken with the Copilot measurement in hand: a callback-only
233
- * sibling names itself and then stops at the first tool its TASK needs. omp offers no argv
234
- * grammar for a narrower grant at all — `tools.approval.<tool>` is a config axis, not a flag —
235
- * so the honest choice was between `write` and `yolo`, and `yolo` matches what a human-typed
236
- * `omp` gets on this host. `--approval-mode` takes both the space and equals form (measured);
237
- * the space form is used for symmetry with `--model`.
238
- *
239
- * WHY OMP ALONE CARRIES NO PROMPT, AND WHY THAT IS A MEASUREMENT RATHER THAN A PREFERENCE.
240
- * `[LIVE 2026-08-30]` the first public fresh call at omp DID pass the full framing as a bare
241
- * positional. The window opened, the record minted (garden `20260830T181342-452167`), the
242
- * prompt arrived byte-identical as a user message at `09:13:42.413Z` — and the model answered
243
- * the literal text `ACK` with ZERO tool calls, because the callback tool did not exist yet.
244
- * `[source]` the interactive UI defers MCP discovery and only refreshes the tool list once
245
- * `discoverAndConnect()` settles (`sdk.ts:1847-1855`, `:1881-1905`), while the positional
246
- * `initialMessage` prompts immediately after `await mode.init()` (`main.ts:540-565`,
247
- * `595-610`). `[측정]` a `/tmp` observer on the same runtime: `turn_start` at +654ms with the
248
- * entwurf tools ABSENT, callback tool present only at +1484ms — the turn began ~830ms before
249
- * the tool it was told to call existed. No argv can close that gap, because the gap is a race
250
- * inside the host. So the composition hands omp a PAYLOAD instead of a turn, and the
251
- * in-process birth extension — which can see when the tool becomes callable — owns the first
252
- * two messages (`pi-extensions/meta-bridge-omp.ts`, "THE TWO-STAGE FRESH BOOTSTRAP").
253
- *
254
- * The flag is fixed and one-purpose ON PURPOSE. `[측정 2026-08-30]` a normal discovered
255
- * extension that registers a flag receives the operator's argv value byte-identical — quotes,
256
- * `$VAR`, backticks and a semicolon all survived a 137-byte JSON payload — because extensions
257
- * load before argv classification and the reparse writes the registered map
258
- * (`main.ts:1799-1810`, `cli/extension-flags.ts:36-43`). An env carrier or a temp file would
259
- * have needed its own quoting, its own lifetime and its own refusal rules; argv already owns
260
- * all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
261
- * carrier would hand callers the launch-shaping power this rail exists to refuse.
262
- *
263
- * CODEX ALONE CARRIES A DIRECTORY IN ITS ARGV, AND OMITTING IT IS A WRONG ANSWER RATHER THAN A
264
- * NEUTRAL ONE. `[source rust-v0.153.4]` an explicit `--remote <endpoint>` — which this argv
265
- * always passes — resolves to `AppServerTarget::Remote` (`codex-rs/tui/src/lib.rs:875-876`;
266
- * `LocalDaemon` is only the IMPLICIT no-flag reuse path), and a Remote target takes its new
267
- * thread's directory from `remote_cwd_override` ALONE: `thread_cwd_from_config`
268
- * (`codex-rs/tui/src/app_server_session.rs:2022-2033`) answers `None` without it, and the
269
- * app-server then opens the thread in ITS OWN directory. That override is exactly `-C/--cd`,
270
- * retained only for a remote target (`codex-rs/tui/src/startup_orchestration.rs:191-194`), and
271
- * the flag lives on the shared interactive options this argv already uses for
272
- * `--dangerously-bypass-approvals-and-sandbox` (`codex-rs/utils/cli/src/shared_options.rs:53-68`)
273
- * — not only on the `agents` subcommand that also spells it (`codex-rs/cli/src/main.rs:334`).
274
- *
275
- * `[측정 2026-09-16]` with the flag absent, three citizens of one chain (pi → Codex → Claude
276
- * Code) all recorded the app-server's `~/repos/gh/entwurf` while the panes themselves sat in
277
- * `~/repos/gh/agent-config`, and the birth hook wrote that vendor-supplied cwd into each record
278
- * honestly (#95 lane C §1). So the token is ALWAYS present for codex: ONE directory, TWO
279
- * CARRIERS — tmux `-c` places the PANE, codex `-C` places the THREAD, and `launchCwd` is the
280
- * single value both receive. It is not a new input axis: the value is chosen by the cwd rules
281
- * this module already has, one layer up.
282
- */
283
- export function buildBackendArgs(backend, composition, model, env = process.env,
284
- /** Where the sibling will actually start: the directory `freshCall` chose when it chose one,
285
- * and otherwise THIS process's own — which is what tmux gives a window opened with no `-c`
286
- * (`[측정 ×2]`, module header). The default is that inherited fact rather than a
287
- * convenience, so a caller that omits it still names the truth to codex. */
288
- launchCwd = process.cwd()) {
289
- switch (backend) {
290
- case "pi":
291
- return [composition.prompt, "--entwurf-control", "--model", model];
292
- case "claude-code":
293
- return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
294
- case "copilot":
295
- return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
296
- case "omp":
297
- return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
298
- case "codex":
299
- return [
300
- "--remote",
301
- `unix://${resolveCodexDefaultSocketPath(env)}`,
302
- "-C",
303
- launchCwd,
304
- "--model",
305
- model,
306
- "--dangerously-bypass-approvals-and-sandbox",
307
- composition.prompt,
308
- ];
309
- }
310
- }
311
- /**
312
- * The omp bootstrap flag, spelled WITHOUT dashes — the vendor's flag map is keyed by bare
313
- * name (`extensions/loader.ts:221-228`) and this composition adds the `--` itself.
314
- *
315
- * Held equal to the installed extension's own constant by
316
- * `test/omp-fresh-bootstrap.contract.test.ts`. The two copies exist because the extension
317
- * ships INSIDE the omp agent dir carrying only its own small closure and cannot import this
318
- * module; the gate is what keeps the duplication from becoming drift.
319
- */
320
- export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
321
- /** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
322
- * unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
323
- export const OMP_BOOTSTRAP_VERSION = 1;
324
- /**
325
- * The whole of what a fresh omp sibling is launched with.
326
- *
327
- * THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
328
- * contract: who to call back, the nonce that proves it is this call, and the task that is
329
- * released only after that callback succeeds. There is no command here, no path, no env name
330
- * and no model — the model is already an explicit argv token, and a second copy of it inside
331
- * a payload would be a second place for it to disagree with the launch.
332
- */
333
- export function buildOmpBootstrapPayload(params) {
334
- return JSON.stringify({
335
- v: OMP_BOOTSTRAP_VERSION,
336
- target: params.callerGardenId,
337
- nonce: params.nonce,
338
- task: params.task,
339
- });
340
- }
341
- /**
342
- * The first-turn framing. Order is the contract: the callback is the FIRST action and the task
343
- * follows it, so a sibling that gets stuck in the task has already told the caller who it is.
344
- *
345
- * The three prohibitions are not politeness. Each names a detour that was measured to produce a
346
- * confidently wrong answer or a wasted turn.
347
- */
348
- export function buildFreshCallPrompt(params) {
349
- const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
350
- return [
351
- "You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
352
- "",
353
- `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
354
- `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
355
- `message set to exactly ${params.nonce} — that string alone, nothing added.`,
356
- "That call is how the agent that opened you learns your address. Do not skip it, do not",
357
- "defer it until the task is done, and do not reword the message.",
358
- "",
359
- "Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
360
- "server yourself. Your own report of your identity is not the address anyone needs.",
361
- "",
362
- "After the tool receipt, carry out this task:",
363
- "",
364
- params.task,
365
- ].join("\n");
366
- }
367
157
  /**
368
158
  * WHICH RULE PICKS THE TARGET SESSION. Three in strict order, and the order is the contract:
369
159
  *
@@ -390,14 +180,6 @@ export function selectFreshCallSeat(placement) {
390
180
  return { tmuxSession: placement.tmuxSession, source: "requested" };
391
181
  return null;
392
182
  }
393
- /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
394
- * encoded any of those would invite exactly the guessing this rail exists to refuse. */
395
- export function mintNonce(randomHex = defaultRandomHex) {
396
- return `mux-fresh-call-${randomHex()}`;
397
- }
398
- function defaultRandomHex() {
399
- return randomBytes(12).toString("hex");
400
- }
401
183
  /**
402
184
  * The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
403
185
  *
@@ -481,28 +263,19 @@ export function buildFreshCallArgs(targetSessionId, runtimePath, backendArgs, cw
481
263
  * from a seat, a workspace map or a project name.
482
264
  */
483
265
  export function freshCall(params, env = process.env, nonce = mintNonce()) {
484
- if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
485
- return { ok: false, reason: "caller-identity-unavailable" };
486
- }
487
- const model = params.model.trim();
488
- if (model.length === 0)
489
- return { ok: false, reason: "model-empty" };
490
- if (!isSafeFreshCallModel(model))
491
- return { ok: false, reason: "model-invalid" };
492
- const task = params.task.trim();
493
- if (task.length === 0)
494
- return { ok: false, reason: "task-empty" };
495
- if (task.length > TASK_MAX_CHARS)
496
- return { ok: false, reason: "task-too-long" };
497
- // ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
498
- // value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
499
- // classification below instead of being silently repaired into a different directory. The
500
- // caller's own record directory answers the SAME two-value emptiness rule and is consulted
501
- // ONLY second: an explicit request always wins, and a caller that supplies neither leaves the
502
- // pane to inherit this process's directory exactly as before — a pi caller's argv is
503
- // byte-identical, because its process directory IS its own and a `-c` token would change
504
- // nothing about where that window lands.
505
- const requestedCwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
266
+ // The caller-facing input contract lives in the composition leaf so BOTH rails answer a
267
+ // mistyped model or an oversized task with the same words. Order, trimming and the
268
+ // cwd-omission rule (`undefined` and the exact empty string, and nothing else, mean "no
269
+ // cwd") are unchanged from when they lived here.
270
+ const normalized = normalizeFreshCallInputs(params);
271
+ if (!normalized.ok)
272
+ return { ok: false, reason: normalized.reason };
273
+ const { callerGardenId, model, task, cwd: requestedCwd } = normalized.inputs;
274
+ // The caller's own record directory answers the SAME two-value emptiness rule the leaf
275
+ // applies to a requested cwd, and is consulted ONLY second: an explicit request always wins,
276
+ // and a caller that supplies neither leaves the pane to inherit this process's directory
277
+ // exactly as before — a pi caller's argv is byte-identical, because its process directory IS
278
+ // its own and a `-c` token would change nothing about where that window lands.
506
279
  const callerCwd = params.callerCwd === undefined || params.callerCwd === "" ? undefined : params.callerCwd;
507
280
  const chosenCwd = requestedCwd !== undefined
508
281
  ? { value: requestedCwd, source: "requested" }
@@ -596,10 +369,10 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
596
369
  prompt: buildFreshCallPrompt({
597
370
  backend: params.backend,
598
371
  task,
599
- callerGardenId: params.callerGardenId,
372
+ callerGardenId,
600
373
  nonce,
601
374
  }),
602
- bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
375
+ bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId, nonce, task }),
603
376
  };
604
377
  const backendArgs = buildBackendArgs(params.backend, composition, model, env, cwd);
605
378
  // THE LAUNCH-DIRECTORY NOTE, AND IT IS A DIAGNOSTIC RATHER THAN A GATE. `[측정 2026-09-16]` a