pi-daddy 0.15.0 → 0.16.0

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 (66) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +27 -12
  3. package/dist/cli.js +0 -0
  4. package/dist/executor.d.ts +38 -0
  5. package/dist/executor.d.ts.map +1 -0
  6. package/dist/executor.js +93 -0
  7. package/dist/executor.js.map +1 -0
  8. package/dist/herdr-cli.d.ts +78 -0
  9. package/dist/herdr-cli.d.ts.map +1 -0
  10. package/dist/herdr-cli.js +113 -0
  11. package/dist/herdr-cli.js.map +1 -0
  12. package/dist/herdr-name.d.ts +37 -0
  13. package/dist/herdr-name.d.ts.map +1 -0
  14. package/dist/herdr-name.js +59 -0
  15. package/dist/herdr-name.js.map +1 -0
  16. package/dist/herdr-poll.d.ts +104 -0
  17. package/dist/herdr-poll.d.ts.map +1 -0
  18. package/dist/herdr-poll.js +150 -0
  19. package/dist/herdr-poll.js.map +1 -0
  20. package/dist/herdr-stage.d.ts +40 -0
  21. package/dist/herdr-stage.d.ts.map +1 -0
  22. package/dist/herdr-stage.js +54 -0
  23. package/dist/herdr-stage.js.map +1 -0
  24. package/dist/ledger-report.d.ts +18 -0
  25. package/dist/ledger-report.d.ts.map +1 -1
  26. package/dist/ledger-report.js +10 -0
  27. package/dist/ledger-report.js.map +1 -1
  28. package/dist/ledger.d.ts +17 -0
  29. package/dist/ledger.d.ts.map +1 -1
  30. package/dist/ledger.js +1 -0
  31. package/dist/ledger.js.map +1 -1
  32. package/dist/pane-reaper.d.ts +66 -4
  33. package/dist/pane-reaper.d.ts.map +1 -1
  34. package/dist/pane-reaper.js +131 -9
  35. package/dist/pane-reaper.js.map +1 -1
  36. package/dist/progress.d.ts +96 -0
  37. package/dist/progress.d.ts.map +1 -0
  38. package/dist/progress.js +167 -0
  39. package/dist/progress.js.map +1 -0
  40. package/dist/run-child.d.ts +27 -0
  41. package/dist/run-child.d.ts.map +1 -1
  42. package/dist/run-child.js +84 -7
  43. package/dist/run-child.js.map +1 -1
  44. package/dist/run-herdr.d.ts +41 -28
  45. package/dist/run-herdr.d.ts.map +1 -1
  46. package/dist/run-herdr.js +150 -167
  47. package/dist/run-herdr.js.map +1 -1
  48. package/extensions/delegation.ts +94 -2
  49. package/extensions/grants-command.ts +26 -1
  50. package/extensions/grants.ts +85 -163
  51. package/extensions/run-delegation.ts +70 -8
  52. package/extensions/session-report.ts +231 -0
  53. package/extensions/session.ts +63 -11
  54. package/extensions/tripwire.ts +44 -0
  55. package/package.json +17 -1
  56. package/src/executor.ts +122 -0
  57. package/src/herdr-cli.ts +125 -0
  58. package/src/herdr-name.ts +61 -0
  59. package/src/herdr-poll.ts +185 -0
  60. package/src/herdr-stage.ts +55 -0
  61. package/src/ledger-report.ts +21 -0
  62. package/src/ledger.ts +18 -0
  63. package/src/pane-reaper.ts +147 -9
  64. package/src/progress.ts +206 -0
  65. package/src/run-child.ts +96 -7
  66. package/src/run-herdr.ts +170 -174
package/src/run-herdr.ts CHANGED
@@ -21,16 +21,33 @@
21
21
  * child. Humans are not this project's threat model, but nothing here should be read as containing one.
22
22
  */
23
23
 
24
- import { execFile } from "node:child_process";
25
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
26
- import { tmpdir } from "node:os";
27
- import { join } from "node:path";
24
+ import { rm } from "node:fs/promises";
28
25
  import type { ChildRunResult } from "./run-child.ts";
29
26
  import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS } from "./run-child.ts";
