@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,1018 @@
1
+ /**
2
+ * herdr-fresh-call — open ONE visible sibling in a new herdr tab, from inside herdr.
3
+ *
4
+ * THE TMUX RAIL IS NOT TOUCHED AND NOT REACHED FROM HERE. This module imports no `mux-*` and no
5
+ * `entwurf-*`: the pieces both rails genuinely share live in `fresh-call-composition.ts`, and
6
+ * everything below that line is different. herdr places with `tab create` + `agent start`, which
7
+ * is TWO mutations where tmux had one; it resolves no runtime for us; and its coordinates are
8
+ * workspace/tab/pane rather than server/session/window. Sharing a placement type between those
9
+ * would have been a shape, not a contract.
10
+ *
11
+ * WHY THE PROMPT ARRIVES JSON-ENCODED (#116 option G, measured 2026-09-14).
12
+ * `[file:line @ c77af189]` `src/app/agents.rs:157-162` refuses an `agent start` whose args contain
13
+ * ANY Unicode control character, before it even looks the pane up:
14
+ *
15
+ * if params.args.iter().any(|arg| arg.chars().any(char::is_control)) { InvalidArgument }
16
+ *
17
+ * `[측정, 변이 0]` proven against a NONEXISTENT pane, so nothing was created: a two-line argument
18
+ * returned `invalid_agent_argument`, the same call with a one-line argument got as far as
19
+ * `agent_pane_not_found`. The first-turn framing is multi-line by contract and a caller's task is
20
+ * routinely multi-line, so neither can ride `-- <argv>` as written.
21
+ *
22
+ * Three other channels were measured and rejected before this one:
23
+ * - `[측정]` delivering the framing to a BLANK sibling through the entwurf mailbox — the claude
24
+ * sibling read it, stamped the receipt, and correctly refused to act on an imperative that
25
+ * arrived as untrusted data. Rewriting it truthfully, with no birth claim and no id in the
26
+ * body, changed nothing: it refused on AUTHORITY, not wording. A blank sibling has no reason
27
+ * to exist yet, so the only thing that can give it one is the channel it was born through.
28
+ * - keystrokes (`agent prompt`) are terminal input, which this product does not use to launch.
29
+ * - a vendor extension decoder exists for omp, but not for claude-code, and this pilot is
30
+ * `pi | claude-code`.
31
+ *
32
+ * So the WHOLE prompt is wrapped as one JSON string literal inside one truthful sentence telling
33
+ * the sibling to decode it. `[측정 2026-09-14, both pilots]` the sibling decodes it, calls the
34
+ * callback FIRST, and only then answers the task — the order the framing exists to fix survived
35
+ * the encoding on both axes.
36
+ *
37
+ * WHAT THIS MODULE DOES NOT DO: it never reads the record store, never resolves a garden id,
38
+ * never retries, never watches, and never puts a garden id or native session id in its receipt.
39
+ * The direct `agent_started` witness is the CALLER's launch evidence; the sibling's nonce callback
40
+ * is separate first-model-action evidence. Neither is a fallback for the other, and this file is
41
+ * not allowed to turn one into the other.
42
+ */
43
+ import { createHash } from "node:crypto";
44
+ import { statSync } from "node:fs";
45
+ import path from "node:path";
46
+ import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallFraming, normalizeFreshCallInputs, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
47
+ /** The pilot set, closed. `[#116 decision]` herdr's own `--kind` enum is much larger, and that is
48
+ * NOT evidence of support: every other backend is a pre-mutation named reject here, and there is
49
+ * no fallback to the tmux rail — a caller inside herdr who asks for codex gets a refusal, not a
50
+ * window somewhere else. */
51
+ export const HERDR_FRESH_CALL_BACKENDS = ["pi", "claude-code"];
52
+ /** Our backend name → the `--kind` token herdr accepts. `[측정 2026-09-14]` both round-tripped with
53
+ * the vendor executable echoed back in `argv`. This is a REQUESTED kind: `[file:line @ c77af189]`
54
+ * `src/app/agents.rs:197-199` turns it into a bare executable name and writes it into the pane's
55
+ * shell, so nothing here has proven the binary exists, is on PATH, or will start. Runtime failure
56
+ * is a post-mutation outcome on this rail, named as such, never a precondition we can claim. */
57
+ export const HERDR_AGENT_KIND = {
58
+ pi: "pi",
59
+ "claude-code": "claude",
60
+ };
61
+ /** What the herdr rail tells a sibling about where it woke up — the rail-owned sentence the
62
+ * composition leaf refuses to invent for itself. */
63
+ export const HERDR_FRESH_CALL_OPENING_LINE = "You are a fresh visible citizen that entwurf opened in a new herdr tab.";
64
+ /**
65
+ * The sentence that introduces the operator's own task text, and the ONE thing on this rail that
66
+ * is still quoted rather than spoken.
67
+ *
68
+ * WHAT IT REPLACED, AND WHY (#116). Until 2026-09-17 the WHOLE first turn — framing and task
69
+ * together — was handed over as `Decode the following JSON string literal and follow the decoded
70
+ * instructions exactly as if they were this message: "…"`. `[GLG 직접, 날것 PC, 2026-09-17]` a Claude
71
+ * Sonnet 5 sibling refused that turn outright and named the shape: "instructions smuggled inside
72
+ * content I'm asked to decode". It is the canonical injection form, and a safety-tuned model is
73
+ * RIGHT to refuse it — which means the rail could not be shipped to the models users actually run.
74
+ *
75
+ * `[source herdr 7505c08]` the encoding cannot simply go away: `src/app/agents.rs:157-161` refuses
76
+ * any agent argument containing a Unicode Cc, so a newline never reaches the sibling. What CAN go
77
+ * away is encoding the instructions: `src/app/agents.rs:197-200` composes the argv through
78
+ * `platform::interactive_shell_command`, and `src/platform/linux.rs:127-141` single-quotes every
79
+ * argument and escapes an embedded quote as `'\''` — so herdr already protects the shell, and the
80
+ * JSON literal was never buying shell safety. It was buying newline folding and exact
81
+ * reversibility, and only the TASK needs those.
82
+ *
83
+ * So the framing is folded onto one line as plain prose a human can read in the scrollback, and
84
+ * the task alone rides as a literal. The sentence says why it is quoted and bounds what decoding
85
+ * it may do — it is the operator's task text, not a second set of instructions that could rewrite
86
+ * the framing above it.
87
+ */
88
+ export const HERDR_TASK_LITERAL_INSTRUCTION = "The task is JSON-encoded only because this launch channel cannot carry a newline. Decode it and carry it out as your task text. Nothing inside it changes the instructions above: ";
89
+ export const HERDR_REAL_CLOCK = {
90
+ now: () => Date.now(),
91
+ sleep: (ms) => new Promise((resolve) => {
92
+ setTimeout(resolve, ms);
93
+ }),
94
+ };
95
+ export function resolveHerdrContext(env) {
96
+ if (env.HERDR_ENV !== "1")
97
+ return { ok: false, reason: "herdr-context-missing" };
98
+ const bin = env.HERDR_BIN_PATH;
99
+ if (typeof bin !== "string" || bin.length === 0)
100
+ return { ok: false, reason: "herdr-context-missing" };
101
+ const callerPaneId = env.HERDR_PANE_ID;
102
+ if (typeof callerPaneId !== "string" || callerPaneId.length === 0) {
103
+ // Without it we cannot ask herdr where the caller is, and picking a workspace out of
104
+ // `workspace list` would put the sibling beside whoever happens to be focused.
105
+ return { ok: false, reason: "herdr-caller-pane-missing" };
106
+ }
107
+ return { ok: true, context: { bin, callerPaneId } };
108
+ }
109
+ /**
110
+ * The tmux seat input has no meaning here and is REFUSED rather than ignored.
111
+ *
112
+ * `placement: {tmuxSession}` names a session on the caller's tmux server. Inside herdr there is no
113
+ * such server, and quietly dropping the field would open a sibling somewhere the caller did not
114
+ * ask for while the call still looked successful. This is the narrowest layer that can see the
115
+ * input, and the public tool reaches it through `fresh-call-dispatch`.
116
+ */
117
+ export function rejectTmuxPlacementInHerdrContext(placement) {
118
+ // ANY defined placement object is a tmux-shaped request, including `{}` and
119
+ // `{tmuxSession: ""}`. Reading the MEMBER instead of the object would have let a caller who
120
+ // asked for a seat and mistyped it get a sibling in the default position with a green
121
+ // receipt — the same silent-relocation failure this refusal exists to prevent.
122
+ return placement === undefined ? null : "herdr-placement-tmux-rejected";
123
+ }
124
+ /**
125
+ * The start directory, classified for herdr rather than borrowed from tmux.
126
+ *
127
+ * The tmux leaf also refuses `#`, because `[측정]` tmux FORMAT-EXPANDS a `-c` value and a `#(…)`
128
+ * was observed executing. herdr has no such expansion: `[file:line @ c77af189]` `src/cli/pane.rs:689-694`
129
+ * clones the `--cwd` value into the request and the server passes it on, so the character is
130
+ * ordinary here. Importing the tmux rule would have been borrowed authority — a refusal whose
131
+ * stated reason is false on this rail. The existence checks below are kept, and for the same
132
+ * measured reason they exist there: a directory that is gone must not produce a cheerful launch
133
+ * somewhere else.
134
+ */
135
+ export function classifyHerdrCwd(cwd) {
136
+ if (!path.isAbsolute(cwd))
137
+ return "cwd-not-absolute";
138
+ let st;
139
+ try {
140
+ st = statSync(cwd);
141
+ }
142
+ catch {
143
+ return "cwd-missing";
144
+ }
145
+ return st.isDirectory() ? null : "cwd-not-directory";
146
+ }
147
+ /** Every Unicode control character, the same class herdr's server refuses. `JSON.stringify` escapes
148
+ * C0 and the quote/backslash pair, but leaves DEL (U+007F) and the C1 block (U+0080–U+009F)
149
+ * LITERAL — so the escape pass below is not decoration, it is the half `JSON.stringify` does not do. */
150
+ const CONTROL_CHARS = /\p{Cc}/gu;
151
+ /** Reversible: `\uXXXX` inside a JSON string decodes back to exactly this character. */
152
+ function escapeRemainingControlChars(json) {
153
+ return json.replace(CONTROL_CHARS, (ch) => `\\u${ch.codePointAt(0)?.toString(16).padStart(4, "0")}`);
154
+ }
155
+ /**
156
+ * Fold the framing onto ONE physical line and append the task as a literal.
157
+ *
158
+ * Two independent things must hold and both are checked rather than assumed: herdr will accept the
159
+ * result (zero `\p{Cc}` anywhere in it), and the sibling can recover the operator's task EXACTLY
160
+ * (the JSON literal parses back to the input, byte for byte — no trimming, no normalisation, no
161
+ * reflow). A round-trip failure is OUR bug, not the caller's input, so it throws instead of
162
+ * returning a reject the caller could not act on.
163
+ *
164
+ * The FRAMING is not round-tripped, because it is not data: it is folded with single spaces and
165
+ * read as prose. What is asserted about it instead is that every composed line survives the fold
166
+ * verbatim, in order — the sibling reads the same sentences, on one line.
167
+ */
168
+ export function encodeBirthPrompt(framing, task) {
169
+ const folded = framing.filter((line) => line.length > 0).join(" ");
170
+ const literal = escapeRemainingControlChars(JSON.stringify(task));
171
+ const argv = `${folded} ${HERDR_TASK_LITERAL_INSTRUCTION}${literal}`;
172
+ // Fail closed. Reachable when the FRAMING carries a control character (a rail opening line is
173
+ // the rail's own input) or if a future JS runtime leaves one both unescaped by JSON.stringify
174
+ // AND unmatched by \p{Cc}; the refusal is cheaper than the orphan pane a server-side rejection
175
+ // would cost us.
176
+ if (containsControlChar(argv))
177
+ return { ok: false, reason: "herdr-argv-control-character" };
178
+ const decoded = JSON.parse(literal);
179
+ if (decoded !== task) {
180
+ throw new Error("herdr-fresh-call: the encoded task did not decode back to the original — refusing to launch a sibling with a task we cannot reproduce");
181
+ }
182
+ return { ok: true, argv };
183
+ }
184
+ export function containsControlChar(value) {
185
+ CONTROL_CHARS.lastIndex = 0;
186
+ return CONTROL_CHARS.test(value);
187
+ }
188
+ /**
189
+ * The agent name herdr will know this sibling by.
190
+ *
191
+ * `[file:line @ c77af189]` `src/app/agents.rs:15-20` requires `[a-z][a-z0-9_-]{0,31}`, which the
192
+ * nonce itself does not satisfy (it is 39 characters). So the name is DERIVED from the per-call
193
+ * nonce by hash: same call, same name; different calls, different names; and the name discloses
194
+ * nothing. It is not a role, not a title and not an address — an operator reading `herdr agent
195
+ * list` should learn only that entwurf opened it.
196
+ */
197
+ export function herdrAgentNameFromNonce(nonce) {
198
+ return `entwurf-${createHash("sha256").update(nonce).digest("hex").slice(0, 20)}`;
199
+ }
200
+ /**
201
+ * The ONE placement policy this rail has: a NEW TAB in the caller's own workspace, without taking
202
+ * the keyboard. `[GLG direct decision 2026-09-15]` after using the rail for real, a tab beside the
203
+ * caller reads better than a pane split under it; `--no-focus` stays, because a sibling opening
204
+ * under the operator's hands must not steal focus either way.
205
+ *
206
+ * `--workspace` IS PASSED EXPLICITLY AND IS NOT OPTIONAL HERE. `[측정 2026-09-15, private server]`
207
+ * omitting it still succeeds — herdr puts the tab in whatever workspace is currently focused. That
208
+ * is a silent relocation exactly like the tmux seat this rail already refuses, so the workspace is
209
+ * resolved from herdr's OWN answer about the caller's pane (see `herdrFreshCall`) and never from
210
+ * parsing the `w<N>:p<M>` shape of a pane id, which `[측정 2026-09-14]` is opaque anyway (`w7:pA`).
211
+ *
212
+ * `[측정 2026-09-15]` a workspace herdr does not know is refused BEFORE anything is created
213
+ * (`{"error":{"code":"workspace_not_found"}}`, exit 1) — the mutation is still one step, not two.
214
+ *
215
+ * This is deliberately NOT a layout manager: no `--label`, no ratio, no second placement axis. A
216
+ * caller who wants a different arrangement moves the tab in herdr, which owns layout.
217
+ *
218
+ * The identity scrub is explicit for the reason the tmux rail learned the hard way: a child that
219
+ * inherits a stale `PI_SESSION_ID` reports itself as a citizen it is not. `[측정 2026-09-15]` the
220
+ * flag behaves the same on `tab create` as it did on `pane split` and it beats inheritance: with
221
+ * `PI_SESSION_ID` deliberately poisoned in the SERVER's own environment, the process launched in
222
+ * the new tab carried `PI_SESSION_ID=''` and `PI_AGENT_ID=''` — read from `/proc/<pid>/environ`,
223
+ * not from a screen. Repeating the flag is the grammar; `--env KEY=` injects the EMPTY value
224
+ * rather than dropping the key.
225
+ */
226
+ export function buildHerdrTabCreateArgs(params) {
227
+ return [
228
+ "tab",
229
+ "create",
230
+ "--workspace",
231
+ params.workspaceId,
232
+ "--no-focus",
233
+ ...(params.cwd === undefined ? [] : ["--cwd", params.cwd]),
234
+ "--env",
235
+ "PI_SESSION_ID=",
236
+ "--env",
237
+ "PI_AGENT_ID=",
238
+ ];
239
+ }
240
+ /** `agent start <name> --kind <kind> --pane <id> -- <backend argv>`. Everything after `--` is the
241
+ * vendor's own argv, echoed back in the response. */
242
+ export function buildHerdrAgentStartArgs(params) {
243
+ return [
244
+ "agent",
245
+ "start",
246
+ params.agentName,
247
+ "--kind",
248
+ params.kind,
249
+ "--pane",
250
+ params.paneId,
251
+ // Never omitted: herdr's own default is 30s, which is shorter than a cold Claude Code start
252
+ // and turns a healthy launch into a named timeout over an unreclaimed pane.
253
+ "--timeout",
254
+ String(HERDR_START_READY_MS),
255
+ "--",
256
+ ...params.backendArgs,
257
+ ];
258
+ }
259
+ /** `[측정 2026-09-14]` both take a POSITIONAL pane id — `herdr pane get --pane <id>` is a usage
260
+ * error, not a synonym. */
261
+ export function buildHerdrPaneGetArgs(paneId) {
262
+ return ["pane", "get", paneId];
263
+ }
264
+ export function buildHerdrPaneCloseArgs(paneId) {
265
+ return ["pane", "close", paneId];
266
+ }
267
+ /** `herdr agent get <target>` — POSITIONAL, exactly one argument (`[file:line @ c77af189]`
268
+ * `src/cli/agent.rs:450-458`). EXACT NAME, never `agent list`: a listing would make us pick "the
269
+ * one that looks like ours" out of every agent on the server, which is the guess this rail
270
+ * refuses. The name we ask about is the nonce-derived one we asked herdr to start. */
271
+ export function buildHerdrAgentGetArgs(agentName) {
272
+ return ["agent", "get", agentName];
273
+ }
274
+ function readPaneFacts(pane) {
275
+ if (typeof pane !== "object" || pane === null)
276
+ return null;
277
+ const row = pane;
278
+ const paneId = row.pane_id;
279
+ const terminalId = row.terminal_id;
280
+ if (typeof paneId !== "string" || paneId.length === 0)
281
+ return null;
282
+ if (typeof terminalId !== "string" || terminalId.length === 0)
283
+ return null;
284
+ const workspaceId = typeof row.workspace_id === "string" ? row.workspace_id : undefined;
285
+ const tabId = typeof row.tab_id === "string" ? row.tab_id : undefined;
286
+ const agent = typeof row.agent === "string" && row.agent.length > 0 ? row.agent : undefined;
287
+ const agentStatus = typeof row.agent_status === "string" && row.agent_status.length > 0 ? row.agent_status : undefined;
288
+ const agentName = typeof row.name === "string" && row.name.length > 0 ? row.name : undefined;
289
+ return {
290
+ paneId,
291
+ terminalId,
292
+ ...(workspaceId === undefined ? {} : { workspaceId }),
293
+ ...(tabId === undefined ? {} : { tabId }),
294
+ hasAgentSession: row.agent_session !== undefined && row.agent_session !== null,
295
+ ...(agent === undefined ? {} : { agent }),
296
+ ...(agentStatus === undefined ? {} : { agentStatus }),
297
+ ...(agentName === undefined ? {} : { agentName }),
298
+ };
299
+ }
300
+ /** `{"id":"cli:pane:get","result":{"pane":{…},"type":"pane_info"}}` — measured. A payload that is
301
+ * not that shape is DECLINED (null); it is never partially believed. */
302
+ export function parseHerdrPaneResponse(stdout) {
303
+ const root = parseJsonObject(stdout);
304
+ if (root === null)
305
+ return null;
306
+ const result = root.result;
307
+ if (typeof result !== "object" || result === null)
308
+ return null;
309
+ return readPaneFacts(result.pane);
310
+ }
311
+ /**
312
+ * `{"id":"cli:tab:create","result":{"root_pane":{…},"tab":{…},"type":"tab_created"}}` — measured
313
+ * verbatim on herdr 0.9.0, 2026-09-15.
314
+ *
315
+ * The reply names the new tab AND hands back its initial pane in one breath, which is why this
316
+ * rail never has to go looking: diffing `pane list` for "the new one" is the guess it refuses
317
+ * everywhere else.
318
+ *
319
+ * BOTH HALVES MUST NAME THE SAME TAB AND THE SAME WORKSPACE, and both must say so out loud.
320
+ * `[측정 2026-09-15]` every `tab_created` reply carried `tab_id` and `workspace_id` on the tab AND
321
+ * on its root pane, so an absent one is herdr disagreeing with this version rather than an
322
+ * optional field — and treating it as optional is how a contradictory reply gets assembled into a
323
+ * green launch whose receipt names one tab while the agent starts in another. Disagreement or
324
+ * absence declines the WHOLE payload; we do not pick the half we prefer.
325
+ */
326
+ export function parseHerdrTabCreateResponse(stdout) {
327
+ const root = parseJsonObject(stdout);
328
+ if (root === null)
329
+ return null;
330
+ const result = root.result;
331
+ if (typeof result !== "object" || result === null)
332
+ return null;
333
+ const rootPane = readPaneFacts(result.root_pane);
334
+ if (rootPane === null)
335
+ return null;
336
+ const tab = result.tab;
337
+ if (typeof tab !== "object" || tab === null)
338
+ return null;
339
+ const tabId = tab.tab_id;
340
+ const workspaceId = tab.workspace_id;
341
+ if (typeof tabId !== "string" || tabId.length === 0)
342
+ return null;
343
+ if (typeof workspaceId !== "string" || workspaceId.length === 0)
344
+ return null;
345
+ // Required on the root pane too, and equal — not "checked when present".
346
+ if (rootPane.tabId !== tabId)
347
+ return null;
348
+ if (rootPane.workspaceId !== workspaceId)
349
+ return null;
350
+ return { tabId, workspaceId, rootPane };
351
+ }
352
+ /** `{"id":"cli:agent:start","result":{"agent":{…},"argv":[…],"type":"agent_started"}}` — measured. */
353
+ export function parseHerdrAgentStartResponse(stdout) {
354
+ const root = parseJsonObject(stdout);
355
+ if (root === null)
356
+ return null;
357
+ const result = root.result;
358
+ if (typeof result !== "object" || result === null)
359
+ return null;
360
+ const pane = readPaneFacts(result.agent);
361
+ if (pane === null)
362
+ return null;
363
+ const rawArgv = result.argv;
364
+ const argv = Array.isArray(rawArgv) && rawArgv.every((token) => typeof token === "string") ? rawArgv : null;
365
+ return { pane, argv };
366
+ }
367
+ /** The canonical executable herdr writes for a requested kind, measured twice today (`["claude"]`
368
+ * and `["pi","--entwurf-control"]`) and pinned upstream at `src/detect/mod.rs:155-156 @ c77af189`.
369
+ * We only assert it for the two pilot kinds we have actually seen. */
370
+ export const HERDR_CANONICAL_EXECUTABLE = {
371
+ pi: "pi",
372
+ "claude-code": "claude",
373
+ };
374
+ /** Did herdr compose the argv we asked for, exactly? A launch whose echoed argv differs is a
375
+ * sibling that was started with something other than our framing, which no later receipt would
376
+ * reveal. */
377
+ export function argvMatchesRequest(echoed, backend, backendArgs) {
378
+ if (echoed === null)
379
+ return false;
380
+ const expected = [HERDR_CANONICAL_EXECUTABLE[backend], ...backendArgs];
381
+ return echoed.length === expected.length && echoed.every((token, index) => token === expected[index]);
382
+ }
383
+ /** `herdr pane get <id>` → `{"result":{"pane":{…}}}`. */
384
+ export const parseHerdrPaneGetResponse = parseHerdrPaneResponse;
385
+ /** `herdr agent get <name>` → `{"result":{"agent":{…AgentInfo…}}}` — the same reduction the start
386
+ * reply gets, because `AgentInfo` carries the same pane/terminal/tab/session keys
387
+ * (`[file:line @ c77af189]` `src/api/schema/agents.rs:187-215`). A payload that is not that shape
388
+ * is DECLINED (null); an absent agent and an unreadable reply are NOT the same answer, and the
389
+ * caller of this parser is the one that keeps them apart. */
390
+ export function parseHerdrAgentGetResponse(stdout) {
391
+ const root = parseJsonObject(stdout);
392
+ if (root === null)
393
+ return null;
394
+ const result = root.result;
395
+ if (typeof result !== "object" || result === null)
396
+ return null;
397
+ return readPaneFacts(result.agent);
398
+ }
399
+ /** herdr's own failure envelope: `{"id":…,"error":{"code":…,"message":…}}` on stderr with exit 1
400
+ * (`[file:line @ c77af189]` `src/cli.rs:745-753`). The code is quoted into our reject, never
401
+ * re-typed into a vocabulary of ours that would go stale the moment herdr adds a case. */
402
+ export function readHerdrErrorCode(stderr) {
403
+ const root = parseJsonObject(stderr);
404
+ if (root === null)
405
+ return undefined;
406
+ const error = root.error;
407
+ if (typeof error !== "object" || error === null)
408
+ return undefined;
409
+ const code = error.code;
410
+ return typeof code === "string" && code.length > 0 ? code : undefined;
411
+ }
412
+ function parseJsonObject(text) {
413
+ const trimmed = text.trim();
414
+ if (trimmed.length === 0)
415
+ return null;
416
+ let parsed;
417
+ try {
418
+ parsed = JSON.parse(trimmed);
419
+ }
420
+ catch {
421
+ // Not JSON at all is what a usage error looks like: `[측정 2026-09-14]` a bad flag prints
422
+ // plain `usage: …` text and exits 2. Declining is the whole handling it needs.
423
+ return null;
424
+ }
425
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
426
+ ? parsed
427
+ : null;
428
+ }
429
+ /**
430
+ * May we close the pane we just opened?
431
+ *
432
+ * Only with WITHIN-GENERATION proof: the pane we are looking at right now must be the same id AND
433
+ * the same terminal our creation receipt named, and it must be EMPTY. A bare pane id is not
434
+ * authority (see `HerdrOrphanReason`), and the honest answer when the proof does not hold is to
435
+ * leave the pane alone and say so by name.
436
+ *
437
+ * EMPTY IS TWO QUESTIONS, NOT ONE, and asking only the second one killed a living sibling.
438
+ * `[측정 oracle 2026-09-18, LIVE run XimC19]` this predicate refused to close only when herdr had
439
+ * already been told the pane's `agent_session`. On the Claude rail that id arrives through a
440
+ * SEPARATE hook round trip the child makes after it starts (`[file:line @ c77af189]`
441
+ * `src/integration/assets/claude/herdr-agent-state.sh:60-99`, one shot, 0.5s socket deadline,
442
+ * failures swallowed), so a pane holding a booted child read as empty and was closed 0.585s after
443
+ * that child wrote its own birth record. Occupancy — herdr naming an AGENT in the pane — is now the
444
+ * first refusal, and it does not depend on anybody having reported a session id. A bound that
445
+ * expires is not authority either: no finite wait converts "nobody told us the id" into "nobody is
446
+ * there".
447
+ *
448
+ * WHY THE RECLAIM IS STILL PANE-LEVEL ON A TAB-FIRST RAIL. `[측정 2026-09-15, private server]`
449
+ * `tab close` takes a bare `tab_id` and nothing else: it closed a tab holding a RUNNING agent and
450
+ * answered `{"result":{"type":"ok"}}`, so adopting it would be claiming authority over every pane
451
+ * a stranger had put in our tab meanwhile. Closing the one pane we own is strictly narrower and it
452
+ * is enough — `[측정]` closing the sole pane of a tab removed the tab with it (`tab get` →
453
+ * `tab_not_found`, no empty tab left behind), and on a tab that had gained a second pane the same
454
+ * close took only ours and left the tab and the stranger's pane alive. Fail-closed in both
455
+ * directions, with the proof we already had.
456
+ *
457
+ * A TOCTOU window remains between this decision and the close, and it is not closable through the
458
+ * public API: `[file:line @ c77af189]` `src/api/schema/common.rs:33-36` gives `close_pane` only a
459
+ * `pane_id`, with no expected-terminal token to make the close conditional server-side. The window
460
+ * is named here rather than papered over.
461
+ */
462
+ export function decideConditionalClose(created, current, getFailed) {
463
+ if (getFailed)
464
+ return { close: false, reason: "pane-get-failed" };
465
+ if (current === null)
466
+ return { close: false, reason: "pane-get-unparsable" };
467
+ if (current.paneId !== created.paneId)
468
+ return { close: false, reason: "pane-id-mismatch" };
469
+ if (current.terminalId !== created.terminalId)
470
+ return { close: false, reason: "terminal-id-mismatch" };
471
+ if (current.agent !== undefined)
472
+ return { close: false, reason: "agent-present" };
473
+ if (current.hasAgentSession)
474
+ return { close: false, reason: "agent-session-present" };
475
+ return { close: true };
476
+ }
477
+ /**
478
+ * Open the sibling.
479
+ *
480
+ * ORDER IS THE ARGUMENT, and it is a different argument from the tmux rail's. There, validation
481
+ * could promise that "nothing above can leave a window behind"; here the tab create IS a mutation
482
+ * and the start can still fail after it. So everything decidable — context, backend, placement
483
+ * input, caller id, task, model, cwd, and the encoded argv's control characters — is decided
484
+ * BEFORE it, and the only failures that can survive into the two-step region are herdr's own.
485
+ *
486
+ * ONE READ SITS BETWEEN THEM, and it creates nothing: `pane get <HERDR_PANE_ID>` asks herdr which
487
+ * workspace the caller is in. It comes LAST among the refusals so a call we would have rejected
488
+ * anyway never reaches the herdr CLI, and its four failure modes are refusals rather than launch
489
+ * failures because a read leaves no tab behind.
490
+ *
491
+ * `callerGardenId` comes from the SURFACE that registered the tool, out of its own record-backed
492
+ * context. This module never derives it, looks it up, or guesses: an empty value is a refusal.
493
+ */
494
+ export async function herdrFreshCall(params, run, env, nonce, clock = HERDR_REAL_CLOCK) {
495
+ const context = resolveHerdrContext(env);
496
+ if (!context.ok)
497
+ return { ok: false, reason: context.reason };
498
+ const tmuxPlacement = rejectTmuxPlacementInHerdrContext(params.placement);
499
+ if (tmuxPlacement !== null)
500
+ return { ok: false, reason: tmuxPlacement };
501
+ if (!HERDR_FRESH_CALL_BACKENDS.includes(params.backend)) {
502
+ // No tmux fallback, on purpose: a sibling opened on a rail the caller did not ask for is
503
+ // worse than a refusal they can read.
504
+ return { ok: false, reason: "herdr-backend-unsupported" };
505
+ }
506
+ const backend = params.backend;
507
+ // The caller's inputs are judged by the SHARED contract — same order, same trimming, same
508
+ // five words — so a caller cannot learn a different vocabulary by being inside herdr. The
509
+ // cwd omission rule comes with it: `undefined` and `""` both mean "no cwd", which this rail
510
+ // previously mistook for an invalid path.
511
+ const normalized = normalizeFreshCallInputs(params);
512
+ if (!normalized.ok)
513
+ return { ok: false, reason: normalized.reason };
514
+ const { callerGardenId, model, task, cwd } = normalized.inputs;
515
+ if (cwd !== undefined) {
516
+ const badCwd = classifyHerdrCwd(cwd);
517
+ if (badCwd !== null)
518
+ return { ok: false, reason: badCwd };
519
+ }
520
+ const framing = composeFreshCallFraming({
521
+ backend,
522
+ callerGardenId,
523
+ nonce,
524
+ openingLine: HERDR_FRESH_CALL_OPENING_LINE,
525
+ });
526
+ const encoded = encodeBirthPrompt(framing, task);
527
+ if (!encoded.ok)
528
+ return { ok: false, reason: encoded.reason };
529
+ const composition = {
530
+ prompt: encoded.argv,
531
+ bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId, nonce, task }),
532
+ };
533
+ const backendArgs = composeBackendArgs(backend, composition, model, () => {
534
+ // Unreachable: codex is not in the pilot set and was refused above. It throws rather than
535
+ // returning a plausible path, so a future widening cannot silently inherit a guess.
536
+ throw new Error("herdr-fresh-call: codex is not a pilot backend on this rail");
537
+ }, () => {
538
+ // Unreachable for the same reason, and refusing for the same reason: codex's `-C` names
539
+ // where a REMOTE thread opens, and this rail has no app-server to name it to.
540
+ throw new Error("herdr-fresh-call: codex is not a pilot backend on this rail");
541
+ });
542
+ // The server checks EVERY argument, not just the prompt, so we check every argument too —
543
+ // while it is still free to refuse.
544
+ if (backendArgs.some(containsControlChar))
545
+ return { ok: false, reason: "herdr-argv-control-character" };
546
+ // Which workspace is the caller in? herdr's own answer about the caller's own pane — not the
547
+ // focused workspace, not a listing, and not the `w<N>:` prefix of an id this rail treats as
548
+ // opaque. A pane we cannot read is a refusal: falling back to an omitted `--workspace` would
549
+ // open the sibling wherever the operator happens to be looking, under a green receipt.
550
+ const callerPaneRun = await run(buildHerdrPaneGetArgs(context.context.callerPaneId));
551
+ if (callerPaneRun.status !== 0)
552
+ return { ok: false, reason: "herdr-caller-pane-get-failed" };
553
+ const callerPane = parseHerdrPaneGetResponse(callerPaneRun.stdout);
554
+ if (callerPane === null)
555
+ return { ok: false, reason: "herdr-caller-pane-unparsable" };
556
+ // The answer must be about the pane we ASKED about. A readable reply describing some other
557
+ // pane carries some other pane's workspace, and using it would open the sibling in a
558
+ // workspace the caller never named — the exact silent relocation this whole policy exists to
559
+ // refuse, arriving through the read instead of through an omitted --workspace.
560
+ if (callerPane.paneId !== context.context.callerPaneId)
561
+ return { ok: false, reason: "herdr-caller-pane-drift" };
562
+ if (callerPane.workspaceId === undefined)
563
+ return { ok: false, reason: "herdr-caller-workspace-missing" };
564
+ // ── everything above this line leaves nothing behind ──────────────────────────────────
565
+ const tabRun = await run(buildHerdrTabCreateArgs({
566
+ workspaceId: callerPane.workspaceId,
567
+ ...(cwd === undefined ? {} : { cwd }),
568
+ }));
569
+ if (tabRun.status !== 0) {
570
+ // WHETHER ANYTHING EXISTS depends on WHO failed, and the stderr says which. herdr's own
571
+ // envelope means herdr answered — it declined before making a tab. NO envelope means we
572
+ // never heard from it at all: `createHerdrRunner` reports its own timeout/kill/spawn
573
+ // failure as the same nonzero status with a plain-text stderr, and a `tab create` we
574
+ // killed mid-flight may well have created the tab. Claiming "nothing was created" there
575
+ // would send an operator away from a tab that is sitting on their screen.
576
+ const code = readHerdrErrorCode(tabRun.stderr);
577
+ return {
578
+ ok: false,
579
+ reason: "herdr-tab-create-failed",
580
+ ...(code === undefined ? {} : { herdrErrorCode: code }),
581
+ recovery: code === undefined ? indeterminate() : nothingCreated(),
582
+ };
583
+ }
584
+ const tab = parseHerdrTabCreateResponse(tabRun.stdout);
585
+ if (tab === null) {
586
+ // A tab may exist and its coordinates are precisely what we could not read. Diffing
587
+ // `tab list` to find "the new one" is the guess this rail refuses, so it is NAMED instead.
588
+ return { ok: false, reason: "herdr-tab-create-unparsable", recovery: indeterminate() };
589
+ }
590
+ const pane = tab.rootPane;
591
+ if (pane.hasAgentSession) {
592
+ // A brand-new tab's initial pane holding an agent is not a pane we understand. Starting
593
+ // into it would either be refused by herdr as busy or, worse, land beside somebody else's
594
+ // sibling. Declining here also means the reclaim below correctly REFUSES to close it.
595
+ return { ok: false, reason: "herdr-tab-root-pane-occupied", recovery: await reclaim(pane, run) };
596
+ }
597
+ const agentName = herdrAgentNameFromNonce(nonce);
598
+ const startRun = await run(buildHerdrAgentStartArgs({
599
+ agentName,
600
+ kind: HERDR_AGENT_KIND[backend],
601
+ paneId: pane.paneId,
602
+ backendArgs,
603
+ }));
604
+ if (startRun.status !== 0) {
605
+ return {
606
+ ok: false,
607
+ reason: "herdr-agent-start-failed",
608
+ ...errorCode(startRun.stderr),
609
+ recovery: await reclaim(pane, run),
610
+ };
611
+ }
612
+ const started = parseHerdrAgentStartResponse(startRun.stdout);
613
+ if (started === null) {
614
+ return { ok: false, reason: "herdr-agent-start-unparsable", recovery: await reclaim(pane, run) };
615
+ }
616
+ // EVERY reclaim below starts from the TAB-CREATE receipt, never from what the start reported:
617
+ // if those two disagree, the create receipt is the only coordinate we have authority over.
618
+ // The tab is checked too when herdr named one — the whole point of this policy is WHICH tab
619
+ // the sibling is in, so a start that reports another one is the same defect as another pane.
620
+ if (started.pane.paneId !== pane.paneId ||
621
+ started.pane.terminalId !== pane.terminalId ||
622
+ (started.pane.tabId !== undefined && started.pane.tabId !== tab.tabId)) {
623
+ // Readable, and actionable — so it is not folded into `unparsable`. Something started
624
+ // somewhere other than the pane we opened, and a green receipt would have pointed the
625
+ // caller at a coordinate that never held their sibling.
626
+ return { ok: false, reason: "herdr-agent-start-pane-drift", recovery: await reclaim(pane, run) };
627
+ }
628
+ if (!argvMatchesRequest(started.argv, backend, backendArgs)) {
629
+ // The framing is the argv. A sibling started with a different one is a sibling we did not
630
+ // compose, and nothing downstream would ever reveal it.
631
+ return { ok: false, reason: "herdr-agent-start-argv-drift", recovery: await reclaim(pane, run) };
632
+ }
633
+ // THE WITNESS IS THE LAST QUESTION, AND IT IS NOT A VETO. Everything above could disqualify the
634
+ // launch; this cannot. `settleAgentWitness` only re-READS, and its two answers are "herdr knows
635
+ // the session id" and "it does not yet" — both of which are successful launches. It fails only
636
+ // when a readable reply says the agent we started is no longer the agent in our pane.
637
+ const settled = await settleAgentWitness({
638
+ agentName,
639
+ kind: HERDR_AGENT_KIND[backend],
640
+ created: pane,
641
+ tabId: tab.tabId,
642
+ started: started.pane,
643
+ run,
644
+ clock,
645
+ });
646
+ if (!settled.ok) {
647
+ return { ok: false, reason: "herdr-agent-start-vanished", recovery: await reclaim(pane, run) };
648
+ }
649
+ return {
650
+ ok: true,
651
+ receipt: {
652
+ backend,
653
+ requestedKind: HERDR_AGENT_KIND[backend],
654
+ model,
655
+ ...(cwd === undefined ? {} : { cwd }),
656
+ herdrAgentName: agentName,
657
+ // The tab coordinates come from the reply that CREATED them, not from the start's
658
+ // echo: the create receipt is what the reclaim above is bound to, so the receipt an
659
+ // operator reads and the coordinate we would close must be the same one.
660
+ herdrWorkspaceId: tab.workspaceId,
661
+ herdrTabId: tab.tabId,
662
+ herdrPaneId: started.pane.paneId,
663
+ herdrTerminalId: started.pane.terminalId,
664
+ nonce,
665
+ witness: settled.witness,
666
+ },
667
+ };
668
+ }
669
+ function errorCode(stderr) {
670
+ const code = readHerdrErrorCode(stderr);
671
+ return code === undefined ? {} : { herdrErrorCode: code };
672
+ }
673
+ /** herdr declined by name, so nothing exists to reclaim. Not an unreclaimed orphan: there is no
674
+ * pane, and no `pane get` was attempted. */
675
+ function nothingCreated() {
676
+ return { outcome: "none" };
677
+ }
678
+ /** We cannot say whether a tab exists — our own bound cut the call off, or the reply was
679
+ * unreadable. Nothing is closed, and the receipt says so instead of guessing either way. */
680
+ function indeterminate() {
681
+ return { outcome: "unknown" };
682
+ }
683
+ /**
684
+ * The launch already succeeded. Wait — bounded, read-only — to see whether herdr learns the new
685
+ * agent's session id, and rebind every read to the launch we actually made.
686
+ *
687
+ * WHY THIS EXISTS AT ALL. `[측정 oracle 2026-09-18, LIVE run XimC19]` the rail used to treat a
688
+ * successful `agent start` whose reply carried no `agent_session` as a launch failure and CLOSED
689
+ * the pane. The premise was that herdr waits for detection before answering; it does not — it waits
690
+ * for `agent_status ∈ {idle,done}` plus `interactive_ready` (`[file:line @ c77af189]`
691
+ * `src/cli/agent.rs:592-615`) — and on the Claude rail the session id arrives afterwards, through
692
+ * the child's own hook. A booted Claude Code sibling was killed 0.585s after it wrote its birth
693
+ * record, and the same bytes passed five minutes later only because that child took 1m27s to
694
+ * become ready.
695
+ *
696
+ * WHAT EACH READ PROVES. `agent get <exact name>` — never a listing. Every reply is rebound to the
697
+ * CREATE receipt (pane id, terminal id, tab id when herdr names one) and to what we asked for
698
+ * (agent name, kind), because a reply about a different agent answers a different question. A read
699
+ * that drifts is not a slow witness; it is `herdr-agent-start-vanished`, and only then may the
700
+ * reclaim below even be consulted.
701
+ *
702
+ * WHAT EXPIRY DOES NOT BUY. Nothing. The window closing means we stop asking, and the launch is
703
+ * still a success with `witness: unavailable`. No finite bound turns "herdr was never told the id"
704
+ * into "there is nobody there" — the reporter is one-shot and swallows its failures.
705
+ */
706
+ export async function settleAgentWitness(params) {
707
+ if (params.started.hasAgentSession)
708
+ return { ok: true, witness: { state: "reported", reads: 0, settleMs: 0 } };
709
+ const settleMs = params.settleMs ?? HERDR_AGENT_SESSION_SETTLE_MS;
710
+ const pollMs = params.pollMs ?? HERDR_AGENT_SESSION_POLL_MS;
711
+ const startedAt = params.clock.now();
712
+ let reads = 0;
713
+ /** Did ANY readable reply rebind the agent to the pane we opened? Nothing else licenses a
714
+ * receipt sentence about the sibling still being there. */
715
+ let observed = false;
716
+ for (;;) {
717
+ await params.clock.sleep(pollMs);
718
+ const getRun = await params.run(buildHerdrAgentGetArgs(params.agentName));
719
+ reads += 1;
720
+ const elapsed = params.clock.now() - startedAt;
721
+ // A failed or unreadable read is NOT evidence that the agent is gone. herdr answers a
722
+ // missing agent with its own error envelope, but so does a socket that blinked, and this
723
+ // module cannot tell those apart from an exit status. So an unreadable read only costs a
724
+ // poll; the ONLY thing that names a vanished launch is a readable reply that disagrees.
725
+ const agent = getRun.status === 0 ? parseHerdrAgentGetResponse(getRun.stdout) : null;
726
+ if (agent !== null) {
727
+ // REBINDING IS ALL FIVE AXES, PRESENT AND EQUAL `[sol 재검 2026-09-18]`. A reply that
728
+ // OMITS tab, name or kind cannot carry the exact-rebinding claim this loop makes, so it
729
+ // is not a usable witness — we keep polling. It is deliberately NOT drift: treating a
730
+ // missing optional field as disagreement would fail a healthy launch on a vendor that
731
+ // simply answered with less. Only a field that IS there and DISAGREES names a vanished
732
+ // sibling, because only that is a reply about somebody else.
733
+ const bound = agent.paneId === params.created.paneId &&
734
+ agent.terminalId === params.created.terminalId &&
735
+ agent.tabId === params.tabId &&
736
+ agent.agentName === params.agentName &&
737
+ agent.agent === params.kind;
738
+ const drifted = agent.paneId !== params.created.paneId ||
739
+ agent.terminalId !== params.created.terminalId ||
740
+ (agent.tabId !== undefined && agent.tabId !== params.tabId) ||
741
+ (agent.agentName !== undefined && agent.agentName !== params.agentName) ||
742
+ (agent.agent !== undefined && agent.agent !== params.kind);
743
+ if (drifted)
744
+ return { ok: false, reads };
745
+ if (bound) {
746
+ observed = true;
747
+ if (agent.hasAgentSession)
748
+ return { ok: true, witness: { state: "reported", reads, settleMs: elapsed } };
749
+ }
750
+ }
751
+ if (elapsed >= settleMs) {
752
+ // The two expiry words are the whole point of `observed`: we may only say the agent is
753
+ // still in our pane if a readable reply said so.
754
+ return {
755
+ ok: true,
756
+ witness: { state: observed ? "unavailable" : "unobserved", reads, settleMs: elapsed },
757
+ };
758
+ }
759
+ }
760
+ }
761
+ /** Reclaim the pane we opened — conditionally, or not at all. */
762
+ async function reclaim(pane, run) {
763
+ const getRun = await run(buildHerdrPaneGetArgs(pane.paneId));
764
+ const decision = decideConditionalClose(pane, getRun.status === 0 ? parseHerdrPaneGetResponse(getRun.stdout) : null, getRun.status !== 0);
765
+ if (!decision.close)
766
+ return { outcome: "orphan-unreclaimed", paneId: pane.paneId, reason: decision.reason };
767
+ const closeRun = await run(buildHerdrPaneCloseArgs(pane.paneId));
768
+ if (closeRun.status !== 0)
769
+ return { outcome: "orphan-unreclaimed", paneId: pane.paneId, reason: "close-failed" };
770
+ return { outcome: "closed", paneId: pane.paneId, terminalId: pane.terminalId };
771
+ }
772
+ /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
773
+ * they will guess about. The five shared input reasons keep the wording the public verb has always
774
+ * used; the rest are this rail's own. */
775
+ const HERDR_REJECT_HINT = {
776
+ "caller-identity-unavailable": "this surface has no record-backed identity, so a sibling would have nowhere to call home",
777
+ "model-empty": "pass the model you want the sibling opened with — it is never inherited from this process",
778
+ "model-invalid": "the model is not in the accepted grammar (no whitespace, no leading dash)",
779
+ "task-empty": "a sibling opened with nothing to do is a window nobody asked for",
780
+ "task-too-long": `the task is over the ${TASK_MAX_CHARS}-character interface bound this verb shares with entwurf_v2`,
781
+ "herdr-context-missing": "HERDR_ENV/HERDR_BIN_PATH are absent, so this process is not inside herdr and this rail does not exist here",
782
+ "herdr-caller-pane-missing": "herdr did not give this process a HERDR_PANE_ID, so there is no way to ask herdr which workspace this caller is in",
783
+ "herdr-caller-pane-get-failed": "herdr would not report this process's own pane — run `herdr pane get $HERDR_PANE_ID` to see what it says",
784
+ "herdr-caller-pane-unparsable": "herdr's reply about this process's own pane could not be read",
785
+ "herdr-caller-pane-drift": "herdr answered about a different pane than HERDR_PANE_ID names, so its workspace says nothing about where this caller is",
786
+ "herdr-caller-workspace-missing": "herdr reported this process's pane with no workspace, and guessing one would open the sibling wherever the operator is looking",
787
+ "herdr-backend-unsupported": "this rail opens pi and claude-code only; nothing is opened elsewhere instead",
788
+ "herdr-placement-tmux-rejected": "`placement` names a tmux session, which does not exist inside herdr — drop it rather than have the sibling silently placed somewhere else",
789
+ "herdr-argv-control-character": "an argument still holds a control character after encoding; herdr refuses those and no pane was created",
790
+ "cwd-not-absolute": "pass an absolute path, or omit cwd to use this agent's own directory",
791
+ "cwd-missing": "the requested start directory does not exist",
792
+ "cwd-not-directory": "the requested start path is not a directory",
793
+ "herdr-tab-create-failed": "the tab create did not succeed — read the recovery line below for whether anything exists",
794
+ "herdr-tab-create-unparsable": "herdr's tab reply could not be read, so a tab may exist that this call cannot name",
795
+ "herdr-tab-root-pane-occupied": "the new tab's initial pane already holds an agent, so nothing was started into it",
796
+ "herdr-agent-start-failed": "herdr refused to start the agent in the tab that was just created",
797
+ "herdr-agent-start-unparsable": "herdr's start reply could not be read",
798
+ "herdr-agent-start-pane-drift": "herdr reported a different pane, terminal or tab than the one it just created",
799
+ "herdr-agent-start-vanished": "herdr started the agent and then answered about a different one, so the sibling it named is not in the pane we opened",
800
+ "herdr-agent-start-argv-drift": "herdr echoed an argv that is not the one we composed",
801
+ };
802
+ /** How a recovery reads to an operator who has to decide whether to go look. `[측정 2026-09-15]`
803
+ * closing the tab's only pane takes the tab with it, so the closed line says that — conditionally,
804
+ * because a pane a stranger added meanwhile keeps the tab alive and this rail never closed it. */
805
+ /** The witness line an operator reads. `unavailable` is a DIAGNOSIS on a successful launch, so it
806
+ * says what it costs (the peers placement column has nothing to join) and what it does not (the
807
+ * address was never a pane; it arrives in the sibling's callback). */
808
+ function renderWitness(witness) {
809
+ const cost = `${witness.reads} read(s), ${witness.settleMs}ms`;
810
+ switch (witness.state) {
811
+ case "reported":
812
+ return `reported to herdr after ${cost} — its placement can be joined in entwurf_peers`;
813
+ case "unavailable":
814
+ return `unavailable after ${cost} — herdr was not told this agent's session id, which its own reporter may never send. herdr DID still show this agent in the pane we opened when we last read it; its peers placement column will read unobserved, and its garden id still arrives in the callback`;
815
+ default:
816
+ // `[sol 재검 2026-09-18]` the branch that used to borrow the sentence above. Every read
817
+ // failed or could not be bound to our pane, so this receipt asserts NOTHING about the
818
+ // sibling's current state — and says so, rather than letting a reader infer liveness
819
+ // from a word that merely means "we stopped asking".
820
+ return `unobserved after ${cost} — no readable answer about this agent ever came back, so this receipt says nothing about whether the sibling is running. Nothing was reclaimed on that silence: an unreadable read is not evidence that a sibling is gone. Its peers placement column will read unobserved, and its garden id still arrives in the callback`;
821
+ }
822
+ }
823
+ function renderRecovery(recovery) {
824
+ switch (recovery.outcome) {
825
+ case "none":
826
+ return " recovery: none — herdr declined by name before making anything, so there is nothing to go look at\n";
827
+ case "unknown":
828
+ return " recovery: UNKNOWN — we never got an answer we can act on, so a tab MAY exist that this call cannot name. Nothing was closed; check herdr\n";
829
+ case "closed":
830
+ return ` recovery: closed ${recovery.paneId} (its terminal still matched the create receipt; the new tab went with it unless something else had joined it)\n`;
831
+ default:
832
+ return ` recovery: orphan-unreclaimed:${recovery.reason} (${recovery.paneId}) — this pane was NOT closed, on purpose\n`;
833
+ }
834
+ }
835
+ /** The failure header, which must not claim more than the recovery below it knows. An earlier
836
+ * shape said "failed after the tab was created" on EVERY post-attempt failure, including the one
837
+ * whose own hint said nothing was created. */
838
+ function renderFailureHeader(recovery) {
839
+ switch (recovery.outcome) {
840
+ case "none":
841
+ return "entwurf_fresh_call failed at the tab create step";
842
+ case "unknown":
843
+ return "entwurf_fresh_call failed at the tab create step with an UNKNOWN outcome";
844
+ default:
845
+ return "entwurf_fresh_call failed after the tab was created";
846
+ }
847
+ }
848
+ /**
849
+ * Three outcomes, and an operator must be able to tell them apart at a glance: a refusal that
850
+ * created nothing, a failure that may have left a tab behind, and a launch.
851
+ */
852
+ export function renderHerdrFreshCall(result) {
853
+ if (!result.ok) {
854
+ const hint = HERDR_REJECT_HINT[result.reason];
855
+ if (!("recovery" in result)) {
856
+ return {
857
+ text: `entwurf_fresh_call rejected: ${result.reason} — ${hint}. No tab and no pane were created.`,
858
+ isError: true,
859
+ };
860
+ }
861
+ const code = result.herdrErrorCode === undefined ? "" : ` [herdr: ${result.herdrErrorCode}]`;
862
+ return {
863
+ text: `${renderFailureHeader(result.recovery)}: ${result.reason}${code} — ${hint}.\n` +
864
+ renderRecovery(result.recovery),
865
+ isError: true,
866
+ };
867
+ }
868
+ const r = result.receipt;
869
+ return {
870
+ text: `[entwurf fresh call → herdr]\n` +
871
+ ` backend: ${r.backend} (requested kind ${r.requestedKind} — herdr resolves the executable, we did not)\n` +
872
+ ` model: ${r.model} (requested on the runtime CLI)\n` +
873
+ (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
874
+ ` agent: ${r.herdrAgentName} (herdr's name for it, derived from the nonce)\n` +
875
+ ` tab: ${r.herdrTabId} in workspace ${r.herdrWorkspaceId} — a NEW tab beside the caller's, opened without taking focus\n` +
876
+ ` pane: ${r.herdrPaneId} terminal ${r.herdrTerminalId} — the tab's initial pane; herdr VIEW coordinates, not an address\n` +
877
+ ` nonce: ${r.nonce}\n` +
878
+ ` witness: ${renderWitness(r.witness)}\n` +
879
+ `\n` +
880
+ `This is a LAUNCH receipt: herdr created a tab and was asked to start the agent above in it. It does NOT ` +
881
+ `mean the sibling is running, that its first turn ran, or that the task was delivered. The tab and pane ` +
882
+ `coordinates are a view and can change under the sibling — they are not an address and nothing may be ` +
883
+ `dispatched to them.\n` +
884
+ `The sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its first ` +
885
+ `action, and the sender envelope of THAT message is the address. Nothing is polling for it; if it never ` +
886
+ `comes, the pane is visible and can be read directly.`,
887
+ isError: false,
888
+ };
889
+ }
890
+ /** How long a herdr command may take. `[측정, herdr 0.9.1 `agent start --help`]` the start verb waits
891
+ * for interactive readiness with a default of 30s and a documented ceiling of 300s, so the start
892
+ * bound is that ceiling: cutting it shorter would kill a launch herdr was still legitimately
893
+ * waiting on. Every other verb is a socket round trip and gets the short bound. */
894
+ export const HERDR_START_TIMEOUT_MS = 300_000;
895
+ /**
896
+ * How long HERDR may wait for interactive readiness — passed as `--timeout`, and NOT the same
897
+ * number as the bound above.
898
+ *
899
+ * `[측정 2026-09-17, oracle, LIVE]` the rail never passed this flag, so herdr used its own 30s
900
+ * default while our process bound sat at 300s. A Claude Code cold start in a fresh pane exceeds
901
+ * 30s on this host, so herdr gave up on an agent that WAS alive and starting, and the rail reported
902
+ * `herdr-agent-start-failed [herdr: timeout]` with an unreclaimed pane holding a healthy sibling.
903
+ * The gate had never seen it because its LIVE cell pinned a runtime that starts fast.
904
+ *
905
+ * It is deliberately BELOW `HERDR_START_TIMEOUT_MS`. The two bounds race, and herdr has to lose:
906
+ * if our process kill landed first we would learn nothing but "no exit status", while herdr's own
907
+ * expiry answers with a named `timeout` we can report and reclaim from. Sixty seconds of daylight
908
+ * is the margin.
909
+ */
910
+ export const HERDR_START_READY_MS = 240_000;
911
+ export const HERDR_CLI_TIMEOUT_MS = 30_000;
912
+ /**
913
+ * How long we keep ASKING herdr for the agent session of a launch that already succeeded — a
914
+ * SEPARATE stage from `HERDR_START_READY_MS`, which is herdr's own interactive-readiness budget and
915
+ * has by then already returned.
916
+ *
917
+ * `[측정 oracle 2026-09-18, LIVE runs XimC19 / TCatjF]` the same bytes on the same host answered
918
+ * twice within five minutes: the fast run returned a successful `agent start` in 4.9s with no
919
+ * `agent_session`, and the slow one took 1m27s and had it. Nothing about the launch differed —
920
+ * upstream returns on `agent_status ∈ {idle,done}` plus `interactive_ready`
921
+ * (`[file:line @ c77af189]` `src/cli/agent.rs:592-615`) and never waits for a session report, so
922
+ * the slow run's 85s of extra waiting was not a bound, it was a race the wait happened to hide.
923
+ *
924
+ * THIS NUMBER IS A PROPOSAL, and it is honest to say so. The red run still had no session 585ms
925
+ * after the child's own birth record, the reporter's socket deadline alone is 0.5s, and hook
926
+ * scheduling sits outside both — so the observed pair justifies neither a few hundred ms nor the
927
+ * slow run's 85s. It is set to one bounded window big enough to absorb an ordinary hook tail and
928
+ * small enough that nobody waits on it, and the LIVE distribution is what may move it.
929
+ *
930
+ * WHAT IT IS NOT: expiry is never authority to close anything. A session id that never arrives is
931
+ * a launch we cannot NAME, not a launch that did not happen — the reporter is one-shot and
932
+ * swallows its own failures, so "never" is a reachable state for a perfectly healthy sibling.
933
+ */
934
+ export const HERDR_AGENT_SESSION_SETTLE_MS = 5_000;
935
+ /** How often we re-read during that window. Matched to the vendor's own start-poll cadence
936
+ * (`[file:line @ c77af189]` `src/cli/agent.rs:9`), because there is no reason to ask a server
937
+ * faster than it changes its own mind. */
938
+ export const HERDR_AGENT_SESSION_POLL_MS = 100;
939
+ /** How much of one herdr reply we are willing to hold. Every verb on this rail answers with one
940
+ * JSON object; a stream larger than this is not a reply we can parse, and an unbounded buffer would
941
+ * let a runaway child take the caller's memory with it. Overflow is reported as the same
942
+ * nonzero-status shape as any other failure rather than truncated into a parse we would believe. */
943
+ export const HERDR_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
944
+ /**
945
+ * The production runner: argv array, no shell, explicit env, bounded — and ASYNCHRONOUS.
946
+ *
947
+ * WHY IT MUST NOT BLOCK `[측정 2026-09-14, C4 첫 LIVE]`. The first thing a sibling does is call its
948
+ * caller back on the caller's own control socket. On this rail the caller is, at that exact moment,
949
+ * inside `agent start` waiting for herdr to report readiness — up to 300s. A SYNCHRONOUS child wait
950
+ * holds the caller's event loop for that whole window, so the callback cannot be read, the sender
951
+ * times out and hangs up, and the caller's late reply then lands on a socket whose peer is gone.
952
+ * The runner is therefore async: the child runs while the loop keeps serving sockets and timers.
953
+ *
954
+ * A spawn that never produced an exit status (binary missing, timeout, signal) is mapped to a
955
+ * nonzero status with the failure on stderr — the SAME shape herdr's own error path produces, so
956
+ * the rail above has one thing to read. Nothing here looks at a terminal.
957
+ *
958
+ * The timeout KILLS: a bound that only stops waiting would leave the child holding a pane while we
959
+ * report it gone. SIGKILL, because a bound we cannot enforce is not a bound. Exactly one settle —
960
+ * `close`, `error` and the timeout all race, and whichever arrives first is the only answer.
961
+ */
962
+ export function createHerdrRunner(bin, env, spawn,
963
+ // The production bounds ARE the exported constants; this parameter exists so a gate can prove
964
+ // the kill and the cap on a real child within a gate's patience instead of waiting 30 seconds
965
+ // for them. The composition root passes nothing, and a structural cell keeps it that way — a
966
+ // caller who could shorten the start bound could kill a launch herdr was still waiting on.
967
+ bounds = {
968
+ startMs: HERDR_START_TIMEOUT_MS,
969
+ cliMs: HERDR_CLI_TIMEOUT_MS,
970
+ maxOutputBytes: HERDR_MAX_OUTPUT_BYTES,
971
+ }) {
972
+ return (args) => {
973
+ const timeout = args[0] === "agent" && args[1] === "start" ? bounds.startMs : bounds.cliMs;
974
+ return new Promise((resolve) => {
975
+ let stdout = "";
976
+ let stderr = "";
977
+ let settled = false;
978
+ const child = spawn(bin, [...args], { env, shell: false });
979
+ const settle = (result) => {
980
+ if (settled)
981
+ return;
982
+ settled = true;
983
+ clearTimeout(timer);
984
+ resolve(result);
985
+ };
986
+ const failed = (why, out = stdout) => settle({ status: 1, stdout: out, stderr: `herdr ${args.join(" ")}: ${why}` });
987
+ const timer = setTimeout(() => {
988
+ child.kill("SIGKILL");
989
+ failed(`no exit status (timeout ${timeout}ms or signal null)`);
990
+ }, timeout);
991
+ // The bound must not itself keep this process alive once the answer is in.
992
+ timer.unref?.();
993
+ const collect = (into) => (chunk) => {
994
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
995
+ if (into === "stdout")
996
+ stdout += text;
997
+ else
998
+ stderr += text;
999
+ if (stdout.length + stderr.length <= bounds.maxOutputBytes)
1000
+ return;
1001
+ child.kill("SIGKILL");
1002
+ failed(`output exceeded ${bounds.maxOutputBytes} bytes`, "");
1003
+ };
1004
+ child.stdout?.on("data", collect("stdout"));
1005
+ child.stderr?.on("data", collect("stderr"));
1006
+ child.on("error", (error) => failed(error.message, ""));
1007
+ child.on("close", (code, signal) => {
1008
+ if (code === null) {
1009
+ // Killed by a signal: herdr said nothing, so we say that rather than inventing
1010
+ // an exit code that would read as herdr's own refusal.
1011
+ failed(`no exit status (timeout ${timeout}ms or signal ${String(signal)})`);
1012
+ return;
1013
+ }
1014
+ settle({ status: code, stdout, stderr });
1015
+ });
1016
+ });
1017
+ };
1018
+ }