@narumitw/pi-subagents 0.12.0 → 0.13.1

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/src/subagents.ts CHANGED
@@ -12,808 +12,15 @@
12
12
  * Uses JSON mode to capture structured output from subagents.
13
13
  */
14
14
 
15
- import { spawn } from "node:child_process";
16
- import * as fs from "node:fs";
17
- import * as os from "node:os";
18
- import * as path from "node:path";
19
- import type { AgentToolResult } from "@earendil-works/pi-agent-core";
20
- import type { Message } from "@earendil-works/pi-ai";
21
- import { StringEnum } from "@earendil-works/pi-ai";
22
- import {
23
- type ExtensionAPI,
24
- getAgentDir,
25
- getMarkdownTheme,
26
- withFileMutationQueue,
27
- DynamicBorder,
28
- } from "@earendil-works/pi-coding-agent";
29
- import {
30
- Container,
31
- Markdown,
32
- Spacer,
33
- Text,
34
- type SelectItem,
35
- SelectList,
36
- Key,
37
- matchesKey,
38
- truncateToWidth,
39
- } from "@earendil-works/pi-tui";
40
- import { Type } from "typebox";
41
- import {
42
- THINKING_LEVELS,
43
- type AgentConfig,
44
- type AgentScope,
45
- type AgentSource,
46
- type SubagentAgentConfig,
47
- type SubagentSettings,
48
- type SubagentThinkingLevel,
49
- discoverAgents,
50
- isThinkingLevel,
51
- } from "./agents.js";
52
-
53
- const MAX_PARALLEL_TASKS = 8;
54
- const MAX_CONCURRENCY = 4;
55
- const COLLAPSED_ITEM_COUNT = 10;
56
- const DEFAULT_TIMEOUT_MS = parsePositiveInteger(process.env.PI_SUBAGENT_TIMEOUT_MS) ?? 10 * 60 * 1000;
57
- const KILL_GRACE_MS = 5000;
58
- const STATUS_KEY = "subagents";
59
- const activeStatuses = new Map<string, string>();
60
-
61
- export function parsePositiveInteger(value: string | undefined): number | undefined {
62
- if (!value) return undefined;
63
- const parsed = Number.parseInt(value, 10);
64
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
65
- }
66
-
67
- interface StatusContext {
68
- ui: { setStatus: (key: string, value: string | undefined) => void };
69
- }
70
-
71
- function startSubagentStatus(ctx: StatusContext, toolCallId: string, status: string) {
72
- let cleared = false;
73
-
74
- const update = (nextStatus: string) => {
75
- if (cleared) return;
76
- activeStatuses.set(toolCallId, nextStatus);
77
- publishSubagentStatus(ctx);
78
- };
79
-
80
- update(status);
81
-
82
- return {
83
- update,
84
- clear() {
85
- if (cleared) return;
86
- cleared = true;
87
- activeStatuses.delete(toolCallId);
88
- publishSubagentStatus(ctx);
89
- },
90
- };
91
- }
92
-
93
- function publishSubagentStatus(ctx: StatusContext) {
94
- const statuses = [...activeStatuses.values()];
95
- if (statuses.length === 0) {
96
- ctx.ui.setStatus(STATUS_KEY, undefined);
97
- return;
98
- }
99
-
100
- const suffix = statuses.length > 1 ? ` +${statuses.length - 1}` : "";
101
- ctx.ui.setStatus(STATUS_KEY, `${statuses[0]}${suffix}`);
102
- }
103
-
104
- function singleStatus(agent: string): string {
105
- return `${agent}`;
106
- }
107
-
108
- function chainStatus(step: number, total: number, agent?: string): string {
109
- return `chain ${step}/${total}${agent ? ` ${agent}` : ""}`;
110
- }
111
-
112
- function parallelStatus(done: number, total: number, running: number): string {
113
- return `parallel ${done}/${total} done${running > 0 ? ` ${running} running` : ""}`;
114
- }
115
-
116
- function fanInStatus(agent: string): string {
117
- return `fan-in ${agent}`;
118
- }
119
-
120
- export function formatTokens(count: number): string {
121
- if (count < 1000) return count.toString();
122
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
123
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
124
- return `${(count / 1000000).toFixed(1)}M`;
125
- }
126
-
127
- export function formatUsageStats(
128
- usage: {
129
- input: number;
130
- output: number;
131
- cacheRead: number;
132
- cacheWrite: number;
133
- cost: number;
134
- contextTokens?: number;
135
- turns?: number;
136
- },
137
- model?: string,
138
- thinkingLevel?: SubagentThinkingLevel,
139
- ): string {
140
- const parts: string[] = [];
141
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
142
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
143
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
144
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
145
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
146
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
147
- if (usage.contextTokens && usage.contextTokens > 0) {
148
- parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
149
- }
150
- if (model) parts.push(model);
151
- if (thinkingLevel) parts.push(`thinking:${thinkingLevel}`);
152
- return parts.join(" ");
153
- }
154
-
155
- function formatToolCall(
156
- toolName: string,
157
- args: Record<string, unknown>,
158
- themeFg: (color: any, text: string) => string,
159
- ): string {
160
- const shortenPath = (p: string) => {
161
- const home = os.homedir();
162
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
163
- };
164
-
165
- switch (toolName) {
166
- case "bash": {
167
- const command = (args.command as string) || "...";
168
- const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
169
- return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
170
- }
171
- case "read": {
172
- const rawPath = (args.file_path || args.path || "...") as string;
173
- const filePath = shortenPath(rawPath);
174
- const offset = args.offset as number | undefined;
175
- const limit = args.limit as number | undefined;
176
- let text = themeFg("accent", filePath);
177
- if (offset !== undefined || limit !== undefined) {
178
- const startLine = offset ?? 1;
179
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
180
- text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
181
- }
182
- return themeFg("muted", "read ") + text;
183
- }
184
- case "write": {
185
- const rawPath = (args.file_path || args.path || "...") as string;
186
- const filePath = shortenPath(rawPath);
187
- const content = (args.content || "") as string;
188
- const lines = content.split("\n").length;
189
- let text = themeFg("muted", "write ") + themeFg("accent", filePath);
190
- if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
191
- return text;
192
- }
193
- case "edit": {
194
- const rawPath = (args.file_path || args.path || "...") as string;
195
- return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
196
- }
197
- case "ls": {
198
- const rawPath = (args.path || ".") as string;
199
- return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
200
- }
201
- case "find": {
202
- const pattern = (args.pattern || "*") as string;
203
- const rawPath = (args.path || ".") as string;
204
- return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
205
- }
206
- case "grep": {
207
- const pattern = (args.pattern || "") as string;
208
- const rawPath = (args.path || ".") as string;
209
- return (
210
- themeFg("muted", "grep ") +
211
- themeFg("accent", `/${pattern}/`) +
212
- themeFg("dim", ` in ${shortenPath(rawPath)}`)
213
- );
214
- }
215
- default: {
216
- const argsStr = JSON.stringify(args);
217
- const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
218
- return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
219
- }
220
- }
221
- }
222
-
223
- interface UsageStats {
224
- input: number;
225
- output: number;
226
- cacheRead: number;
227
- cacheWrite: number;
228
- cost: number;
229
- contextTokens: number;
230
- turns: number;
231
- }
232
-
233
- interface SingleResult {
234
- agent: string;
235
- agentSource: AgentSource | "unknown";
236
- task: string;
237
- exitCode: number;
238
- messages: Message[];
239
- stderr: string;
240
- usage: UsageStats;
241
- model?: string;
242
- thinkingLevel?: SubagentThinkingLevel;
243
- stopReason?: string;
244
- errorMessage?: string;
245
- step?: number;
246
- finalOutput?: string;
247
- timedOut?: boolean;
248
- timeoutMs?: number;
249
- }
250
-
251
- interface SubagentDetails {
252
- mode: "single" | "parallel" | "chain";
253
- agentScope: AgentScope;
254
- projectAgentsDir: string | null;
255
- results: SingleResult[];
256
- aggregator?: SingleResult;
257
- }
258
-
259
- function getFinalOutput(messages: Message[]): string {
260
- for (let i = messages.length - 1; i >= 0; i--) {
261
- const msg = messages[i];
262
- if (msg.role === "assistant") {
263
- for (const part of msg.content) {
264
- if (part.type === "text") return part.text;
265
- }
266
- }
267
- }
268
- return "";
269
- }
270
-
271
- function getResultFinalOutput(result: SingleResult): string {
272
- return result.finalOutput ?? getFinalOutput(result.messages);
273
- }
274
-
275
- function buildFanInContext(results: SingleResult[]): string {
276
- return results
277
- .map((result, index) => {
278
- const status = result.exitCode === 0 ? "completed" : result.exitCode === -1 ? "running" : "failed";
279
- const output = getResultFinalOutput(result);
280
- const error = result.errorMessage || result.stderr.trim();
281
- return [
282
- `## Result ${index + 1}: ${result.agent} (${status})`,
283
- `Task: ${result.task}`,
284
- output ? `Output:\n${output}` : error ? `Error:\n${error}` : "Output: (no output)",
285
- ].join("\n\n");
286
- })
287
- .join("\n\n---\n\n");
288
- }
289
-
290
- type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
291
-
292
- function getDisplayItems(messages: Message[]): DisplayItem[] {
293
- const items: DisplayItem[] = [];
294
- for (const msg of messages) {
295
- if (msg.role === "assistant") {
296
- for (const part of msg.content) {
297
- if (part.type === "text") items.push({ type: "text", text: part.text });
298
- else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
299
- }
300
- }
301
- }
302
- return items;
303
- }
304
-
305
- async function mapWithConcurrencyLimit<TIn, TOut>(
306
- items: TIn[],
307
- concurrency: number,
308
- fn: (item: TIn, index: number) => Promise<TOut>,
309
- ): Promise<TOut[]> {
310
- if (items.length === 0) return [];
311
- const limit = Math.max(1, Math.min(concurrency, items.length));
312
- const results: TOut[] = new Array(items.length);
313
- let nextIndex = 0;
314
- const workers = new Array(limit).fill(null).map(async () => {
315
- while (true) {
316
- const current = nextIndex++;
317
- if (current >= items.length) return;
318
- results[current] = await fn(items[current], current);
319
- }
320
- });
321
- await Promise.all(workers);
322
- return results;
323
- }
324
-
325
- async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
326
- const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
327
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
328
- const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
329
- await withFileMutationQueue(filePath, async () => {
330
- await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
331
- });
332
- return { dir: tmpDir, filePath };
333
- }
334
-
335
- export function buildPiArgs(options: {
336
- model?: string;
337
- thinkingLevel?: SubagentThinkingLevel;
338
- tools?: string[];
339
- systemPromptPath?: string;
340
- task: string;
341
- }): string[] {
342
- const args: string[] = ["--mode", "json", "-p", "--no-session"];
343
- if (options.model) args.push("--model", options.model);
344
- if (options.thinkingLevel) args.push("--thinking", options.thinkingLevel);
345
- if (Array.isArray(options.tools)) {
346
- if (options.tools.length > 0) args.push("--tools", options.tools.join(","));
347
- else args.push("--no-tools");
348
- }
349
- if (options.systemPromptPath) args.push("--append-system-prompt", options.systemPromptPath);
350
- args.push(`Task: ${options.task}`);
351
- return args;
352
- }
353
-
354
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
355
- const currentScript = process.argv[1];
356
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
357
- if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
358
- return { command: process.execPath, args: [currentScript, ...args] };
359
- }
360
-
361
- const execName = path.basename(process.execPath).toLowerCase();
362
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
363
- if (!isGenericRuntime) {
364
- return { command: process.execPath, args };
365
- }
366
-
367
- return { command: "pi", args };
368
- }
369
-
370
- function terminateProcess(proc: ReturnType<typeof spawn>) {
371
- if (proc.killed) return;
372
- if (process.platform !== "win32" && proc.pid) {
373
- try {
374
- process.kill(-proc.pid, "SIGTERM");
375
- } catch {
376
- proc.kill("SIGTERM");
377
- }
378
- } else {
379
- proc.kill("SIGTERM");
380
- }
381
-
382
- setTimeout(() => {
383
- if (proc.killed) return;
384
- if (process.platform !== "win32" && proc.pid) {
385
- try {
386
- process.kill(-proc.pid, "SIGKILL");
387
- } catch {
388
- proc.kill("SIGKILL");
389
- }
390
- } else {
391
- proc.kill("SIGKILL");
392
- }
393
- }, KILL_GRACE_MS).unref();
394
- }
395
-
396
- type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
397
-
398
- async function runSingleAgent(
399
- defaultCwd: string,
400
- agents: AgentConfig[],
401
- agentName: string,
402
- task: string,
403
- cwd: string | undefined,
404
- step: number | undefined,
405
- signal: AbortSignal | undefined,
406
- thinkingLevel: SubagentThinkingLevel | undefined,
407
- timeoutMs: number,
408
- onUpdate: OnUpdateCallback | undefined,
409
- makeDetails: (results: SingleResult[]) => SubagentDetails,
410
- ): Promise<SingleResult> {
411
- const agent = agents.find((a) => a.name === agentName);
412
-
413
- if (!agent) {
414
- const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
415
- return {
416
- agent: agentName,
417
- agentSource: "unknown",
418
- task,
419
- exitCode: 1,
420
- messages: [],
421
- stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
422
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
423
- thinkingLevel,
424
- step,
425
- finalOutput: "",
426
- };
427
- }
428
-
429
- let tmpPromptDir: string | null = null;
430
- let tmpPromptPath: string | null = null;
431
-
432
- const currentResult: SingleResult = {
433
- agent: agentName,
434
- agentSource: agent.source,
435
- task,
436
- exitCode: 0,
437
- messages: [],
438
- stderr: "",
439
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
440
- model: agent.model ?? undefined,
441
- thinkingLevel,
442
- step,
443
- timeoutMs,
444
- };
445
-
446
- const emitUpdate = () => {
447
- currentResult.finalOutput = getFinalOutput(currentResult.messages);
448
- if (onUpdate) {
449
- onUpdate({
450
- content: [{ type: "text", text: currentResult.finalOutput || "(running...)" }],
451
- details: makeDetails([currentResult]),
452
- });
453
- }
454
- };
455
-
456
- try {
457
- if (agent.systemPrompt.trim()) {
458
- const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
459
- tmpPromptDir = tmp.dir;
460
- tmpPromptPath = tmp.filePath;
461
- }
462
-
463
- const args = buildPiArgs({
464
- model: agent.model,
465
- thinkingLevel,
466
- tools: agent.tools,
467
- systemPromptPath: tmpPromptPath ?? undefined,
468
- task,
469
- });
470
- let wasAborted = false;
471
- let timedOut = false;
472
-
473
- const exitCode = await new Promise<number>((resolve) => {
474
- const invocation = getPiInvocation(args);
475
- let settled = false;
476
- const finish = (code: number) => {
477
- if (settled) return;
478
- settled = true;
479
- clearTimeout(timeout);
480
- resolve(code);
481
- };
482
- const proc = spawn(invocation.command, invocation.args, {
483
- cwd: cwd ?? defaultCwd,
484
- detached: process.platform !== "win32",
485
- shell: false,
486
- stdio: ["ignore", "pipe", "pipe"],
487
- });
488
- let buffer = "";
489
- const timeout = setTimeout(() => {
490
- timedOut = true;
491
- currentResult.timedOut = true;
492
- currentResult.stopReason = "timeout";
493
- currentResult.errorMessage = `Subagent timed out after ${timeoutMs}ms`;
494
- currentResult.stderr += `${currentResult.stderr ? "\n" : ""}Subagent timed out after ${timeoutMs}ms.`;
495
- emitUpdate();
496
- terminateProcess(proc);
497
- }, timeoutMs);
498
- timeout.unref();
499
-
500
- const processLine = (line: string) => {
501
- if (!line.trim()) return;
502
- let event: any;
503
- try {
504
- event = JSON.parse(line);
505
- } catch {
506
- return;
507
- }
508
-
509
- if (event.type === "message_end" && event.message) {
510
- const msg = event.message as Message;
511
- currentResult.messages.push(msg);
512
-
513
- if (msg.role === "assistant") {
514
- currentResult.usage.turns++;
515
- const usage = msg.usage;
516
- if (usage) {
517
- currentResult.usage.input += usage.input || 0;
518
- currentResult.usage.output += usage.output || 0;
519
- currentResult.usage.cacheRead += usage.cacheRead || 0;
520
- currentResult.usage.cacheWrite += usage.cacheWrite || 0;
521
- currentResult.usage.cost += usage.cost?.total || 0;
522
- currentResult.usage.contextTokens = usage.totalTokens || 0;
523
- }
524
- if (!currentResult.model && msg.model) currentResult.model = msg.model;
525
- if (msg.stopReason) currentResult.stopReason = msg.stopReason;
526
- if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
527
- }
528
- emitUpdate();
529
- }
530
-
531
- if (event.type === "tool_result_end" && event.message) {
532
- currentResult.messages.push(event.message as Message);
533
- emitUpdate();
534
- }
535
- };
536
-
537
- proc.stdout.on("data", (data) => {
538
- buffer += data.toString();
539
- const lines = buffer.split("\n");
540
- buffer = lines.pop() || "";
541
- for (const line of lines) processLine(line);
542
- });
543
-
544
- proc.stderr.on("data", (data) => {
545
- currentResult.stderr += data.toString();
546
- });
547
-
548
- proc.on("close", (code) => {
549
- if (buffer.trim()) processLine(buffer);
550
- finish(timedOut ? 124 : (code ?? 0));
551
- });
552
-
553
- proc.on("error", (error) => {
554
- currentResult.errorMessage = error.message;
555
- currentResult.stderr += `${currentResult.stderr ? "\n" : ""}${error.message}`;
556
- finish(1);
557
- });
558
-
559
- if (signal) {
560
- const killProc = () => {
561
- wasAborted = true;
562
- currentResult.stopReason = "aborted";
563
- currentResult.errorMessage = "Subagent was aborted";
564
- terminateProcess(proc);
565
- };
566
- if (signal.aborted) killProc();
567
- else signal.addEventListener("abort", killProc, { once: true });
568
- }
569
- });
570
-
571
- currentResult.exitCode = exitCode;
572
- currentResult.finalOutput = getFinalOutput(currentResult.messages);
573
- if (wasAborted && !timedOut) throw new Error("Subagent was aborted");
574
- return currentResult;
575
- } finally {
576
- if (tmpPromptPath)
577
- try {
578
- fs.unlinkSync(tmpPromptPath);
579
- } catch {
580
- /* ignore */
581
- }
582
- if (tmpPromptDir)
583
- try {
584
- fs.rmdirSync(tmpPromptDir);
585
- } catch {
586
- /* ignore */
587
- }
588
- }
589
- }
590
-
591
- const TimeoutMs = Type.Number({
592
- description:
593
- "Hard timeout in milliseconds for each subagent subprocess. Defaults to PI_SUBAGENT_TIMEOUT_MS or 600000.",
594
- minimum: 1,
595
- });
596
-
597
- const ThinkingLevelSchema = StringEnum(THINKING_LEVELS, {
598
- description: "Pi thinking level for the subagent process: off, minimal, low, medium, high, or xhigh.",
599
- });
600
-
601
- const TaskItem = Type.Object({
602
- agent: Type.String({ description: "Name of the agent to invoke" }),
603
- task: Type.String({ description: "Task to delegate to the agent" }),
604
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
605
- timeoutMs: Type.Optional(TimeoutMs),
606
- thinkingLevel: Type.Optional(ThinkingLevelSchema),
607
- });
608
-
609
- const ChainItem = Type.Object({
610
- agent: Type.String({ description: "Name of the agent to invoke" }),
611
- task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
612
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
613
- timeoutMs: Type.Optional(TimeoutMs),
614
- thinkingLevel: Type.Optional(ThinkingLevelSchema),
615
- });
616
-
617
- const AggregatorItem = Type.Object({
618
- agent: Type.String({ description: "Name of the fan-in agent to invoke after parallel tasks complete" }),
619
- task: Type.String({ description: "Fan-in task. Use {previous} to include all parallel outputs." }),
620
- cwd: Type.Optional(Type.String({ description: "Working directory for the aggregator process" })),
621
- timeoutMs: Type.Optional(TimeoutMs),
622
- thinkingLevel: Type.Optional(ThinkingLevelSchema),
623
- });
624
-
625
- const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
626
- description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
627
- default: "user",
628
- });
629
-
630
- const SubagentParams = Type.Object({
631
- agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
632
- task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
633
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
634
- chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
635
- aggregator: Type.Optional(AggregatorItem),
636
- agentScope: Type.Optional(AgentScopeSchema),
637
- confirmProjectAgents: Type.Optional(
638
- Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
639
- ),
640
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
641
- timeoutMs: Type.Optional(TimeoutMs),
642
- thinkingLevel: Type.Optional(ThinkingLevelSchema),
643
- });
644
-
645
- // ---- Settings helpers ----
646
-
647
- function hasOwn(obj: object, key: PropertyKey): boolean {
648
- return Object.hasOwn(obj, key);
649
- }
650
-
651
- function isPlainObject(value: unknown): value is Record<string, unknown> {
652
- return typeof value === "object" && value !== null && !Array.isArray(value);
653
- }
654
-
655
- function isStringArray(value: unknown): value is string[] {
656
- return Array.isArray(value) && value.every((item) => typeof item === "string");
657
- }
658
-
659
- function isPositiveNumber(value: unknown): value is number {
660
- return typeof value === "number" && Number.isFinite(value) && value >= 1;
661
- }
662
-
663
- export function normalizeAgentSettings(value: unknown): SubagentAgentConfig | undefined {
664
- if (!isPlainObject(value)) return undefined;
665
-
666
- const config: SubagentAgentConfig = {};
667
- let hasKnownField = false;
668
-
669
- if (hasOwn(value, "tools")) {
670
- if (!isStringArray(value.tools)) return undefined;
671
- config.tools = value.tools;
672
- hasKnownField = true;
673
- }
674
-
675
- if (hasOwn(value, "model")) {
676
- if (value.model !== null && typeof value.model !== "string") return undefined;
677
- config.model = value.model;
678
- hasKnownField = true;
679
- }
680
-
681
- if (hasOwn(value, "thinkingLevel")) {
682
- if (value.thinkingLevel !== null && !isThinkingLevel(value.thinkingLevel)) return undefined;
683
- config.thinkingLevel = value.thinkingLevel;
684
- hasKnownField = true;
685
- }
686
-
687
- if (hasOwn(value, "timeoutMs")) {
688
- if (value.timeoutMs !== null && !isPositiveNumber(value.timeoutMs)) return undefined;
689
- config.timeoutMs = value.timeoutMs;
690
- hasKnownField = true;
691
- }
692
-
693
- return hasKnownField ? config : undefined;
694
- }
695
-
696
- export function normalizeSubagentSettings(value: unknown): SubagentSettings | undefined {
697
- if (!isPlainObject(value)) return undefined;
698
- if (!hasOwn(value, "agents")) return {};
699
- if (!isPlainObject(value.agents)) return undefined;
700
-
701
- const agents: Record<string, SubagentAgentConfig> = {};
702
- for (const [name, rawConfig] of Object.entries(value.agents)) {
703
- const config = normalizeAgentSettings(rawConfig);
704
- if (config) agents[name] = config;
705
- }
706
-
707
- return Object.keys(agents).length > 0 ? { agents } : {};
708
- }
709
-
710
- function readSubagentSettings(): SubagentSettings | undefined {
711
- const configPath = path.join(getAgentDir(), "pi-subagents-config.json");
712
- if (!fs.existsSync(configPath)) return undefined;
713
- try {
714
- return normalizeSubagentSettings(JSON.parse(fs.readFileSync(configPath, "utf-8")));
715
- } catch {
716
- return undefined;
717
- }
718
- }
719
-
720
- function saveSubagentConfig(settings: SubagentSettings): void {
721
- const agentDir = getAgentDir();
722
- fs.mkdirSync(agentDir, { recursive: true });
723
-
724
- const configPath = path.join(agentDir, "pi-subagents-config.json");
725
- fs.writeFileSync(configPath, `${JSON.stringify(settings, null, "\t")}\n`, "utf-8");
726
- }
727
-
728
- export function uniqueToolNames(tools: string[]): string[] {
729
- return [...new Set(tools)];
730
- }
731
-
732
- export function sameToolSet(left: string[], right: string[]): boolean {
733
- const leftSet = new Set(left);
734
- const rightSet = new Set(right);
735
- if (leftSet.size !== rightSet.size) return false;
736
- return [...leftSet].every((tool) => rightSet.has(tool));
737
- }
738
-
739
- export function resolveSubagentThinkingLevel(
740
- agents: readonly Pick<AgentConfig, "name" | "thinkingLevel">[],
741
- agentName: string,
742
- topLevelThinkingLevel?: SubagentThinkingLevel,
743
- localThinkingLevel?: SubagentThinkingLevel,
744
- ): SubagentThinkingLevel | undefined {
745
- return localThinkingLevel ?? topLevelThinkingLevel ?? agents.find((agent) => agent.name === agentName)?.thinkingLevel;
746
- }
747
-
748
- function hasAnyAgentOverride(config: SubagentAgentConfig): boolean {
749
- return (
750
- hasOwn(config, "tools") ||
751
- hasOwn(config, "model") ||
752
- hasOwn(config, "thinkingLevel") ||
753
- hasOwn(config, "timeoutMs")
754
- );
755
- }
756
-
757
- // ---- Tool toggle component ----
758
-
759
- class ToolToggleList {
760
- private items: { name: string; selected: boolean }[];
761
- private cursor = 0;
762
- private cachedWidth?: number;
763
- private cachedLines?: string[];
764
- onDone?: (selected: string[]) => void;
765
- onCancel?: () => void;
766
-
767
- constructor(tools: string[], selected: Set<string>) {
768
- this.items = tools.map((name) => ({ name, selected: selected.has(name) }));
769
- }
770
-
771
- private getSelectedNames(): string[] {
772
- return this.items.filter((i) => i.selected).map((i) => i.name);
773
- }
774
-
775
- handleInput(data: string): void {
776
- if (matchesKey(data, Key.escape)) {
777
- this.onCancel?.();
778
- return;
779
- }
780
- if (data === "s" || data === "S") {
781
- this.onDone?.(this.getSelectedNames());
782
- return;
783
- }
784
- if (this.items.length === 0) return;
785
-
786
- if (matchesKey(data, Key.up) && this.cursor > 0) {
787
- this.cursor--;
788
- this.invalidate();
789
- } else if (matchesKey(data, Key.down) && this.cursor < this.items.length - 1) {
790
- this.cursor++;
791
- this.invalidate();
792
- } else if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
793
- this.items[this.cursor].selected = !this.items[this.cursor].selected;
794
- this.invalidate();
795
- }
796
- }
797
-
798
- render(width: number): string[] {
799
- if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
800
- this.cachedWidth = width;
801
- this.cachedLines = this.items.map((item, i) => {
802
- const pointer = i === this.cursor ? ">" : " ";
803
- const check = item.selected ? "✓" : "○";
804
- return truncateToWidth(`${pointer} ${check} ${item.name}`, width);
805
- });
806
- return this.cachedLines;
807
- }
808
-
809
- invalidate(): void {
810
- this.cachedWidth = undefined;
811
- this.cachedLines = undefined;
812
- }
813
- }
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { registerSubagentConfigCommand } from "./config-ui.js";
17
+ import { SubagentParams } from "./params.js";
18
+ import { executeSubagent } from "./execution.js";
19
+ import { renderSubagentCall, renderSubagentResult } from "./render.js";
20
+ import type { SubagentDetails } from "./runner.js";
814
21
 
