pi-daddy 0.14.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 (80) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +37 -14
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +10 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/executor.d.ts +38 -0
  7. package/dist/executor.d.ts.map +1 -0
  8. package/dist/executor.js +93 -0
  9. package/dist/executor.js.map +1 -0
  10. package/dist/grant-store.d.ts +68 -0
  11. package/dist/grant-store.d.ts.map +1 -0
  12. package/dist/grant-store.js +142 -0
  13. package/dist/grant-store.js.map +1 -0
  14. package/dist/herdr-cli.d.ts +78 -0
  15. package/dist/herdr-cli.d.ts.map +1 -0
  16. package/dist/herdr-cli.js +113 -0
  17. package/dist/herdr-cli.js.map +1 -0
  18. package/dist/herdr-name.d.ts +37 -0
  19. package/dist/herdr-name.d.ts.map +1 -0
  20. package/dist/herdr-name.js +59 -0
  21. package/dist/herdr-name.js.map +1 -0
  22. package/dist/herdr-poll.d.ts +104 -0
  23. package/dist/herdr-poll.d.ts.map +1 -0
  24. package/dist/herdr-poll.js +150 -0
  25. package/dist/herdr-poll.js.map +1 -0
  26. package/dist/herdr-stage.d.ts +40 -0
  27. package/dist/herdr-stage.d.ts.map +1 -0
  28. package/dist/herdr-stage.js +54 -0
  29. package/dist/herdr-stage.js.map +1 -0
  30. package/dist/ledger-report.d.ts +18 -0
  31. package/dist/ledger-report.d.ts.map +1 -1
  32. package/dist/ledger-report.js +10 -0
  33. package/dist/ledger-report.js.map +1 -1
  34. package/dist/ledger.d.ts +17 -0
  35. package/dist/ledger.d.ts.map +1 -1
  36. package/dist/ledger.js +1 -0
  37. package/dist/ledger.js.map +1 -1
  38. package/dist/pane-reaper.d.ts +66 -4
  39. package/dist/pane-reaper.d.ts.map +1 -1
  40. package/dist/pane-reaper.js +131 -9
  41. package/dist/pane-reaper.js.map +1 -1
  42. package/dist/progress.d.ts +96 -0
  43. package/dist/progress.d.ts.map +1 -0
  44. package/dist/progress.js +167 -0
  45. package/dist/progress.js.map +1 -0
  46. package/dist/run-child.d.ts +27 -0
  47. package/dist/run-child.d.ts.map +1 -1
  48. package/dist/run-child.js +84 -7
  49. package/dist/run-child.js.map +1 -1
  50. package/dist/run-herdr.d.ts +41 -28
  51. package/dist/run-herdr.d.ts.map +1 -1
  52. package/dist/run-herdr.js +150 -167
  53. package/dist/run-herdr.js.map +1 -1
  54. package/dist/skill-packages.d.ts +16 -0
  55. package/dist/skill-packages.d.ts.map +1 -1
  56. package/dist/skill-packages.js +39 -10
  57. package/dist/skill-packages.js.map +1 -1
  58. package/extensions/delegation.ts +94 -2
  59. package/extensions/grants-command.ts +57 -1
  60. package/extensions/grants.ts +109 -165
  61. package/extensions/init-command.ts +132 -0
  62. package/extensions/run-delegation.ts +70 -8
  63. package/extensions/session-report.ts +231 -0
  64. package/extensions/session.ts +138 -17
  65. package/extensions/tripwire.ts +44 -0
  66. package/package.json +17 -1
  67. package/src/cli.ts +10 -4
  68. package/src/executor.ts +122 -0
  69. package/src/grant-store.ts +151 -0
  70. package/src/herdr-cli.ts +125 -0
  71. package/src/herdr-name.ts +61 -0
  72. package/src/herdr-poll.ts +185 -0
  73. package/src/herdr-stage.ts +55 -0
  74. package/src/ledger-report.ts +21 -0
  75. package/src/ledger.ts +18 -0
  76. package/src/pane-reaper.ts +147 -9
  77. package/src/progress.ts +206 -0
  78. package/src/run-child.ts +96 -7
  79. package/src/run-herdr.ts +170 -174
  80. package/src/skill-packages.ts +41 -10
package/src/run-child.ts CHANGED
@@ -10,8 +10,35 @@
10
10
  */
11
11
 
12
12
  import { spawn } from "node:child_process";
13
+ import { StringDecoder } from "node:string_decoder";
13
14
  import { parseBound } from "./propagation.ts";
14
15
 
