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.
- package/CHANGELOG.md +123 -0
- package/README.md +37 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -4
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +38 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +93 -0
- package/dist/executor.js.map +1 -0
- package/dist/grant-store.d.ts +68 -0
- package/dist/grant-store.d.ts.map +1 -0
- package/dist/grant-store.js +142 -0
- package/dist/grant-store.js.map +1 -0
- package/dist/herdr-cli.d.ts +78 -0
- package/dist/herdr-cli.d.ts.map +1 -0
- package/dist/herdr-cli.js +113 -0
- package/dist/herdr-cli.js.map +1 -0
- package/dist/herdr-name.d.ts +37 -0
- package/dist/herdr-name.d.ts.map +1 -0
- package/dist/herdr-name.js +59 -0
- package/dist/herdr-name.js.map +1 -0
- package/dist/herdr-poll.d.ts +104 -0
- package/dist/herdr-poll.d.ts.map +1 -0
- package/dist/herdr-poll.js +150 -0
- package/dist/herdr-poll.js.map +1 -0
- package/dist/herdr-stage.d.ts +40 -0
- package/dist/herdr-stage.d.ts.map +1 -0
- package/dist/herdr-stage.js +54 -0
- package/dist/herdr-stage.js.map +1 -0
- package/dist/ledger-report.d.ts +18 -0
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +10 -0
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +17 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +1 -0
- package/dist/ledger.js.map +1 -1
- package/dist/pane-reaper.d.ts +66 -4
- package/dist/pane-reaper.d.ts.map +1 -1
- package/dist/pane-reaper.js +131 -9
- package/dist/pane-reaper.js.map +1 -1
- package/dist/progress.d.ts +96 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +167 -0
- package/dist/progress.js.map +1 -0
- package/dist/run-child.d.ts +27 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +84 -7
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +41 -28
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +150 -167
- package/dist/run-herdr.js.map +1 -1
- package/dist/skill-packages.d.ts +16 -0
- package/dist/skill-packages.d.ts.map +1 -1
- package/dist/skill-packages.js +39 -10
- package/dist/skill-packages.js.map +1 -1
- package/extensions/delegation.ts +94 -2
- package/extensions/grants-command.ts +57 -1
- package/extensions/grants.ts +109 -165
- package/extensions/init-command.ts +132 -0
- package/extensions/run-delegation.ts +70 -8
- package/extensions/session-report.ts +231 -0
- package/extensions/session.ts +138 -17
- package/extensions/tripwire.ts +44 -0
- package/package.json +17 -1
- package/src/cli.ts +10 -4
- package/src/executor.ts +122 -0
- package/src/grant-store.ts +151 -0
- package/src/herdr-cli.ts +125 -0
- package/src/herdr-name.ts +61 -0
- package/src/herdr-poll.ts +185 -0
- package/src/herdr-stage.ts +55 -0
- package/src/ledger-report.ts +21 -0
- package/src/ledger.ts +18 -0
- package/src/pane-reaper.ts +147 -9
- package/src/progress.ts +206 -0
- package/src/run-child.ts +96 -7
- package/src/run-herdr.ts +170 -174
- 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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
33
|
-
|
|
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 `
|
|
156
|
-
//
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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)
|
|
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 &&
|
|
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
|
-
//
|
|
197
|
-
//
|
|
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
|
-
/**
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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,
|
|
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",
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
?
|
|
248
|
-
:
|
|
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
|
-
}
|
package/src/skill-packages.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|