@fyeeme/pi-review 1.0.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/LICENSE +21 -0
- package/README.md +123 -0
- package/index.ts +28 -0
- package/package.json +52 -0
- package/skills/code-review/SKILL.md +369 -0
- package/skills/simplify/SKILL.md +157 -0
- package/src/agent/dispatch.ts +353 -0
- package/src/commands/code-review.ts +100 -0
- package/src/commands/code-simplify.ts +66 -0
- package/src/skills.ts +22 -0
- package/src/tools/subagent.ts +324 -0
package/src/skills.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/skills.ts — resolve paths to skills bundled inside this extension.
|
|
3
|
+
*
|
|
4
|
+
* Skills ship in <pkg>/skills/ (not under ~/.pi/agent/skills/), so the command
|
|
5
|
+
* handlers need the extension's own install root to point the agent's read tool
|
|
6
|
+
* at the bundled SKILL.md. import.meta.url resolves under node ESM, bun, and
|
|
7
|
+
* jiti 2.x; realpath collapses any symlink in the install path (the extension
|
|
8
|
+
* is often symlinked into the pi extensions dir).
|
|
9
|
+
*/
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
// This file lives at <pkg>/src/skills.ts → ".." is the package root.
|
|
15
|
+
const PKG_ROOT = fs.realpathSync(
|
|
16
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/** Absolute path to a skill file bundled in this extension's skills/ dir. */
|
|
20
|
+
export function bundledSkillPath(rel: string): string {
|
|
21
|
+
return path.join(PKG_ROOT, "skills", rel);
|
|
22
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/subagent.ts — the `subagent` LLM tool.
|
|
3
|
+
*
|
|
4
|
+
* A general-purpose fan-out tool: spawn one or more real pi subprocesses to
|
|
5
|
+
* run prompts as independent agents. This is the capability the code-review
|
|
6
|
+
* skill needs for its medium+ multi-agent flow (parallel finders, an
|
|
7
|
+
* independent verify agent, a gap-hunter) — the skill calls this tool instead
|
|
8
|
+
* of doing multi-perspective work inline.
|
|
9
|
+
*
|
|
10
|
+
* Modes:
|
|
11
|
+
* - single run prompts[0] once
|
|
12
|
+
* - parallel run all prompts concurrently (capped at MAX_CONCURRENCY)
|
|
13
|
+
* - chain run sequentially; each later prompt receives prior output
|
|
14
|
+
*/
|
|
15
|
+
import { defineTool, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import {
|
|
21
|
+
abortAgent,
|
|
22
|
+
createSpawnRegistry,
|
|
23
|
+
mapWithConcurrencyLimit,
|
|
24
|
+
spawnAgent,
|
|
25
|
+
type AgentSpawnRegistry,
|
|
26
|
+
type AgentSpawnOptions,
|
|
27
|
+
type AgentSpawnResult,
|
|
28
|
+
} from "../agent/dispatch.ts";
|
|
29
|
+
|
|
30
|
+
/** Cap on concurrent subprocesses (matches pi-dynamic-workflows + examples/subagent). */
|
|
31
|
+
const MAX_CONCURRENCY = 8;
|
|
32
|
+
|
|
33
|
+
// Module-level registry so abortAgent can reach in-flight calls. callIds are
|
|
34
|
+
// unique per tool call (toolCallId#index), so a single registry is safe.
|
|
35
|
+
const registry: AgentSpawnRegistry = createSpawnRegistry();
|
|
36
|
+
|
|
37
|
+
const SubagentParams = Type.Object({
|
|
38
|
+
// single: run prompts[0] once. parallel: run all prompts concurrently.
|
|
39
|
+
// chain: run sequentially, each later prompt sees prior output.
|
|
40
|
+
mode: Type.Union([Type.Literal("single"), Type.Literal("parallel"), Type.Literal("chain")]),
|
|
41
|
+
prompts: Type.Array(Type.String(), {
|
|
42
|
+
description: "Prompt(s) for the sub-agent(s). single/chain use order; parallel runs all.",
|
|
43
|
+
}),
|
|
44
|
+
model: Type.Optional(Type.String({ description: "Full model id (e.g. claude-sonnet-5). Omit for the session default." })),
|
|
45
|
+
systemPrompt: Type.Optional(Type.String({ description: "Appended to the sub-agent's system prompt." })),
|
|
46
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Tool whitelist for the sub-agent. Omit for default tools." })),
|
|
47
|
+
parallelism: Type.Optional(
|
|
48
|
+
Type.Number({ description: `Max concurrent agents in parallel mode (default min(prompts.length, ${MAX_CONCURRENCY})).` }),
|
|
49
|
+
),
|
|
50
|
+
maxTurns: Type.Optional(
|
|
51
|
+
Type.Number({ description: "Max assistant turns per sub-agent. When reached, the subprocess is aborted. Omit for unlimited." }),
|
|
52
|
+
),
|
|
53
|
+
cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the session cwd." })),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
interface SubagentEntry {
|
|
57
|
+
index: number;
|
|
58
|
+
exitCode: number;
|
|
59
|
+
text: string;
|
|
60
|
+
aborted: boolean;
|
|
61
|
+
/** Killed because the maxTurns budget was reached (not an external cancel). */
|
|
62
|
+
maxTurnsReached: boolean;
|
|
63
|
+
errorMessage?: string;
|
|
64
|
+
/**
|
|
65
|
+
* 完整对话转录文件路径(总是写入,含 user/assistant/tool result 全量消息)。
|
|
66
|
+
* 内联预览只展示最终文本,模型可随时用 read 读取该文件深挖完整过程。
|
|
67
|
+
*/
|
|
68
|
+
transcriptFile?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SubagentDetails {
|
|
72
|
+
mode: string;
|
|
73
|
+
results: SubagentEntry[];
|
|
74
|
+
stats: { agents: number; turns: number; cost: number; aborted: number };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Pull the assistant text out of a spawn result's messages. Defensive about
|
|
78
|
+
* the Message.content shape (string | content-block array). */
|
|
79
|
+
function resultText(r: AgentSpawnResult): string {
|
|
80
|
+
const texts: string[] = [];
|
|
81
|
+
for (const m of r.messages) {
|
|
82
|
+
if (m.role !== "assistant") continue;
|
|
83
|
+
const content: unknown = (m as { content?: unknown }).content;
|
|
84
|
+
if (typeof content === "string") {
|
|
85
|
+
texts.push(content);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(content)) {
|
|
89
|
+
for (const block of content) {
|
|
90
|
+
if (block && typeof block === "object" && "text" in block) {
|
|
91
|
+
const text = (block as { text?: unknown }).text;
|
|
92
|
+
if (typeof text === "string") texts.push(text);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return texts.join("\n").trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 提取单条消息的可读文本(string content 或 content block 数组)。
|
|
102
|
+
*
|
|
103
|
+
* 转录用:除 text 外也渲染 thinking 与 toolCall 块,否则转录会静默丢弃中间推理与
|
|
104
|
+
* 工具调用——与“全量转录含 tool result”的声明不符。内联预览(resultText)仍只取
|
|
105
|
+
* assistant 的 text,保持简短。
|
|
106
|
+
*/
|
|
107
|
+
function messageText(m: { role?: string; content?: unknown }): string {
|
|
108
|
+
const content = m.content;
|
|
109
|
+
if (typeof content === "string") return content;
|
|
110
|
+
if (!Array.isArray(content)) return "";
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
for (const block of content) {
|
|
113
|
+
if (!block || typeof block !== "object") continue;
|
|
114
|
+
const b = block as { type?: string; text?: unknown; thinking?: unknown; name?: unknown; arguments?: unknown };
|
|
115
|
+
switch (b.type) {
|
|
116
|
+
case "text":
|
|
117
|
+
if (typeof b.text === "string") parts.push(b.text);
|
|
118
|
+
break;
|
|
119
|
+
case "thinking":
|
|
120
|
+
if (typeof b.thinking === "string") parts.push(`[thinking] ${b.thinking}`);
|
|
121
|
+
break;
|
|
122
|
+
case "toolCall":
|
|
123
|
+
if (typeof b.name === "string") {
|
|
124
|
+
const args = b.arguments === undefined ? "" : JSON.stringify(b.arguments);
|
|
125
|
+
parts.push(`[tool_call] ${b.name}(${args})`);
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
default:
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return parts.join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 将 agent 的完整对话(全部消息:user / assistant / tool result)渲染为可读转录文本。
|
|
137
|
+
* 内联预览只含最终 assistant 文本;这里保留中间推理与工具调用过程,供模型深挖。
|
|
138
|
+
*/
|
|
139
|
+
function transcriptText(messages: AgentSpawnResult["messages"]): string {
|
|
140
|
+
const parts: string[] = [];
|
|
141
|
+
for (const m of messages) {
|
|
142
|
+
const body = messageText(m).trim();
|
|
143
|
+
if (!body) continue;
|
|
144
|
+
const role = m.role ?? "message";
|
|
145
|
+
// toolResult 消息标出工具名,便于定位是哪个工具的返回。
|
|
146
|
+
const label =
|
|
147
|
+
role === "toolResult" && typeof (m as { toolName?: unknown }).toolName === "string"
|
|
148
|
+
? `${role}(${(m as { toolName: string }).toolName})`
|
|
149
|
+
: role;
|
|
150
|
+
parts.push(`--- ${label} ---\n${body}`);
|
|
151
|
+
}
|
|
152
|
+
return parts.join("\n\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 将 agent 的完整对话转录写入临时文件。总是写入(借鉴 tintinweb/pi-subagents:
|
|
157
|
+
* 完整转录落盘 + 路径随结果给出),不只在输出超限时才写。
|
|
158
|
+
*/
|
|
159
|
+
async function writeTranscriptFile(tmpDir: string, callId: string, text: string): Promise<string> {
|
|
160
|
+
const safeName = callId.replace(/[^\w.-]+/g, "_");
|
|
161
|
+
const filePath = path.join(tmpDir, `agent-${safeName}.txt`);
|
|
162
|
+
await fs.promises.writeFile(filePath, text, { encoding: "utf-8", mode: 0o600 });
|
|
163
|
+
return filePath;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 单个 agent 的展示预览:最终文本用 pi 内置截断(默认 50KB / 2000 行)呈现——
|
|
168
|
+
* 正常体量输出直接完整内联;超限时保留截断内容并标注。无论是否超限,都附上
|
|
169
|
+
* 完整对话转录文件路径,模型可随时 read 深挖。
|
|
170
|
+
*/
|
|
171
|
+
function previewEntry(r: SubagentEntry): string {
|
|
172
|
+
const trunc = truncateHead(r.text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
173
|
+
let preview = trunc.content;
|
|
174
|
+
const extras: string[] = [];
|
|
175
|
+
if (trunc.truncated) {
|
|
176
|
+
extras.push(`输出截断: ${trunc.outputLines}/${trunc.totalLines} 行, ${formatSize(trunc.outputBytes)}/${formatSize(trunc.totalBytes)}`);
|
|
177
|
+
}
|
|
178
|
+
if (r.transcriptFile) {
|
|
179
|
+
extras.push(`完整转录: ${r.transcriptFile}`);
|
|
180
|
+
}
|
|
181
|
+
if (extras.length > 0) preview += `\n [${extras.join(" · ")}]`;
|
|
182
|
+
return preview;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function summarize(d: SubagentDetails): string {
|
|
186
|
+
const lines = [
|
|
187
|
+
`subagent (${d.mode}) → ${d.results.length} agent(s), ${d.stats.turns} turn(s), $${d.stats.cost.toFixed(4)}`,
|
|
188
|
+
];
|
|
189
|
+
for (const r of d.results) {
|
|
190
|
+
const tag = r.maxTurnsReached
|
|
191
|
+
? "[max-turns]"
|
|
192
|
+
: r.aborted
|
|
193
|
+
? "[aborted]"
|
|
194
|
+
: r.errorMessage
|
|
195
|
+
? "[error]"
|
|
196
|
+
: "[ok]";
|
|
197
|
+
lines.push(` ${tag} #${r.index + 1}: ${previewEntry(r)}`);
|
|
198
|
+
}
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const subagentTool = defineTool<typeof SubagentParams, SubagentDetails>({
|
|
203
|
+
name: "subagent",
|
|
204
|
+
label: "Sub-agent",
|
|
205
|
+
description:
|
|
206
|
+
"Spawn one or more sub-agents (real pi subprocesses) to fan out work — e.g. parallel code-review finders, an independent verify agent, or a gap-hunter. Each sub-agent is a full pi run in --mode json. 每个 agent 的完整对话转录都会写入临时文件并在结果中附上路径;内联预览展示最终文本(超长时截断),需要完整过程时可用 read 工具读取转录文件。",
|
|
207
|
+
promptSnippet: "subagent — spawn parallel/sequential sub-agents (fan-out finders, independent verify, gap-hunt)",
|
|
208
|
+
promptGuidelines: [
|
|
209
|
+
"Use `subagent` with mode:parallel to fan out multiple reviewers/finders at once (e.g. one per angle); collect their outputs and synthesize.",
|
|
210
|
+
"Use mode:single for an independent second opinion (e.g. verify a candidate finding without your own confirmation bias).",
|
|
211
|
+
"Do not fake fan-out by doing the work yourself inline — call `subagent` so the work genuinely runs in parallel subprocesses.",
|
|
212
|
+
],
|
|
213
|
+
parameters: SubagentParams,
|
|
214
|
+
|
|
215
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
216
|
+
const prompts = params.prompts;
|
|
217
|
+
if (prompts.length === 0) throw new Error("subagent: prompts must be non-empty");
|
|
218
|
+
if (params.mode === "single" && prompts.length > 1) {
|
|
219
|
+
throw new Error(`subagent: mode "single" takes exactly one prompt (got ${prompts.length})`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const cwd = params.cwd ?? ctx.cwd;
|
|
223
|
+
const baseSystem = params.systemPrompt;
|
|
224
|
+
// Per-call-agnostic subset of AgentSpawnOptions; callId/task are added per spawn.
|
|
225
|
+
const baseOpts: Omit<AgentSpawnOptions, "callId" | "task"> = {
|
|
226
|
+
cwd,
|
|
227
|
+
model: params.model,
|
|
228
|
+
tools: params.tools,
|
|
229
|
+
signal,
|
|
230
|
+
maxTurns: params.maxTurns,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const partial: SubagentDetails = {
|
|
234
|
+
mode: params.mode,
|
|
235
|
+
results: [],
|
|
236
|
+
stats: { agents: 0, turns: 0, cost: 0, aborted: 0 },
|
|
237
|
+
};
|
|
238
|
+
const emit = (): void => {
|
|
239
|
+
onUpdate?.({ content: [{ type: "text" as const, text: summarize(partial) }], details: partial });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// 本次 tool call 内所有 agent 共享一个临时目录(N 个 agent → 1 个目录),
|
|
243
|
+
// 取代每个 agent 各自 mkdtemp。转录需保留供模型稍后 read,故此处不清理。
|
|
244
|
+
let sharedTmpDir: string | null = null;
|
|
245
|
+
const getTmpDir = async (): Promise<string> => {
|
|
246
|
+
if (!sharedTmpDir) {
|
|
247
|
+
sharedTmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-cr-out-"));
|
|
248
|
+
}
|
|
249
|
+
return sharedTmpDir;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const runOne = async (prompt: string, index: number, extraSystem?: string): Promise<SubagentEntry> => {
|
|
253
|
+
const callId = `${toolCallId}#${index}`;
|
|
254
|
+
const systemPrompt = extraSystem
|
|
255
|
+
? [baseSystem, extraSystem].filter(Boolean).join("\n\n")
|
|
256
|
+
: baseSystem;
|
|
257
|
+
const r = await spawnAgent(registry, { callId, task: prompt, ...baseOpts, systemPrompt });
|
|
258
|
+
const entry: SubagentEntry = {
|
|
259
|
+
index,
|
|
260
|
+
exitCode: r.exitCode,
|
|
261
|
+
text: resultText(r),
|
|
262
|
+
aborted: r.aborted,
|
|
263
|
+
maxTurnsReached: r.maxTurnsReached,
|
|
264
|
+
errorMessage:
|
|
265
|
+
r.errorMessage ||
|
|
266
|
+
(r.exitCode !== 0 && !r.aborted
|
|
267
|
+
? `exit ${r.exitCode}${r.stderr ? `: ${r.stderr.slice(0, 200)}` : ""}`
|
|
268
|
+
: undefined),
|
|
269
|
+
};
|
|
270
|
+
// 总是写入完整对话转录文件并附路径(借鉴 tintinweb/pi-subagents)。写失败不阻塞结果。
|
|
271
|
+
try {
|
|
272
|
+
entry.transcriptFile = await writeTranscriptFile(await getTmpDir(), callId, transcriptText(r.messages));
|
|
273
|
+
} catch {
|
|
274
|
+
/* 转录写失败不阻塞:previewEntry 无路径时仅展示内联预览 */
|
|
275
|
+
}
|
|
276
|
+
partial.results.push(entry);
|
|
277
|
+
partial.stats.agents++;
|
|
278
|
+
partial.stats.turns += r.usage.turns;
|
|
279
|
+
partial.stats.cost += r.usage.cost;
|
|
280
|
+
// Only count genuine external cancels as aborted; a maxTurns budget
|
|
281
|
+
// stop is a normal bounded completion, not a cancellation.
|
|
282
|
+
if (r.aborted && !r.maxTurnsReached) partial.stats.aborted++;
|
|
283
|
+
// Keep results sorted by index for stable output (parallel settles out of order).
|
|
284
|
+
partial.results.sort((a, b) => a.index - b.index);
|
|
285
|
+
emit();
|
|
286
|
+
return entry;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
if (params.mode === "parallel") {
|
|
290
|
+
const conc = Math.min(params.parallelism ?? MAX_CONCURRENCY, MAX_CONCURRENCY, prompts.length);
|
|
291
|
+
await mapWithConcurrencyLimit(prompts, conc, (p, i) => runOne(p, i));
|
|
292
|
+
} else {
|
|
293
|
+
// single or chain
|
|
294
|
+
let chainContext = "";
|
|
295
|
+
for (let i = 0; i < prompts.length; i++) {
|
|
296
|
+
const extra = params.mode === "chain" && chainContext ? `Previous sub-agent output:\n${chainContext}` : undefined;
|
|
297
|
+
const entry = await runOne(prompts[i], i, extra);
|
|
298
|
+
// Stop the chain on a hard failure so downstream prompts don't run
|
|
299
|
+
// on missing/garbage context (and don't waste subprocesses). An
|
|
300
|
+
// aborted/maxTurns step may still carry useful text worth chaining.
|
|
301
|
+
if (entry.errorMessage) break;
|
|
302
|
+
// 始终赋值(即便是空串)以清空链上下文:否则空文本步骤会让后续步骤复用
|
|
303
|
+
// 更早步骤的输出作为“Previous sub-agent output”,传递陈旧上下文。
|
|
304
|
+
chainContext = entry.text;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Surface a hard failure (non-aborted, non-zero exit) by throwing so the
|
|
309
|
+
// agent loop marks isError=true (pi tool contract: throw, don't return isError).
|
|
310
|
+
const failed = partial.results.find((r) => r.errorMessage && !r.aborted);
|
|
311
|
+
if (failed) throw new Error(`subagent: agent #${failed.index + 1} failed — ${failed.errorMessage}`);
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
content: [{ type: "text" as const, text: summarize(partial) }],
|
|
315
|
+
details: partial,
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/** Abort one in-flight sub-agent by its callId. Exposed for future per-agent
|
|
321
|
+
* abort UI; the caller-level `signal` already handles run-wide ESC. */
|
|
322
|
+
export function abortSubagent(callId: string): boolean {
|
|
323
|
+
return abortAgent(registry, callId);
|
|
324
|
+
}
|