pi-daddy 0.13.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 (105) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/LICENSE +21 -0
  3. package/README.md +584 -0
  4. package/dist/approval-prompt.d.ts +116 -0
  5. package/dist/approval-prompt.d.ts.map +1 -0
  6. package/dist/approval-prompt.js +124 -0
  7. package/dist/approval-prompt.js.map +1 -0
  8. package/dist/approval-store.d.ts +149 -0
  9. package/dist/approval-store.d.ts.map +1 -0
  10. package/dist/approval-store.js +311 -0
  11. package/dist/approval-store.js.map +1 -0
  12. package/dist/approval.d.ts +221 -0
  13. package/dist/approval.d.ts.map +1 -0
  14. package/dist/approval.js +244 -0
  15. package/dist/approval.js.map +1 -0
  16. package/dist/capabilities.d.ts +31 -0
  17. package/dist/capabilities.d.ts.map +1 -0
  18. package/dist/capabilities.js +45 -0
  19. package/dist/capabilities.js.map +1 -0
  20. package/dist/catalog.d.ts +81 -0
  21. package/dist/catalog.d.ts.map +1 -0
  22. package/dist/catalog.js +164 -0
  23. package/dist/catalog.js.map +1 -0
  24. package/dist/definitions.d.ts +101 -0
  25. package/dist/definitions.d.ts.map +1 -0
  26. package/dist/definitions.js +182 -0
  27. package/dist/definitions.js.map +1 -0
  28. package/dist/delegate.d.ts +133 -0
  29. package/dist/delegate.d.ts.map +1 -0
  30. package/dist/delegate.js +261 -0
  31. package/dist/delegate.js.map +1 -0
  32. package/dist/fanout.d.ts +62 -0
  33. package/dist/fanout.d.ts.map +1 -0
  34. package/dist/fanout.js +84 -0
  35. package/dist/fanout.js.map +1 -0
  36. package/dist/file-lock.d.ts +54 -0
  37. package/dist/file-lock.d.ts.map +1 -0
  38. package/dist/file-lock.js +142 -0
  39. package/dist/file-lock.js.map +1 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +7 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/ledger-report.d.ts +106 -0
  45. package/dist/ledger-report.d.ts.map +1 -0
  46. package/dist/ledger-report.js +157 -0
  47. package/dist/ledger-report.js.map +1 -0
  48. package/dist/ledger.d.ts +139 -0
  49. package/dist/ledger.d.ts.map +1 -0
  50. package/dist/ledger.js +94 -0
  51. package/dist/ledger.js.map +1 -0
  52. package/dist/pane-reaper.d.ts +49 -0
  53. package/dist/pane-reaper.d.ts.map +1 -0
  54. package/dist/pane-reaper.js +120 -0
  55. package/dist/pane-reaper.js.map +1 -0
  56. package/dist/pi-tools.d.ts +20 -0
  57. package/dist/pi-tools.d.ts.map +1 -0
  58. package/dist/pi-tools.js +21 -0
  59. package/dist/pi-tools.js.map +1 -0
  60. package/dist/propagation.d.ts +185 -0
  61. package/dist/propagation.d.ts.map +1 -0
  62. package/dist/propagation.js +255 -0
  63. package/dist/propagation.js.map +1 -0
  64. package/dist/resolve.d.ts +110 -0
  65. package/dist/resolve.d.ts.map +1 -0
  66. package/dist/resolve.js +181 -0
  67. package/dist/resolve.js.map +1 -0
  68. package/dist/run-child.d.ts +50 -0
  69. package/dist/run-child.d.ts.map +1 -0
  70. package/dist/run-child.js +109 -0
  71. package/dist/run-child.js.map +1 -0
  72. package/dist/run-herdr.d.ts +91 -0
  73. package/dist/run-herdr.d.ts.map +1 -0
  74. package/dist/run-herdr.js +306 -0
  75. package/dist/run-herdr.js.map +1 -0
  76. package/dist/spawn.d.ts +60 -0
  77. package/dist/spawn.d.ts.map +1 -0
  78. package/dist/spawn.js +116 -0
  79. package/dist/spawn.js.map +1 -0
  80. package/extensions/approvals.ts +285 -0
  81. package/extensions/delegation.ts +237 -0
  82. package/extensions/grants-command.ts +310 -0
  83. package/extensions/grants.ts +346 -0
  84. package/extensions/run-delegation.ts +270 -0
  85. package/extensions/session.ts +234 -0
  86. package/package.json +122 -0
  87. package/src/approval-prompt.ts +222 -0
  88. package/src/approval-store.ts +383 -0
  89. package/src/approval.ts +364 -0
  90. package/src/capabilities.ts +51 -0
  91. package/src/catalog.ts +186 -0
  92. package/src/definitions.ts +233 -0
  93. package/src/delegate.ts +383 -0
  94. package/src/fanout.ts +96 -0
  95. package/src/file-lock.ts +142 -0
  96. package/src/index.ts +59 -0
  97. package/src/ledger-report.ts +227 -0
  98. package/src/ledger.ts +214 -0
  99. package/src/pane-reaper.ts +132 -0
  100. package/src/pi-tools.ts +25 -0
  101. package/src/propagation.ts +308 -0
  102. package/src/resolve.ts +235 -0
  103. package/src/run-child.ts +149 -0
  104. package/src/run-herdr.ts +357 -0
  105. package/src/spawn.ts +156 -0
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Run a governed child process under hard limits.
3
+ *
4
+ * G8 (review findings A-R1/B-I5, A-R3). `delegate` used to spawn `pi` with no output cap, no timeout,
5
+ * an abort listener attached too late to observe an already-aborted signal, and a non-zero exit reported
6
+ * to the model as an ordinary result. Each of those is a way for a child to outlive or overwhelm the
7
+ * orchestrator that is supposed to be governing it — which is the whole premise of this package.
8
+ *
9
+ * It lives here, out of `extensions/grants.ts`, so it can be tested against real processes without pi.
10
+ */
11
+
12
+ import { spawn } from "node:child_process";
13
+ import { parseBound } from "./propagation.ts";
14
+
15
+ /**
16
+ * Operator override for the child wall-clock limit, in seconds.
17
+ *
18
+ * Deliberately NOT in `GRANT_ENV_KEYS`: those are stripped from a child's environment and re-supplied
19
+ * only by the spawn plan, which is right for capability state and wrong for an operator preference. This
20
+ * one should simply inherit, so a bound set at the root applies all the way down.
21
+ */
22
+ export const ENV_CHILD_TIMEOUT = "PI_GRANTS_CHILD_TIMEOUT";
23
+
24
+ /** Read the override, falling back to the default on absent *or* malformed input (G7's rule). */
25
+ export function timeoutFromEnv(raw: string | undefined): number {
26
+ const seconds = parseBound(raw);
27
+ // `null` (malformed) and `0` both fall back rather than disabling the limit: a timeout that can be
28
+ // switched off by a typo is the A-S4 defect wearing different clothes.
29
+ return seconds === undefined || seconds === null || seconds === 0 ? DEFAULT_TIMEOUT_MS : seconds * 1000;
30
+ }
31
+
32
+ export interface ChildRunRequest {
33
+ command: string;
34
+ args: string[];
35
+ env: NodeJS.ProcessEnv;
36
+ cwd: string;
37
+ signal?: AbortSignal;
38
+ /** Hard cap on captured output. Beyond it the child is killed and the result flagged. */
39
+ maxOutputBytes?: number;
40
+ /** Wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
41
+ timeoutMs?: number;
42
+ killGraceMs?: number;
43
+ }
44
+
45
+ export interface ChildRunResult {
46
+ /** Exit code, or `null` when nothing was spawned or the child was killed by a signal. */
47
+ code: number | null;
48
+ text: string;
49
+ truncated: boolean;
50
+ timedOut: boolean;
51
+ aborted: boolean;
52
+ /** Set when the process could not be started at all. */
53
+ spawnError?: string;
54
+ }
55
+
56
+ /** 1 MiB. A delegation returns a summary; anything larger is a runaway, not an answer. */
57
+ export const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
58
+ /** 10 minutes. Long enough for a real sub-agent task, short enough that a hang is not forever. */
59
+ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
60
+ /** Grace between SIGTERM and SIGKILL. A child that ignores SIGTERM must not make the timeout advisory. */
61
+ export const DEFAULT_KILL_GRACE_MS = 5000;
62
+
63
+ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
64
+ const maxOutputBytes = request.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
65
+ const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
66
+ const killGraceMs = request.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
67
+
68
+ // A-R3: checked BEFORE spawning. `AbortSignal` does not replay, so a listener attached after an
69
+ // `await` cannot observe an abort that already happened — and the child would then run to completion
70
+ // outside the cancellation that was supposed to stop it. Nothing is started at all here.
71
+ if (request.signal?.aborted) {
72
+ return Promise.resolve({ code: null, text: "", truncated: false, timedOut: false, aborted: true });
73
+ }
74
+
75
+ return new Promise<ChildRunResult>((settle) => {
76
+ let child: ReturnType<typeof spawn>;
77
+ try {
78
+ child = spawn(request.command, request.args, {
79
+ env: request.env,
80
+ cwd: request.cwd,
81
+ stdio: ["ignore", "pipe", "pipe"],
82
+ });
83
+ } catch (error) {
84
+ settle({ code: null, text: "", truncated: false, timedOut: false, aborted: false, spawnError: String(error) });
85
+ return;
86
+ }
87
+
88
+ let text = "";
89
+ let bytes = 0;
90
+ let truncated = false;
91
+ let timedOut = false;
92
+ let aborted = false;
93
+ let done = false;
94
+
95
+ const timers: NodeJS.Timeout[] = [];
96
+ const clearTimers = () => timers.forEach(clearTimeout);
97
+
98
+ /** SIGTERM, then SIGKILL if the child is still alive — so a handler cannot ignore its way out. */
99
+ const stop = () => {
100
+ child.kill("SIGTERM");
101
+ timers.push(setTimeout(() => child.kill("SIGKILL"), killGraceMs));
102
+ };
103
+
104
+ const capture = (chunk: unknown) => {
105
+ 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);
113
+ truncated = true;
114
+ stop();
115
+ return;
116
+ }
117
+ text += s;
118
+ };
119
+
120
+ child.stdout?.on("data", capture);
121
+ child.stderr?.on("data", capture);
122
+
123
+ timers.push(
124
+ setTimeout(() => {
125
+ timedOut = true;
126
+ stop();
127
+ }, timeoutMs),
128
+ );
129
+
130
+ const onAbort = () => {
131
+ aborted = true;
132
+ stop();
133
+ };
134
+ request.signal?.addEventListener("abort", onAbort, { once: true });
135
+
136
+ const finish = (result: ChildRunResult) => {
137
+ if (done) return;
138
+ done = true;
139
+ clearTimers();
140
+ request.signal?.removeEventListener("abort", onAbort);
141
+ settle(result);
142
+ };
143
+
144
+ child.on("error", (error) =>
145
+ finish({ code: null, text, truncated, timedOut, aborted, spawnError: String(error) }),
146
+ );
147
+ child.on("close", (code) => finish({ code, text, truncated, timedOut, aborted }));
148
+ });
149
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Run a governed child in a herdr pane — ADR-0016 point 6.
3
+ *
4
+ * The second executor for the same plan. `runChild` spawns `pi` directly and captures its stdout;
5
+ * `runHerdrPane` asks herdr to launch it in a visible, attachable terminal pane. `planSpawn` produces the
6
+ * argv either way, so the grant is identical and only the *place it runs* differs.
7
+ *
8
+ * **Why go through herdr's CLI rather than the third-party `pi-herdr` extension.** That extension exposes
9
+ * `agentArgs` and `env` as MODEL-facing tool parameters (R-30), which hands a model an argv array and the
10
+ * environment variable the grant travels on. Here the model chooses a definition and a task; this package
11
+ * builds the argv. Measured facts this relies on (`docs/probes/g16-herdr`):
12
+ *
13
+ * - `herdr agent start … -- <args>` delivers argv **verbatim**, echoed back in the reply.
14
+ * - `--tools` is enforced inside a pane exactly as it is for a direct spawn; `--no-tools` yields none.
15
+ * - `herdr agent start` has **no `--env`**, but `tab create` / `pane split` do, and a pane's environment
16
+ * reaches the shell that launches the agent — verified by reading `$PI_GRANTS_GRANT` back out of a
17
+ * pane created with it. That is how the grant, depth and ledger path propagate on this path.
18
+ *
19
+ * **What a pane is not: a boundary.** It is a terminal. `--tools` remains the enforcement point, ADR-0012's
20
+ * `bash` escape is unchanged, and a pane is *attachable by design*, so a human can type into a governed
21
+ * child. Humans are not this project's threat model, but nothing here should be read as containing one.
22
+ */
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";
28
+ import type { ChildRunResult } from "./run-child.ts";
29
+ import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS } from "./run-child.ts";
30
+ import { trackPane, untrackPane } from "./pane-reaper.ts";
31
+
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 }>;
34
+
35
+ export interface HerdrRunRequest {
36
+ /** `planSpawn` args **without** the prompt — see `prompt`. */
37
+ args: string[];
38
+ /**
39
+ * The task, delivered with `herdr agent prompt` rather than as an argv element.
40
+ *
41
+ * This is strictly safer than the direct-spawn path, which has to defend a model-authored string from
42
+ * pi's argv parser by prefixing a space (`neutralisePrompt`, `docs/probes/g1-argv`). Here the task never
43
+ * reaches argv at all, so there is no parser in front of it.
44
+ */
45
+ prompt: string;
46
+ /** Grant/depth/ledger variables. Set on the PANE, which the agent's shell inherits. */
47
+ env: Record<string, string>;
48
+ cwd: string;
49
+ /** Unique pane and agent name. */
50
+ name: string;
51
+ /** herdr workspace to create the tab in. Omitted lets herdr choose. */
52
+ workspace?: string;
53
+ signal?: AbortSignal;
54
+ timeoutMs?: number;
55
+ maxOutputBytes?: number;
56
+ /**
57
+ * Leave the pane open after the run so a human can read or resume it.
58
+ *
59
+ * Default **false**: a fan-out that leaks a pane per child fills the operator's workspace, and
60
+ * `docs/probes/g16-herdr` records that panes are not trivially closable once orphaned.
61
+ */
62
+ keepPane?: boolean;
63
+ exec?: HerdrExec;
64
+ }
65
+
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
+
91
+ /** How often to poll `agent get` while waiting for the child to settle. */
92
+ export const POLL_INTERVAL_MS = 750;
93
+ /** How often to retry `agent start` while a freshly created pane is still reaching its shell prompt. */
94
+ export const PANE_READY_POLL_MS = 300;
95
+
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
+
124
+ /**
125
+ * Run one governed child in a pane and return its output.
126
+ *
127
+ * Deliberately returns `ChildRunResult` — the same shape as `runChild` — so the extension can choose an
128
+ * executor without knowing which one it got.
129
+ */
130
+ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunResult> {
131
+ const exec = request.exec ?? defaultExec;
132
+ const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
133
+ const maxOutputBytes = request.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
134
+ const deadline = Date.now() + timeoutMs;
135
+ const empty: ChildRunResult = { code: null, text: "", truncated: false, timedOut: false, aborted: false };
136
+
137
+ // A-R3's rule, kept on this path too: checked BEFORE anything is created. An `AbortSignal` does not
138
+ // replay, so a check after the first `await` cannot see an abort that already happened — and we would
139
+ // have left a pane behind for a run that was never wanted.
140
+ if (request.signal?.aborted) return { ...empty, aborted: true };
141
+
142
+ // `--print` makes pi process the prompt and exit, so it never reaches the interactive readiness that
143
+ // `herdr agent start` waits for — the agent is simply never detected. Found by the first end-to-end run,
144
+ // which failed inside herdr with an empty reply. Caught here instead, naming the flag, because a
145
+ // mismatch between a plan and its executor is a caller bug and should read like one.
146
+ if (request.args.includes("--print") || request.args.includes("-p")) {
147
+ return {
148
+ ...empty,
149
+ spawnError:
150
+ "herdr needs an INTERACTIVE agent, but the plan contains --print, which makes pi exit immediately " +
151
+ "and never be detected. Build the plan with `print: false` for this executor.",
152
+ };
153
+ }
154
+
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
+ }
170
+
171
+ const create = ["tab", "create", "--label", request.name, "--cwd", request.cwd];
172
+ if (request.workspace) create.push("--workspace", request.workspace);
173
+ for (const [key, value] of Object.entries(request.env)) create.push("--env", `${key}=${value}`);
174
+
175
+ const created = parseReply(await exec(create));
176
+ if (created.error) return { ...empty, spawnError: `herdr tab create failed: ${created.error}` };
177
+
178
+ const rootPane = (created.result?.root_pane ?? {}) as { pane_id?: string; tab_id?: string };
179
+ const paneId = rootPane.pane_id;
180
+ const tabId = rootPane.tab_id;
181
+ // **Tracked BEFORE the pane-id check, not after.** A tab can exist from the moment this reply is parsed,
182
+ // so registering later leaves a window — one herdr round-trip wide — in which a killed process orphans a
183
+ // tab nothing would reap. The normal path had no such window and the error path did, which is backwards:
184
+ // 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 });
186
+
187
+ if (!paneId) {
188
+ // The tab may exist even though the reply carried no pane id, and this return used to be BEFORE
189
+ // `cleanup` was defined — so the one path where herdr half-succeeded was the one that leaked a tab.
190
+ if (tabId && !request.keepPane) {
191
+ const reply = await exec(["tab", "close", tabId]).catch(() => undefined);
192
+ // Same rule as `cleanup`: untrack only what is provably gone, so a close herdr refused stays the
193
+ // reaper's problem rather than being dropped on the assumption that it worked.
194
+ if (reply !== undefined && !parseReply(reply).error) untrackPane(tabId);
195
+ }
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.
199
+ if (promptDir && !request.keepPane) await rm(promptDir, { recursive: true, force: true }).catch(() => undefined);
200
+ return { ...empty, spawnError: "herdr tab create returned no pane id" };
201
+ }
202
+
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
+ // 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
+ };
222
+
223
+ try {
224
+ const started = await startAgent(exec, request.name, paneId, effectiveArgs, deadline);
225
+ if (started.error) return { ...empty, spawnError: `herdr agent start failed: ${started.error}` };
226
+
227
+ // The state counter BEFORE prompting is what makes the wait correct — see the R-33 note below.
228
+ const before = seqOf(started.result);
229
+
230
+ const prompted = parseReply(await exec(["agent", "prompt", request.name, request.prompt]));
231
+ if (prompted.error) return { ...empty, spawnError: `herdr agent prompt failed: ${prompted.error}` };
232
+
233
+ const settled = await waitForSettled(exec, request, before, deadline);
234
+ if (settled.aborted || settled.timedOut) {
235
+ // Still read: a timed-out child usually produced something, and a partial answer labelled partial is
236
+ // 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 };
239
+ }
240
+ if (settled.spawnError) return { ...empty, spawnError: settled.spawnError };
241
+
242
+ const out = await readPane(exec, request.name, maxOutputBytes);
243
+ return {
244
+ code: settled.status === "blocked" ? 1 : 0,
245
+ text:
246
+ settled.status === "blocked"
247
+ ? `${out.text}\n\n[grants] this agent is BLOCKED waiting for a human in pane ${paneId}.`
248
+ : out.text,
249
+ truncated: out.truncated,
250
+ timedOut: false,
251
+ aborted: false,
252
+ };
253
+ } finally {
254
+ await cleanup();
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Start the agent, retrying while the pane is still coming up.
260
+ *
261
+ * **Measured, and only visible once automated.** A pane created by `tab create` is not immediately at a
262
+ * shell prompt, and `herdr agent start` requires one — it fails with
263
+ * `agent_pane_busy: … is not an available shell`. Driving the two commands by hand hid this completely,
264
+ * because the think-time between them was longer than the shell took to start; the first scripted run hit
265
+ * it every time.
266
+ *
267
+ * Retried rather than preceded by a fixed sleep: a sleep long enough for a loaded machine is wasted on
268
+ * every spawn, and a fan-out pays it per child. Only the busy condition is retried — any other error is a
269
+ * real failure and returns immediately.
270
+ */
271
+ async function startAgent(
272
+ exec: HerdrExec,
273
+ name: string,
274
+ paneId: string,
275
+ args: string[],
276
+ deadline: number,
277
+ ): Promise<{ result?: Record<string, unknown>; error?: string }> {
278
+ for (;;) {
279
+ const reply = parseReply(await exec(["agent", "start", name, "--kind", "pi", "--pane", paneId, "--", ...args]));
280
+ if (!reply.error) return reply;
281
+ const busy = /not an available shell|agent_pane_busy/.test(reply.error);
282
+ if (!busy || Date.now() >= deadline) return reply;
283
+ await new Promise((r) => setTimeout(r, PANE_READY_POLL_MS));
284
+ }
285
+ }
286
+
287
+ function seqOf(result: Record<string, unknown> | undefined): number {
288
+ const agent = (result?.agent ?? {}) as { state_change_seq?: number };
289
+ return typeof agent.state_change_seq === "number" ? agent.state_change_seq : -1;
290
+ }
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
+ }
package/src/spawn.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Build the argv for a governed descendant pi process.
3
+ *
4
+ * The enforcement point is pi core, not this package: `--tools` and `--no-tools` hard-block extension
5
+ * tools, and an explicitly `-e`-loaded extension cannot re-add its tool past them (verified,
6
+ * docs/probes/pi-fabric-eval probes 9–11). So governance reduces to "compute the allowlist correctly
7
+ * and hand it to pi", with no runtime inside the descendant.
8
+ */
9
+
10
+ import { toPiToolsAllowlist, type Capability } from "./resolve.ts";
11
+
12
+ export interface SpawnPlanInput {
13
+ effective: Capability[];
14
+ prompt: string;
15
+ model?: string;
16
+ provider?: string;
17
+ thinking?: string;
18
+ /** Session file path, or omit for an ephemeral child. */
19
+ sessionFile?: string;
20
+ /** Non-interactive by default: a governed child should not prompt a human. */
21
+ print?: boolean;
22
+ /**
23
+ * Absolute path per skill NAME (`review` -> `/…/review`), as the catalog discovered them.
24
+ *
25
+ * R-32. Only skills named by a `skill:` capability in `effective` are passed, and a granted skill
26
+ * absent from this map is reported in `unresolvedSkills` rather than dropped.
27
+ */
28
+ skillPaths?: Record<string, string>;
29
+ /**
30
+ * Let the child load `AGENTS.md` / `CLAUDE.md`. **Default false** — see the `--no-context-files`
31
+ * note in `planSpawn`.
32
+ */
33
+ contextFiles?: boolean;
34
+ /**
35
+ * Instructions appended to the child's system prompt — a definition's `SKILL.md` body (ADR-0016).
36
+ *
37
+ * Appended rather than replacing, so pi's own coding-assistant prompt survives underneath and a
38
+ * definition only has to describe its own job.
39
+ */
40
+ systemPrompt?: string;
41
+ }
42
+
43
+ export interface SpawnPlan {
44
+ args: string[];
45
+ /** The tool names pi will allow, for the ledger. `null` means no tools at all. */
46
+ allowlist: string[] | null;
47
+ /** Skill paths handed to the child, for the ledger. */
48
+ skills: string[];
49
+ /**
50
+ * Granted `skill:` capabilities with no known path. **Non-empty means the caller should refuse**:
51
+ * the child would silently lack a capability its grant says it holds.
52
+ */
53
+ unresolvedSkills: Capability[];
54
+ }
55
+
56
+ /**
57
+ * Produce `pi` arguments enforcing `effective`.
58
+ *
59
+ * Note the `--no-tools` branch: pi rejects an empty `--tools` list, and passing no flag at all would
60
+ * silently fall back to pi's defaults — which is the opposite of a zero grant. An empty grant must be
61
+ * expressed explicitly.
62
+ */
63
+ export function planSpawn(input: SpawnPlanInput): SpawnPlan {
64
+ const allowlist = toPiToolsAllowlist(input.effective);
65
+ const args: string[] = [];
66
+
67
+ if (input.print !== false) args.push("--print");
68
+ if (input.provider) args.push("--provider", input.provider);
69
+ if (input.model) args.push("--model", input.model);
70
+ if (input.thinking) args.push("--thinking", input.thinking);
71
+ if (input.sessionFile) args.push("--session", input.sessionFile);
72
+ else args.push("--no-session");
73
+
74
+ // Disable discovery so ambient user extensions cannot widen a governed child's surface. Explicit
75
+ // `-e` paths would still load, so this package never passes one.
76
+ args.push("--no-extensions");
77
+
78
+ // R-32. `--no-extensions` governs EXTENSIONS ONLY — measured, not assumed: a child spawned with
79
+ // `--tools read` still loaded all eight of the operator's skills and `CLAUDE.md`
80
+ // (`docs/probes/g16-herdr` §4-5). Skills are injected into the system prompt rather than passed as
81
+ // tools, so `--tools` cannot reach them and the `skill:` namespace enforced nothing at all.
82
+ //
83
+ // `--no-skills` is unconditional and `--skill` is added on top, because that is exactly how pi
84
+ // resolves them (`dist/core/resource-loader.js:329`): with `noSkills` set, discovered skills are
85
+ // dropped and explicitly-passed paths are kept. Passing `--skill` WITHOUT `--no-skills` would add to
86
+ // the discovered set instead of replacing it — an allowlist that widens, which is the failure
87
+ // direction this package exists to prevent.
88
+ const skills: string[] = [];
89
+ const unresolvedSkills: Capability[] = [];
90
+ for (const capability of input.effective) {
91
+ if (!capability.startsWith("skill:")) continue;
92
+ const path = input.skillPaths?.[capability.slice(6)];
93
+ if (path) skills.push(path);
94
+ else unresolvedSkills.push(capability);
95
+ }
96
+ args.push("--no-skills");
97
+ for (const path of skills) args.push("--skill", path);
98
+
99
+ // Context files are model-directing text that NO capability describes and no ledger line records, so
100
+ // a child inheriting them holds influence its grant does not express. Under ADR-0012's threat model
101
+ // — prompt injection explicitly in scope — an untrusted repository's `CLAUDE.md` reaching a governed
102
+ // child is the injection vector. Off by default therefore, but deliberately still expressible:
103
+ // inheriting a project's conventions is often exactly what an operator wants, and that should be a
104
+ // decision they make rather than one this package makes silently in either direction.
105
+ if (!input.contextFiles) args.push("--no-context-files");
106
+
107
+ // Prompt templates are the third resource class that `--no-extensions` does not cover — found in the
108
+ // banner of the very run that verified the two above. Lower risk than skills (a template is expanded
109
+ // when a human invokes `/name`, not injected into the system prompt) but withheld for consistency:
110
+ // under a herdr backend a governed child runs in an attachable pane WITH a human, so "only what the
111
+ // grant names" must not depend on which executor ran it.
112
+ args.push("--no-prompt-templates");
113
+
114
+ // A definition's body, so the child IS the reviewer rather than merely holding a reviewer's tools.
115
+ // Note this is operator-authored text from a file on disk, not a model-chosen string — the model
116
+ // picks WHICH definition, never its contents. That is what keeps it out of `neutralisePrompt`'s
117
+ // remit: the G1 hazard is a model-controlled string reaching a parser, and this is not one.
118
+ if (input.systemPrompt) args.push("--append-system-prompt", input.systemPrompt);
119
+
120
+ if (allowlist) args.push("--tools", allowlist.join(","));
121
+ else args.push("--no-tools");
122
+
123
+ args.push(neutralisePrompt(input.prompt));
124
+ return { args, allowlist, skills, unresolvedSkills };
125
+ }
126
+
127
+ /**
128
+ * Make a model-controlled task unparseable by pi's CLI.
129
+ *
130
+ * pi's usage is `pi [options] [@files...] [messages...]`, and both of the non-message forms are reached
131
+ * by the FIRST CHARACTER of an argv element:
132
+ *
133
+ * - `@…` is resolved as a file and its contents injected into the child's prompt — absolute paths, `~`
134
+ * expansion, no sandbox. This happens in `main.js` before any tool is constructed, so `--tools` and
135
+ * `--no-tools` never apply to it. A child granted nothing at all still reads the file. Verified
136
+ * against pi 0.83.0 (review finding A-C1 / B-C7, and `docs/probes/g1-argv`).
137
+ * - `-…` is parsed as a flag, and pi ships `--approve` ("trust project-local files for this run").
138
+ *
139
+ * The task comes from the model, so this is the one place in the package where a model-authored string
140
+ * would otherwise reach a parser that outranks the enforcement point. A single leading space removes it
141
+ * from both branches: pi does not trim before dispatching on the prefix (measured), and a leading space
142
+ * is semantically nil inside a prompt.
143
+ *
144
+ * **It is applied unconditionally, and that is the point.** Rewriting only arguments that start with
145
+ * `@` or `-` would encode pi 0.83.0's current parser into this package and silently re-open the hole
146
+ * the moment pi — or an extension registering its own flags — adds a third prefix. The guarantee here is
147
+ * positional rather than pattern-based: the first character of that argv element is never the task's.
148
+ *
149
+ * Deliberately NOT a refusal. The review also suggested rejecting `@`/`-` tasks in `planDelegation` so
150
+ * they land in the ledger. Neutralising costs no false refusals and needs no judgement about which
151
+ * prompts are legitimate, so it is the whole fix; recording an attempt is an audit question (G6), not a
152
+ * security one, and is left to that group.
153
+ */
154
+ function neutralisePrompt(prompt: string): string {
155
+ return ` ${prompt}`;
156
+ }