30
- import { trackPane, untrackPane } from "./pane-reaper.ts";
27
+ import { MAX_OPEN_PANES, markPaneSettled, trackPane, trimOpenPanes, untrackPane } from "./pane-reaper.ts";
28
+ import { defaultExec, parseReply, type HerdrExec } from "./herdr-cli.ts";
29
+ import { readPane, waitForSettled } from "./herdr-poll.ts";
30
+ import { stageSystemPrompt } from "./herdr-stage.ts";
31
+ import { uniqueAgentName } from "./herdr-name.ts";
31
32
 
32
- /** One herdr CLI invocation. Injectable so every rule below is testable without herdr installed. */
33
- export type HerdrExec = (args: string[]) => Promise<{ code: number | null; stdout: string; stderr: string }>;
33
+ /**
34
+ * Re-exported so importers of the executor still reach the protocol at the name they always used.
35
+ *
36
+ * Deliberate rather than lazy: `test/run-herdr.test.ts` imports `HerdrExec` from here and must pass
37
+ * **unmodified** across this extraction — that is the only available proof the move changed no behaviour.
38
+ */
39
+ export { type HerdrExec, parseReply } from "./herdr-cli.ts";
40
+ /** Re-exported: `splitSystemPrompt` moved to `./herdr-stage.ts` under the ceiling, its tests import it here. */
41
+ export { splitSystemPrompt, stageSystemPrompt } from "./herdr-stage.ts";
42
+ /** Re-exported: the name rules moved to `./herdr-name.ts` under the ceiling; tests import them here. */
43
+ export { uniqueAgentName } from "./herdr-name.ts";
44
+ /**
45
+ * Re-exported so `test/run-herdr.test.ts` and any importer keep reaching these where they always were.
46
+ *
47
+ * `POLL_INTERVAL_MS` and `newSuffix` moved to `./herdr-poll.ts` under the 400-line ceiling; the names are part
48
+ * of this module's surface and moving a file should not move an export.
49
+ */
50
+ export { DEFAULT_SNAPSHOT_LINES, POLL_INTERVAL_MS, readPane, tailLines, waitForSettled, type PollTarget } from "./herdr-poll.ts";
34
51
 
