@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.
- package/README.md +45 -11
- package/index.ts +15 -3
- package/package.json +10 -5
- package/skills/code-review/SKILL.md +207 -99
- package/skills/simplify/SKILL.md +184 -61
- package/src/commands/code-simplify.ts +43 -9
- package/src/tools/review_report.ts +326 -0
- package/src/tools/subagent.ts +50 -7
- package/src/agent/dispatch.ts +0 -353
package/src/agent/dispatch.ts
DELETED
|
@@ -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
|
-
}
|