16
+ /**
17
+ * The longest prefix of `text` that fits in `budget` BYTES, never splitting a character.
18
+ *
19
+ * Both halves matter. Truncating by `String.prototype.slice` counts UTF-16 code units against a byte budget,
20
+ * which overruns by the encoding width of whatever the child printed. Truncating the *buffer* instead would
21
+ * respect the budget and split a multi-byte character or a surrogate pair, putting a lone `\ud83d` in the
22
+ * result. So the walk is by code POINT (`for…of` iterates code points, keeping surrogate pairs whole) and the
23
+ * budget is checked in bytes before each one is admitted.
24
+ *
25
+ * Exported for the test that feeds it CJK and emoji: an ASCII-only test cannot fail on any of this, which is
26
+ * exactly why the original defect survived a test named for it.
27
+ */
28
+ export function takeBytes(text: string, budget: number): string {
29
+ if (budget <= 0) return "";
30
+ if (Buffer.byteLength(text) <= budget) return text;
31
+ let out = "";
32
+ let used = 0;
33
+ for (const character of text) {
34
+ const size = Buffer.byteLength(character);
35
+ if (used + size > budget) break;
36
+ out += character;
37
+ used += size;
38
+ }
39
+ return out;
40
+ }
41
+
15
42
  /**
16
43
  * Operator override for the child wall-clock limit, in seconds.
17
44
  *
@@ -40,6 +67,20 @@ export interface ChildRunRequest {
40
67
  /** Wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
41
68
  timeoutMs?: number;
42
69
  killGraceMs?: number;
70
+ /**
71
+ * Called with each chunk as it arrives, for the parent's progress display (ADR-0032).
72
+ *
73
+ * **Display only, and the distinction is load-bearing.** The child's answer is still `text`, assembled here
74
+ * and returned; a caller must never treat what it saw streamed as the result. A partial stream that could be
75
+ * mistaken for a complete answer is R-03's defect — a missing result indistinguishable from an empty one —
76
+ * with a new cause.
77
+ *
78
+ * Bounded by the same `maxOutputBytes` as `text`, so the cap governs the transcript and not merely memory.
79
+ *
80
+ * Exceptions are swallowed: a renderer is not a governance control, and one that throws must not kill a
81
+ * governed child mid-task.
82
+ */
83
+ onOutput?: (chunk: string) => void;
43
84
  }
44
85
 
45
86
  export interface ChildRunResult {
@@ -85,6 +126,9 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
85
126
  return;
86
127
  }
87
128
 
129
+ // One decoder for BOTH streams is deliberate: they are already interleaved into one `text`, and two
130
+ // decoders would each hold their own partial character, so a split byte could surface out of order.
131
+ const decoder = new StringDecoder("utf8");
88
132
  let text = "";
89
133
  let bytes = 0;
90
134
  let truncated = false;
@@ -101,20 +145,65 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
101
145
  timers.push(setTimeout(() => child.kill("SIGKILL"), killGraceMs));
102
146
  };
103
147
 
148
+ /** How much of `text` the progress display has already been shown. */
149
+ let emittedUpTo = 0;
150
+
151
+ /**
152
+ * Stream whatever `text` has gained since the last call.
153
+ *
154
+ * **Derived from `text` rather than from the incoming chunk**, and that is the whole correctness argument:
155
+ * what gets streamed is then *by construction* a prefix of what gets returned, so the output cap bounds the
156
+ * parent's transcript exactly as it bounds the result. The first version emitted the raw chunk and pushed
157
+ * 1924 bytes through a 1024-byte cap on the truncating write — the cap bounded memory and not the screen
158
+ * it had just been extended to protect. Found by the test, not by reading it.
159
+ *
160
+ * Swallowing is deliberate: `onOutput` renders, and a renderer that throws must not become a way to kill a
161
+ * governed child. Nothing downstream depends on it having run.
162
+ */
163
+ const flush = () => {
164
+ if (!request.onOutput || text.length <= emittedUpTo) return;
165
+ const chunk = text.slice(emittedUpTo);
166
+ emittedUpTo = text.length;
167
+ try {
168
+ request.onOutput(chunk);
169
+ } catch {
170
+ /* display only */
171
+ }
172
+ };
173
+
104
174
  const capture = (chunk: unknown) => {
105
175
  if (truncated) return;
106
- const s = String(chunk);
107
- bytes += Buffer.byteLength(s);
108
- if (bytes > maxOutputBytes) {
109
- // Keep what fits, mark it, and stop the child: an unbounded producer must not be able to
110
- // exhaust the orchestrator's memory just because it was granted a tool that prints.
111
- text += s;
112
- text = text.slice(0, maxOutputBytes);
176
+ // **Decoded through a StringDecoder, not `String(chunk)`.** A pipe splits on byte boundaries, not
177
+ // character ones, so a multi-byte character straddling two `data` events was decoded as two invalid
178
+ // halves and became U+FFFD — in the child's ANSWER, not merely the display. Measured: 300,000 bytes of
179
+ // CJK produced **twelve** replacement characters, because 65536 % 3 == 1. Emoji happened to survive
180
+ // (65536 % 4 == 0), which is why width-dependent corruption went unnoticed. The decoder holds the
181
+ // partial bytes until the rest arrives.
182
+ const s = decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
183
+ if (s.length === 0) return;
184
+
185
+ const size = Buffer.byteLength(s);
186
+ if (bytes + size > maxOutputBytes) {
187
+ // Keep what fits, mark it, and stop the child: an unbounded producer must not be able to exhaust the
188
+ // orchestrator's memory just because it was granted a tool that prints.
189
+ //
190
+ // **Trimmed by BYTES, and that is a fix rather than a refinement.** This was
191
+ // `text.slice(0, maxOutputBytes)` — a count of UTF-16 code units against a budget measured in bytes,
192
+ // so any non-ASCII output overran the cap by its own encoding width: 300 bytes of CJK through a
193
+ // 100-byte cap, and a measured **2048 bytes through the real 1024 default**. It is the same defect as
194
+ // the 1924-byte one already fixed here, one layer down: that fix bounded the *unit* count, and the
195
+ // budget was never in units. An odd cap also split a surrogate pair, putting a lone `\ud83d` into
196
+ // both the transcript and the returned answer.
197
+ text += takeBytes(s, maxOutputBytes - bytes);
198
+ bytes = maxOutputBytes;
113
199
  truncated = true;
200
+ flush();
114
201
  stop();
115
202
  return;
116
203
  }
204
+ bytes += size;
117
205
  text += s;
206
+ flush();
118
207
  };
119
208
 
120
209
  child.stdout?.on("data", capture);
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
- }
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { readdir, readFile, realpath } from "node:fs/promises";
29
+ import { homedir } from "node:os";
29
30
  import { join, resolve, sep } from "node:path";