35
52
  export interface HerdrRunRequest {
36
53
  /** `planSpawn` args **without** the prompt — see `prompt`. */
@@ -61,65 +78,34 @@ export interface HerdrRunRequest {
61
78
  */
62
79
  keepPane?: boolean;
63
80
  exec?: HerdrExec;
81
+ /**
82
+ * The pane's last few lines, re-reported every poll — a SNAPSHOT the consumer REPLACES, not appends.
83
+ *
84
+ * **Display only**, exactly as in `runChild`: the child's answer is still the returned `text`. Unlike
85
+ * `runChild`'s `onOutput`, this is not a stream: `agent read` returns a snapshot of a bounded terminal, and
86
+ * the previous append-shaped design produced an 89,000× amplification once a pane scrolled or passed the
87
+ * output cap. See `tailLines` in `./herdr-poll.ts`.
88
+ *
89
+ * Exceptions are swallowed; a renderer must not be able to break a governed run.
90
+ */
91
+ onSnapshot?: (lines: string[]) => void;
92
+ /** How many pane lines to report per poll. Defaults to `DEFAULT_SNAPSHOT_LINES`. */
93
+ snapshotLines?: number;
94
+ /**
95
+ * The pane id, reported as soon as `tab create` returns it.
96
+ *
97
+ * Separate from `onOutput` because it arrives once and arrives *early*: it is what lets the parent print a
98
+ * name a human can switch to **while the child is alive**, which is the whole point of the pane surviving.
99
+ */
100
+ onPane?: (paneId: string, agentName: string) => void;
101
+ /** Poll cadence override. Exists so tests do not wait `POLL_INTERVAL_MS` per state transition. */
102
+ pollIntervalMs?: number;
64
103
  }
65
104
 
66
- /**
67
- * Move a multi-line `--append-system-prompt` out of argv, because herdr cannot encode it.
68
- *
69
- * **Measured.** `herdr agent start` types the argv into the pane's shell, so a value containing newlines
70
- * is rejected outright: `invalid_agent_argument — agent arguments cannot be encoded safely for the target
71
- * shell`. A definition's `SKILL.md` body is always multi-line, so every `delegate({agent})` spawn would
72
- * fail on this path.
73
- *
74
- * pi accepts a **file path** there as readily as literal text (`resolvePromptInput` + `existsSync` in
75
- * `dist/core/resource-loader.js`), so the fix is to write the body to a temp file and pass its path — one
76
- * short, shell-safe argument.
77
- *
78
- * The split lives here rather than in `planSpawn` because the constraint is **herdr's**, not pi's: the
79
- * direct executor passes the same text inline with no trouble, and a plan builder that pre-emptively wrote
80
- * temp files for everybody would be paying one executor's tax on both paths.
81
- */
82
- export function splitSystemPrompt(args: string[]): { args: string[]; systemPrompt?: string } {
83
- const at = args.indexOf("--append-system-prompt");
84
- if (at === -1 || at + 1 >= args.length) return { args };
85
- return { args: [...args.slice(0, at), ...args.slice(at + 2)], systemPrompt: args[at + 1] };
86
- }
87
-
88
- /** Statuses herdr reports for a settled agent. `blocked` counts: it is waiting for a human, not working. */
89
- const TERMINAL = new Set(["idle", "done", "blocked"]);
90
105
 
91
- /** How often to poll `agent get` while waiting for the child to settle. */
92
- export const POLL_INTERVAL_MS = 750;
93
106
  /** How often to retry `agent start` while a freshly created pane is still reaching its shell prompt. */
94
107
  export const PANE_READY_POLL_MS = 300;
95
108
 
96
- const defaultExec: HerdrExec = (args) =>
97
- new Promise((settle) => {
98
- execFile("herdr", args, { maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => {
99
- const code = error && typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : error ? 1 : 0;
100
- settle({ code, stdout: String(stdout), stderr: String(stderr) });
101
- });
102
- });
103
-
104
- /**
105
- * Parse herdr's JSON envelope. Every command replies `{id, result}` or `{id, error:{code,message}}`.
106
- *
107
- * `stderr` is folded into the message because the first end-to-end run failed with an EMPTY stdout and the
108
- * real reason on stderr, producing the useless diagnostic "unparseable herdr reply: ". A wrapper that
109
- * hides the substrate's own error message costs more time than it saves.
110
- */
111
- function parseReply(reply: { stdout: string; stderr: string }): { result?: Record<string, unknown>; error?: string } {
112
- try {
113
- const parsed = JSON.parse(reply.stdout) as { result?: Record<string, unknown>; error?: { message?: string; code?: string } };
114
- if (parsed.error) return { error: parsed.error.message ?? parsed.error.code ?? "herdr reported an error" };
115
- return { result: parsed.result };
116
- } catch {
117
- // A non-JSON reply is a herdr-version or PATH problem, not a governance decision. Surfaced as a spawn
118
- // error so the caller reports "could not start" rather than "the child produced nothing".
119
- const detail = [reply.stdout.trim(), reply.stderr.trim()].filter((t) => t.length > 0).join(" | ");
120
- return { error: `unparseable herdr reply: ${detail.slice(0, 300) || "(no output)"}` };
121
- }
122
- }
123
109
 
124
110
  /**
125
111
  * Run one governed child in a pane and return its output.
@@ -152,28 +138,29 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
152
138
  };
153
139
  }
154
140
 
155
- // A multi-line system prompt becomes a file; see `splitSystemPrompt`. Removed in `cleanup`, so a
156
- // definition's instructions do not accumulate in /tmp across a fan-out.
157
- const split = splitSystemPrompt(request.args);
158
- let promptDir: string | undefined;
159
- let effectiveArgs = split.args;
160
- if (split.systemPrompt !== undefined) {
161
- try {
162
- promptDir = await mkdtemp(join(tmpdir(), "grants-herdr-"));
163
- const file = join(promptDir, "system-prompt.md");
164
- await writeFile(file, split.systemPrompt, "utf8");
165
- effectiveArgs = [...split.args, "--append-system-prompt", file];
166
- } catch (error) {
167
- return { ...empty, spawnError: `could not stage the system prompt for herdr: ${String(error)}` };
168
- }
169
- }
141
+ // A multi-line system prompt becomes a file; see `stageSystemPrompt`. Removed on every path that does not
142
+ // end up tracking a pane, because only a tracked pane's dir is reachable by a sweep.
143
+ const staged = await stageSystemPrompt(request.args);
144
+ if (staged.error) return { ...empty, spawnError: staged.error };
145
+ const promptDir = staged.promptDir;
146
+ const effectiveArgs = staged.args;
170
147
 
171
148
  const create = ["tab", "create", "--label", request.name, "--cwd", request.cwd];
172
149
  if (request.workspace) create.push("--workspace", request.workspace);
173
150
  for (const [key, value] of Object.entries(request.env)) create.push("--env", `${key}=${value}`);
174
151
 
152
+ // The name herdr will actually know this agent by. Every later command in this function uses `agentName`,
153
+ // never `request.name`, which is only a base.
154
+ const agentName = uniqueAgentName(request.name);
175
155
  const created = parseReply(await exec(create));
176
- if (created.error) return { ...empty, spawnError: `herdr tab create failed: ${created.error}` };
156
+ if (created.error) {
157
+ // **The staged prompt is removed here too.** This path returned without touching it, and nothing tracked it,
158
+ // so neither sweep could ever reach it — a permanent `/tmp/grants-herdr-*` per failed `tab create`. Measured
159
+ // by a reviewer, who also found leftovers from earlier runs already sitting in `/tmp`. It is the most likely
160
+ // failure on a machine where herdr is not running, i.e. the common case.
161
+ if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => undefined);
162
+ return { ...empty, spawnError: `herdr tab create failed: ${created.error}` };
163
+ }
177
164
 
178
165
  const rootPane = (created.result?.root_pane ?? {}) as { pane_id?: string; tab_id?: string };
179
166
  const paneId = rootPane.pane_id;
@@ -182,7 +169,33 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
182
169
  // so registering later leaves a window — one herdr round-trip wide — in which a killed process orphans a
183
170
  // tab nothing would reap. The normal path had no such window and the error path did, which is backwards:
184
171
  // the error path is the one more likely to be taken while something is already going wrong.
185
- if (tabId && !request.keepPane) trackPane({ tab: tabId, name: request.name, promptDir });
172
+ if (tabId && request.keepPane && promptDir) {
173
+ // `keepPane` keeps the PANE, and the staged prompt with it — a human inspecting the pane may want to see
174
+ // what the child was told. Registered `settled` so the trim never touches it, but registered nonetheless so
175
+ // the `exit` sweep can remove the temp dir rather than leaving one per kept pane forever. Measured: this
176
+ // path leaked a `/tmp/grants-herdr-*` that neither sweep could reach.
177
+ trackPane({ tab: tabId, name: agentName, promptDir, settled: true, keepTab: true });
178
+ }
179
+ if (tabId && !request.keepPane) {
180
+ trackPane({ tab: tabId, name: agentName, promptDir });
181
+ // ADR-0032: panes now live until `agent_settled`, so they accumulate across one agent run — and a plain
182
+ // blocking `delegate` spends nothing from the fan-out budget, so the count is otherwise unbounded. Trimmed
183
+ // here rather than at settle time so the bound holds DURING a long run, not only after it.
184
+ //
185
+ // Whatever is closed is reported through `onOutput` rather than dropped: an operator whose pane vanished
186
+ // while they were reading it deserves to know why (R-48's rule, applied to a display).
187
+ const dropped = await trimOpenPanes(exec);
188
+ if (dropped.length > 0 && request.onSnapshot) {
189
+ try {
190
+ request.onSnapshot([
191
+ `[grants] closed ${dropped.length} older pane(s) to stay within the ${MAX_OPEN_PANES}-pane limit: ` +
192
+ dropped.join(", "),
193
+ ]);
194
+ } catch {
195
+ /* display only */
196
+ }
197
+ }
198
+ }
186
199
 
