@fyeeme/pi-review 1.0.0 → 1.0.2

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.
@@ -1,353 +0,0 @@
1
- /**
2
- * src/agent/dispatch.ts — agent dispatch via pi subprocess.
3
- *
4
- * Reuses the spawn pattern from examples/extensions/subagent and
5
- * pi-dynamic-workflows/src/agent/dispatch.ts: one `pi --mode json -p
6
- * --no-session` subprocess per agent call, stdout parsed for
7
- * {message_end, tool_result_end} events, AbortSignal → SIGTERM with a
8
- * 5s SIGKILL escalation.
9
- *
10
- * This is a SELF-CONTAINED copy (no pi-dynamic-workflows dependency): the
11
- * workflow-engine retry/skip/lifecycle machinery is stripped, leaving only
12
- * spawnAgent + mapWithConcurrencyLimit + a per-call abort registry. The
13
- * subagent tool builds its single/parallel/chain modes on top of this.
14
- *
15
- * When pi promotes spawnAgent to a public @earendil-works/pi-coding-agent
16
- * export, this file should be deleted in favor of that import.
17
- */
18
- import { spawn, type ChildProcess } from "node:child_process";
19
- import { StringDecoder } from "node:string_decoder";
20
- import * as fs from "node:fs";
21
- import * as os from "node:os";
22
- import * as path from "node:path";
23
- import type { Message } from "@earendil-works/pi-ai";
24
-
25
- /** Stable id for one agent call; the registry key for per-call abort. */
26
- export type AgentCallId = string;
27
-
28
- /** callId → per-call AbortController. */
29
- export type AgentAbortMap = Map<AgentCallId, AbortController>;
30
-
31
- // ---------------------------------------------------------------------------
32
- // Concurrency limiter (ported from examples/extensions/subagent)
33
- // ---------------------------------------------------------------------------
34
-
35
- /**
36
- * Run `fn` over `items` with at most `concurrency` in flight, preserving
37
- * input order in the output array. parallel mode builds on this.
38
- */
39
- export async function mapWithConcurrencyLimit<TIn, TOut>(
40
- items: TIn[],
41
- concurrency: number,
42
- fn: (item: TIn, index: number) => Promise<TOut>,
43
- ): Promise<TOut[]> {
44
- if (items.length === 0) return [];
45
- const limit = Math.max(1, Math.min(concurrency, items.length));
46
- const results: TOut[] = new Array(items.length);
47
- let nextIndex = 0;
48
- // Stop dispatching NEW items once any worker has errored, so a rejection
49
- // doesn't leave sibling workers pulling more items and spawning unawaited
50
- // subprocesses. In-flight calls finish; the failing worker rethrows.
51
- let failed = false;
52
- const workers = new Array(limit).fill(null).map(async () => {
53
- while (!failed) {
54
- const current = nextIndex++;
55
- if (current >= items.length) return;
56
- try {
57
- results[current] = await fn(items[current], current);
58
- } catch (err) {
59
- failed = true;
60
- throw err;
61
- }
62
- }
63
- });
64
- await Promise.all(workers);
65
- return results;
66
- }
67
-
68
- // ---------------------------------------------------------------------------
69
- // pi binary resolution (ported from examples/extensions/subagent)
70
- // ---------------------------------------------------------------------------
71
-
72
- /**
73
- * Resolve the `pi` invocation for the subprocess. Prefers re-entering the
74
- * current script (node <script> / bun <script>); falls back to the `pi`
75
- * binary on PATH when run under a generic runtime.
76
- */
77
- export function getPiInvocation(args: string[]): { command: string; args: string[] } {
78
- const currentScript = process.argv[1];
79
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
80
- if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
81
- return { command: process.execPath, args: [currentScript, ...args] };
82
- }
83
-
84
- const execName = path.basename(process.execPath).toLowerCase();
85
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
86
- if (!isGenericRuntime) {
87
- return { command: process.execPath, args };
88
- }
89
-
90
- return { command: "pi", args };
91
- }
92
-
93
- // ---------------------------------------------------------------------------
94
- // Usage + result
95
- // ---------------------------------------------------------------------------
96
-
97
- export interface AgentUsage {
98
- input: number;
99
- output: number;
100
- cacheRead: number;
101
- cacheWrite: number;
102
- cost: number;
103
- contextTokens: number;
104
- turns: number;
105
- }
106
-
107
- export interface AgentSpawnOptions {
108
- /** Stable id for this call; the registry key for per-call abort. */
109
- readonly callId: AgentCallId;
110
- /** Prompt passed as the final positional arg to `pi -p`. */
111
- readonly task: string;
112
- /** Working directory for the spawned pi process. Defaults to process.cwd(). */
113
- readonly cwd?: string;
114
- /** `--model` override. */
115
- readonly model?: string;
116
- /** `--tools` whitelist (comma-joined). */
117
- readonly tools?: string[];
118
- /** System prompt appended via a temp file (`--append-system-prompt`). */
119
- readonly systemPrompt?: string;
120
- /** Caller-level abort signal; linked to this call's per-call controller. */
121
- readonly signal?: AbortSignal;
122
- /** Max assistant turns. When reached, the subprocess is aborted (SIGTERM). Omit for unlimited. */
123
- readonly maxTurns?: number;
124
- }
125
-
126
- export interface AgentSpawnResult {
127
- callId: AgentCallId;
128
- exitCode: number;
129
- messages: Message[];
130
- stderr: string;
131
- usage: AgentUsage;
132
- model?: string;
133
- stopReason?: string;
134
- errorMessage?: string;
135
- /** True if aborted. exitCode may be null/non-zero. */
136
- aborted: boolean;
137
- /** True if killed because the caller's maxTurns budget was reached. Distinct
138
- * from `aborted` (external cancel): the agent did useful bounded work. */
139
- maxTurnsReached: boolean;
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // Registry — Map<callId, ChildProcess> + per-call AbortController
144
- // ---------------------------------------------------------------------------
145
-
146
- export interface AgentSpawnRegistry {
147
- /** callId → child process. The table that translates abort → SIGTERM on one process. */
148
- readonly processes: Map<AgentCallId, ChildProcess>;
149
- /** callId → per-call controller. */
150
- readonly controllers: AgentAbortMap;
151
- }
152
-
153
- export function createSpawnRegistry(): AgentSpawnRegistry {
154
- return {
155
- processes: new Map(),
156
- controllers: new Map(),
157
- };
158
- }
159
-
160
- /**
161
- * Abort exactly one in-flight call by id. Aborts the call's per-call
162
- * controller; spawnAgent's race-safe listener translates that into
163
- * SIGTERM→SIGKILL on exactly the one subprocess. Returns false if the
164
- * callId is not in flight.
165
- */
166
- export function abortAgent(registry: AgentSpawnRegistry, callId: AgentCallId): boolean {
167
- const controller = registry.controllers.get(callId);
168
- if (!controller) return false;
169
- controller.abort();
170
- return true;
171
- }
172
-
173
- // ---------------------------------------------------------------------------
174
- // Spawn — the dispatch primitive
175
- // ---------------------------------------------------------------------------
176
-
177
- export async function spawnAgent(
178
- registry: AgentSpawnRegistry,
179
- options: AgentSpawnOptions,
180
- ): Promise<AgentSpawnResult> {
181
- const { callId, task, cwd, model, tools, systemPrompt, signal } = options;
182
-
183
- // Per-call controller — the abort entry point.
184
- const controller = new AbortController();
185
- registry.controllers.set(callId, controller);
186
-
187
- // Link the caller-level signal to this call's controller so a run-wide
188
- // abort reaches every in-flight call. Named + removed in finally — otherwise
189
- // a normally-completing call leaks a listener on the parent signal.
190
- const onParentAbort = (): void => controller.abort();
191
- if (signal) {
192
- if (signal.aborted) controller.abort();
193
- else signal.addEventListener("abort", onParentAbort);
194
- }
195
-
196
- const args: string[] = ["--mode", "json", "-p", "--no-session"];
197
- if (model) args.push("--model", model);
198
- if (tools && tools.length > 0) args.push("--tools", tools.join(","));
199
-
200
- let tmpPromptDir: string | null = null;
201
- let tmpPromptPath: string | null = null;
202
-
203
- const result: AgentSpawnResult = {
204
- callId,
205
- exitCode: 0,
206
- messages: [],
207
- stderr: "",
208
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
209
- aborted: false,
210
- maxTurnsReached: false,
211
- };
212
-
213
- try {
214
- if (systemPrompt && systemPrompt.trim()) {
215
- const tmp = await writePromptToTempFile(callId, systemPrompt);
216
- tmpPromptDir = tmp.dir;
217
- tmpPromptPath = tmp.filePath;
218
- args.push("--append-system-prompt", tmpPromptPath);
219
- }
220
-
221
- // The prompt is the final positional arg consumed by `-p`.
222
- args.push(task);
223
-
224
- const exitCode = await new Promise<number>((resolve) => {
225
- const invocation = getPiInvocation(args);
226
- const proc = spawn(invocation.command, invocation.args, {
227
- cwd: cwd ?? process.cwd(),
228
- shell: false,
229
- stdio: ["ignore", "pipe", "pipe"],
230
- });
231
- registry.processes.set(callId, proc);
232
-
233
- let buffer = "";
234
- const decoder = new StringDecoder("utf8");
235
-
236
- const processLine = (line: string) => {
237
- if (!line.trim()) return;
238
- let event: { type: string; message?: Message };
239
- try {
240
- event = JSON.parse(line) as { type: string; message?: Message };
241
- } catch {
242
- return;
243
- }
244
-
245
- if (event.type === "message_end" && event.message) {
246
- const msg = event.message;
247
- result.messages.push(msg);
248
- if (msg.role === "assistant") {
249
- result.usage.turns++;
250
- // Enforce maxTurns: abort the subprocess when the limit is reached.
251
- // killProc handles the SIGTERM→SIGKILL escalation. Use `!= null` so an
252
- // explicit maxTurns: 0 is honored (and marked) rather than treated as "unset".
253
- if (options.maxTurns != null && result.usage.turns >= options.maxTurns) {
254
- result.maxTurnsReached = true;
255
- controller.abort();
256
- }
257
- const usage = msg.usage;
258
- if (usage) {
259
- result.usage.input += usage.input || 0;
260
- result.usage.output += usage.output || 0;
261
- result.usage.cacheRead += usage.cacheRead || 0;
262
- result.usage.cacheWrite += usage.cacheWrite || 0;
263
- result.usage.cost += Number(usage.cost?.total) || 0;
264
- result.usage.contextTokens = usage.totalTokens || 0;
265
- }
266
- if (!result.model && msg.model) result.model = msg.model;
267
- if (msg.stopReason) result.stopReason = msg.stopReason;
268
- if (msg.errorMessage) result.errorMessage = msg.errorMessage;
269
- }
270
- }
271
-
272
- if (event.type === "tool_result_end" && event.message) {
273
- result.messages.push(event.message);
274
- }
275
- };
276
-
277
- proc.stdout.on("data", (data) => {
278
- // StringDecoder buffers incomplete multi-byte UTF-8 sequences across chunk
279
- // boundaries so a CJK char split between two `data` events isn't replaced
280
- // with U+FFFD (which would corrupt the line and silently drop the event).
281
- buffer += decoder.write(data);
282
- const lines = buffer.split("\n");
283
- buffer = lines.pop() || "";
284
- for (const line of lines) processLine(line);
285
- });
286
-
287
- proc.stderr.on("data", (data) => {
288
- result.stderr += data.toString();
289
- });
290
-
291
- proc.on("close", (code) => {
292
- const tail = decoder.end();
293
- if (tail) buffer += tail;
294
- if (buffer.trim()) processLine(buffer);
295
- resolve(code ?? 1); // code===null → signal-killed (OOM/SIGKILL): treat as failure, not silent empty success
296
- });
297
-
298
- proc.on("error", (err) => {
299
- // Surface the spawn error (e.g. ENOENT when `pi` is not on PATH) instead
300
- // of swallowing it.
301
- result.errorMessage = err.message;
302
- result.stderr += err.message;
303
- resolve(1);
304
- });
305
-
306
- // Per-call abort → SIGTERM (SIGKILL after 5s). Race-safe: if the controller
307
- // was already aborted before this listener registered, kill now.
308
- const killProc = () => {
309
- // Late-abort guard: if the proc already exited, don't flip a successful
310
- // result's `aborted` flag.
311
- if (proc.exitCode !== null || proc.signalCode !== null) return;
312
- result.aborted = true;
313
- proc.kill("SIGTERM");
314
- const timer = setTimeout(() => {
315
- // SIGTERM may be ignored — force SIGKILL after the grace period.
316
- proc.kill("SIGKILL");
317
- }, 5000);
318
- // Clear the timer once the proc exits so we don't leak a libuv handle.
319
- proc.once("close", () => clearTimeout(timer));
320
- };
321
- if (controller.signal.aborted) killProc();
322
- else controller.signal.addEventListener("abort", killProc, { once: true });
323
- });
324
-
325
- result.exitCode = exitCode;
326
- return result;
327
- } finally {
328
- // Always release registry slots, the parent-signal listener, and temp files.
329
- registry.processes.delete(callId);
330
- registry.controllers.delete(callId);
331
- if (signal) signal.removeEventListener("abort", onParentAbort);
332
- if (tmpPromptPath)
333
- try {
334
- fs.unlinkSync(tmpPromptPath);
335
- } catch {
336
- /* ignore */
337
- }
338
- if (tmpPromptDir)
339
- try {
340
- fs.rmdirSync(tmpPromptDir);
341
- } catch {
342
- /* ignore */
343
- }
344
- }
345
- }
346
-
347
- async function writePromptToTempFile(callId: string, prompt: string): Promise<{ dir: string; filePath: string }> {
348
- const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-cr-agent-"));
349
- const safeName = callId.replace(/[^\w.-]+/g, "_");
350
- const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
351
- await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
352
- return { dir: tmpDir, filePath };
353
- }