815
22
  export default function (pi: ExtensionAPI) {
816
- pi.registerTool({
23
+ pi.registerTool<typeof SubagentParams, SubagentDetails>({
817
24
  name: "subagent",
818
25
  label: "Subagent",
819
26
  description: [
@@ -838,891 +45,28 @@ export default function (pi: ExtensionAPI) {
838
45
  parameters: SubagentParams,
839
46
 
840
47
  async execute(toolCallId, params, signal, onUpdate, ctx) {
841
- const agentScope: AgentScope = params.agentScope ?? "user";
842
- const config = readSubagentSettings();
843
- const discovery = discoverAgents(ctx.cwd, agentScope, config);
844
- const agents = discovery.agents;
845
- const confirmProjectAgents = params.confirmProjectAgents ?? true;
846
- const resolveTimeoutMs = (agentName: string, localTimeoutMs?: number) =>
847
- localTimeoutMs ??
848
- params.timeoutMs ??
849
- agents.find((agent) => agent.name === agentName)?.timeoutMs ??
850
- DEFAULT_TIMEOUT_MS;
851
- const resolveThinkingLevel = (agentName: string, localThinkingLevel?: SubagentThinkingLevel) =>
852
- resolveSubagentThinkingLevel(agents, agentName, params.thinkingLevel, localThinkingLevel);
853
-
854
- const hasChain = (params.chain?.length ?? 0) > 0;
855
- const hasTasks = (params.tasks?.length ?? 0) > 0;
856
- const hasSingle = Boolean(params.agent && params.task);
857
- const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
858
-
859
- const makeDetails =
860
- (mode: "single" | "parallel" | "chain") =>
861
- (results: SingleResult[], aggregator?: SingleResult): SubagentDetails => ({
862
- mode,
863
- agentScope,
864
- projectAgentsDir: discovery.projectAgentsDir,
865
- results,
866
- aggregator,
867
- });
868
-
869
- if (modeCount !== 1 || (params.aggregator && !hasTasks)) {
870
- const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
871
- const reason =
872
- modeCount !== 1
873
- ? "Provide exactly one mode."
874
- : "Aggregator is only valid with parallel tasks.";
875
- return {
876
- content: [
877
- {
878
- type: "text",
879
- text: `Invalid parameters. ${reason}\nAvailable agents: ${available}`,
880
- },
881
- ],
882
- details: makeDetails("single")([]),
883
- };
884
- }
885
-
886
- if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
887
- const requestedAgentNames = new Set<string>();
888
- if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
889
- if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
890
- if (params.aggregator) requestedAgentNames.add(params.aggregator.agent);
891
- if (params.agent) requestedAgentNames.add(params.agent);
892
-
893
- const projectAgentsRequested = Array.from(requestedAgentNames)
894
- .map((name) => agents.find((a) => a.name === name))
895
- .filter((a): a is AgentConfig => a?.source === "project");
896
-
897
- if (projectAgentsRequested.length > 0) {
898
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
899
- const dir = discovery.projectAgentsDir ?? "(unknown)";
900
- const ok = await ctx.ui.confirm(
901
- "Run project-local agents?",
902
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
903
- );
904
- if (!ok)
905
- return {
906
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
907
- details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
908
- };
909
- }
910
- }
911
-
912
- if (params.chain && params.chain.length > 0) {
913
- const results: SingleResult[] = [];
914
- let previousOutput = "";
915
- const status = startSubagentStatus(ctx, toolCallId, chainStatus(0, params.chain.length));
916
-
917
- try {
918
- for (let i = 0; i < params.chain.length; i++) {
919
- const step = params.chain[i];
920
- status.update(chainStatus(i + 1, params.chain.length, step.agent));
921
- const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
922
-
923
- // Create update callback that includes all previous results
924
- const chainUpdate: OnUpdateCallback | undefined = onUpdate
925
- ? (partial) => {
926
- // Combine completed results with current streaming result
927
- const currentResult = partial.details?.results[0];
928
- if (currentResult) {
929
- const allResults = [...results, currentResult];
930
- onUpdate({
931
- content: partial.content,
932
- details: makeDetails("chain")(allResults),
933
- });
934
- }
935
- }
936
- : undefined;
937
-
938
- const result = await runSingleAgent(
939
- ctx.cwd,
940
- agents,
941
- step.agent,
942
- taskWithContext,
943
- step.cwd,
944
- i + 1,
945
- signal,
946
- resolveThinkingLevel(step.agent, step.thinkingLevel),
947
- resolveTimeoutMs(step.agent, step.timeoutMs),
948
- chainUpdate,
949
- makeDetails("chain"),
950
- );
951
- results.push(result);
952
-
953
- const isError =
954
- result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
955
- if (isError) {
956
- const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
957
- return {
958
- content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
959
- details: makeDetails("chain")(results),
960
- isError: true,
961
- };
962
- }
963
- previousOutput = getResultFinalOutput(result);
964
- }
965
- return {
966
- content: [{ type: "text", text: getResultFinalOutput(results[results.length - 1]) || "(no output)" }],
967
- details: makeDetails("chain")(results),
968
- };
969
- } finally {
970
- status.clear();
971
- }
972
- }
973
-
974
- if (params.tasks && params.tasks.length > 0) {
975
- if (params.tasks.length > MAX_PARALLEL_TASKS)
976
- return {
977
- content: [
978
- {
979
- type: "text",
980
- text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
981
- },
982
- ],
983
- details: makeDetails("parallel")([]),
984
- };
985
-
986
- const status = startSubagentStatus(ctx, toolCallId, parallelStatus(0, params.tasks.length, params.tasks.length));
987
-
988
- try {
989
- // Track all results for streaming updates
990
- const allResults: SingleResult[] = new Array(params.tasks.length);
991
-
992
- // Initialize placeholder results
993
- for (let i = 0; i < params.tasks.length; i++) {
994
- allResults[i] = {
995
- agent: params.tasks[i].agent,
996
- agentSource: "unknown",
997
- task: params.tasks[i].task,
998
- exitCode: -1, // -1 = still running
999
- messages: [],
1000
- stderr: "",
1001
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
1002
- thinkingLevel: resolveThinkingLevel(params.tasks[i].agent, params.tasks[i].thinkingLevel),
1003
- finalOutput: "",
1004
- };
1005
- }
1006
-
1007
- let doneCount = 0;
1008
- let runningCount = params.tasks.length;
1009
-
1010
- const emitParallelUpdate = () => {
1011
- status.update(parallelStatus(doneCount, allResults.length, runningCount));
1012
- if (onUpdate) {
1013
- onUpdate({
1014
- content: [
1015
- {
1016
- type: "text",
1017
- text: `Parallel: ${doneCount}/${allResults.length} done, ${runningCount} running...`,
1018
- },
1019
- ],
1020
- details: makeDetails("parallel")([...allResults]),
1021
- });
1022
- }
1023
- };
1024
-
1025
- const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
1026
- const result = await runSingleAgent(
1027
- ctx.cwd,
1028
- agents,
1029
- t.agent,
1030
- t.task,
1031
- t.cwd,
1032
- undefined,
1033
- signal,
1034
- resolveThinkingLevel(t.agent, t.thinkingLevel),
1035
- resolveTimeoutMs(t.agent, t.timeoutMs),
1036
- // Per-task update callback
1037
- (partial) => {
1038
- if (partial.details?.results[0]) {
1039
- allResults[index] = { ...partial.details.results[0], exitCode: -1 };
1040
- emitParallelUpdate();
1041
- }
1042
- },
1043
- makeDetails("parallel"),
1044
- );
1045
- allResults[index] = result;
1046
- doneCount += 1;
1047
- runningCount -= 1;
1048
- emitParallelUpdate();
1049
- return result;
1050
- });
1051
-
1052
- let aggregatorResult: SingleResult | undefined;
1053
- if (params.aggregator) {
1054
- const aggregator = params.aggregator;
1055
- status.update(fanInStatus(aggregator.agent));
1056
- const fanInContext = buildFanInContext(results);
1057
- const aggregatorTask = aggregator.task.includes("{previous}")
1058
- ? aggregator.task.replace(/\{previous\}/g, fanInContext)
1059
- : `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`;
1060
- aggregatorResult = await runSingleAgent(
1061
- ctx.cwd,
1062
- agents,
1063
- aggregator.agent,
1064
- aggregatorTask,
1065
- aggregator.cwd,
1066
- undefined,
1067
- signal,
1068
- resolveThinkingLevel(aggregator.agent, aggregator.thinkingLevel),
1069
- resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
1070
- (partial) => {
1071
- status.update(fanInStatus(aggregator.agent));
1072
- if (onUpdate && partial.details?.results[0]) {
1073
- onUpdate({
1074
- content: partial.content,
1075
- details: makeDetails("parallel")(results, partial.details.results[0]),
1076
- });
1077
- }
1078
- },
1079
- makeDetails("parallel"),
1080
- );
1081
- }
1082
-
1083
- const successCount = results.filter((r) => r.exitCode === 0).length;
1084
- const summaries = results.map((r) => {
1085
- const output = getResultFinalOutput(r);
1086
- const error = r.errorMessage || r.stderr.trim();
1087
- const summaryText = output || error;
1088
- const preview = summaryText.slice(0, 160) + (summaryText.length > 160 ? "..." : "");
1089
- return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
1090
- });
1091
- const aggregatorOutput = aggregatorResult ? getResultFinalOutput(aggregatorResult) : "";
1092
- const aggregatorError = aggregatorResult?.errorMessage || aggregatorResult?.stderr.trim() || "";
1093
- return {
1094
- content: [
1095
- {
1096
- type: "text",
1097
- text: aggregatorResult
1098
- ? aggregatorOutput || aggregatorError || `(aggregator ${aggregatorResult.agent} produced no output)`
1099
- : `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
1100
- },
1101
- ],
1102
- details: makeDetails("parallel")(results, aggregatorResult),
1103
- isError: aggregatorResult
1104
- ? aggregatorResult.exitCode !== 0 ||
1105
- aggregatorResult.stopReason === "error" ||
1106
- aggregatorResult.stopReason === "aborted"
1107
- : undefined,
1108
- };
1109
- } finally {
1110
- status.clear();
1111
- }
1112
- }
1113
-
1114
- if (params.agent && params.task) {
1115
- const status = startSubagentStatus(ctx, toolCallId, singleStatus(params.agent));
1116
-
1117
- try {
1118
- const result = await runSingleAgent(
1119
- ctx.cwd,
1120
- agents,
1121
- params.agent,
1122
- params.task,
1123
- params.cwd,
1124
- undefined,
1125
- signal,
1126
- resolveThinkingLevel(params.agent, params.thinkingLevel),
1127
- resolveTimeoutMs(params.agent, params.timeoutMs),
1128
- onUpdate,
1129
- makeDetails("single"),
1130
- );
1131
- const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
1132
- if (isError) {
1133
- const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
1134
- return {
1135
- content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
1136
- details: makeDetails("single")([result]),
1137
- isError: true,
1138
- };
1139
- }
1140
- return {
1141
- content: [{ type: "text", text: getResultFinalOutput(result) || "(no output)" }],
1142
- details: makeDetails("single")([result]),
1143
- };
1144
- } finally {
1145
- status.clear();
1146
- }
1147
- }
1148
-
1149
- const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
1150
- return {
1151
- content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
1152
- details: makeDetails("single")([]),
1153
- };
48
+ return executeSubagent(toolCallId, params, signal, onUpdate, ctx);
1154
49
  },
1155
50
 
1156
- renderCall(args, theme, _context) {
1157
- const scope: AgentScope = args.agentScope ?? "user";
1158
- if (args.chain && args.chain.length > 0) {
1159
- let text =
1160
- theme.fg("toolTitle", theme.bold("subagent ")) +
1161
- theme.fg("accent", `chain (${args.chain.length} steps)`) +
1162
- theme.fg("muted", ` [${scope}]`);
1163
- for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
1164
- const step = args.chain[i];
1165
- // Clean up {previous} placeholder for display
1166
- const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
1167
- const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
1168
- text +=
1169
- "\n " +
1170
- theme.fg("muted", `${i + 1}.`) +
1171
- " " +
1172
- theme.fg("accent", step.agent) +
1173
- theme.fg("dim", ` ${preview}`);
1174
- }
1175
- if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
1176
- return new Text(text, 0, 0);
1177
- }
1178
- if (args.tasks && args.tasks.length > 0) {
1179
- let text =
1180
- theme.fg("toolTitle", theme.bold("subagent ")) +
1181
- theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
1182
- theme.fg("muted", ` [${scope}]`);
1183
- for (const t of args.tasks.slice(0, 3)) {
1184
- const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
1185
- text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
1186
- }
1187
- if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
1188
- if (args.aggregator) {
1189
- const preview =
1190
- args.aggregator.task.length > 40 ? `${args.aggregator.task.slice(0, 40)}...` : args.aggregator.task;
1191
- text += `\n ${theme.fg("muted", "fan-in → ")}${theme.fg("accent", args.aggregator.agent)}${theme.fg(
1192
- "dim",
1193
- ` ${preview}`,
1194
- )}`;
1195
- }
1196
- return new Text(text, 0, 0);
1197
- }
1198
- const agentName = args.agent || "...";
1199
- const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
1200
- let text =
1201
- theme.fg("toolTitle", theme.bold("subagent ")) +
1202
- theme.fg("accent", agentName) +
1203
- theme.fg("muted", ` [${scope}]`);
1204
- text += `\n ${theme.fg("dim", preview)}`;
1205
- return new Text(text, 0, 0);
51
+ renderCall(args, theme) {
52
+ return renderSubagentCall(args, theme);
1206
53
  },
1207
54
 
1208
- renderResult(result, { expanded }, theme, _context) {
1209
- const details = result.details as SubagentDetails | undefined;
1210
- if (!details || details.results.length === 0) {
1211
- const text = result.content[0];
1212
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
1213
- }
1214
-
1215
- const mdTheme = getMarkdownTheme();
1216
-
1217
- const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
1218
- const toShow = limit ? items.slice(-limit) : items;
1219
- const skipped = limit && items.length > limit ? items.length - limit : 0;
1220
- let text = "";
1221
- if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
1222
- for (const item of toShow) {
1223
- if (item.type === "text") {
1224
- const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
1225
- text += `${theme.fg("toolOutput", preview)}\n`;
1226
- } else {
1227
- text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
1228
- }
1229
- }
1230
- return text.trimEnd();
1231
- };
1232
-
1233
- if (details.mode === "single" && details.results.length === 1) {
1234
- const r = details.results[0];
1235
- const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
1236
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
1237
- const displayItems = getDisplayItems(r.messages);
1238
- const finalOutput = getResultFinalOutput(r);
1239
-
1240
- if (expanded) {
1241
- const container = new Container();
1242
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
1243
- if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
1244
- container.addChild(new Text(header, 0, 0));
1245
- if (isError && r.errorMessage)
1246
- container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
1247
- container.addChild(new Spacer(1));
1248
- container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
1249
- container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
1250
- container.addChild(new Spacer(1));
1251
- container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
1252
- if (displayItems.length === 0 && !finalOutput) {
1253
- container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
1254
- } else {
1255
- for (const item of displayItems) {
1256
- if (item.type === "toolCall")
1257
- container.addChild(
1258
- new Text(
1259
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
1260
- 0,
1261
- 0,
1262
- ),
1263
- );
1264
- }
1265
- if (finalOutput) {
1266
- container.addChild(new Spacer(1));
1267
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1268
- }
1269
- }
1270
- const usageStr = formatUsageStats(r.usage, r.model, r.thinkingLevel);
1271
- if (usageStr) {
1272
- container.addChild(new Spacer(1));
1273
- container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
1274
- }
1275
- return container;
1276
- }
1277
-
1278
- let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
1279
- if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
1280
- if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
1281
- else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
1282
- else {
1283
- text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
1284
- if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1285
- }
1286
- const usageStr = formatUsageStats(r.usage, r.model, r.thinkingLevel);
1287
- if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
1288
- return new Text(text, 0, 0);
1289
- }
1290
-
1291
- const aggregateUsage = (results: SingleResult[]) => {
1292
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
1293
- for (const r of results) {
1294
- total.input += r.usage.input;
1295
- total.output += r.usage.output;
1296
- total.cacheRead += r.usage.cacheRead;
1297
- total.cacheWrite += r.usage.cacheWrite;
1298
- total.cost += r.usage.cost;
1299
- total.turns += r.usage.turns;
1300
- }
1301
- return total;
1302
- };
1303
-
1304
- if (details.mode === "chain") {
1305
- const successCount = details.results.filter((r) => r.exitCode === 0).length;
1306
- const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
1307
-
1308
- if (expanded) {
1309
- const container = new Container();
1310
- container.addChild(
1311
- new Text(
1312
- icon +
1313
- " " +
1314
- theme.fg("toolTitle", theme.bold("chain ")) +
1315
- theme.fg("accent", `${successCount}/${details.results.length} steps`),
1316
- 0,
1317
- 0,
1318
- ),
1319
- );
1320
-
1321
- for (const r of details.results) {
1322
- const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1323
- const displayItems = getDisplayItems(r.messages);
1324
- const finalOutput = getResultFinalOutput(r);
1325
-
1326
- container.addChild(new Spacer(1));
1327
- container.addChild(
1328
- new Text(
1329
- `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
1330
- 0,
1331
- 0,
1332
- ),
1333
- );
1334
- container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
1335
-
1336
- // Show tool calls
1337
- for (const item of displayItems) {
1338
- if (item.type === "toolCall") {
1339
- container.addChild(
1340
- new Text(
1341
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
1342
- 0,
1343
- 0,
1344
- ),
1345
- );
1346
- }
1347
- }
1348
-
1349
- // Show final output as markdown
1350
- if (finalOutput) {
1351
- container.addChild(new Spacer(1));
1352
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1353
- }
1354
-
1355
- const stepUsage = formatUsageStats(r.usage, r.model, r.thinkingLevel);
1356
- if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
1357
- }
1358
-
1359
- const usageStr = formatUsageStats(aggregateUsage(details.results));
1360
- if (usageStr) {
1361
- container.addChild(new Spacer(1));
1362
- container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
1363
- }
1364
- return container;
1365
- }
1366
-
1367
- // Collapsed view
1368
- let text =
1369
- icon +
1370
- " " +
1371
- theme.fg("toolTitle", theme.bold("chain ")) +
1372
- theme.fg("accent", `${successCount}/${details.results.length} steps`);
1373
- for (const r of details.results) {
1374
- const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1375
- const displayItems = getDisplayItems(r.messages);
1376
- text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
1377
- if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
1378
- else text += `\n${renderDisplayItems(displayItems, 5)}`;
1379
- }
1380
- const usageStr = formatUsageStats(aggregateUsage(details.results));
1381
- if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
1382
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1383
- return new Text(text, 0, 0);
1384
- }
1385
-
1386
- if (details.mode === "parallel") {
1387
- const running = details.results.filter((r) => r.exitCode === -1).length;
1388
- const successCount = details.results.filter((r) => r.exitCode === 0).length;
1389
- const failCount = details.results.filter((r) => r.exitCode > 0).length;
1390
- const aggregator = details.aggregator;
1391
- const aggregatorRunning = aggregator?.exitCode === -1;
1392
- const aggregatorFailed = aggregator ? aggregator.exitCode > 0 || aggregator.stopReason === "error" : false;
1393
- const isRunning = running > 0 || aggregatorRunning;
1394
- const icon = isRunning
1395
- ? theme.fg("warning", "⏳")
1396
- : failCount > 0 || aggregatorFailed
1397
- ? theme.fg("warning", "◐")
1398
- : theme.fg("success", "✓");
1399
- const status = isRunning
1400
- ? aggregatorRunning
1401
- ? `${successCount + failCount}/${details.results.length} done, fan-in running`
1402
- : `${successCount + failCount}/${details.results.length} done, ${running} running`
1403
- : aggregator
1404
- ? `${successCount}/${details.results.length} tasks + fan-in`
1405
- : `${successCount}/${details.results.length} tasks`;
1406
-
1407
- if (expanded && !isRunning) {
1408
- const container = new Container();
1409
- container.addChild(
1410
- new Text(
1411
- `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
1412
- 0,
1413
- 0,
1414
- ),
1415
- );
1416
-
1417
- for (const r of details.results) {
1418
- const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1419
- const displayItems = getDisplayItems(r.messages);
1420
- const finalOutput = getResultFinalOutput(r);
1421
-
1422
- container.addChild(new Spacer(1));
1423
- container.addChild(
1424
- new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
1425
- );
1426
- container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
1427
-
1428
- // Show tool calls
1429
- for (const item of displayItems) {
1430
- if (item.type === "toolCall") {
1431
- container.addChild(
1432
- new Text(
1433
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
1434
- 0,
1435
- 0,
1436
- ),
1437
- );
1438
- }
1439
- }
1440
-
1441
- // Show final output as markdown
1442
- if (finalOutput) {
1443
- container.addChild(new Spacer(1));
1444
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1445
- }
1446
-
1447
- const taskUsage = formatUsageStats(r.usage, r.model, r.thinkingLevel);
1448
- if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
1449
- }
1450
-
1451
- if (aggregator) {
1452
- const rIcon = aggregator.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1453
- const displayItems = getDisplayItems(aggregator.messages);
1454
- const finalOutput = getResultFinalOutput(aggregator);
1455
-
1456
- container.addChild(new Spacer(1));
1457
- container.addChild(
1458
- new Text(
1459
- `${theme.fg("muted", "─── fan-in → ") + theme.fg("accent", aggregator.agent)} ${rIcon}`,
1460
- 0,
1461
- 0,
1462
- ),
1463
- );
1464
- container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", aggregator.task), 0, 0));
1465
- for (const item of displayItems) {
1466
- if (item.type === "toolCall") {
1467
- container.addChild(
1468
- new Text(
1469
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
1470
- 0,
1471
- 0,
1472
- ),
1473
- );
1474
- }
1475
- }
1476
- if (finalOutput) {
1477
- container.addChild(new Spacer(1));
1478
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1479
- }
1480
- const fanInUsage = formatUsageStats(aggregator.usage, aggregator.model, aggregator.thinkingLevel);
1481
- if (fanInUsage) container.addChild(new Text(theme.fg("dim", fanInUsage), 0, 0));
1482
- }
1483
-
1484
- const usageResults = aggregator ? [...details.results, aggregator] : details.results;
1485
- const usageStr = formatUsageStats(aggregateUsage(usageResults));
1486
- if (usageStr) {
1487
- container.addChild(new Spacer(1));
1488
- container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
1489
- }
1490
- return container;
1491
- }
1492
-
1493
- // Collapsed view (or still running)
1494
- let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
1495
- for (const r of details.results) {
1496
- const rIcon =
1497
- r.exitCode === -1
1498
- ? theme.fg("warning", "⏳")
1499
- : r.exitCode === 0
1500
- ? theme.fg("success", "✓")
1501
- : theme.fg("error", "✗");
1502
- const displayItems = getDisplayItems(r.messages);
1503
- text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
1504
- if (displayItems.length === 0)
1505
- text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
1506
- else text += `\n${renderDisplayItems(displayItems, 5)}`;
1507
- }
1508
- if (aggregator) {
1509
- const rIcon =
1510
- aggregator.exitCode === -1
1511
- ? theme.fg("warning", "⏳")
1512
- : aggregator.exitCode === 0
1513
- ? theme.fg("success", "✓")
1514
- : theme.fg("error", "✗");
1515
- const displayItems = getDisplayItems(aggregator.messages);
1516
- text += `\n\n${theme.fg("muted", "─── fan-in → ")}${theme.fg("accent", aggregator.agent)} ${rIcon}`;
1517
- if (displayItems.length === 0)
1518
- text += `\n${theme.fg("muted", aggregator.exitCode === -1 ? "(running...)" : "(no output)")}`;
1519
- else text += `\n${renderDisplayItems(displayItems, 5)}`;
1520
- }
1521
- if (!isRunning) {
1522
- const usageResults = aggregator ? [...details.results, aggregator] : details.results;
1523
- const usageStr = formatUsageStats(aggregateUsage(usageResults));
1524
- if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
1525
- }
1526
- if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1527
- return new Text(text, 0, 0);
1528
- }
1529
-
1530
- const text = result.content[0];
1531
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
55
+ renderResult(result, options, theme) {
56
+ return renderSubagentResult(result, options, theme);
1532
57
  },
1533
58
  });
1534
59
 
1535
- // ---- Configuration command ----
1536
-
1537
- pi.registerCommand("subagents:config", {
1538
- description: "Configure which tools each subagent can use",
1539
- handler: async (_args, ctx) => {
1540
- if (!ctx.hasUI) {
1541
- return;
1542
- }
1543
-
1544
- // Get current settings
1545
- const currentSettings = readSubagentSettings() ?? {};
1546
- const currentAgents = currentSettings.agents ?? {};
1547
-
1548
- // Discover agents to show which ones are available
1549
- const discovery = discoverAgents(ctx.cwd, "user", currentSettings);
1550
- const agents = discovery.agents;
1551
-
1552
- if (agents.length === 0) {
1553
- ctx.ui.notify("No agents found", "warning");
1554
- return;
1555
- }
1556
-
1557
- // Loop: agent selection → tool toggle (Esc in tools returns here)
1558
- while (true) {
1559
- // Step 1: pick an agent to configure
1560
- const agentItems: SelectItem[] = agents.map((a) => {
1561
- const cfg = currentAgents[a.name];
1562
- const hasToolsOverride = cfg ? hasOwn(cfg, "tools") : false;
1563
- const toolSummary = hasToolsOverride
1564
- ? cfg?.tools && cfg.tools.length > 0
1565
- ? cfg.tools.join(", ")
1566
- : "none"
1567
- : "defaults";
1568
- return {
1569
- value: a.name,
1570
- label: a.name,
1571
- description: `${a.source} · tools: ${toolSummary}`,
1572
- };
1573
- });
1574
-
1575
- const agentName = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1576
- const container = new Container();
1577
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1578
- container.addChild(
1579
- new Text(theme.fg("accent", theme.bold("Subagent Tool Configuration")), 1, 0),
1580
- );
1581
- container.addChild(new Spacer(1));
1582
- container.addChild(
1583
- new Text(theme.fg("muted", "Select an agent to configure its allowed tools:"), 1, 0),
1584
- );
1585
- container.addChild(new Spacer(1));
1586
- const selectList = new SelectList(agentItems, Math.min(agentItems.length + 2, 15), {
1587
- selectedPrefix: (t: string) => theme.fg("accent", t),
1588
- selectedText: (t: string) => theme.fg("accent", t),
1589
- description: (t: string) => theme.fg("muted", t),
1590
- scrollInfo: (t: string) => theme.fg("dim", t),
1591
- noMatch: (t: string) => theme.fg("warning", t),
1592
- });
1593
- selectList.onSelect = (item) => done(item.value);
1594
- selectList.onCancel = () => done(null);
1595
- container.addChild(selectList);
1596
- container.addChild(
1597
- new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 1, 0),
1598
- );
1599
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1600
- return {
1601
- render: (w: number) => container.render(w),
1602
- invalidate: () => container.invalidate(),
1603
- handleInput: (data: string) => {
1604
- selectList.handleInput(data);
1605
- tui.requestRender();
1606
- },
1607
- };
1608
- });
1609
-
1610
- if (!agentName) return;
1611
-
1612
- const agent = agents.find((a) => a.name === agentName);
1613
- if (!agent) return;
1614
-
1615
- // Step 2: toggle tools for the selected agent
1616
- // Discover without overrides to get original built-in/frontmatter defaults.
1617
- // The main discovery above applies saved overrides, so agent.tools is already
1618
- // overridden — using it for the reset-to-default comparison would match the
1619
- // override against itself and silently delete it on a no-op save.
1620
- const defaultDiscovery = discoverAgents(ctx.cwd, "user");
1621
- const defaultTools = defaultDiscovery.agents.find((a) => a.name === agentName)?.tools;
1622
- const currentAgentSettings = currentAgents[agentName];
1623
- const configuredTools =
1624
- currentAgentSettings && hasOwn(currentAgentSettings, "tools")
1625
- ? (currentAgentSettings.tools ?? [])
1626
- : undefined;
1627
-
1628
- // Get all available tools from pi's registry
1629
- const allTools = uniqueToolNames(pi.getAllTools().map((t) => t.name)).sort((a, b) =>
1630
- a.localeCompare(b),
1631
- );
1632
- const currentTools = uniqueToolNames(configuredTools ?? defaultTools ?? allTools);
1633
- // Sort: currently selected tools first, then rest alphabetically. Preserve
1634
- // unavailable configured tools so saving does not silently drop them.
1635
- const currentSet = new Set(currentTools);
1636
- const selectedFirst = [...currentTools, ...allTools.filter((t) => !currentSet.has(t))];
1637
-
1638
- const selectedTools = await ctx.ui.custom<string[] | null>((tui, theme, _kb, done) => {
1639
- const toggleList = new ToolToggleList(selectedFirst, currentSet);
1640
60
 
1641
- const container = new Container();
1642
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1643
- container.addChild(
1644
- new Text(
1645
- theme.fg("accent", theme.bold(`${agentName} tools`)) +
1646
- theme.fg("muted", ` (${agent.source})`),
1647
- 1,
1648
- 0,
1649
- ),
1650
- );
1651
- container.addChild(new Spacer(1));
1652
- container.addChild(
1653
- new Text(theme.fg("muted", "Toggle tools with Enter/Space. S to save, Esc to cancel."), 1, 0),
1654
- );
1655
- container.addChild(new Spacer(1));
1656
-
1657
- const listContainer = new Container();
1658
- listContainer.addChild({
1659
- render: (w: number) => toggleList.render(w),
1660
- invalidate: () => toggleList.invalidate(),
1661
- });
1662
- container.addChild(listContainer);
1663
-
1664
- container.addChild(new Spacer(1));
1665
- container.addChild(
1666
- new Text(theme.fg("dim", "↑↓ navigate · enter/space toggle · S save · esc cancel"), 1, 0),
1667
- );
1668
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1669
-
1670
- toggleList.onDone = (tools) => done(tools);
1671
- toggleList.onCancel = () => done(null);
1672
-
1673
- return {
1674
- render: (w: number) => container.render(w),
1675
- invalidate: () => container.invalidate(),
1676
- handleInput: (data: string) => {
1677
- toggleList.handleInput(data);
1678
- tui.requestRender();
1679
- },
1680
- };
1681
- });
1682
-
1683
- // null means user cancelled — loop back to agent selection
1684
- if (selectedTools === null) continue;
1685
-
1686
- // Save to global settings
1687
- const updatedAgents = { ...currentAgents };
1688
- let restoredDefaults = false;
1689
-
1690
- const isSameAsDefault =
1691
- defaultTools === undefined
1692
- ? sameToolSet(selectedTools, allTools)
1693
- : sameToolSet(selectedTools, defaultTools);
1694
-
1695
- if (isSameAsDefault) {
1696
- // Tools match defaults — remove only the tools override.
1697
- // Keep other settings (model, timeoutMs) if present.
1698
- const existing = updatedAgents[agentName];
1699
- if (existing) {
1700
- const nextConfig = { ...existing };
1701
- delete nextConfig.tools;
1702
- if (hasAnyAgentOverride(nextConfig)) updatedAgents[agentName] = nextConfig;
1703
- else delete updatedAgents[agentName];
1704
- }
1705
- restoredDefaults = true;
1706
- } else {
1707
- updatedAgents[agentName] = {
1708
- ...updatedAgents[agentName],
1709
- tools: selectedTools,
1710
- };
1711
- }
1712
-
1713
- const newSettings: SubagentSettings = {
1714
- ...currentSettings,
1715
- agents: Object.keys(updatedAgents).length > 0 ? updatedAgents : undefined,
1716
- };
1717
-
1718
- saveSubagentConfig(newSettings);
1719
- const message = restoredDefaults
1720
- ? `${agentName}: defaults restored`
1721
- : `${agentName}: ${selectedTools.length} tool${selectedTools.length !== 1 ? "s" : ""} configured`;
1722
- ctx.ui.notify(message, "info");
1723
- // Saved — exit the loop
1724
- break;
1725
- }
1726
- },
1727
- });
61
+ registerSubagentConfigCommand(pi);
1728
62
  }
63
+ export { formatTokens, formatUsageStats } from "./render.js";
64
+ export { buildPiArgs } from "./runner.js";
65
+ export {
66
+ normalizeAgentSettings,
67
+ normalizeSubagentSettings,
68
+ resolveSubagentThinkingLevel,
69
+ sameToolSet,
70
+ uniqueToolNames,
71
+ } from "./settings.js";
72
+ export { parsePositiveInteger } from "./execution.js";