187
200
  if (!paneId) {
188
201
  // The tab may exist even though the reply carried no pane id, and this return used to be BEFORE
@@ -193,65 +206,115 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
193
206
  // reaper's problem rather than being dropped on the assumption that it worked.
194
207
  if (reply !== undefined && !parseReply(reply).error) untrackPane(tabId);
195
208
  }
196
- // `keepPane` keeps the staged prompt for the same reason `cleanup` does — a human inspecting the pane
197
- // may want to see what the child was told. This branch used to remove it unconditionally, which threw
198
- // that away on the one path where there is no agent in the pane to ask instead.
209
+ // There is no agent in this pane to ask, so the staged prompt has no reader — remove it. Kept only under
210
+ // `keepPane`, where the operator asked to inspect whatever exists.
199
211
  if (promptDir && !request.keepPane) await rm(promptDir, { recursive: true, force: true }).catch(() => undefined);
200
212
  return { ...empty, spawnError: "herdr tab create returned no pane id" };
201
213
  }
202
214
 
203
- /** Close what we opened, whatever happened. A leaked pane per child is how fan-out fills a workspace. */
204
- const cleanup = async () => {
205
- await exec(["agent", "stop", request.name]).catch(() => undefined);
206
- let closed = false;
207
- if (!request.keepPane && tabId) {
208
- // **The reply must be PARSED, not merely awaited.** `defaultExec` resolves with `{code: 1}` on
209
- // failure and never rejects, so the `.catch` here was dead code and a herdr that REFUSED to close the
210
- // pane looked identical to one that closed it. The pane was then untracked, so the exit reaper — the
211
- // one thing built for exactly this failure — would not retry it. The single case the reaper exists
212
- // for was the case that disabled it.
213
- const reply = await exec(["tab", "close", tabId]).catch(() => undefined);
214
- closed = reply !== undefined && !parseReply(reply).error;
215
+ /**
216
+ * End this call. **A child that did not settle has its tab closed, because that is the only way to stop it.**
217
+ *
218
+ * ADR-0032 keeps a pane alive past its tool call so a human can read it. The original version of that also
219
+ * called `herdr agent stop` and claimed *"the agent is still stopped here… what survives is a terminal, not a
220
+ * running descendant."* **`herdr agent stop` does not exist.** Measured against herdr 0.7.5: the `agent`
221
+ * subcommands are `list get read send-keys prompt rename focus wait attach start explain`, and `agent stop`
222
+ * prints the usage banner and exits 0 — which `defaultExec` reports as a success, so nothing ever noticed.
223
+ * `docs/probes/g16-herdr/README.md` asserted it worked, in a *How to rerun* block that was never run.
224
+ *
225
+ * So there is exactly one kill available: closing the tab. That forces the distinction below.
226
+ *
227
+ * - **Settled cleanly** (`idle`/`done`/`blocked` with an advanced counter): the child has answered and is
228
+ * sitting idle. Its pane stays, which is the whole feature, and the `agent_settled` sweep reaps it.
229
+ * - **Anything else** — a timeout, an abort, a failed start, a failed prompt, an unreadable pane: the child
230
+ * may still be **working, with its grant**, after its tool call has already returned a result. Leaving
231
+ * that running is a governance failure, not an untidy workspace, so the tab is closed here and now.
232
+ *
233
+ * `keepPane` is the operator overriding the whole question, and it is honoured either way: nothing is tracked
234
+ * and nothing is closed, which is what "keep this pane for me to inspect" has to mean.
235
+ */
236
+ const cleanup = async (settledCleanly: boolean) => {
237
+ if (request.keepPane) return;
238
+ if (settledCleanly) return;
239
+ if (!tabId) return;
240
+ const reply = await exec(["tab", "close", tabId]).catch(() => undefined);
241
+ // Untracked only if provably closed — R-62's rule. A close herdr refused stays the reaper's problem rather
242
+ // than being dropped on the assumption that it worked.
243
+ if (reply !== undefined && !parseReply(reply).error) {
244
+ untrackPane(tabId);
245
+ if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => undefined);
215
246
  }
216
- // Kept when the pane is kept: a human inspecting the pane may want to see what the child was told.
217
- if (promptDir && !request.keepPane) await rm(promptDir, { recursive: true, force: true }).catch(() => undefined);
218
- // Untrack only what is genuinely gone. A pane we failed to close stays registered so `exit` tries once
219
- // more; `openPaneCount()` is what a test asserts to prove the registry does not grow per delegation.
220
- if (tabId && (closed || request.keepPane)) untrackPane(tabId);
221
247
  };
222
248
 
249
+ // ADR-0032: the pane exists and has a name, so tell the caller NOW. A pane id that arrives with the result is
250
+ // useless — the point is switching to a child while it is still working. Guarded because a renderer must not
251
+ // be able to strand a pane we have already created.
252
+ if (request.onPane) {
253
+ try {
254
+ request.onPane(paneId, agentName);
255
+ } catch {
256
+ /* display only */
257
+ }
258
+ }
259
+
260
+ // Set only on the one path where the child answered and stopped working. Everything else — including an
261
+ // early `return` from a failed start or prompt — leaves it false, so `cleanup` closes the tab.
262
+ let settledCleanly = false;
263
+
223
264
  try {
224
- const started = await startAgent(exec, request.name, paneId, effectiveArgs, deadline);
265
+ const started = await startAgent(exec, agentName, paneId, effectiveArgs, deadline);
225
266
  if (started.error) return { ...empty, spawnError: `herdr agent start failed: ${started.error}` };
226
267
 
227
268
  // The state counter BEFORE prompting is what makes the wait correct — see the R-33 note below.
228
269
  const before = seqOf(started.result);
229
270
 
230
- const prompted = parseReply(await exec(["agent", "prompt", request.name, request.prompt]));
271
+ const prompted = parseReply(await exec(["agent", "prompt", agentName, request.prompt]));
231
272
  if (prompted.error) return { ...empty, spawnError: `herdr agent prompt failed: ${prompted.error}` };
232
273
 
233
- const settled = await waitForSettled(exec, request, before, deadline);
274
+ const settled = await waitForSettled(exec, { ...request, name: agentName }, before, deadline, maxOutputBytes);
234
275
  if (settled.aborted || settled.timedOut) {
235
276
  // Still read: a timed-out child usually produced something, and a partial answer labelled partial is
236
277
  // more useful than none. R-03's rule — a missing result must never look like an empty one.
237
- const partial = await readPane(exec, request.name, maxOutputBytes);
238
- return { ...empty, ...settled, text: partial.text, truncated: partial.truncated };
278
+ const partial = await readPane(exec, agentName, maxOutputBytes);
279
+ return { ...empty, ...settled, text: partial.readFailed ? "" : partial.text, truncated: partial.truncated };
239
280
  }
240
281
  if (settled.spawnError) return { ...empty, spawnError: settled.spawnError };
241
282
 
242
- const out = await readPane(exec, request.name, maxOutputBytes);
283
+ const out = await readPane(exec, agentName, maxOutputBytes);
284
+ // **A failed read is a failed spawn, not an empty answer.** This branch used to return `readPane`'s own
285
+ // diagnostic as `text` with `code: 0`, so the orchestrator received
286
+ // `[grants] could not read the agent pane: …` **as the sub-agent's report** — R-03 exactly, and measured.
287
+ // The child may well have done its work; what we cannot do is claim to know what it said.
288
+ if (out.readFailed) {
289
+ return {
290
+ ...empty,
291
+ spawnError:
292
+ `the child settled but its pane could not be read (${out.readFailed}), so its answer is unknown. ` +
293
+ `The work may have been done — check pane ${paneId} if it is still open.`,
294
+ };
295
+ }
296
+ settledCleanly = true;
297
+ // The trim may now reclaim this pane if the cap is exceeded. Until this point it must not: closing the tab
298
+ // kills the child, and the child was still working.
299
+ if (tabId) markPaneSettled(tabId);
243
300
  return {
244
301
  code: settled.status === "blocked" ? 1 : 0,
245
- text:
302
+ // `truncated` is returned in the result too, but it was never SAID on this path, so a model received a
303
+ // tail with no indication that a head existed. The direct executor has always flagged it; a delegation
304
+ // that silently drops the first megabyte of an answer is the same defect wearing the other executor.
305
+ text: [
306
+ out.truncated ? `[grants] this pane exceeded the output cap; only its most recent output is below.\n` : "",
307
+ out.text,
246
308
  settled.status === "blocked"
247
- ? `${out.text}\n\n[grants] this agent is BLOCKED waiting for a human in pane ${paneId}.`
248
- : out.text,
309
+ ? `\n\n[grants] this agent is BLOCKED waiting for a human in pane ${paneId}.`
310
+ : "",
311
+ ].join(""),
249
312
  truncated: out.truncated,
250
313
  timedOut: false,
251
314
  aborted: false,
252
315
  };
253
316
  } finally {
254
- await cleanup();
317
+ await cleanup(settledCleanly);
255
318
  }
256
319
  }
