@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,493 @@
1
+ /**
2
+ * FRESH-CALL COMPOSITION — the part of opening a sibling that is the same wherever the
3
+ * sibling is placed.
4
+ *
5
+ * WHY THIS FILE EXISTS (#116 S2-a). A herdr rail opens the same five-backend dialect, hands the
6
+ * same first-turn framing and mints the same correlation nonce as the tmux rail — but it places
7
+ * the sibling with `pane split` + `agent start` rather than `new-window`, and it cannot inherit
8
+ * one line of tmux placement to get there. Leaving this composition inside `mux-fresh-call.ts`
9
+ * would have forced the herdr module to import that file, and that file imports tmux placement,
10
+ * the launch preflights and the Codex socket resolver. One import would have dragged the whole
11
+ * tmux rail behind it.
12
+ *
13
+ * SO THE FENCE IS THE POINT: this leaf imports NOTHING from `mux-*` or `entwurf-*`. Only the
14
+ * Node standard library. Anything a rail owns arrives as an explicit argument — the opening
15
+ * sentence that says where the sibling was placed, and the Codex socket path. Both are injected
16
+ * rather than resolved here, because resolving either one would re-open the coupling this file
17
+ * exists to close.
18
+ *
19
+ * WHAT DELIBERATELY STAYED BEHIND in `mux-fresh-call.ts`: placement, seat selection, runtime
20
+ * proof, the mutation-order safety argument, and the receipt type. None of those are shared —
21
+ * a herdr coordinate is not a tmux coordinate, herdr abandons pre-mutation runtime proof
22
+ * (#116 D1), and its two-step launch breaks the ordering argument outright.
23
+ */
24
+
25
+ import { randomBytes } from "node:crypto";
26
+
27
+ /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
28
+ * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
29
+ * by #95 after its system birth and app-server rails were measured. The set is joined to the
30
+ * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
31
+ * missing HERE is not an unwired convenience, it is a release blocker. */
32
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"] as const;
33
+ export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
34
+
35
+ /**
36
+ * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
37
+ * capability directly (`entwurf_v2`), while an MCP-hosted session reaches it under whatever
38
+ * name that harness composes. Naming the wrong one costs the whole first turn.
39
+ *
40
+ * `[측정]` Copilot CLI 1.0.80 composes `<mcpServerName>-<mcpToolName>` — NOT Claude Code's
41
+ * `mcp__<server>__<tool>`. Read from two independent sessions' own event logs
42
+ * (`~/.copilot/session-state/<id>/events.jsonl`): `assistant.message.toolRequests[].name` and
43
+ * `tool.execution_start.toolName` both carry `entwurf-bridge-entwurf_v2`, with
44
+ * `mcpServerName`/`mcpToolName` beside them as the parts. Derive-and-measure, never copy a
45
+ * sibling's spelling (`docs/adding-a-harness.md` step 5).
46
+ *
47
+ * `[측정]` omp 18.0.0 is the sharpest case for that rule: it mints
48
+ * `mcp__${sanitizedServerName}_${normalizedToolName}` with a sanitizer whose charset is
49
+ * `[a-z_]` (`mcp/tool-bridge.ts:351-357`, `:396`), so the DIGIT IN `entwurf_v2` IS EATEN and the
50
+ * hyphen in the server key becomes an underscore — the model-facing name is
51
+ * `mcp__entwurf_bridge_entwurf_v`, not `..._entwurf_v2` and not Claude's double-underscore form.
52
+ * Confirmed against a live tool dump of all seven bridge tools and a real session transcript
53
+ * (`scripts/raw-omp-measure/README.md` "Tool-name dialect"). Unlike Copilot there is no second
54
+ * permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
55
+ */
56
+ export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
57
+ pi: "entwurf_v2",
58
+ "claude-code": "mcp__entwurf-bridge__entwurf_v2",
59
+ copilot: "entwurf-bridge-entwurf_v2",
60
+ omp: "mcp__entwurf_bridge_entwurf_v",
61
+ codex: "mcp__entwurf_bridge__entwurf_v2",
62
+ };
63
+
64
+ /**
65
+ * The same dialects, for the tool the framing OFFERS rather than requires.
66
+ *
67
+ * `[sol D2, 2026-09-18]` the corroboration sentence named a bare `entwurf_peers` to every backend,
68
+ * including the four that reach it under a composed MCP name. The prohibition it replaced was
69
+ * removed precisely because a sibling should be able to check who called it; naming that tool in a
70
+ * spelling its own session does not expose puts the offer back out of reach for exactly the
71
+ * backends that needed it most.
72
+ *
73
+ * Every entry is the callback dialect applied to `entwurf_peers`, so the two maps drift together
74
+ * or not at all. omp is the one that has to be read rather than pattern-matched: its sanitizer
75
+ * charset is `[a-z_]`, which ate the digit in `entwurf_v2` — `entwurf_peers` has no digit, so the
76
+ * SAME rule produces the whole word here.
77
+ */
78
+ export const FRESH_CALL_PEERS_TOOL: Record<FreshCallBackend, string> = {
79
+ pi: "entwurf_peers",
80
+ "claude-code": "mcp__entwurf-bridge__entwurf_peers",
81
+ copilot: "entwurf-bridge-entwurf_peers",
82
+ omp: "mcp__entwurf_bridge_entwurf_peers",
83
+ codex: "mcp__entwurf_bridge__entwurf_peers",
84
+ };
85
+
86
+ /**
87
+ * ONE BACKEND NEEDS TO BE TOLD HOW TO REACH ITS OWN CALLBACK TOOL `[측정 2026-09-18, n=5 + n=3]`.
88
+ *
89
+ * A Claude Code child of this rail could not call `entwurf_v2` on its first turn because the tool
90
+ * was not callable yet — claude-code 2.1.267 surfaces an MCP server that is still connecting as
91
+ * DEFERRED: the NAME is listed, the schema is not, and a call without the schema fails. Across five
92
+ * isolated probes the bridge's tools were directly callable ZERO times (4 DEFERRED, 1 still
93
+ * connecting), and the framing said "FIRST ACTION … call `entwurf_v2`" without saying the one thing
94
+ * that makes that possible. A child that cannot call the tool answers in text and goes idle — which
95
+ * is exactly the "silence" this rail measured in production, 5 of 14 recorded launches.
96
+ *
97
+ * The timing story that preceded this one is retired: re-read at n=13 rather than n=4, the success
98
+ * and silence latencies overlap completely (a 52 ms child called back; a 94 ms child did not), and
99
+ * the repair worked on a 782 ms child — slower than every silent run.
100
+ *
101
+ * `[측정, n=3, same model]` adding the sentence below produced 3/3 callbacks. It is a FACT about the
102
+ * runtime, in the register the rest of this framing uses since the prohibitions came out: it grants
103
+ * nothing, forbids nothing, and names the one call that turns a listed name into a callable tool.
104
+ * Both tools ride ONE `ToolSearch` select, so the corroboration the framing offers stays reachable
105
+ * for a child that takes the offer.
106
+ *
107
+ * Every other backend gets an EMPTY array, deliberately: pi has no deferred-tool surface, and the
108
+ * other three were never measured to need it. A hint invented for a runtime nobody probed would be
109
+ * this rail guessing about a vendor again.
110
+ */
111
+ export const FRESH_CALL_TOOL_LOAD_HINT: Record<FreshCallBackend, readonly string[]> = {
112
+ pi: [],
113
+ "claude-code": [
114
+ "If ${callbackTool} is not callable yet, the entwurf-bridge server is still connecting or its",
115
+ 'tools are deferred: load them first with ToolSearch("select:${callbackTool},${peersTool}") —',
116
+ "it waits for the server — then call ${callbackTool}.",
117
+ ],
118
+ copilot: [],
119
+ omp: [],
120
+ codex: [],
121
+ };
122
+
123
+ /**
124
+ * What a launch has to say, in the two shapes the five backends need. Four of them are
125
+ * handed a first-turn PROMPT; omp is handed a bootstrap PAYLOAD its own installed extension
126
+ * unpacks. Both are always built, because building one is cheap and a backend switch must
127
+ * never be able to reach a field that was not composed.
128
+ */
129
+ export interface FreshCallComposition {
130
+ prompt: string;
131
+ bootstrapPayload: string;
132
+ }
133
+
134
+ /**
135
+ * The omp bootstrap flag, spelled WITHOUT dashes — the vendor's flag map is keyed by bare
136
+ * name (`extensions/loader.ts:221-228`) and this composition adds the `--` itself.
137
+ *
138
+ * Held equal to the installed extension's own constant by
139
+ * `test/omp-fresh-bootstrap.contract.test.ts`. The two copies exist because the extension
140
+ * ships INSIDE the omp agent dir carrying only its own small closure and cannot import this
141
+ * module; the gate is what keeps the duplication from becoming drift.
142
+ */
143
+ export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
144
+
145
+ /** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
146
+ * unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
147
+ export const OMP_BOOTSTRAP_VERSION = 1;
148
+
149
+ /**
150
+ * The whole of what a fresh omp sibling is launched with.
151
+ *
152
+ * THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
153
+ * contract: who to call back, the nonce that proves it is this call, and the task that is
154
+ * released only after that callback succeeds. There is no command here, no path, no env name
155
+ * and no model — the model is already an explicit argv token, and a second copy of it inside
156
+ * a payload would be a second place for it to disagree with the launch.
157
+ */
158
+ export function buildOmpBootstrapPayload(params: { callerGardenId: string; nonce: string; task: string }): string {
159
+ return JSON.stringify({
160
+ v: OMP_BOOTSTRAP_VERSION,
161
+ target: params.callerGardenId,
162
+ nonce: params.nonce,
163
+ task: params.task,
164
+ });
165
+ }
166
+
167
+ /**
168
+ * THE CODEX SOCKET IS INJECTED, NOT RESOLVED HERE, and that is the one seam #116 S2-a had to
169
+ * open. `resolveCodexDefaultSocketPath` lives in `native-push/codex-ws-client.ts`, which imports
170
+ * `entwurf-v2-contract` — importing it would have put a delivery-contract module inside the leaf
171
+ * that is supposed to know nothing about delivery. The alternative, re-deriving the path here,
172
+ * would have been a silent second copy of a vendor coordinate. So the caller passes a THUNK: it
173
+ * is evaluated only inside the codex arm, exactly as the inline call was, so a missing HOME still
174
+ * fails only for codex and never for pi.
175
+ */
176
+ /**
177
+ * Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
178
+ * getting them wrong first (rail §6-a):
179
+ *
180
+ * pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
181
+ * Flag-first submitted no message; Pi rejects the equals form for `--model`.
182
+ * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
183
+ * for allowedTools is variadic and eats the prompt as an option value.
184
+ * copilot — the managed VERB first, then the prompt as the value of `-i/--interactive`,
185
+ * `--model`, value as two tokens, and the policy as the explicit `--yolo`
186
+ * token. Measured from `copilot --help` (1.0.80).
187
+ * omp — NO positional prompt at all. The task rides `--entwurf-bootstrap`, a fixed
188
+ * flag the installed birth extension registers, then `--model`, value as two
189
+ * tokens, then the policy as `--approval-mode`, `yolo`. `-p/--print` remains
190
+ * the flag this argv must never carry — it processes a prompt and EXITS,
191
+ * closing the window on a sibling that has to stay open to be delivered to.
192
+ *
193
+ * Both pi/claude failures looked identical from outside: window open, record and socket minted,
194
+ * no turn.
195
+ *
196
+ * For pi and claude-code the equals form is NOT a permission guarantee — on the measured host
197
+ * the tool was already permitted, so the option's effect was unobservable. What was observed is
198
+ * that it does no harm to the argv. Permission stays a documented host precondition there.
199
+ *
200
+ * Copilot is the backend where the policy IS carried explicitly (step 9 clause 2), and three of
201
+ * its argv facts are load-bearing:
202
+ *
203
+ * - `copilot` is argv[0] of the RUNTIME `entwurf`, i.e. the managed verb — see
204
+ * `FRESH_CALL_RUNTIME`. Everything after it is forwarded byte-identical by
205
+ * `scripts/copilot-launch.sh`.
206
+ * - the prompt rides `--interactive`, never `-p/--prompt`: `-p` runs the prompt and EXITS,
207
+ * which would close the window on a sibling that is supposed to stay open and be delivered
208
+ * to. `--interactive <prompt>` is non-variadic, so the space form is safe here.
209
+ * - the policy token is `--yolo`, STATED HERE rather than left to the launcher: the launcher
210
+ * injects `--yolo` only when the argv names no policy, and step 9 clause 2 requires the
211
+ * fresh composition to state its model and permission policy explicitly, never to rely
212
+ * invisibly on someone else's default.
213
+ *
214
+ * `--yolo` is a GLG operator decision, not a drifted default. The first cut passed a
215
+ * callback-only `--allow-tool=entwurf-bridge(entwurf_v2)` grant, and GLG's 2026-08-25 operator
216
+ * LIVE measured the consequence: the fresh sibling's footer showed no `YOLO`, and every tool
217
+ * its task needed stopped on a confirmation prompt, which made the sibling impractical to work
218
+ * with. GLG then set the policy explicitly: a fresh Copilot sibling carries the same managed
219
+ * `--yolo` profile a human-typed `entwurf copilot` gets. (Copilot 1.0.80 help: `--yolo` = all
220
+ * tools + all paths + all URLs.) The permission GRAMMAR lesson from that first cut — Copilot's
221
+ * `--allow-tool` takes `<mcp-server-name>(tool-name?)`, a different dialect from the
222
+ * model-facing tool name — stays recorded in `docs/adding-a-harness.md` step 9's worked
223
+ * example; it is a measured vendor fact even though this argv no longer uses it.
224
+ *
225
+ * OMP'S POLICY TOKEN IS THE ONE MOST EASILY ARGUED AWAY, SO READ THIS BEFORE DELETING IT.
226
+ * `[측정]` omp 18.0.0's schema default for `tools.approvalMode` IS ALREADY `yolo`
227
+ * (vendor doc `omp://approval-mode.md`; `omp config get tools.approvalMode` → `yolo` on the
228
+ * acceptance host). So dropping `--approval-mode yolo` changes NOTHING observable: the callback
229
+ * still fires, the LIVE smoke still passes, and the argv silently starts depending on a vendor
230
+ * default and on whatever the operator's config happens to say. That is exactly the drift step 9
231
+ * clause 2 forbids — "carry the chosen width as an explicit argv token rather than relying on a
232
+ * launcher's injected default" — and the reason the width is stated here even though the host
233
+ * would have granted it anyway. The width itself (task-wide, not callback-only) is a GLG
234
+ * operator decision of 2026-08-30, taken with the Copilot measurement in hand: a callback-only
235
+ * sibling names itself and then stops at the first tool its TASK needs. omp offers no argv
236
+ * grammar for a narrower grant at all — `tools.approval.<tool>` is a config axis, not a flag —
237
+ * so the honest choice was between `write` and `yolo`, and `yolo` matches what a human-typed
238
+ * `omp` gets on this host. `--approval-mode` takes both the space and equals form (measured);
239
+ * the space form is used for symmetry with `--model`.
240
+ *
241
+ * WHY OMP ALONE CARRIES NO PROMPT, AND WHY THAT IS A MEASUREMENT RATHER THAN A PREFERENCE.
242
+ * `[LIVE 2026-08-30]` the first public fresh call at omp DID pass the full framing as a bare
243
+ * positional. The window opened, the record minted (garden `20260830T181342-452167`), the
244
+ * prompt arrived byte-identical as a user message at `09:13:42.413Z` — and the model answered
245
+ * the literal text `ACK` with ZERO tool calls, because the callback tool did not exist yet.
246
+ * `[source]` the interactive UI defers MCP discovery and only refreshes the tool list once
247
+ * `discoverAndConnect()` settles (`sdk.ts:1847-1855`, `:1881-1905`), while the positional
248
+ * `initialMessage` prompts immediately after `await mode.init()` (`main.ts:540-565`,
249
+ * `595-610`). `[측정]` a `/tmp` observer on the same runtime: `turn_start` at +654ms with the
250
+ * entwurf tools ABSENT, callback tool present only at +1484ms — the turn began ~830ms before
251
+ * the tool it was told to call existed. No argv can close that gap, because the gap is a race
252
+ * inside the host. So the composition hands omp a PAYLOAD instead of a turn, and the
253
+ * in-process birth extension — which can see when the tool becomes callable — owns the first
254
+ * two messages (`pi-extensions/meta-bridge-omp.ts`, "THE TWO-STAGE FRESH BOOTSTRAP").
255
+ *
256
+ * The flag is fixed and one-purpose ON PURPOSE. `[측정 2026-08-30]` a normal discovered
257
+ * extension that registers a flag receives the operator's argv value byte-identical — quotes,
258
+ * `$VAR`, backticks and a semicolon all survived a 137-byte JSON payload — because extensions
259
+ * load before argv classification and the reparse writes the registered map
260
+ * (`main.ts:1799-1810`, `cli/extension-flags.ts:36-43`). An env carrier or a temp file would
261
+ * have needed its own quoting, its own lifetime and its own refusal rules; argv already owns
262
+ * all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
263
+ * carrier would hand callers the launch-shaping power this rail exists to refuse.
264
+ *
265
+ * CODEX ALONE CARRIES A DIRECTORY IN ITS ARGV, AND OMITTING IT IS A WRONG ANSWER RATHER THAN A
266
+ * NEUTRAL ONE. `[source rust-v0.153.4]` an explicit `--remote <endpoint>` — which this argv
267
+ * always passes — resolves to `AppServerTarget::Remote` (`codex-rs/tui/src/lib.rs:875-876`;
268
+ * `LocalDaemon` is only the IMPLICIT no-flag reuse path), and a Remote target takes its new
269
+ * thread's directory from `remote_cwd_override` ALONE: `thread_cwd_from_config`
270
+ * (`codex-rs/tui/src/app_server_session.rs:2022-2033`) answers `None` without it, and the
271
+ * app-server then opens the thread in ITS OWN directory. That override is exactly `-C/--cd`,
272
+ * retained only for a remote target (`codex-rs/tui/src/startup_orchestration.rs:191-194`), and
273
+ * the flag lives on the shared interactive options this argv already uses for
274
+ * `--dangerously-bypass-approvals-and-sandbox` (`codex-rs/utils/cli/src/shared_options.rs:53-68`)
275
+ * — not only on the `agents` subcommand that also spells it (`codex-rs/cli/src/main.rs:334`).
276
+ *
277
+ * `[측정 2026-09-16]` with the flag absent, three citizens of one chain (pi → Codex → Claude
278
+ * Code) all recorded the app-server's `~/repos/gh/entwurf` while the panes themselves sat in
279
+ * `~/repos/gh/agent-config`, and the birth hook wrote that vendor-supplied cwd into each record
280
+ * honestly (#95 lane C §1). So the token is ALWAYS present for codex: ONE directory, TWO
281
+ * CARRIERS — tmux `-c` places the PANE, codex `-C` places the THREAD, and the rail's chosen
282
+ * directory is the single value both receive. It is not a new input axis: the value is chosen by
283
+ * the cwd rules the rail already has, one layer up.
284
+ *
285
+ * BOTH CODEX FACTS ARRIVE AS THUNKS, and for the same reason: they are rail knowledge, and only
286
+ * the codex branch may pay for them. A host with no Codex home must still open pi and claude
287
+ * siblings, and the herdr rail — where codex is not a pilot backend and is refused before this
288
+ * point — supplies throwing resolvers so a future widening cannot silently inherit a guess.
289
+ */
290
+ export function composeBackendArgs(
291
+ backend: FreshCallBackend,
292
+ composition: FreshCallComposition,
293
+ model: string,
294
+ resolveCodexSocketPath: () => string,
295
+ resolveCodexLaunchCwd: () => string,
296
+ ): string[] {
297
+ switch (backend) {
298
+ case "pi":
299
+ return [composition.prompt, "--entwurf-control", "--model", model];
300
+ case "claude-code":
301
+ return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
302
+ case "copilot":
303
+ return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
304
+ case "omp":
305
+ return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
306
+ case "codex":
307
+ return [
308
+ "--remote",
309
+ `unix://${resolveCodexSocketPath()}`,
310
+ "-C",
311
+ resolveCodexLaunchCwd(),
312
+ "--model",
313
+ model,
314
+ "--dangerously-bypass-approvals-and-sandbox",
315
+ composition.prompt,
316
+ ];
317
+ }
318
+ }
319
+
320
+ /**
321
+ * PLACEMENT SENTENCE. The first line names where the sibling was opened, and only the RAIL knows
322
+ * that — "the operator's tmux session" is true of a `new-window` launch and false of a herdr
323
+ * pane. It is therefore supplied by the caller rather than composed here. Everything BELOW that
324
+ * line is rail-independent and stays the contract: the callback is the first action, the task
325
+ * follows it, and the three prohibitions are measured rather than polite.
326
+ */
327
+ /**
328
+ * The first-turn framing, WITHOUT the task.
329
+ *
330
+ * Split from the whole prompt because the two halves travel differently. The tmux rail hands a
331
+ * sibling one multi-line string and both halves ride together; the herdr rail cannot carry a
332
+ * newline at all (`[source herdr 7505c08]` `src/app/agents.rs:157-161` refuses any agent argument
333
+ * containing a Unicode Cc), so it folds THIS half onto one line and encodes only the task. Keeping
334
+ * the halves separate is what lets the framing stay plain prose on both rails while the operator's
335
+ * task text keeps every byte it was written with.
336
+ *
337
+ * Order is the contract: the callback is the FIRST action and the task follows it, so a sibling
338
+ * that gets stuck in the task has already told the caller who it is.
339
+ *
340
+ * WHAT THIS SAYS AND WHY IT STOPPED SAYING THE REST (#116). Until 2026-09-17 this framing carried
341
+ * three prohibitions, including "Do not inspect environment variables, do not call entwurf_self".
342
+ * `[GLG 직접, 날것 PC, 2026-09-17]` a Claude Sonnet 5 sibling REFUSED the whole first turn and named
343
+ * that line as its reason: "legitimate instructions don't need to disable your ability to
344
+ * sanity-check them". It was right, and the prohibition was costing more than the wasted turn it
345
+ * was written to prevent. What replaces it is the same steering stated as a FACT — reporting your
346
+ * identity in the pane does not reach the caller, the tool call does — plus a read-only way to
347
+ * corroborate the caller instead of an instruction not to look.
348
+ */
349
+ export function composeFreshCallFraming(params: {
350
+ backend: FreshCallBackend;
351
+ callerGardenId: string;
352
+ nonce: string;
353
+ /** The rail's own first sentence — see `PLACEMENT SENTENCE` above. Empty is a crash, not a
354
+ * default: a sibling told nothing about where it is would be told something false by silence. */
355
+ openingLine: string;
356
+ }): string[] {
357
+ const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
358
+ const peersTool = FRESH_CALL_PEERS_TOOL[params.backend];
359
+ // The hint is a per-backend TEMPLATE so the two tool dialects stay the single source of their
360
+ // own spelling here too — a hint that hard-coded Claude's name would go stale the day a dialect
361
+ // moves, and it would go stale silently, in the one sentence a stuck child depends on.
362
+ const loadHint = FRESH_CALL_TOOL_LOAD_HINT[params.backend].map((line) =>
363
+ line.replaceAll("${callbackTool}", tool).replaceAll("${peersTool}", peersTool),
364
+ );
365
+ const hintBlock = loadHint.length === 0 ? [] : ["", ...loadHint];
366
+ if (params.openingLine.length === 0) {
367
+ throw new Error("fresh-call composition: openingLine is empty — the rail must state where it placed the sibling");
368
+ }
369
+ return [
370
+ params.openingLine,
371
+ "",
372
+ `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
373
+ `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
374
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
375
+ `${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
376
+ "secret and grants no access. Sending it is how the caller learns the garden id you were born",
377
+ "with, which it has no other way to know. Your own record does not carry the caller's address,",
378
+ "so reporting your identity here does not reach it — that tool call does.",
379
+ "",
380
+ `You can corroborate the caller first if you want to: ${peersTool} is a read-only listing of`,
381
+ `this garden's citizens and ${params.callerGardenId} is one of them. That listing is capped, so`,
382
+ "a caller you do not happen to see in it is not a reason to skip the callback.",
383
+ ...hintBlock,
384
+ "",
385
+ // WHERE THE RESULT GOES, said once, as topology. `[GLG 직접, 2026-09-18]` a Sonnet sibling
386
+ // finished its task and printed the answer in its own window; the caller never saw it. An
387
+ // investigation found no broken injection — NEITHER framing, old or new, had ever said this.
388
+ // It used to work because a hand-written briefing named the target and the tool, and when the
389
+ // briefing stopped doing that the sentence turned out to live nowhere.
390
+ //
391
+ // It is a FACT about the topology, not a command about behaviour: the second half is what
392
+ // makes the first half necessary, because a sibling with a visible window has no way to know
393
+ // its window is not the delivery. Nothing here watches for completion or sends on the
394
+ // sibling's behalf — this rail launches and lets go (Hard Rule 16, and the reason there is no
395
+ // supervisor anywhere in it).
396
+ `When the task reaches its requested final result, send that result to the same target with ${tool}.`,
397
+ "Output in this sibling window is not delivered to the caller.",
398
+ ];
399
+ }
400
+
401
+ /**
402
+ * The whole first turn for a rail that can carry newlines: the framing, then the task.
403
+ */
404
+ export function composeFreshCallPrompt(params: {
405
+ backend: FreshCallBackend;
406
+ task: string;
407
+ callerGardenId: string;
408
+ nonce: string;
409
+ openingLine: string;
410
+ }): string {
411
+ return [...composeFreshCallFraming(params), "", "After the tool receipt, carry out this task:", "", params.task].join(
412
+ "\n",
413
+ );
414
+ }
415
+
416
+ /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
417
+ * encoded any of those would invite exactly the guessing this rail exists to refuse. */
418
+ export function mintNonce(randomHex: () => string = defaultRandomHex): string {
419
+ return `mux-fresh-call-${randomHex()}`;
420
+ }
421
+
422
+ function defaultRandomHex(): string {
423
+ return randomBytes(12).toString("hex");
424
+ }
425
+
426
+ /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
427
+ * delivery surface, not a claim that a task of this size was measured through any one rail. An
428
+ * argv that the OS refuses is a launch failure and fails loud — it never reads as a delivered
429
+ * task. */
430
+ export const TASK_MAX_CHARS = 16000;
431
+ export const MODEL_MAX_CHARS = 200;
432
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:[\]-]*$/;
433
+
434
+ /** A model is an explicit launch input, not ambient process state. The grammar admits canonical
435
+ * pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
436
+ * whitespace and shell/tmux control syntax. It is passed without a shell using each runtime's
437
+ * measured CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
438
+ export function isSafeFreshCallModel(model: string): boolean {
439
+ return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
440
+ }
441
+
442
+ /** Why a fresh call was refused on its INPUTS, before any rail was asked to do anything. These
443
+ * five strings are the caller-facing contract of the public verb, so both rails answer with the
444
+ * same words: a caller who mistypes a model must not learn a different name depending on which
445
+ * placement owner happens to be running. */
446
+ export type FreshCallInputRejectReason =
447
+ | "caller-identity-unavailable"
448
+ | "model-empty"
449
+ | "model-invalid"
450
+ | "task-empty"
451
+ | "task-too-long";
452
+
453
+ export interface FreshCallInputs {
454
+ readonly callerGardenId: string;
455
+ readonly model: string;
456
+ readonly task: string;
457
+ readonly cwd?: string;
458
+ }
459
+
460
+ /**
461
+ * Normalise and validate what the CALLER supplied, in the one order both rails share.
462
+ *
463
+ * Model and task are TRIMMED — that is the public contract and predates both rails, so a task
464
+ * of spaces is `task-empty` rather than a sibling born with nothing to do. Everything after this
465
+ * point travels byte-exact; the trim is the only normalisation anywhere in a launch.
466
+ *
467
+ * `cwd` is the exception that must not be "fixed": ONLY `undefined` and the exact empty string
468
+ * mean "no cwd". Every other value is the literal path, deliberately untrimmed, so a
469
+ * whitespace-mangled directory is refused loudly by each rail's classifier instead of being
470
+ * silently repaired into a different directory. The classification itself stays on the rails,
471
+ * because the reasons differ — tmux format-expands a `-c` value and herdr does not.
472
+ */
473
+ export function normalizeFreshCallInputs(params: {
474
+ readonly callerGardenId: string | null;
475
+ readonly model: string;
476
+ readonly task: string;
477
+ readonly cwd?: string;
478
+ }): { ok: true; inputs: FreshCallInputs } | { ok: false; reason: FreshCallInputRejectReason } {
479
+ if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
480
+ return { ok: false, reason: "caller-identity-unavailable" };
481
+ }
482
+ const model = params.model.trim();
483
+ if (model.length === 0) return { ok: false, reason: "model-empty" };
484
+ if (!isSafeFreshCallModel(model)) return { ok: false, reason: "model-invalid" };
485
+ const task = params.task.trim();
486
+ if (task.length === 0) return { ok: false, reason: "task-empty" };
487
+ if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
488
+ const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
489
+ return {
490
+ ok: true,
491
+ inputs: { callerGardenId: params.callerGardenId, model, task, ...(cwd === undefined ? {} : { cwd }) },
492
+ };
493
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * fresh-call-dispatch — the ONE place that decides which rail opens a sibling, and the only module
3
+ * allowed to know that there are two.
4
+ *
5
+ * WHY A COMPOSITION ROOT AND NOT A BRANCH IN EACH SURFACE. Two surfaces register
6
+ * `entwurf_fresh_call`: pi's own extension and the MCP bridge. Before this file each of them
7
+ * carried the Codex preflight ordering and the render call itself, and adding a rail choice to
8
+ * both would have made four places where the wrong one could be picked. A defect planted in one
9
+ * copy survives on the other, which is precisely the shape a mutant proves nothing about. So the
10
+ * surfaces now supply identity and an env, and this module supplies everything else.
11
+ *
12
+ * THE RAIL IS A CAPABILITY FACT, NOT A PARAMETER. `HERDR_ENV=1` — exactly that value, herdr's own
13
+ * variable — means this process is inside herdr and the herdr rail is the honest placement. Every
14
+ * other value, including a missing one, stays on the tmux rail. A caller cannot ask for a rail:
15
+ * a parameter would let someone request tmux placement from inside herdr, where no tmux server
16
+ * exists, and the failure would arrive after a mutation rather than before one.
17
+ *
18
+ * NO FALLBACK, EITHER DIRECTION. If herdr was selected and its context is incomplete, the call is
19
+ * refused with the HERDR reason. Silently opening a tmux window instead would put a sibling
20
+ * somewhere the operator cannot see from where they are standing, and the receipt would look fine.
21
+ *
22
+ * WHAT THIS MODULE DOES NOT DO: it never reads the record store, never resolves a garden id, and
23
+ * never turns the direct herdr witness into a second public address. The nonce callback remains
24
+ * the one address path, on both rails.
25
+ */
26
+
27
+ import { spawn as spawnChildProcess } from "node:child_process";
28
+ import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-preflight.ts";
29
+ import { mintNonce } from "./fresh-call-composition.ts";
30
+ import {
31
+ createHerdrRunner,
32
+ type HerdrFreshCallResult,
33
+ type HerdrRun,
34
+ herdrFreshCall,
35
+ renderHerdrFreshCall,
36
+ resolveHerdrContext,
37
+ type SpawnFn,
38
+ } from "./herdr-fresh-call.ts";
39
+ import { type FreshCallResult, freshCall, renderFreshCall } from "./mux-fresh-call.ts";
40
+
41
+ export type FreshCallRail = "herdr" | "tmux";
42
+
43
+ /** herdr's own marker, read for its EXACT value. `[측정 2026-09-14]` herdr exports `HERDR_ENV=1`
44
+ * to every process it starts; anything else is not a claim herdr made. */
45
+ export function selectFreshCallRail(env: NodeJS.ProcessEnv): FreshCallRail {
46
+ return env.HERDR_ENV === "1" ? "herdr" : "tmux";
47
+ }
48
+
49
+ export interface FreshCallRequest {
50
+ readonly backend: string;
51
+ readonly model: string;
52
+ readonly task: string;
53
+ readonly cwd?: string;
54
+ readonly placement?: { readonly tmuxSession?: string };
55
+ /** Supplied by the SURFACE from its own record-backed context — never a tool parameter. */
56
+ readonly callerGardenId: string | null;
57
+ /** Set by the surface exactly when the reconciled caller is a record-backed codex citizen,
58
+ * carrying that citizen's `nativeSessionId`. TMUX-RAIL INPUT ONLY: it exists to find the
59
+ * caller's own pane by its terminal title (#95 lane B), and a herdr tab has no such pane. */
60
+ readonly callerNativeSessionId?: string;
61
+ /** The SAME codex citizen's record cwd, under the same condition (#95 lane C). Also tmux-only,
62
+ * and consulted only when the call requested no cwd of its own. */
63
+ readonly callerCwd?: string;
64
+ }
65
+
66
+ export type DispatchedFreshCall =
67
+ | { readonly rail: "tmux"; readonly result: FreshCallResult }
68
+ | { readonly rail: "herdr"; readonly result: HerdrFreshCallResult };
69
+
70
+ /**
71
+ * Open a sibling on whichever rail this process is actually standing in.
72
+ *
73
+ * ORDER MATTERS TWICE. On tmux, the Codex capability preflights run BEFORE the composition, which
74
+ * is the pre-existing contract. On herdr, the backend refusal comes first: codex is not a pilot
75
+ * backend there, so running a Codex preflight would ask an irrelevant question and could fail for
76
+ * a reason that has nothing to do with why the call is impossible.
77
+ *
78
+ * THE TWO CODEX CALLER INPUTS ARE TMUX-RAIL FACTS and stop at this boundary. #95 lane B/C measured
79
+ * both against a tmux pane and an app-server-hosted bridge: a pane title is what carries the
80
+ * caller's thread-id, and a record cwd exists as an input because THAT process's directory is the
81
+ * app-server's. Inside herdr the tab is created by herdr, there is no pane title to anchor to, and
82
+ * neither fact has been measured — so the herdr rail is handed the request without consulting
83
+ * them rather than inheriting a rule from a topology it does not share.
84
+ */
85
+ export async function dispatchFreshCall(
86
+ request: FreshCallRequest,
87
+ env: NodeJS.ProcessEnv = process.env,
88
+ spawn?: SpawnFn,
89
+ nonce: string = mintNonce(),
90
+ ): Promise<DispatchedFreshCall> {
91
+ if (selectFreshCallRail(env) === "herdr") {
92
+ const context = resolveHerdrContext(env);
93
+ if (!context.ok) {
94
+ // Selected herdr, incomplete herdr: the refusal names the herdr fact. Falling back to
95
+ // tmux here would open a window the operator cannot see from inside herdr.
96
+ return { rail: "herdr", result: { ok: false, reason: context.reason } };
97
+ }
98
+ const run: HerdrRun = createHerdrRunner(context.context.bin, env, spawn ?? (spawnChildProcess as SpawnFn));
99
+ return { rail: "herdr", result: await herdrFreshCall(request, run, env, nonce) };
100
+ }
101
+ // TWO capability axes, in this order, both pre-mutation and neither standing in for the
102
+ // other. The TARGET axis first — "entwurf cannot open a Codex sibling here at all" is the
103
+ // more fundamental answer than "and it would not know where to put it". The CALLER axis
104
+ // second, and only when the seat anchor will actually be consulted: a codex caller that
105
+ // named an explicit placement never reads a pane title, so refusing it for a missing
106
+ // `thread-id` would refuse an unused capability.
107
+ const targetMissing = request.backend === "codex" ? await codexFreshPreflight(env) : null;
108
+ const callerMissing =
109
+ targetMissing === null && request.callerNativeSessionId !== undefined && request.placement === undefined
110
+ ? codexCallerFreshPreflight(env)
111
+ : null;
112
+ const missing = targetMissing ?? callerMissing;
113
+ if (missing) return { rail: "tmux", result: { ok: false, reason: missing } };
114
+ return {
115
+ rail: "tmux",
116
+ result: freshCall(
117
+ {
118
+ backend: request.backend as Parameters<typeof freshCall>[0]["backend"],
119
+ model: request.model,
120
+ task: request.task,
121
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
122
+ ...(request.placement?.tmuxSession === undefined
123
+ ? {}
124
+ : { placement: { tmuxSession: request.placement.tmuxSession } }),
125
+ callerGardenId: request.callerGardenId,
126
+ ...(request.callerNativeSessionId === undefined
127
+ ? {}
128
+ : { callerNativeSessionId: request.callerNativeSessionId }),
129
+ ...(request.callerCwd === undefined ? {} : { callerCwd: request.callerCwd }),
130
+ },
131
+ env,
132
+ nonce,
133
+ ),
134
+ };
135
+ }
136
+
137
+ /** Each rail renders its own receipt: the coordinates and the failure modes differ, and a single
138
+ * "universal" renderer would have to speak about panes and windows at once, which is how prose
139
+ * starts claiming every call uses tmux. */
140
+ export function renderDispatchedFreshCall(dispatched: DispatchedFreshCall): { text: string; isError: boolean } {
141
+ return dispatched.rail === "herdr" ? renderHerdrFreshCall(dispatched.result) : renderFreshCall(dispatched.result);
142
+ }