30
31
  import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
31
32
  import { WILDCARD } from "./pi-tools.ts";
@@ -219,16 +220,53 @@ export async function readSkillPackage(packageDir: string): Promise<SkillPackage
219
220
  * `node_modules`: a transitive skill package is not something an operator asked to install definitions
220
221
  * from, and scaffolding one into their project would be a surprise wearing a helpful face.
221
222
  */
223
+ /**
224
+ * Every place a pi package can be installed, most specific first (R-75).
225
+ *
226
+ * **Two roots, because pi has two install paths and only one of them is `npm install`.** `pi install
227
+ * npm:principal-pi-skills` — the documented, pi-native way, and the only one that registers the package so
228
+ * pi will auto-load its extension — puts it in `$PI_CODING_AGENT_DIR/npm/node_modules`, and leaves the
229
+ * project without a `node_modules` at all. Searching only `<cwd>/node_modules` therefore found **nothing**
230
+ * for an operator who followed pi's own instructions, and said "install principal-pi-skills" to somebody
231
+ * who just had.
232
+ *
233
+ * Measured, in a fresh `PI_CODING_AGENT_DIR` with an empty project: `pi install` populates the agent root
234
+ * and creates no project root.
235
+ *
236
+ * Project first when both exist, because a package pinned in the repository is the one the team agreed on.
237
+ */
238
+ export function skillPackageRoots(cwd: string): string[] {
239
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
240
+ return [join(cwd, "node_modules"), join(agentDir, "npm", "node_modules")];
241
+ }
242
+
222
243
  export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]> {
223
- const root = join(cwd, "node_modules");
244
+ const dirs: string[] = [];
245
+ const seenNames = new Set<string>();
246
+ for (const root of skillPackageRoots(cwd)) await collectFrom(root, dirs);
247
+
248
+ const packages: SkillPackage[] = [];
249
+ for (const dir of dirs) {
250
+ const found = await readSkillPackage(dir);
251
+ // First root wins on a name collision: the project's pinned copy outranks the machine-wide one, and
252
+ // silently preferring the other would make a committed lockfile stop meaning anything.
253
+ if (found && !seenNames.has(found.name)) {
254
+ seenNames.add(found.name);
255
+ packages.push(found);
256
+ }
257
+ }
258
+ return packages.sort((a, b) => a.name.localeCompare(b.name));
259
+ }
260
+
261
+ /** Append every package directory under one `node_modules`, scoped packages included. */
262
+ async function collectFrom(root: string, dirs: string[]): Promise<void> {
224
263
  let entries: string[];
225
264
  try {
226
265
  entries = await readdir(root);
227
266
  } catch {
228
- return []; // no node_modules is a normal state, not a failure
267
+ return; // a root that does not exist is a normal state, not a failure
229
268
  }
230
269
 
231
- const dirs: string[] = [];
232
270
  for (const entry of entries.sort()) {
233
271
  if (entry.startsWith(".")) continue; // .bin, .package-lock.json
234
272
  if (entry.startsWith("@")) {
@@ -241,11 +279,4 @@ export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]
241
279
  dirs.push(join(root, entry));
242
280
  }
243
281
  }
244
-
245
- const packages: SkillPackage[] = [];
246
- for (const dir of dirs) {
247
- const found = await readSkillPackage(dir);
248
- if (found) packages.push(found);
249
- }
250
- return packages.sort((a, b) => a.name.localeCompare(b.name));
251
282
  }