257
320
 
@@ -288,70 +351,3 @@ function seqOf(result: Record<string, unknown> | undefined): number {
288
351
  const agent = (result?.agent ?? {}) as { state_change_seq?: number };
289
352
  return typeof agent.state_change_seq === "number" ? agent.state_change_seq : -1;
290
353
  }
291
-
292
- /**
293
- * Wait for the child to settle, without accepting the state it was already in.
294
- *
295
- * **R-33, measured.** `herdr agent wait --until idle` called right after `agent prompt` returned
296
- * *immediately*, matching the agent's **pre-existing** idle state with `state_change_seq` unchanged — a
297
- * reply indistinguishable from a completed run. For fan-out that is not an inconvenience but a
298
- * correctness bug: an orchestrator would "collect" N children that never ran and merge N empty results
299
- * into a confident summary (R-03 with a new cause).
300
- *
301
- * So this polls `agent get` and requires **both** that the status is terminal **and** that
302
- * `state_change_seq` has advanced past the value observed before prompting. `agent wait` is deliberately
303
- * not used at all: its contract cannot express "settled *after* this point".
304
- */
305
- async function waitForSettled(
306
- exec: HerdrExec,
307
- request: HerdrRunRequest,
308
- before: number,
309
- deadline: number,
310
- ): Promise<{ status?: string; timedOut?: boolean; aborted?: boolean; spawnError?: string }> {
311
- for (;;) {
312
- if (request.signal?.aborted) return { aborted: true };
313
- if (Date.now() >= deadline) return { timedOut: true };
314
-
315
- const reply = parseReply(await exec(["agent", "get", request.name]));
316
- if (reply.error) return { spawnError: `herdr agent get failed: ${reply.error}` };
317
-
318
- const agent = (reply.result?.agent ?? reply.result ?? {}) as { agent_status?: string; state_change_seq?: number };
319
- const status = agent.agent_status;
320
- const seq = typeof agent.state_change_seq === "number" ? agent.state_change_seq : -1;
321
-
322
- if (status && TERMINAL.has(status) && seq > before) return { status };
323
-
324
- await new Promise((r) => setTimeout(r, Math.min(POLL_INTERVAL_MS, Math.max(0, deadline - Date.now()))));
325
- }
326
- }
327
-
328
- /**
329
- * Read the pane's contents.
330
- *
331
- * `agent read` is the ONE command that does not return herdr's JSON envelope — it writes the terminal's
332
- * text straight to stdout. Running it through `parseReply` turned every successful read into
333
- * "unparseable herdr reply", i.e. reported the child's actual answer as a failure to read it. Found by the
334
- * end-to-end run; the unit fake had been written to the envelope shape and so agreed with the bug.
335
- *
336
- * A JSON envelope is still accepted first, because an `error` reply here IS JSON and must not be mistaken
337
- * for terminal output.
338
- */
339
- async function readPane(exec: HerdrExec, name: string, maxOutputBytes: number): Promise<{ text: string; truncated: boolean }> {
340
- const reply = await exec(["agent", "read", name]);
341
- let text: string;
342
- try {
343
- const parsed = JSON.parse(reply.stdout) as { result?: Record<string, unknown>; error?: { message?: string } };
344
- if (parsed.error) {
345
- return { text: `[grants] could not read the agent pane: ${parsed.error.message ?? "unknown error"}`, truncated: false };
346
- }
347
- const raw = parsed.result?.output ?? parsed.result?.text ?? parsed.result?.content ?? "";
348
- text = typeof raw === "string" ? raw : JSON.stringify(raw);
349
- } catch {
350
- text = reply.stdout;
351
- }
352
- if (Buffer.byteLength(text) <= maxOutputBytes) return { text, truncated: false };
353
- // Keep the TAIL, not the head: a terminal's useful content is its most recent output, and the head is
354
- // the startup banner. `runChild` keeps the head because it streams and must stop a runaway producer;
355
- // here the output is already complete, so the choice is free and the tail is the answer.
356
- return { text: text.slice(-maxOutputBytes), truncated: true };
357
- }