@in-the-loop-labs/pair-review 2.0.1 → 2.0.3
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/.pi/extensions/task/index.ts +722 -0
- package/.pi/skills/pair-review-api/SKILL.md +448 -0
- package/.pi/skills/review-model-guidance/SKILL.md +260 -0
- package/.pi/skills/review-roulette/SKILL.md +144 -0
- package/bin/pair-review.js +1 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +99 -3
- package/public/js/components/DiffOptionsDropdown.js +207 -0
- package/public/js/components/ReviewModal.js +8 -3
- package/public/js/local.js +31 -1
- package/public/js/pr.js +61 -3
- package/public/local.html +6 -0
- package/public/pr.html +6 -0
- package/src/database.js +5 -3
- package/src/github/client.js +80 -20
- package/src/local-review.js +5 -4
- package/src/routes/context-files.js +1 -1
- package/src/routes/local.js +17 -1
- package/src/routes/pr.js +60 -11
- package/src/utils/auto-context.js +1 -1
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Task Tool — Generic subagent for delegating work with isolated context
|
|
4
|
+
*
|
|
5
|
+
* Like Claude Code's Task tool. Spawns a separate `pi` process for each
|
|
6
|
+
* invocation, giving it a fresh context window with full tool access.
|
|
7
|
+
* The parent conversation's context is preserved while the subtask runs
|
|
8
|
+
* in isolation.
|
|
9
|
+
*
|
|
10
|
+
* No agent definitions or configuration required — just describe the task.
|
|
11
|
+
*
|
|
12
|
+
* Note: This extension runs inside pi, so `pi` is always available on PATH
|
|
13
|
+
* (the parent process IS pi). No availability check is needed.
|
|
14
|
+
*
|
|
15
|
+
* Modes:
|
|
16
|
+
* - Single: { task: "..." }
|
|
17
|
+
* - Parallel: { tasks: [{ task: "...", model?: "..." }, ...] }
|
|
18
|
+
*
|
|
19
|
+
* Based on pi's subagent example extension, simplified for generic use.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
27
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
28
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
29
|
+
import { Text, Container, Spacer } from "@mariozechner/pi-tui";
|
|
30
|
+
import { Type } from "@sinclair/typebox";
|
|
31
|
+
|
|
32
|
+
const MAX_PARALLEL = 8;
|
|
33
|
+
const MAX_CONCURRENCY = 4;
|
|
34
|
+
const COLLAPSED_ITEMS = 10;
|
|
35
|
+
const EXTENSION_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
36
|
+
const _parsedMaxDepth = parseInt(process.env.PI_TASK_MAX_DEPTH || "1", 10);
|
|
37
|
+
const MAX_TASK_DEPTH = Number.isNaN(_parsedMaxDepth) ? 1 : _parsedMaxDepth;
|
|
38
|
+
// PI_CMD allows wrappers (e.g., `devx pi`) to tell subtasks how to invoke pi.
|
|
39
|
+
// Falls back to `pi` if unset. The value is propagated to child processes automatically.
|
|
40
|
+
const PI_CMD = process.env.PI_CMD || "pi";
|
|
41
|
+
|
|
42
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function formatTokens(n: number): string {
|
|
45
|
+
if (n < 1000) return n.toString();
|
|
46
|
+
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
|
47
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
48
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface UsageStats {
|
|
52
|
+
input: number;
|
|
53
|
+
output: number;
|
|
54
|
+
cacheRead: number;
|
|
55
|
+
cacheWrite: number;
|
|
56
|
+
cost: number;
|
|
57
|
+
contextTokens: number;
|
|
58
|
+
turns: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function emptyUsage(): UsageStats {
|
|
62
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatUsage(u: UsageStats, model?: string): string {
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
if (u.turns) parts.push(`${u.turns} turn${u.turns > 1 ? "s" : ""}`);
|
|
68
|
+
if (u.input) parts.push(`↑${formatTokens(u.input)}`);
|
|
69
|
+
if (u.output) parts.push(`↓${formatTokens(u.output)}`);
|
|
70
|
+
if (u.cacheRead) parts.push(`R${formatTokens(u.cacheRead)}`);
|
|
71
|
+
if (u.cacheWrite) parts.push(`W${formatTokens(u.cacheWrite)}`);
|
|
72
|
+
if (u.cost) parts.push(`$${u.cost.toFixed(4)}`);
|
|
73
|
+
if (u.contextTokens > 0) parts.push(`ctx:${formatTokens(u.contextTokens)}`);
|
|
74
|
+
if (model) parts.push(model);
|
|
75
|
+
return parts.join(" ");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function shortenPath(p: string): string {
|
|
79
|
+
const home = os.homedir();
|
|
80
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatToolCall(
|
|
84
|
+
name: string,
|
|
85
|
+
args: Record<string, unknown>,
|
|
86
|
+
fg: (color: any, text: string) => string,
|
|
87
|
+
): string {
|
|
88
|
+
switch (name) {
|
|
89
|
+
case "bash": {
|
|
90
|
+
const cmd = (args.command as string) || "...";
|
|
91
|
+
const preview = cmd.length > 80 ? `${cmd.slice(0, 80)}...` : cmd;
|
|
92
|
+
return fg("muted", "$ ") + fg("toolOutput", preview);
|
|
93
|
+
}
|
|
94
|
+
case "read": {
|
|
95
|
+
const raw = (args.file_path || args.path || "...") as string;
|
|
96
|
+
let text = fg("accent", shortenPath(raw));
|
|
97
|
+
const offset = args.offset as number | undefined;
|
|
98
|
+
const limit = args.limit as number | undefined;
|
|
99
|
+
if (offset !== undefined || limit !== undefined) {
|
|
100
|
+
const start = offset ?? 1;
|
|
101
|
+
const end = limit !== undefined ? start + limit - 1 : "";
|
|
102
|
+
text += fg("warning", `:${start}${end ? `-${end}` : ""}`);
|
|
103
|
+
}
|
|
104
|
+
return fg("muted", "read ") + text;
|
|
105
|
+
}
|
|
106
|
+
case "write": {
|
|
107
|
+
const raw = (args.file_path || args.path || "...") as string;
|
|
108
|
+
const content = (args.content || "") as string;
|
|
109
|
+
const lines = content.split("\n").length;
|
|
110
|
+
let text = fg("muted", "write ") + fg("accent", shortenPath(raw));
|
|
111
|
+
if (lines > 1) text += fg("dim", ` (${lines} lines)`);
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
case "edit": {
|
|
115
|
+
const raw = (args.file_path || args.path || "...") as string;
|
|
116
|
+
return fg("muted", "edit ") + fg("accent", shortenPath(raw));
|
|
117
|
+
}
|
|
118
|
+
case "ls": {
|
|
119
|
+
const raw = (args.path || ".") as string;
|
|
120
|
+
return fg("muted", "ls ") + fg("accent", shortenPath(raw));
|
|
121
|
+
}
|
|
122
|
+
case "find": {
|
|
123
|
+
const pattern = (args.pattern || "*") as string;
|
|
124
|
+
const raw = (args.path || ".") as string;
|
|
125
|
+
return fg("muted", "find ") + fg("accent", pattern) + fg("dim", ` in ${shortenPath(raw)}`);
|
|
126
|
+
}
|
|
127
|
+
case "grep": {
|
|
128
|
+
const pattern = (args.pattern || "") as string;
|
|
129
|
+
const raw = (args.path || ".") as string;
|
|
130
|
+
return fg("muted", "grep ") + fg("accent", `/${pattern}/`) + fg("dim", ` in ${shortenPath(raw)}`);
|
|
131
|
+
}
|
|
132
|
+
default: {
|
|
133
|
+
const s = JSON.stringify(args);
|
|
134
|
+
const preview = s.length > 60 ? `${s.slice(0, 60)}...` : s;
|
|
135
|
+
return fg("accent", name) + fg("dim", ` ${preview}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
interface TaskResult {
|
|
143
|
+
task: string;
|
|
144
|
+
exitCode: number;
|
|
145
|
+
messages: Message[];
|
|
146
|
+
stderr: string;
|
|
147
|
+
usage: UsageStats;
|
|
148
|
+
model?: string;
|
|
149
|
+
stopReason?: string;
|
|
150
|
+
errorMessage?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface TaskDetails {
|
|
154
|
+
mode: "single" | "parallel";
|
|
155
|
+
results: TaskResult[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type DisplayItem =
|
|
159
|
+
| { type: "text"; text: string }
|
|
160
|
+
| { type: "toolCall"; name: string; args: Record<string, any> };
|
|
161
|
+
|
|
162
|
+
function getFinalOutput(messages: Message[]): string {
|
|
163
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
164
|
+
const msg = messages[i];
|
|
165
|
+
if (msg.role === "assistant") {
|
|
166
|
+
for (const part of msg.content) {
|
|
167
|
+
if (part.type === "text") return part.text;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getDisplayItems(messages: Message[]): DisplayItem[] {
|
|
175
|
+
const items: DisplayItem[] = [];
|
|
176
|
+
for (const msg of messages) {
|
|
177
|
+
if (msg.role === "assistant") {
|
|
178
|
+
for (const part of msg.content) {
|
|
179
|
+
if (part.type === "text") items.push({ type: "text", text: part.text });
|
|
180
|
+
else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return items;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Process runner ───────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
function writeTempPrompt(label: string, content: string): { dir: string; file: string } {
|
|
190
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-task-"));
|
|
191
|
+
const safe = label.replace(/[^\w.-]+/g, "_").slice(0, 40);
|
|
192
|
+
const file = path.join(dir, `prompt-${safe}.md`);
|
|
193
|
+
fs.writeFileSync(file, content, { encoding: "utf-8", mode: 0o600 });
|
|
194
|
+
return { dir, file };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type OnUpdate = (partial: AgentToolResult<TaskDetails>) => void;
|
|
198
|
+
|
|
199
|
+
async function runTask(
|
|
200
|
+
cwd: string,
|
|
201
|
+
task: string,
|
|
202
|
+
systemPrompt: string | undefined,
|
|
203
|
+
model: string | undefined,
|
|
204
|
+
signal: AbortSignal | undefined,
|
|
205
|
+
onUpdate: OnUpdate | undefined,
|
|
206
|
+
makeDetails: (results: TaskResult[]) => TaskDetails,
|
|
207
|
+
): Promise<TaskResult> {
|
|
208
|
+
// Build args: full tool access, JSON output, no session persistence
|
|
209
|
+
const args: string[] = [
|
|
210
|
+
"--mode", "json", "-p", "--no-session",
|
|
211
|
+
"--no-extensions", "--no-skills", "--no-prompt-templates",
|
|
212
|
+
"-e", EXTENSION_DIR,
|
|
213
|
+
];
|
|
214
|
+
if (model) {
|
|
215
|
+
// Support provider/model format (e.g., 'anthropic/claude-haiku-4-5')
|
|
216
|
+
if (model.includes('/')) {
|
|
217
|
+
const [provider, ...rest] = model.split('/');
|
|
218
|
+
args.push('--provider', provider, '--model', rest.join('/'));
|
|
219
|
+
} else {
|
|
220
|
+
args.push('--model', model);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Propagate the parent's active tool list so subtasks inherit tool restrictions
|
|
224
|
+
// (e.g., if the parent is read-only, subtasks won't get edit/write).
|
|
225
|
+
// Filter out "task" since it's loaded via -e extension, not --tools.
|
|
226
|
+
if (piApi) {
|
|
227
|
+
const parentTools = piApi.getActiveTools().filter((t: string) => t !== "task");
|
|
228
|
+
if (parentTools.length > 0) {
|
|
229
|
+
args.push("--tools", parentTools.join(","));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Validate working directory before spawning
|
|
234
|
+
if (!fs.existsSync(cwd)) {
|
|
235
|
+
return {
|
|
236
|
+
task,
|
|
237
|
+
exitCode: 1,
|
|
238
|
+
messages: [],
|
|
239
|
+
stderr: "",
|
|
240
|
+
usage: emptyUsage(),
|
|
241
|
+
model,
|
|
242
|
+
errorMessage: `Working directory does not exist: ${cwd}`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let tmpDir: string | null = null;
|
|
247
|
+
let tmpFile: string | null = null;
|
|
248
|
+
|
|
249
|
+
const result: TaskResult = {
|
|
250
|
+
task,
|
|
251
|
+
exitCode: 0,
|
|
252
|
+
messages: [],
|
|
253
|
+
stderr: "",
|
|
254
|
+
usage: emptyUsage(),
|
|
255
|
+
model,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const emitUpdate = () => {
|
|
259
|
+
onUpdate?.({
|
|
260
|
+
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
261
|
+
details: makeDetails([result]),
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
// Optionally append a system prompt (for future use by skills/agents)
|
|
267
|
+
if (systemPrompt?.trim()) {
|
|
268
|
+
const tmp = writeTempPrompt("task", systemPrompt);
|
|
269
|
+
tmpDir = tmp.dir;
|
|
270
|
+
tmpFile = tmp.file;
|
|
271
|
+
args.push("--append-system-prompt", tmpFile);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Task text goes as the positional prompt argument
|
|
275
|
+
args.push(task);
|
|
276
|
+
|
|
277
|
+
let wasAborted = false;
|
|
278
|
+
|
|
279
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
280
|
+
const currentDepth = parseInt(process.env.PI_TASK_DEPTH || "0", 10);
|
|
281
|
+
const useShell = PI_CMD.includes(" ");
|
|
282
|
+
const proc = spawn(useShell ? `${PI_CMD} ${args.join(" ")}` : PI_CMD, useShell ? [] : args, {
|
|
283
|
+
cwd,
|
|
284
|
+
shell: useShell,
|
|
285
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
286
|
+
env: { ...process.env, PI_TASK_DEPTH: String(currentDepth + 1), PI_CMD },
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
let buffer = "";
|
|
290
|
+
|
|
291
|
+
const processLine = (line: string) => {
|
|
292
|
+
if (!line.trim()) return;
|
|
293
|
+
let event: any;
|
|
294
|
+
try {
|
|
295
|
+
event = JSON.parse(line);
|
|
296
|
+
} catch {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (event.type === "message_end" && event.message) {
|
|
301
|
+
const msg = event.message as Message;
|
|
302
|
+
result.messages.push(msg);
|
|
303
|
+
|
|
304
|
+
if (msg.role === "assistant") {
|
|
305
|
+
result.usage.turns++;
|
|
306
|
+
const usage = msg.usage;
|
|
307
|
+
if (usage) {
|
|
308
|
+
result.usage.input += usage.input || 0;
|
|
309
|
+
result.usage.output += usage.output || 0;
|
|
310
|
+
result.usage.cacheRead += usage.cacheRead || 0;
|
|
311
|
+
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
312
|
+
result.usage.cost += usage.cost?.total || 0;
|
|
313
|
+
result.usage.contextTokens = usage.totalTokens || 0;
|
|
314
|
+
}
|
|
315
|
+
if (!result.model && msg.model) result.model = msg.model;
|
|
316
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
317
|
+
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
318
|
+
}
|
|
319
|
+
emitUpdate();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (event.type === "tool_result_end" && event.message) {
|
|
323
|
+
result.messages.push(event.message as Message);
|
|
324
|
+
emitUpdate();
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
329
|
+
buffer += data.toString();
|
|
330
|
+
const lines = buffer.split("\n");
|
|
331
|
+
buffer = lines.pop() || "";
|
|
332
|
+
for (const line of lines) processLine(line);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
336
|
+
result.stderr += data.toString();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
proc.on("close", (code: number | null) => {
|
|
340
|
+
if (buffer.trim()) processLine(buffer);
|
|
341
|
+
if (signal && killFn) signal.removeEventListener("abort", killFn);
|
|
342
|
+
resolve(code ?? 0);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
proc.on("error", (err) => {
|
|
346
|
+
if (signal && killFn) signal.removeEventListener("abort", killFn);
|
|
347
|
+
result.errorMessage = `Failed to spawn pi: ${err.message}`;
|
|
348
|
+
resolve(1);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
let killFn: (() => void) | undefined;
|
|
352
|
+
if (signal) {
|
|
353
|
+
killFn = () => {
|
|
354
|
+
wasAborted = true;
|
|
355
|
+
proc.kill("SIGTERM");
|
|
356
|
+
setTimeout(() => {
|
|
357
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
358
|
+
}, 5000);
|
|
359
|
+
};
|
|
360
|
+
if (signal.aborted) killFn();
|
|
361
|
+
else signal.addEventListener("abort", killFn, { once: true });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
result.exitCode = exitCode;
|
|
366
|
+
if (wasAborted) {
|
|
367
|
+
result.exitCode = exitCode || 1;
|
|
368
|
+
result.stopReason = "aborted";
|
|
369
|
+
result.errorMessage = "Task was aborted";
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
} finally {
|
|
374
|
+
if (tmpDir) try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function mapConcurrent<T, R>(
|
|
379
|
+
items: T[],
|
|
380
|
+
concurrency: number,
|
|
381
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
382
|
+
): Promise<R[]> {
|
|
383
|
+
if (items.length === 0) return [];
|
|
384
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
385
|
+
const results: R[] = new Array(items.length);
|
|
386
|
+
let next = 0;
|
|
387
|
+
let firstError: unknown;
|
|
388
|
+
const workers = Array.from({ length: limit }, async () => {
|
|
389
|
+
while (true) {
|
|
390
|
+
const i = next++;
|
|
391
|
+
if (i >= items.length) return;
|
|
392
|
+
try {
|
|
393
|
+
results[i] = await fn(items[i], i);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (!firstError) firstError = err;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
await Promise.allSettled(workers);
|
|
400
|
+
if (firstError) throw firstError;
|
|
401
|
+
return results;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Module-level reference to the pi API so runTask() can access it
|
|
405
|
+
let piApi: ExtensionAPI | undefined;
|
|
406
|
+
|
|
407
|
+
// ── Extension entry point ────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
export default function (pi: ExtensionAPI) {
|
|
410
|
+
piApi = pi;
|
|
411
|
+
pi.registerTool({
|
|
412
|
+
name: "task",
|
|
413
|
+
label: "Task",
|
|
414
|
+
description: [
|
|
415
|
+
"Delegate a task to a subagent with an isolated context window and full tool access.",
|
|
416
|
+
"Use this to preserve your current context while performing work that requires",
|
|
417
|
+
"exploring the codebase, running commands, or making changes.",
|
|
418
|
+
"The subtask gets its own fresh context with the same tools available to the parent session.",
|
|
419
|
+
"For parallel work, pass an array of task objects, each with an optional model override.",
|
|
420
|
+
"Use when the user says things like: 'use a task to...', 'use a subtask to...',",
|
|
421
|
+
"'use a subagent to...', 'delegate to...', 'spawn a task for...',",
|
|
422
|
+
"'in a separate context...', 'without losing context...',",
|
|
423
|
+
"'run this in isolation', or 'in parallel, do...'.",
|
|
424
|
+
"Only use this tool when the task genuinely benefits from isolated context or a different model.",
|
|
425
|
+
"Also consider using this tool when the task would consume significant context if run directly.",
|
|
426
|
+
"",
|
|
427
|
+
"Each subtask starts fresh with no conversation history — provide detailed context in the task",
|
|
428
|
+
"description so it can work autonomously. Subtask results are returned to you but are NOT visible",
|
|
429
|
+
"to the user; you must relay or summarize findings in your own response.",
|
|
430
|
+
"When multiple independent tasks are needed, launch them concurrently via the parallel tasks array.",
|
|
431
|
+
"",
|
|
432
|
+
"IMPORTANT: Do NOT use subtasks for simple operations like reading files, running basic commands,",
|
|
433
|
+
"or gathering information that you could do directly with read, bash, or other built-in tools.",
|
|
434
|
+
"Each subtask spawns a full pi process with significant startup overhead and failure risk.",
|
|
435
|
+
"If a task is just 'read file X and summarize it' or 'run command Y and report the output',",
|
|
436
|
+
"do it yourself with the built-in tools instead. Reserve subtasks for multi-step work that",
|
|
437
|
+
"would consume substantial context (many tool calls, large outputs) or that genuinely benefits",
|
|
438
|
+
"from parallel execution of complex, independent analyses.",
|
|
439
|
+
].join(" "),
|
|
440
|
+
parameters: Type.Object({
|
|
441
|
+
task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
|
|
442
|
+
tasks: Type.Optional(Type.Array(
|
|
443
|
+
Type.Object({
|
|
444
|
+
task: Type.String({ description: "The task to perform" }),
|
|
445
|
+
model: Type.Optional(Type.String({ description: "Override model for this specific task (e.g., 'anthropic/claude-haiku-4-5'). Use provider/model format for cross-provider switching." })),
|
|
446
|
+
}),
|
|
447
|
+
{ description: "Multiple tasks to run in parallel, each with an optional model override" },
|
|
448
|
+
)),
|
|
449
|
+
model: Type.Optional(Type.String({ description: "Model to use (e.g., 'anthropic/claude-haiku-4-5'). Use provider/model format for cross-provider switching." })),
|
|
450
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the subtask" })),
|
|
451
|
+
}),
|
|
452
|
+
|
|
453
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
454
|
+
const currentDepth = parseInt(process.env.PI_TASK_DEPTH || "0", 10);
|
|
455
|
+
if (currentDepth >= MAX_TASK_DEPTH) {
|
|
456
|
+
return {
|
|
457
|
+
content: [{ type: "text", text: `Maximum task nesting depth (${MAX_TASK_DEPTH}) reached. Cannot spawn further subtasks.` }],
|
|
458
|
+
details: { mode: "single" as const, results: [] },
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const hasSingle = Boolean(params.task);
|
|
463
|
+
const hasParallel = (params.tasks?.length ?? 0) > 0;
|
|
464
|
+
|
|
465
|
+
if (!hasSingle && !hasParallel) {
|
|
466
|
+
return {
|
|
467
|
+
content: [{ type: "text", text: "Provide either `task` (string) or `tasks` (array of strings)." }],
|
|
468
|
+
details: { mode: "single" as const, results: [] },
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (hasSingle && hasParallel) {
|
|
473
|
+
return {
|
|
474
|
+
content: [{ type: "text", text: "Provide either `task` or `tasks`, not both." }],
|
|
475
|
+
details: { mode: "single" as const, results: [] },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const makeDetails = (mode: "single" | "parallel") => (results: TaskResult[]): TaskDetails => ({
|
|
480
|
+
mode,
|
|
481
|
+
results,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const workDir = params.cwd ?? ctx.cwd;
|
|
485
|
+
|
|
486
|
+
// ── Single task ──────────────────────────────────────────────
|
|
487
|
+
if (params.task) {
|
|
488
|
+
const result = await runTask(
|
|
489
|
+
workDir,
|
|
490
|
+
params.task,
|
|
491
|
+
undefined, // no extra system prompt
|
|
492
|
+
params.model,
|
|
493
|
+
signal,
|
|
494
|
+
onUpdate,
|
|
495
|
+
makeDetails("single"),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
499
|
+
if (isError) {
|
|
500
|
+
const msg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
501
|
+
return {
|
|
502
|
+
content: [{ type: "text", text: `Task ${result.stopReason || "failed"}: ${msg}` }],
|
|
503
|
+
details: makeDetails("single")([result]),
|
|
504
|
+
isError: true,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
|
510
|
+
details: makeDetails("single")([result]),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── Parallel tasks ───────────────────────────────────────────
|
|
515
|
+
const tasks = params.tasks!;
|
|
516
|
+
if (tasks.length > MAX_PARALLEL) {
|
|
517
|
+
return {
|
|
518
|
+
content: [{ type: "text", text: `Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL}.` }],
|
|
519
|
+
details: makeDetails("parallel")([]),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const allResults: TaskResult[] = tasks.map((t) => ({
|
|
524
|
+
task: t.task,
|
|
525
|
+
exitCode: -1, // -1 = running
|
|
526
|
+
messages: [],
|
|
527
|
+
stderr: "",
|
|
528
|
+
usage: emptyUsage(),
|
|
529
|
+
model: t.model ?? params.model,
|
|
530
|
+
}));
|
|
531
|
+
|
|
532
|
+
const emitParallelUpdate = () => {
|
|
533
|
+
if (!onUpdate) return;
|
|
534
|
+
const running = allResults.filter((r) => r.exitCode === -1).length;
|
|
535
|
+
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
|
536
|
+
onUpdate({
|
|
537
|
+
content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
|
|
538
|
+
details: makeDetails("parallel")([...allResults]),
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const results = await mapConcurrent(tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
543
|
+
const result = await runTask(
|
|
544
|
+
workDir,
|
|
545
|
+
t.task,
|
|
546
|
+
undefined,
|
|
547
|
+
t.model ?? params.model,
|
|
548
|
+
signal,
|
|
549
|
+
(partial) => {
|
|
550
|
+
if (partial.details?.results[0]) {
|
|
551
|
+
allResults[index] = partial.details.results[0];
|
|
552
|
+
emitParallelUpdate();
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
makeDetails("parallel"),
|
|
556
|
+
);
|
|
557
|
+
allResults[index] = result;
|
|
558
|
+
emitParallelUpdate();
|
|
559
|
+
return result;
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const ok = results.filter((r) => r.exitCode === 0).length;
|
|
563
|
+
const summaries = results.map((r) => {
|
|
564
|
+
const out = getFinalOutput(r.messages);
|
|
565
|
+
const preview = out.slice(0, 200) + (out.length > 200 ? "..." : "");
|
|
566
|
+
return `[task] ${r.exitCode === 0 ? "✓" : "✗"}: ${preview || "(no output)"}`;
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
content: [{ type: "text", text: `Parallel: ${ok}/${results.length} succeeded\n\n${summaries.join("\n\n")}` }],
|
|
571
|
+
details: makeDetails("parallel")(results),
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
// ── Rendering ────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
renderCall(args, theme) {
|
|
578
|
+
if (args.tasks && args.tasks.length > 0) {
|
|
579
|
+
let text = theme.fg("toolTitle", theme.bold("task "))
|
|
580
|
+
+ theme.fg("accent", `parallel (${args.tasks.length})`);
|
|
581
|
+
if (args.model) text += theme.fg("muted", ` [${args.model}]`);
|
|
582
|
+
for (const t of args.tasks.slice(0, 3)) {
|
|
583
|
+
const taskText = typeof t === "string" ? t : t.task;
|
|
584
|
+
const taskModel = typeof t === "string" ? undefined : t.model;
|
|
585
|
+
const preview = taskText.length > 60 ? `${taskText.slice(0, 60)}...` : taskText;
|
|
586
|
+
let line = `\n ${theme.fg("dim", preview)}`;
|
|
587
|
+
if (taskModel) line += theme.fg("muted", ` [${taskModel}]`);
|
|
588
|
+
text += line;
|
|
589
|
+
}
|
|
590
|
+
if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
|
|
591
|
+
return new Text(text, 0, 0);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const preview = args.task
|
|
595
|
+
? (args.task.length > 80 ? `${args.task.slice(0, 80)}...` : args.task)
|
|
596
|
+
: "...";
|
|
597
|
+
let text = theme.fg("toolTitle", theme.bold("task"));
|
|
598
|
+
if (args.model) text += theme.fg("muted", ` [${args.model}]`);
|
|
599
|
+
text += `\n ${theme.fg("dim", preview)}`;
|
|
600
|
+
return new Text(text, 0, 0);
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
renderResult(result, { expanded }, theme) {
|
|
604
|
+
const details = result.details as TaskDetails | undefined;
|
|
605
|
+
if (!details || details.results.length === 0) {
|
|
606
|
+
const text = result.content[0];
|
|
607
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const renderItems = (items: DisplayItem[], limit?: number) => {
|
|
611
|
+
const show = limit ? items.slice(-limit) : items;
|
|
612
|
+
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
613
|
+
let text = "";
|
|
614
|
+
if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
|
|
615
|
+
for (const item of show) {
|
|
616
|
+
if (item.type === "text") {
|
|
617
|
+
const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
|
|
618
|
+
text += `${theme.fg("toolOutput", preview)}\n`;
|
|
619
|
+
} else {
|
|
620
|
+
text += `${theme.fg("muted", "→ ")}${formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return text.trimEnd();
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// ── Single result ────────────────────────────────────────────
|
|
627
|
+
if (details.mode === "single" && details.results.length === 1) {
|
|
628
|
+
const r = details.results[0];
|
|
629
|
+
const isErr = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
|
630
|
+
const icon = isErr ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
631
|
+
const items = getDisplayItems(r.messages);
|
|
632
|
+
const usage = formatUsage(r.usage, r.model);
|
|
633
|
+
|
|
634
|
+
if (expanded) {
|
|
635
|
+
const c = new Container();
|
|
636
|
+
let hdr = `${icon} ${theme.fg("toolTitle", theme.bold("task"))}`;
|
|
637
|
+
if (isErr && r.stopReason) hdr += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
638
|
+
c.addChild(new Text(hdr, 0, 0));
|
|
639
|
+
if (isErr && r.errorMessage) c.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
|
|
640
|
+
c.addChild(new Spacer(1));
|
|
641
|
+
c.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
642
|
+
c.addChild(new Text(theme.fg("dim", r.task), 0, 0));
|
|
643
|
+
c.addChild(new Spacer(1));
|
|
644
|
+
c.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
|
645
|
+
if (items.length === 0) {
|
|
646
|
+
c.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
647
|
+
} else {
|
|
648
|
+
for (const item of items) {
|
|
649
|
+
if (item.type === "toolCall") {
|
|
650
|
+
c.addChild(new Text(
|
|
651
|
+
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0));
|
|
652
|
+
} else {
|
|
653
|
+
c.addChild(new Text(theme.fg("toolOutput", item.text), 0, 0));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (usage) { c.addChild(new Spacer(1)); c.addChild(new Text(theme.fg("dim", usage), 0, 0)); }
|
|
658
|
+
return c;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold("task"))}`;
|
|
662
|
+
if (isErr && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
663
|
+
if (isErr && r.errorMessage) {
|
|
664
|
+
text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
|
|
665
|
+
} else if (items.length === 0) {
|
|
666
|
+
text += `\n${theme.fg("muted", "(no output)")}`;
|
|
667
|
+
} else {
|
|
668
|
+
text += `\n${renderItems(items, COLLAPSED_ITEMS)}`;
|
|
669
|
+
if (items.length > COLLAPSED_ITEMS) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
670
|
+
}
|
|
671
|
+
if (usage) text += `\n${theme.fg("dim", usage)}`;
|
|
672
|
+
return new Text(text, 0, 0);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Parallel results ─────────────────────────────────────────
|
|
676
|
+
const running = details.results.filter((r) => r.exitCode === -1).length;
|
|
677
|
+
const ok = details.results.filter((r) => r.exitCode === 0).length;
|
|
678
|
+
const fail = details.results.filter((r) => r.exitCode !== 0 && r.exitCode !== -1).length;
|
|
679
|
+
const isRunning = running > 0;
|
|
680
|
+
const icon = isRunning
|
|
681
|
+
? theme.fg("warning", "⏳")
|
|
682
|
+
: fail > 0
|
|
683
|
+
? theme.fg("warning", "◐")
|
|
684
|
+
: theme.fg("success", "✓");
|
|
685
|
+
const status = isRunning
|
|
686
|
+
? `${ok + fail}/${details.results.length} done, ${running} running`
|
|
687
|
+
: `${ok}/${details.results.length} tasks`;
|
|
688
|
+
|
|
689
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold("task "))}${theme.fg("accent", status)}`;
|
|
690
|
+
for (const r of details.results) {
|
|
691
|
+
const rIcon = r.exitCode === -1
|
|
692
|
+
? theme.fg("warning", "⏳")
|
|
693
|
+
: r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
694
|
+
const items = getDisplayItems(r.messages);
|
|
695
|
+
const taskPreview = r.task.length > 50 ? `${r.task.slice(0, 50)}...` : r.task;
|
|
696
|
+
const modelTag = r.model ? theme.fg("muted", ` [${r.model}]`) : "";
|
|
697
|
+
text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("dim", taskPreview)}${modelTag} ${rIcon}`;
|
|
698
|
+
if (items.length === 0) {
|
|
699
|
+
text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
|
|
700
|
+
} else {
|
|
701
|
+
text += `\n${renderItems(items, expanded ? undefined : 5)}`;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (!isRunning) {
|
|
706
|
+
const total = emptyUsage();
|
|
707
|
+
for (const r of details.results) {
|
|
708
|
+
total.input += r.usage.input;
|
|
709
|
+
total.output += r.usage.output;
|
|
710
|
+
total.cacheRead += r.usage.cacheRead;
|
|
711
|
+
total.cacheWrite += r.usage.cacheWrite;
|
|
712
|
+
total.cost += r.usage.cost;
|
|
713
|
+
total.turns += r.usage.turns;
|
|
714
|
+
}
|
|
715
|
+
const u = formatUsage(total);
|
|
716
|
+
if (u) text += `\n\n${theme.fg("dim", `Total: ${u}`)}`;
|
|
717
|
+
}
|
|
718
|
+
if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
719
|
+
return new Text(text, 0, 0);
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
}
|