@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.
- package/AGENTS.md +4 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- 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
|
+
}
|