pi-subagents 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,2186 @@
1
+ /**
2
+ * Subagent Tool
3
+ *
4
+ * Full-featured subagent with sync and async modes.
5
+ * - Sync (default): Streams output, renders markdown, tracks usage
6
+ * - Async: Background execution, emits events when done
7
+ *
8
+ * Modes: single (agent + task), parallel (tasks[]), chain (chain[] with {previous})
9
+ * Toggle: async parameter (default: false, configurable via config.json)
10
+ *
11
+ * Config file: ~/.pi/agent/extensions/subagent/config.json
12
+ * { "asyncByDefault": true }
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { randomUUID } from "node:crypto";
17
+ import * as fs from "node:fs";
18
+ import { createRequire } from "node:module";
19
+ import * as os from "node:os";
20
+ import * as path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
23
+ import type { Message } from "@mariozechner/pi-ai";
24
+ import { type ExtensionAPI, type ExtensionContext, type ToolDefinition, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
25
+ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
26
+ import { Type } from "@sinclair/typebox";
27
+ import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
28
+ import {
29
+ loadSubagentSettings,
30
+ resolveChainTemplatesV2,
31
+ createChainDir,
32
+ removeChainDir,
33
+ cleanupOldChainDirs,
34
+ resolveStepBehavior,
35
+ resolveParallelBehaviors,
36
+ buildChainInstructions,
37
+ createParallelDirs,
38
+ aggregateParallelOutputs,
39
+ isParallelStep,
40
+ isSequentialStep,
41
+ getStepAgents,
42
+ type StepOverrides,
43
+ type ChainStep,
44
+ type SequentialStep,
45
+ type ParallelStep,
46
+ type ParallelTaskResult,
47
+ type ResolvedTemplates,
48
+ } from "./settings.js";
49
+ import { ChainClarifyComponent, type ChainClarifyResult, type BehaviorOverride } from "./chain-clarify.js";
50
+ import {
51
+ appendJsonl,
52
+ cleanupOldArtifacts,
53
+ ensureArtifactsDir,
54
+ getArtifactPaths,
55
+ getArtifactsDir,
56
+ writeArtifact,
57
+ writeMetadata,
58
+ } from "./artifacts.js";
59
+ import {
60
+ type AgentProgress,
61
+ type ArtifactConfig,
62
+ type ArtifactPaths,
63
+ DEFAULT_ARTIFACT_CONFIG,
64
+ DEFAULT_MAX_OUTPUT,
65
+ type MaxOutputConfig,
66
+ type ProgressSummary,
67
+ type TruncationResult,
68
+ truncateOutput,
69
+ } from "./types.js";
70
+
71
+ const MAX_PARALLEL = 8;
72
+ const MAX_CONCURRENCY = 4;
73
+ const COLLAPSED_ITEMS = 8;
74
+ const RESULTS_DIR = "/tmp/pi-async-subagent-results";
75
+ const ASYNC_DIR = "/tmp/pi-async-subagent-runs";
76
+ const WIDGET_KEY = "subagent-async";
77
+ const POLL_INTERVAL_MS = 1000;
78
+ const MAX_WIDGET_JOBS = 4;
79
+
80
+ const require = createRequire(import.meta.url);
81
+ const jitiCliPath: string | undefined = (() => {
82
+ try {
83
+ return path.join(path.dirname(require.resolve("jiti/package.json")), "lib/jiti-cli.mjs");
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ })();
88
+
89
+ interface Usage {
90
+ input: number;
91
+ output: number;
92
+ cacheRead: number;
93
+ cacheWrite: number;
94
+ cost: number;
95
+ turns: number;
96
+ }
97
+
98
+ interface SingleResult {
99
+ agent: string;
100
+ task: string;
101
+ exitCode: number;
102
+ messages: Message[];
103
+ usage: Usage;
104
+ model?: string;
105
+ error?: string;
106
+ sessionFile?: string;
107
+ // Sharing disabled - module resolution issues
108
+ // shareUrl?: string;
109
+ // gistUrl?: string;
110
+ // shareError?: string;
111
+ progress?: AgentProgress;
112
+ progressSummary?: ProgressSummary;
113
+ artifactPaths?: ArtifactPaths;
114
+ truncation?: TruncationResult;
115
+ }
116
+
117
+ interface Details {
118
+ mode: "single" | "parallel" | "chain";
119
+ results: SingleResult[];
120
+ asyncId?: string;
121
+ asyncDir?: string;
122
+ progress?: AgentProgress[];
123
+ progressSummary?: ProgressSummary;
124
+ artifacts?: {
125
+ dir: string;
126
+ files: ArtifactPaths[];
127
+ };
128
+ truncation?: {
129
+ truncated: boolean;
130
+ originalBytes?: number;
131
+ originalLines?: number;
132
+ artifactPath?: string;
133
+ };
134
+ // Chain metadata for observability
135
+ chainAgents?: string[]; // Agent names in order, e.g., ["scout", "planner"]
136
+ totalSteps?: number; // Total steps in chain
137
+ currentStepIndex?: number; // 0-indexed current step (for running chains)
138
+ }
139
+
140
+ type DisplayItem = { type: "text"; text: string } | { type: "tool"; name: string; args: Record<string, unknown> };
141
+
142
+ interface TokenUsage {
143
+ input: number;
144
+ output: number;
145
+ total: number;
146
+ }
147
+
148
+ interface AsyncStatus {
149
+ runId: string;
150
+ mode: "single" | "chain";
151
+ state: "queued" | "running" | "complete" | "failed";
152
+ startedAt: number;
153
+ endedAt?: number;
154
+ lastUpdate?: number;
155
+ currentStep?: number;
156
+ steps?: Array<{ agent: string; status: string; durationMs?: number; tokens?: TokenUsage }>;
157
+ sessionDir?: string;
158
+ outputFile?: string;
159
+ totalTokens?: TokenUsage;
160
+ sessionFile?: string;
161
+ // Sharing disabled
162
+ // shareUrl?: string;
163
+ // shareError?: string;
164
+ }
165
+
166
+ interface AsyncJobState {
167
+ asyncId: string;
168
+ asyncDir: string;
169
+ status: "queued" | "running" | "complete" | "failed";
170
+ mode?: "single" | "chain";
171
+ agents?: string[];
172
+ currentStep?: number;
173
+ stepsTotal?: number;
174
+ startedAt?: number;
175
+ updatedAt?: number;
176
+ sessionDir?: string;
177
+ outputFile?: string;
178
+ totalTokens?: TokenUsage;
179
+ sessionFile?: string;
180
+ // shareUrl?: string;
181
+ }
182
+
183
+ function formatTokens(n: number): string {
184
+ return n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
185
+ }
186
+
187
+ function formatUsage(u: Usage, model?: string): string {
188
+ const parts: string[] = [];
189
+ if (u.turns) parts.push(`${u.turns} turn${u.turns > 1 ? "s" : ""}`);
190
+ if (u.input) parts.push(`in:${formatTokens(u.input)}`);
191
+ if (u.output) parts.push(`out:${formatTokens(u.output)}`);
192
+ if (u.cacheRead) parts.push(`R${formatTokens(u.cacheRead)}`);
193
+ if (u.cacheWrite) parts.push(`W${formatTokens(u.cacheWrite)}`);
194
+ if (u.cost) parts.push(`$${u.cost.toFixed(4)}`);
195
+ if (model) parts.push(model);
196
+ return parts.join(" ");
197
+ }
198
+
199
+ function formatDuration(ms: number): string {
200
+ if (ms < 1000) return `${ms}ms`;
201
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
202
+ return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
203
+ }
204
+
205
+ function buildChainSummary(
206
+ steps: ChainStep[],
207
+ results: SingleResult[],
208
+ chainDir: string,
209
+ status: "completed" | "failed",
210
+ failedStep?: { index: number; error: string },
211
+ ): string {
212
+ // Build step names for display
213
+ const stepNames = steps
214
+ .map((s) => (isParallelStep(s) ? `parallel[${s.parallel.length}]` : (s as SequentialStep).agent))
215
+ .join(" → ");
216
+
217
+ // Calculate total duration from results
218
+ const totalDuration = results.reduce((sum, r) => sum + (r.progress?.durationMs || 0), 0);
219
+ const durationStr = formatDuration(totalDuration);
220
+
221
+ // Check for progress.md
222
+ const progressPath = path.join(chainDir, "progress.md");
223
+ const hasProgress = fs.existsSync(progressPath);
224
+
225
+ if (status === "completed") {
226
+ const stepWord = results.length === 1 ? "step" : "steps";
227
+ return `✅ Chain completed: ${stepNames} (${results.length} ${stepWord}, ${durationStr})
228
+
229
+ 📋 Progress: ${hasProgress ? progressPath : "(none)"}
230
+ 📁 Artifacts: ${chainDir}`;
231
+ } else {
232
+ const stepInfo = failedStep ? ` at step ${failedStep.index + 1}` : "";
233
+ const errorInfo = failedStep?.error ? `: ${failedStep.error}` : "";
234
+ return `❌ Chain failed${stepInfo}${errorInfo}
235
+
236
+ 📋 Progress: ${hasProgress ? progressPath : "(none)"}
237
+ 📁 Artifacts: ${chainDir}`;
238
+ }
239
+ }
240
+
241
+ function readStatus(asyncDir: string): AsyncStatus | null {
242
+ const statusPath = path.join(asyncDir, "status.json");
243
+ if (!fs.existsSync(statusPath)) return null;
244
+ try {
245
+ const content = fs.readFileSync(statusPath, "utf-8");
246
+ return JSON.parse(content) as AsyncStatus;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ function getOutputTail(outputFile: string | undefined, maxLines: number = 3): string[] {
253
+ if (!outputFile || !fs.existsSync(outputFile)) return [];
254
+ let fd: number | null = null;
255
+ try {
256
+ const stat = fs.statSync(outputFile);
257
+ if (stat.size === 0) return [];
258
+ const tailBytes = 4096;
259
+ const start = Math.max(0, stat.size - tailBytes);
260
+ fd = fs.openSync(outputFile, "r");
261
+ const buffer = Buffer.alloc(Math.min(tailBytes, stat.size));
262
+ fs.readSync(fd, buffer, 0, buffer.length, start);
263
+ const content = buffer.toString("utf-8");
264
+ const lines = content.split("\n").filter((l) => l.trim());
265
+ return lines.slice(-maxLines).map((l) => l.slice(0, 80) + (l.length > 80 ? "..." : ""));
266
+ } catch {
267
+ return [];
268
+ } finally {
269
+ if (fd !== null) {
270
+ try {
271
+ fs.closeSync(fd);
272
+ } catch {}
273
+ }
274
+ }
275
+ }
276
+
277
+ function getLastActivity(outputFile: string | undefined): string {
278
+ if (!outputFile || !fs.existsSync(outputFile)) return "";
279
+ try {
280
+ const stat = fs.statSync(outputFile);
281
+ const ago = Date.now() - stat.mtimeMs;
282
+ if (ago < 1000) return "active now";
283
+ if (ago < 60000) return `active ${Math.floor(ago / 1000)}s ago`;
284
+ return `active ${Math.floor(ago / 60000)}m ago`;
285
+ } catch {
286
+ return "";
287
+ }
288
+ }
289
+
290
+ function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void {
291
+ if (!ctx.hasUI) return;
292
+ if (jobs.length === 0) {
293
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
294
+ return;
295
+ }
296
+
297
+ const theme = ctx.ui.theme;
298
+ const lines: string[] = [];
299
+ lines.push(theme.fg("accent", "Async subagents"));
300
+
301
+ for (const job of jobs.slice(0, MAX_WIDGET_JOBS)) {
302
+ const id = job.asyncId.slice(0, 6);
303
+ const status =
304
+ job.status === "complete"
305
+ ? theme.fg("success", "complete")
306
+ : job.status === "failed"
307
+ ? theme.fg("error", "failed")
308
+ : theme.fg("warning", "running");
309
+
310
+ const stepsTotal = job.stepsTotal ?? (job.agents?.length ?? 1);
311
+ const stepIndex = job.currentStep !== undefined ? job.currentStep + 1 : undefined;
312
+ const stepText = stepIndex !== undefined ? `step ${stepIndex}/${stepsTotal}` : `steps ${stepsTotal}`;
313
+ const endTime = (job.status === "complete" || job.status === "failed") ? (job.updatedAt ?? Date.now()) : Date.now();
314
+ const elapsed = job.startedAt ? formatDuration(endTime - job.startedAt) : "";
315
+ const agentLabel = job.agents ? job.agents.join(" -> ") : (job.mode ?? "single");
316
+
317
+ const tokenText = job.totalTokens ? ` | ${formatTokens(job.totalTokens.total)} tok` : "";
318
+ const activityText = job.status === "running" ? getLastActivity(job.outputFile) : "";
319
+ const activitySuffix = activityText ? ` | ${theme.fg("dim", activityText)}` : "";
320
+
321
+ lines.push(`- ${id} ${status} | ${agentLabel} | ${stepText}${elapsed ? ` | ${elapsed}` : ""}${tokenText}${activitySuffix}`);
322
+
323
+ if (job.status === "running" && job.outputFile) {
324
+ const tail = getOutputTail(job.outputFile, 3);
325
+ for (const line of tail) {
326
+ lines.push(theme.fg("dim", ` > ${line}`));
327
+ }
328
+ }
329
+ }
330
+
331
+ ctx.ui.setWidget(WIDGET_KEY, lines);
332
+ }
333
+
334
+ function findByPrefix(dir: string, prefix: string, suffix?: string): string | null {
335
+ if (!fs.existsSync(dir)) return null;
336
+ const entries = fs.readdirSync(dir).filter((entry) => entry.startsWith(prefix));
337
+ if (suffix) {
338
+ const withSuffix = entries.filter((entry) => entry.endsWith(suffix));
339
+ if (withSuffix.length > 0) return path.join(dir, withSuffix.sort()[0]);
340
+ }
341
+ if (entries.length === 0) return null;
342
+ return path.join(dir, entries.sort()[0]);
343
+ }
344
+
345
+ function getFinalOutput(messages: Message[]): string {
346
+ for (let i = messages.length - 1; i >= 0; i--) {
347
+ const msg = messages[i];
348
+ if (msg.role === "assistant") {
349
+ for (const part of msg.content) {
350
+ if (part.type === "text") return part.text;
351
+ }
352
+ }
353
+ }
354
+ return "";
355
+ }
356
+
357
+ interface ErrorInfo {
358
+ hasError: boolean;
359
+ exitCode?: number;
360
+ errorType?: string;
361
+ details?: string;
362
+ }
363
+
364
+ function detectSubagentError(messages: Message[]): ErrorInfo {
365
+ for (const msg of messages) {
366
+ if (msg.role === "toolResult" && (msg as any).isError) {
367
+ const text = msg.content.find((c) => c.type === "text");
368
+ const details = text && "text" in text ? text.text : undefined;
369
+ const exitMatch = details?.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
370
+ return {
371
+ hasError: true,
372
+ exitCode: exitMatch ? parseInt(exitMatch[1], 10) : 1,
373
+ errorType: (msg as any).toolName || "tool",
374
+ details: details?.slice(0, 200),
375
+ };
376
+ }
377
+ }
378
+
379
+ for (const msg of messages) {
380
+ if (msg.role !== "toolResult") continue;
381
+ const toolName = (msg as any).toolName;
382
+ if (toolName !== "bash") continue;
383
+
384
+ const text = msg.content.find((c) => c.type === "text");
385
+ if (!text || !("text" in text)) continue;
386
+ const output = text.text;
387
+
388
+ const exitMatch = output.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
389
+ if (exitMatch) {
390
+ const code = parseInt(exitMatch[1], 10);
391
+ if (code !== 0) {
392
+ return { hasError: true, exitCode: code, errorType: "bash", details: output.slice(0, 200) };
393
+ }
394
+ }
395
+
396
+ const errorPatterns = [
397
+ /command not found/i,
398
+ /permission denied/i,
399
+ /no such file or directory/i,
400
+ /segmentation fault/i,
401
+ /killed|terminated/i,
402
+ /out of memory/i,
403
+ /connection refused/i,
404
+ /timeout/i,
405
+ ];
406
+ for (const pattern of errorPatterns) {
407
+ if (pattern.test(output)) {
408
+ return { hasError: true, exitCode: 1, errorType: "bash", details: output.slice(0, 200) };
409
+ }
410
+ }
411
+ }
412
+
413
+ return { hasError: false };
414
+ }
415
+
416
+ function getDisplayItems(messages: Message[]): DisplayItem[] {
417
+ const items: DisplayItem[] = [];
418
+ for (const msg of messages) {
419
+ if (msg.role === "assistant") {
420
+ for (const part of msg.content) {
421
+ if (part.type === "text") items.push({ type: "text", text: part.text });
422
+ else if (part.type === "toolCall") items.push({ type: "tool", name: part.name, args: part.arguments });
423
+ }
424
+ }
425
+ }
426
+ return items;
427
+ }
428
+
429
+ function shortenPath(p: string): string {
430
+ const home = os.homedir();
431
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
432
+ }
433
+
434
+ function formatToolCall(name: string, args: Record<string, unknown>): string {
435
+ switch (name) {
436
+ case "bash":
437
+ return `$ ${((args.command as string) || "").slice(0, 60)}${(args.command as string)?.length > 60 ? "..." : ""}`;
438
+ case "read":
439
+ return `read ${shortenPath((args.path || args.file_path || "") as string)}`;
440
+ case "write":
441
+ return `write ${shortenPath((args.path || args.file_path || "") as string)}`;
442
+ case "edit":
443
+ return `edit ${shortenPath((args.path || args.file_path || "") as string)}`;
444
+ default: {
445
+ const s = JSON.stringify(args);
446
+ return `${name} ${s.slice(0, 40)}${s.length > 40 ? "..." : ""}`;
447
+ }
448
+ }
449
+ }
450
+
451
+ function extractToolArgsPreview(args: Record<string, unknown>): string {
452
+ const previewKeys = ["command", "path", "file_path", "pattern", "query", "url", "task"];
453
+ for (const key of previewKeys) {
454
+ if (args[key] && typeof args[key] === "string") {
455
+ const value = args[key] as string;
456
+ return value.length > 60 ? `${value.slice(0, 57)}...` : value;
457
+ }
458
+ }
459
+ return "";
460
+ }
461
+
462
+ function extractTextFromContent(content: unknown): string {
463
+ if (!Array.isArray(content)) return "";
464
+ for (const part of content) {
465
+ if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part) {
466
+ return String(part.text);
467
+ }
468
+ }
469
+ return "";
470
+ }
471
+
472
+ function writePrompt(agent: string, prompt: string): { dir: string; path: string } {
473
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
474
+ const p = path.join(dir, `${agent.replace(/[^\w.-]/g, "_")}.md`);
475
+ fs.writeFileSync(p, prompt, { mode: 0o600 });
476
+ return { dir, path: p };
477
+ }
478
+
479
+ function findLatestSessionFile(sessionDir: string): string | null {
480
+ try {
481
+ const files = fs
482
+ .readdirSync(sessionDir)
483
+ .filter((f) => f.endsWith(".jsonl"))
484
+ .map((f) => path.join(sessionDir, f));
485
+ if (files.length === 0) return null;
486
+ files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
487
+ return files[0] ?? null;
488
+ } catch {
489
+ return null;
490
+ }
491
+ }
492
+
493
+ // HTML export and sharing removed - module resolution issues with global pi installation
494
+ // The session files are still available at the paths shown in the output
495
+
496
+ interface RunSyncOptions {
497
+ cwd?: string;
498
+ signal?: AbortSignal;
499
+ onUpdate?: (r: AgentToolResult<Details>) => void;
500
+ maxOutput?: MaxOutputConfig;
501
+ artifactsDir?: string;
502
+ artifactConfig?: ArtifactConfig;
503
+ runId: string;
504
+ index?: number;
505
+ sessionDir?: string;
506
+ share?: boolean;
507
+ }
508
+
509
+ async function runSync(
510
+ runtimeCwd: string,
511
+ agents: AgentConfig[],
512
+ agentName: string,
513
+ task: string,
514
+ options: RunSyncOptions,
515
+ ): Promise<SingleResult> {
516
+ const { cwd, signal, onUpdate, maxOutput, artifactsDir, artifactConfig, runId, index } = options;
517
+ const agent = agents.find((a) => a.name === agentName);
518
+ if (!agent) {
519
+ return {
520
+ agent: agentName,
521
+ task,
522
+ exitCode: 1,
523
+ messages: [],
524
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
525
+ error: `Unknown agent: ${agentName}`,
526
+ };
527
+ }
528
+
529
+ const args = ["--mode", "json", "-p"];
530
+ const shareEnabled = options.share === true;
531
+ const sessionEnabled = Boolean(options.sessionDir) || shareEnabled;
532
+ if (!sessionEnabled) {
533
+ args.push("--no-session");
534
+ }
535
+ if (options.sessionDir) {
536
+ try {
537
+ fs.mkdirSync(options.sessionDir, { recursive: true });
538
+ } catch {}
539
+ args.push("--session-dir", options.sessionDir);
540
+ }
541
+ if (agent.model) args.push("--model", agent.model);
542
+ if (agent.tools?.length) {
543
+ const builtinTools: string[] = [];
544
+ const extensionPaths: string[] = [];
545
+ for (const tool of agent.tools) {
546
+ if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
547
+ extensionPaths.push(tool);
548
+ } else {
549
+ builtinTools.push(tool);
550
+ }
551
+ }
552
+ if (builtinTools.length > 0) {
553
+ args.push("--tools", builtinTools.join(","));
554
+ }
555
+ for (const extPath of extensionPaths) {
556
+ args.push("--extension", extPath);
557
+ }
558
+ }
559
+
560
+ let tmpDir: string | null = null;
561
+ if (agent.systemPrompt?.trim()) {
562
+ const tmp = writePrompt(agent.name, agent.systemPrompt);
563
+ tmpDir = tmp.dir;
564
+ args.push("--append-system-prompt", tmp.path);
565
+ }
566
+ args.push(`Task: ${task}`);
567
+
568
+ const result: SingleResult = {
569
+ agent: agentName,
570
+ task,
571
+ exitCode: 0,
572
+ messages: [],
573
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
574
+ };
575
+
576
+ const progress: AgentProgress = {
577
+ index: index ?? 0,
578
+ agent: agentName,
579
+ status: "running",
580
+ task,
581
+ recentTools: [],
582
+ recentOutput: [],
583
+ toolCount: 0,
584
+ tokens: 0,
585
+ durationMs: 0,
586
+ };
587
+ result.progress = progress;
588
+
589
+ const startTime = Date.now();
590
+ const jsonlLines: string[] = [];
591
+
592
+ let artifactPathsResult: ArtifactPaths | undefined;
593
+ if (artifactsDir && artifactConfig?.enabled !== false) {
594
+ artifactPathsResult = getArtifactPaths(artifactsDir, runId, agentName, index);
595
+ ensureArtifactsDir(artifactsDir);
596
+ if (artifactConfig?.includeInput !== false) {
597
+ writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${task}`);
598
+ }
599
+ }
600
+
601
+ const exitCode = await new Promise<number>((resolve) => {
602
+ const proc = spawn("pi", args, { cwd: cwd ?? runtimeCwd, stdio: ["ignore", "pipe", "pipe"] });
603
+ let buf = "";
604
+
605
+ const processLine = (line: string) => {
606
+ if (!line.trim()) return;
607
+ jsonlLines.push(line);
608
+ try {
609
+ const evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; args?: unknown };
610
+ const now = Date.now();
611
+ progress.durationMs = now - startTime;
612
+
613
+ if (evt.type === "tool_execution_start") {
614
+ progress.toolCount++;
615
+ progress.currentTool = evt.toolName;
616
+ progress.currentToolArgs = extractToolArgsPreview((evt.args || {}) as Record<string, unknown>);
617
+ if (onUpdate)
618
+ onUpdate({
619
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
620
+ details: { mode: "single", results: [result], progress: [progress] },
621
+ });
622
+ }
623
+
624
+ if (evt.type === "tool_execution_end") {
625
+ if (progress.currentTool) {
626
+ progress.recentTools.unshift({
627
+ tool: progress.currentTool,
628
+ args: progress.currentToolArgs || "",
629
+ endMs: now,
630
+ });
631
+ if (progress.recentTools.length > 5) {
632
+ progress.recentTools.pop();
633
+ }
634
+ }
635
+ progress.currentTool = undefined;
636
+ progress.currentToolArgs = undefined;
637
+ if (onUpdate)
638
+ onUpdate({
639
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
640
+ details: { mode: "single", results: [result], progress: [progress] },
641
+ });
642
+ }
643
+
644
+ if (evt.type === "message_end" && evt.message) {
645
+ result.messages.push(evt.message);
646
+ if (evt.message.role === "assistant") {
647
+ result.usage.turns++;
648
+ const u = evt.message.usage;
649
+ if (u) {
650
+ result.usage.input += u.input || 0;
651
+ result.usage.output += u.output || 0;
652
+ result.usage.cacheRead += u.cacheRead || 0;
653
+ result.usage.cacheWrite += u.cacheWrite || 0;
654
+ result.usage.cost += u.cost?.total || 0;
655
+ progress.tokens = result.usage.input + result.usage.output;
656
+ }
657
+ if (!result.model && evt.message.model) result.model = evt.message.model;
658
+ if (evt.message.errorMessage) result.error = evt.message.errorMessage;
659
+
660
+ const text = extractTextFromContent(evt.message.content);
661
+ if (text) {
662
+ const lines = text
663
+ .split("\n")
664
+ .filter((l) => l.trim())
665
+ .slice(-8);
666
+ progress.recentOutput = lines;
667
+ }
668
+ }
669
+ if (onUpdate)
670
+ onUpdate({
671
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
672
+ details: { mode: "single", results: [result], progress: [progress] },
673
+ });
674
+ }
675
+ if (evt.type === "tool_result_end" && evt.message) {
676
+ result.messages.push(evt.message);
677
+ if (onUpdate)
678
+ onUpdate({
679
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
680
+ details: { mode: "single", results: [result], progress: [progress] },
681
+ });
682
+ }
683
+ } catch {}
684
+ };
685
+
686
+ let stderrBuf = "";
687
+ let lastUpdateTime = 0;
688
+ const UPDATE_THROTTLE_MS = 150;
689
+
690
+ proc.stdout.on("data", (d) => {
691
+ buf += d.toString();
692
+ const lines = buf.split("\n");
693
+ buf = lines.pop() || "";
694
+ lines.forEach(processLine);
695
+
696
+ // Throttled periodic update for smoother progress display
697
+ const now = Date.now();
698
+ if (onUpdate && now - lastUpdateTime > UPDATE_THROTTLE_MS) {
699
+ lastUpdateTime = now;
700
+ progress.durationMs = now - startTime;
701
+ onUpdate({
702
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
703
+ details: { mode: "single", results: [result], progress: [progress] },
704
+ });
705
+ }
706
+ });
707
+ proc.stderr.on("data", (d) => {
708
+ stderrBuf += d.toString();
709
+ });
710
+ proc.on("close", (code) => {
711
+ if (buf.trim()) processLine(buf);
712
+ if (code !== 0 && stderrBuf.trim() && !result.error) {
713
+ result.error = stderrBuf.trim();
714
+ }
715
+ resolve(code ?? 0);
716
+ });
717
+ proc.on("error", () => resolve(1));
718
+
719
+ if (signal) {
720
+ const kill = () => {
721
+ proc.kill("SIGTERM");
722
+ setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
723
+ };
724
+ if (signal.aborted) kill();
725
+ else signal.addEventListener("abort", kill, { once: true });
726
+ }
727
+ });
728
+
729
+ if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
730
+ result.exitCode = exitCode;
731
+
732
+ if (exitCode === 0 && !result.error) {
733
+ const errInfo = detectSubagentError(result.messages);
734
+ if (errInfo.hasError) {
735
+ result.exitCode = errInfo.exitCode ?? 1;
736
+ result.error = errInfo.details
737
+ ? `${errInfo.errorType} failed (exit ${errInfo.exitCode}): ${errInfo.details}`
738
+ : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
739
+ }
740
+ }
741
+
742
+ progress.status = result.exitCode === 0 ? "completed" : "failed";
743
+ progress.durationMs = Date.now() - startTime;
744
+ if (result.error) {
745
+ progress.error = result.error;
746
+ if (progress.currentTool) {
747
+ progress.failedTool = progress.currentTool;
748
+ }
749
+ }
750
+
751
+ result.progress = progress;
752
+ result.progressSummary = {
753
+ toolCount: progress.toolCount,
754
+ tokens: progress.tokens,
755
+ durationMs: progress.durationMs,
756
+ };
757
+
758
+ if (artifactPathsResult && artifactConfig?.enabled !== false) {
759
+ result.artifactPaths = artifactPathsResult;
760
+ const fullOutput = getFinalOutput(result.messages);
761
+
762
+ if (artifactConfig?.includeOutput !== false) {
763
+ writeArtifact(artifactPathsResult.outputPath, fullOutput);
764
+ }
765
+ if (artifactConfig?.includeJsonl !== false) {
766
+ for (const line of jsonlLines) {
767
+ appendJsonl(artifactPathsResult.jsonlPath, line);
768
+ }
769
+ }
770
+ if (artifactConfig?.includeMetadata !== false) {
771
+ writeMetadata(artifactPathsResult.metadataPath, {
772
+ runId,
773
+ agent: agentName,
774
+ task,
775
+ exitCode: result.exitCode,
776
+ usage: result.usage,
777
+ model: result.model,
778
+ durationMs: progress.durationMs,
779
+ toolCount: progress.toolCount,
780
+ error: result.error,
781
+ timestamp: Date.now(),
782
+ });
783
+ }
784
+
785
+ if (maxOutput) {
786
+ const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput };
787
+ const truncationResult = truncateOutput(fullOutput, config, artifactPathsResult.outputPath);
788
+ if (truncationResult.truncated) {
789
+ result.truncation = truncationResult;
790
+ }
791
+ }
792
+ } else if (maxOutput) {
793
+ const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput };
794
+ const fullOutput = getFinalOutput(result.messages);
795
+ const truncationResult = truncateOutput(fullOutput, config);
796
+ if (truncationResult.truncated) {
797
+ result.truncation = truncationResult;
798
+ }
799
+ }
800
+
801
+ if (shareEnabled && options.sessionDir) {
802
+ const sessionFile = findLatestSessionFile(options.sessionDir);
803
+ if (sessionFile) {
804
+ result.sessionFile = sessionFile;
805
+ // HTML export disabled - module resolution issues with global pi installation
806
+ // Users can still access the session file directly
807
+ }
808
+ }
809
+
810
+ return result;
811
+ }
812
+
813
+ async function mapConcurrent<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
814
+ const results: R[] = new Array(items.length);
815
+ let next = 0;
816
+ await Promise.all(
817
+ Array(Math.min(limit, items.length))
818
+ .fill(0)
819
+ .map(async () => {
820
+ while (next < items.length) {
821
+ const i = next++;
822
+ results[i] = await fn(items[i], i);
823
+ }
824
+ }),
825
+ );
826
+ return results;
827
+ }
828
+
829
+ const TaskItem = Type.Object({ agent: Type.String(), task: Type.String(), cwd: Type.Optional(Type.String()) });
830
+
831
+ // Sequential chain step (single agent)
832
+ const SequentialStepSchema = Type.Object({
833
+ agent: Type.String(),
834
+ task: Type.Optional(Type.String({ description: "Task template. Use {task}, {previous}, {chain_dir}. Required for first step." })),
835
+ cwd: Type.Optional(Type.String()),
836
+ // Chain behavior overrides
837
+ output: Type.Optional(Type.Union([
838
+ Type.String(),
839
+ Type.Boolean(),
840
+ ], { description: "Override output filename (string), or false for text-only" })),
841
+ reads: Type.Optional(Type.Union([
842
+ Type.Array(Type.String()),
843
+ Type.Boolean(),
844
+ ], { description: "Override files to read from {chain_dir} (array), or false to disable" })),
845
+ progress: Type.Optional(Type.Boolean({ description: "Override progress tracking" })),
846
+ });
847
+
848
+ // Parallel task item (within a parallel step)
849
+ const ParallelTaskSchema = Type.Object({
850
+ agent: Type.String(),
851
+ task: Type.Optional(Type.String({ description: "Task template. Defaults to {previous}." })),
852
+ cwd: Type.Optional(Type.String()),
853
+ output: Type.Optional(Type.Union([
854
+ Type.String(),
855
+ Type.Boolean(),
856
+ ], { description: "Override output filename (string), or false for text-only" })),
857
+ reads: Type.Optional(Type.Union([
858
+ Type.Array(Type.String()),
859
+ Type.Boolean(),
860
+ ], { description: "Override files to read from {chain_dir} (array), or false to disable" })),
861
+ progress: Type.Optional(Type.Boolean({ description: "Override progress tracking" })),
862
+ });
863
+
864
+ // Parallel chain step (multiple agents running concurrently)
865
+ const ParallelStepSchema = Type.Object({
866
+ parallel: Type.Array(ParallelTaskSchema, { minItems: 1, description: "Tasks to run in parallel" }),
867
+ concurrency: Type.Optional(Type.Number({ description: "Max concurrent tasks (default: 4)" })),
868
+ failFast: Type.Optional(Type.Boolean({ description: "Stop on first failure (default: false)" })),
869
+ });
870
+
871
+ // Chain item can be either sequential or parallel
872
+ const ChainItem = Type.Union([SequentialStepSchema, ParallelStepSchema]);
873
+
874
+ const MaxOutputSchema = Type.Optional(
875
+ Type.Object({
876
+ bytes: Type.Optional(Type.Number({ description: "Max bytes (default: 204800)" })),
877
+ lines: Type.Optional(Type.Number({ description: "Max lines (default: 5000)" })),
878
+ }),
879
+ );
880
+
881
+ const Params = Type.Object({
882
+ agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode)" })),
883
+ task: Type.Optional(Type.String({ description: "Task (SINGLE mode)" })),
884
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task}, ...]" })),
885
+ chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: [{agent}, {agent, task:'{previous}'}] - sequential pipeline" })),
886
+ async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
887
+ agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'user')" })),
888
+ cwd: Type.Optional(Type.String()),
889
+ maxOutput: MaxOutputSchema,
890
+ artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),
891
+ includeProgress: Type.Optional(Type.Boolean({ description: "Include full progress in result (default: false)" })),
892
+ share: Type.Optional(Type.Boolean({ description: "Create shareable session log (default: true)", default: true })),
893
+ sessionDir: Type.Optional(
894
+ Type.String({ description: "Directory to store session logs (default: temp; enables sessions even if share=false)" }),
895
+ ),
896
+ // Chain clarification TUI
897
+ clarify: Type.Optional(Type.Boolean({ description: "Show TUI to clarify chain templates (default: true for chains). Implies sync mode." })),
898
+ // Solo agent output override
899
+ output: Type.Optional(Type.Union([
900
+ Type.String(),
901
+ Type.Boolean(),
902
+ ], { description: "Override output file for single agent (string), or false to disable (uses agent default if omitted)" })),
903
+ });
904
+
905
+ const StatusParams = Type.Object({
906
+ id: Type.Optional(Type.String({ description: "Async run id or prefix" })),
907
+ dir: Type.Optional(Type.String({ description: "Async run directory (overrides id search)" })),
908
+ });
909
+
910
+ interface ExtensionConfig {
911
+ asyncByDefault?: boolean;
912
+ }
913
+
914
+ function loadConfig(): ExtensionConfig {
915
+ const configPath = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent", "config.json");
916
+ try {
917
+ if (fs.existsSync(configPath)) {
918
+ return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ExtensionConfig;
919
+ }
920
+ } catch {}
921
+ return {};
922
+ }
923
+
924
+ export default function registerSubagentExtension(pi: ExtensionAPI): void {
925
+ fs.mkdirSync(RESULTS_DIR, { recursive: true });
926
+ fs.mkdirSync(ASYNC_DIR, { recursive: true });
927
+
928
+ // Cleanup old chain directories on startup (after 24h)
929
+ cleanupOldChainDirs();
930
+
931
+ const config = loadConfig();
932
+ const asyncByDefault = config.asyncByDefault === true;
933
+
934
+ const tempArtifactsDir = getArtifactsDir(null);
935
+ cleanupOldArtifacts(tempArtifactsDir, DEFAULT_ARTIFACT_CONFIG.cleanupDays);
936
+ let baseCwd = process.cwd();
937
+ let currentSessionId: string | null = null;
938
+ const asyncJobs = new Map<string, AsyncJobState>();
939
+ let lastUiContext: ExtensionContext | null = null;
940
+ let poller: NodeJS.Timeout | null = null;
941
+
942
+ const ensurePoller = () => {
943
+ if (poller) return;
944
+ poller = setInterval(() => {
945
+ if (!lastUiContext || !lastUiContext.hasUI) return;
946
+ if (asyncJobs.size === 0) {
947
+ renderWidget(lastUiContext, []);
948
+ clearInterval(poller);
949
+ poller = null;
950
+ return;
951
+ }
952
+
953
+ for (const job of asyncJobs.values()) {
954
+ const status = readStatus(job.asyncDir);
955
+ if (status) {
956
+ job.status = status.state;
957
+ job.mode = status.mode;
958
+ job.currentStep = status.currentStep ?? job.currentStep;
959
+ job.stepsTotal = status.steps?.length ?? job.stepsTotal;
960
+ job.startedAt = status.startedAt ?? job.startedAt;
961
+ job.updatedAt = status.lastUpdate ?? Date.now();
962
+ if (status.steps?.length) {
963
+ job.agents = status.steps.map((step) => step.agent);
964
+ }
965
+ job.sessionDir = status.sessionDir ?? job.sessionDir;
966
+ job.outputFile = status.outputFile ?? job.outputFile;
967
+ job.totalTokens = status.totalTokens ?? job.totalTokens;
968
+ job.sessionFile = status.sessionFile ?? job.sessionFile;
969
+ // job.shareUrl = status.shareUrl ?? job.shareUrl;
970
+ } else {
971
+ job.status = job.status === "queued" ? "running" : job.status;
972
+ job.updatedAt = Date.now();
973
+ }
974
+ }
975
+
976
+ renderWidget(lastUiContext, Array.from(asyncJobs.values()));
977
+ }, POLL_INTERVAL_MS);
978
+ };
979
+
980
+ const handleResult = (file: string) => {
981
+ const p = path.join(RESULTS_DIR, file);
982
+ if (!fs.existsSync(p)) return;
983
+ try {
984
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
985
+ if (data.sessionId && data.sessionId !== currentSessionId) return;
986
+ if (!data.sessionId && data.cwd && data.cwd !== baseCwd) return;
987
+ pi.events.emit("subagent:complete", data);
988
+ pi.events.emit("subagent_enhanced:complete", data);
989
+ fs.unlinkSync(p);
990
+ } catch {}
991
+ };
992
+
993
+ const watcher = fs.watch(RESULTS_DIR, (ev, file) => {
994
+ if (ev === "rename" && file?.toString().endsWith(".json")) setTimeout(() => handleResult(file.toString()), 50);
995
+ });
996
+ fs.readdirSync(RESULTS_DIR)
997
+ .filter((f) => f.endsWith(".json"))
998
+ .forEach(handleResult);
999
+
1000
+ const tool: ToolDefinition<typeof Params, Details> = {
1001
+ name: "subagent",
1002
+ label: "Subagent",
1003
+ description: `Delegate to subagents. Use exactly ONE mode:
1004
+ • SINGLE: { agent, task } - one task
1005
+ • CHAIN: { chain: [{agent:"scout"}, {agent:"planner"}] } - sequential, {previous} passes output
1006
+ • PARALLEL: { tasks: [{agent,task}, ...] } - concurrent
1007
+ For "scout → planner" or multi-step flows, use chain (not multiple single calls).`,
1008
+ parameters: Params,
1009
+
1010
+ async execute(_id, params, onUpdate, ctx, signal) {
1011
+ const scope: AgentScope = params.agentScope ?? "user";
1012
+ baseCwd = ctx.cwd;
1013
+ currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1014
+ const agents = discoverAgents(ctx.cwd, scope).agents;
1015
+ const runId = randomUUID().slice(0, 8);
1016
+ const shareEnabled = params.share !== false;
1017
+ const sessionEnabled = shareEnabled || Boolean(params.sessionDir);
1018
+ const sessionRoot = sessionEnabled
1019
+ ? params.sessionDir
1020
+ ? path.resolve(params.sessionDir)
1021
+ : fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"))
1022
+ : undefined;
1023
+ if (sessionRoot) {
1024
+ try {
1025
+ fs.mkdirSync(sessionRoot, { recursive: true });
1026
+ } catch {}
1027
+ }
1028
+ const sessionDirForIndex = (idx?: number) =>
1029
+ sessionRoot ? path.join(sessionRoot, `run-${idx ?? 0}`) : undefined;
1030
+
1031
+ const hasChain = (params.chain?.length ?? 0) > 0;
1032
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
1033
+ const hasSingle = Boolean(params.agent && params.task);
1034
+
1035
+ const requestedAsync = params.async ?? asyncByDefault;
1036
+ const parallelDowngraded = hasTasks && requestedAsync;
1037
+ // clarify implies sync mode (TUI is blocking)
1038
+ // If user requested async without explicit clarify: false, downgrade to sync for chains
1039
+ const effectiveAsync = requestedAsync && !hasTasks && (hasChain ? params.clarify === false : true);
1040
+
1041
+ const artifactConfig: ArtifactConfig = {
1042
+ ...DEFAULT_ARTIFACT_CONFIG,
1043
+ enabled: params.artifacts !== false,
1044
+ };
1045
+
1046
+ const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
1047
+ const artifactsDir = effectiveAsync ? tempArtifactsDir : getArtifactsDir(sessionFile);
1048
+
1049
+ if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
1050
+ return {
1051
+ content: [
1052
+ {
1053
+ type: "text",
1054
+ text: `Provide exactly one mode. Agents: ${agents.map((a) => a.name).join(", ") || "none"}`,
1055
+ },
1056
+ ],
1057
+ isError: true,
1058
+ details: { mode: "single" as const, results: [] },
1059
+ };
1060
+ }
1061
+
1062
+ // Validate chain early (before async/sync branching)
1063
+ if (hasChain && params.chain) {
1064
+ if (params.chain.length === 0) {
1065
+ return {
1066
+ content: [{ type: "text", text: "Chain must have at least one step" }],
1067
+ isError: true,
1068
+ details: { mode: "chain" as const, results: [] },
1069
+ };
1070
+ }
1071
+ // First step must have a task
1072
+ const firstStep = params.chain[0] as ChainStep;
1073
+ if (isParallelStep(firstStep)) {
1074
+ if (!firstStep.parallel[0]?.task) {
1075
+ return {
1076
+ content: [{ type: "text", text: "First parallel task must have a task" }],
1077
+ isError: true,
1078
+ details: { mode: "chain" as const, results: [] },
1079
+ };
1080
+ }
1081
+ } else if (!(firstStep as SequentialStep).task) {
1082
+ return {
1083
+ content: [{ type: "text", text: "First step in chain must have a task" }],
1084
+ isError: true,
1085
+ details: { mode: "chain" as const, results: [] },
1086
+ };
1087
+ }
1088
+ // Validate all agents exist
1089
+ for (let i = 0; i < params.chain.length; i++) {
1090
+ const step = params.chain[i] as ChainStep;
1091
+ const stepAgents = getStepAgents(step);
1092
+ for (const agentName of stepAgents) {
1093
+ if (!agents.find((a) => a.name === agentName)) {
1094
+ return {
1095
+ content: [{ type: "text", text: `Unknown agent: ${agentName} (step ${i + 1})` }],
1096
+ isError: true,
1097
+ details: { mode: "chain" as const, results: [] },
1098
+ };
1099
+ }
1100
+ }
1101
+ // Validate parallel steps have at least one task
1102
+ if (isParallelStep(step) && step.parallel.length === 0) {
1103
+ return {
1104
+ content: [{ type: "text", text: `Parallel step ${i + 1} must have at least one task` }],
1105
+ isError: true,
1106
+ details: { mode: "chain" as const, results: [] },
1107
+ };
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ if (effectiveAsync) {
1113
+ if (!jitiCliPath)
1114
+ return {
1115
+ content: [{ type: "text", text: "jiti not found" }],
1116
+ isError: true,
1117
+ details: { mode: "single" as const, results: [] },
1118
+ };
1119
+ const id = randomUUID();
1120
+ const asyncDir = path.join(ASYNC_DIR, id);
1121
+ try {
1122
+ fs.mkdirSync(asyncDir, { recursive: true });
1123
+ } catch {}
1124
+ const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
1125
+
1126
+ const spawnRunner = (cfg: object, suffix: string): number | undefined => {
1127
+ const cfgPath = path.join(os.tmpdir(), `pi-async-cfg-${suffix}.json`);
1128
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg));
1129
+ const proc = spawn("node", [jitiCliPath!, runner, cfgPath], {
1130
+ cwd: (cfg as any).cwd ?? ctx.cwd,
1131
+ detached: true,
1132
+ stdio: "ignore",
1133
+ });
1134
+ proc.unref();
1135
+ return proc.pid;
1136
+ };
1137
+
1138
+ if (hasChain && params.chain) {
1139
+ // Async mode doesn't support parallel steps (v1 limitation)
1140
+ const chainStepsForAsync = params.chain as ChainStep[];
1141
+ const hasParallelInChain = chainStepsForAsync.some(isParallelStep);
1142
+ if (hasParallelInChain) {
1143
+ return {
1144
+ content: [{ type: "text", text: "Async mode doesn't support chains with parallel steps. Use clarify: true (sync mode) for parallel-in-chain." }],
1145
+ isError: true,
1146
+ details: { mode: "chain" as const, results: [] },
1147
+ };
1148
+ }
1149
+
1150
+ // At this point, all steps are sequential
1151
+ const seqSteps = chainStepsForAsync as SequentialStep[];
1152
+
1153
+ // Validate all agents exist before building steps
1154
+ for (const s of seqSteps) {
1155
+ if (!agents.find((x) => x.name === s.agent)) {
1156
+ return {
1157
+ content: [{ type: "text", text: `Unknown agent: ${s.agent}` }],
1158
+ isError: true,
1159
+ details: { mode: "chain" as const, results: [] },
1160
+ };
1161
+ }
1162
+ }
1163
+ const steps = seqSteps.map((s, i) => {
1164
+ const a = agents.find((x) => x.name === s.agent)!;
1165
+ return {
1166
+ agent: s.agent,
1167
+ // For async, use simple defaults: first step uses inline task, others use {previous}
1168
+ task: s.task ?? (i === 0 ? "{task}" : "{previous}"),
1169
+ cwd: s.cwd,
1170
+ model: a.model,
1171
+ tools: a.tools,
1172
+ systemPrompt: a.systemPrompt?.trim() || null,
1173
+ };
1174
+ });
1175
+ const pid = spawnRunner(
1176
+ {
1177
+ id,
1178
+ steps,
1179
+ resultPath: path.join(RESULTS_DIR, `${id}.json`),
1180
+ cwd: params.cwd ?? ctx.cwd,
1181
+ placeholder: "{previous}",
1182
+ maxOutput: params.maxOutput,
1183
+ artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1184
+ artifactConfig,
1185
+ share: shareEnabled,
1186
+ sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined,
1187
+ asyncDir,
1188
+ sessionId: currentSessionId,
1189
+ },
1190
+ id,
1191
+ );
1192
+ if (pid) {
1193
+ pi.events.emit("subagent_enhanced:started", {
1194
+ id,
1195
+ pid,
1196
+ agent: params.chain[0].agent,
1197
+ task: params.chain[0].task?.slice(0, 50),
1198
+ chain: params.chain.map((s) => s.agent),
1199
+ cwd: params.cwd ?? ctx.cwd,
1200
+ asyncDir,
1201
+ });
1202
+ pi.events.emit("subagent:started", {
1203
+ id,
1204
+ pid,
1205
+ agent: params.chain[0].agent,
1206
+ task: params.chain[0].task?.slice(0, 50),
1207
+ chain: params.chain.map((s) => s.agent),
1208
+ cwd: params.cwd ?? ctx.cwd,
1209
+ asyncDir,
1210
+ });
1211
+ }
1212
+ return {
1213
+ content: [
1214
+ { type: "text", text: `Async chain: ${params.chain.map((s) => s.agent).join(" -> ")} [${id}]` },
1215
+ ],
1216
+ details: { mode: "chain", results: [], asyncId: id, asyncDir },
1217
+ };
1218
+ }
1219
+
1220
+ if (hasSingle) {
1221
+ const a = agents.find((x) => x.name === params.agent);
1222
+ if (!a)
1223
+ return {
1224
+ content: [{ type: "text", text: `Unknown: ${params.agent}` }],
1225
+ isError: true,
1226
+ details: { mode: "single" as const, results: [] },
1227
+ };
1228
+ const pid = spawnRunner(
1229
+ {
1230
+ id,
1231
+ steps: [
1232
+ {
1233
+ agent: params.agent,
1234
+ task: params.task,
1235
+ cwd: params.cwd,
1236
+ model: a.model,
1237
+ tools: a.tools,
1238
+ systemPrompt: a.systemPrompt?.trim() || null,
1239
+ },
1240
+ ],
1241
+ resultPath: path.join(RESULTS_DIR, `${id}.json`),
1242
+ cwd: params.cwd ?? ctx.cwd,
1243
+ placeholder: "{previous}",
1244
+ maxOutput: params.maxOutput,
1245
+ artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1246
+ artifactConfig,
1247
+ share: shareEnabled,
1248
+ sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined,
1249
+ asyncDir,
1250
+ sessionId: currentSessionId,
1251
+ },
1252
+ id,
1253
+ );
1254
+ if (pid) {
1255
+ pi.events.emit("subagent_enhanced:started", {
1256
+ id,
1257
+ pid,
1258
+ agent: params.agent,
1259
+ task: params.task?.slice(0, 50),
1260
+ cwd: params.cwd ?? ctx.cwd,
1261
+ asyncDir,
1262
+ });
1263
+ pi.events.emit("subagent:started", {
1264
+ id,
1265
+ pid,
1266
+ agent: params.agent,
1267
+ task: params.task?.slice(0, 50),
1268
+ cwd: params.cwd ?? ctx.cwd,
1269
+ asyncDir,
1270
+ });
1271
+ }
1272
+ return {
1273
+ content: [{ type: "text", text: `Async: ${params.agent} [${id}]` }],
1274
+ details: { mode: "single", results: [], asyncId: id, asyncDir },
1275
+ };
1276
+ }
1277
+ }
1278
+
1279
+ const allProgress: AgentProgress[] = [];
1280
+ const allArtifactPaths: ArtifactPaths[] = [];
1281
+
1282
+ if (hasChain && params.chain) {
1283
+ // Cast chain to typed steps
1284
+ const chainSteps = params.chain as ChainStep[];
1285
+
1286
+ // Compute chain metadata for observability
1287
+ const chainAgents: string[] = chainSteps.map((step) =>
1288
+ isParallelStep(step)
1289
+ ? `[${step.parallel.map((t) => t.agent).join("+")}]`
1290
+ : (step as SequentialStep).agent,
1291
+ );
1292
+ const totalSteps = chainSteps.length;
1293
+
1294
+ // Get original task from first step
1295
+ const firstStep = chainSteps[0]!;
1296
+ const originalTask = isParallelStep(firstStep)
1297
+ ? firstStep.parallel[0]!.task!
1298
+ : (firstStep as SequentialStep).task!;
1299
+
1300
+ // Create chain directory
1301
+ const chainDir = createChainDir(runId);
1302
+
1303
+ // Check if chain has any parallel steps
1304
+ const hasParallelSteps = chainSteps.some(isParallelStep);
1305
+
1306
+ // Resolve templates using V2 (parallel-aware)
1307
+ const settings = loadSubagentSettings();
1308
+ let templates: ResolvedTemplates = resolveChainTemplatesV2(chainSteps, settings);
1309
+
1310
+ // For TUI: only show if no parallel steps (TUI v1 doesn't support parallel display)
1311
+ // TODO: Update TUI to support parallel steps
1312
+ const shouldClarify = params.clarify !== false && ctx.hasUI && !hasParallelSteps;
1313
+
1314
+ // Behavior overrides from TUI (set if TUI is shown, undefined otherwise)
1315
+ let tuiBehaviorOverrides: (BehaviorOverride | undefined)[] | undefined;
1316
+
1317
+ if (shouldClarify) {
1318
+ // Sequential-only chain: use existing TUI
1319
+ const seqSteps = chainSteps as SequentialStep[];
1320
+
1321
+ // Load agent configs for sequential steps
1322
+ const agentConfigs: AgentConfig[] = [];
1323
+ for (const step of seqSteps) {
1324
+ const config = agents.find((a) => a.name === step.agent);
1325
+ if (!config) {
1326
+ removeChainDir(chainDir);
1327
+ return {
1328
+ content: [{ type: "text", text: `Unknown agent: ${step.agent}` }],
1329
+ isError: true,
1330
+ details: { mode: "chain" as const, results: [] },
1331
+ };
1332
+ }
1333
+ agentConfigs.push(config);
1334
+ }
1335
+
1336
+ // Build step overrides
1337
+ const stepOverrides: StepOverrides[] = seqSteps.map((step) => ({
1338
+ output: step.output,
1339
+ reads: step.reads,
1340
+ progress: step.progress,
1341
+ }));
1342
+
1343
+ // Pre-resolve behaviors for TUI display
1344
+ const resolvedBehaviors = agentConfigs.map((config, i) =>
1345
+ resolveStepBehavior(config, stepOverrides[i]!),
1346
+ );
1347
+
1348
+ // Flatten templates for TUI (all strings for sequential)
1349
+ const flatTemplates = templates as string[];
1350
+
1351
+ const result = await ctx.ui.custom<ChainClarifyResult>(
1352
+ (tui, theme, _kb, done) =>
1353
+ new ChainClarifyComponent(
1354
+ tui,
1355
+ theme,
1356
+ agentConfigs,
1357
+ flatTemplates,
1358
+ originalTask,
1359
+ chainDir,
1360
+ resolvedBehaviors,
1361
+ done,
1362
+ ),
1363
+ {
1364
+ overlay: true,
1365
+ overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" },
1366
+ },
1367
+ );
1368
+
1369
+ if (!result || !result.confirmed) {
1370
+ removeChainDir(chainDir);
1371
+ return {
1372
+ content: [{ type: "text", text: "Chain cancelled" }],
1373
+ details: { mode: "chain", results: [] },
1374
+ };
1375
+ }
1376
+ // Update templates from TUI result
1377
+ templates = result.templates;
1378
+ // Store behavior overrides from TUI (used below in sequential step execution)
1379
+ tuiBehaviorOverrides = result.behaviorOverrides;
1380
+ }
1381
+
1382
+ // Execute chain (handles both sequential and parallel steps)
1383
+ const results: SingleResult[] = [];
1384
+ let prev = "";
1385
+ let globalTaskIndex = 0; // For unique artifact naming
1386
+ let progressCreated = false; // Track if progress.md has been created
1387
+
1388
+ for (let stepIndex = 0; stepIndex < chainSteps.length; stepIndex++) {
1389
+ const step = chainSteps[stepIndex]!;
1390
+ const stepTemplates = templates[stepIndex]!;
1391
+
1392
+ if (isParallelStep(step)) {
1393
+ // === PARALLEL STEP EXECUTION ===
1394
+ const parallelTemplates = stepTemplates as string[];
1395
+ const concurrency = step.concurrency ?? MAX_CONCURRENCY;
1396
+ const failFast = step.failFast ?? false;
1397
+
1398
+ // Create subdirectories for parallel outputs
1399
+ const agentNames = step.parallel.map((t) => t.agent);
1400
+ createParallelDirs(chainDir, stepIndex, step.parallel.length, agentNames);
1401
+
1402
+ // Resolve behaviors for parallel tasks
1403
+ const parallelBehaviors = resolveParallelBehaviors(step.parallel, agents, stepIndex);
1404
+
1405
+ // If any parallel task has progress enabled and progress.md hasn't been created,
1406
+ // create it now to avoid race conditions
1407
+ const anyNeedsProgress = parallelBehaviors.some((b) => b.progress);
1408
+ if (anyNeedsProgress && !progressCreated) {
1409
+ const progressPath = path.join(chainDir, "progress.md");
1410
+ fs.writeFileSync(progressPath, "# Progress\n\n## Status\nIn Progress\n\n## Tasks\n\n## Files Changed\n\n## Notes\n");
1411
+ progressCreated = true;
1412
+ }
1413
+
1414
+ // Track if we should abort remaining tasks (for fail-fast)
1415
+ let aborted = false;
1416
+
1417
+ // Execute parallel tasks
1418
+ const parallelResults = await mapConcurrent(
1419
+ step.parallel,
1420
+ concurrency,
1421
+ async (task, taskIndex) => {
1422
+ if (aborted && failFast) {
1423
+ // Return a placeholder for skipped tasks
1424
+ return {
1425
+ agent: task.agent,
1426
+ task: "(skipped)",
1427
+ exitCode: -1,
1428
+ messages: [],
1429
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
1430
+ error: "Skipped due to fail-fast",
1431
+ } as SingleResult;
1432
+ }
1433
+
1434
+ // Build task string
1435
+ const taskTemplate = parallelTemplates[taskIndex] ?? "{previous}";
1436
+ const templateHasPrevious = taskTemplate.includes("{previous}");
1437
+ let taskStr = taskTemplate;
1438
+ taskStr = taskStr.replace(/\{task\}/g, originalTask);
1439
+ taskStr = taskStr.replace(/\{previous\}/g, prev);
1440
+ taskStr = taskStr.replace(/\{chain_dir\}/g, chainDir);
1441
+
1442
+ // Add chain instructions (include previous summary only if not already in template)
1443
+ const behavior = parallelBehaviors[taskIndex]!;
1444
+ // For parallel, no single "first progress" - each manages independently
1445
+ taskStr += buildChainInstructions(behavior, chainDir, false, templateHasPrevious ? undefined : prev);
1446
+
1447
+ const r = await runSync(ctx.cwd, agents, task.agent, taskStr, {
1448
+ cwd: task.cwd ?? params.cwd,
1449
+ signal,
1450
+ runId,
1451
+ index: globalTaskIndex + taskIndex,
1452
+ sessionDir: sessionDirForIndex(globalTaskIndex + taskIndex),
1453
+ share: shareEnabled,
1454
+ artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1455
+ artifactConfig,
1456
+ onUpdate: onUpdate
1457
+ ? (p) =>
1458
+ onUpdate({
1459
+ ...p,
1460
+ details: {
1461
+ mode: "chain",
1462
+ results: [...results, ...(p.details?.results || [])],
1463
+ progress: [...allProgress, ...(p.details?.progress || [])],
1464
+ chainAgents,
1465
+ totalSteps,
1466
+ currentStepIndex: stepIndex,
1467
+ },
1468
+ })
1469
+ : undefined,
1470
+ });
1471
+
1472
+ if (r.exitCode !== 0 && failFast) {
1473
+ aborted = true;
1474
+ }
1475
+
1476
+ return r;
1477
+ },
1478
+ );
1479
+
1480
+ // Update global task index
1481
+ globalTaskIndex += step.parallel.length;
1482
+
1483
+ // Collect results and progress
1484
+ for (const r of parallelResults) {
1485
+ results.push(r);
1486
+ if (r.progress) allProgress.push(r.progress);
1487
+ if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
1488
+ }
1489
+
1490
+ // Check for failures (track original task index for better error messages)
1491
+ const failures = parallelResults
1492
+ .map((r, originalIndex) => ({ ...r, originalIndex }))
1493
+ .filter((r) => r.exitCode !== 0 && r.exitCode !== -1);
1494
+ if (failures.length > 0) {
1495
+ const failureSummary = failures
1496
+ .map((f) => `- Task ${f.originalIndex + 1} (${f.agent}): ${f.error || "failed"}`)
1497
+ .join("\n");
1498
+ const errorMsg = `Parallel step ${stepIndex + 1} failed:\n${failureSummary}`;
1499
+ const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
1500
+ index: stepIndex,
1501
+ error: errorMsg,
1502
+ });
1503
+ return {
1504
+ content: [{ type: "text", text: summary }],
1505
+ details: {
1506
+ mode: "chain",
1507
+ results,
1508
+ progress: params.includeProgress ? allProgress : undefined,
1509
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1510
+ chainAgents,
1511
+ totalSteps,
1512
+ currentStepIndex: stepIndex,
1513
+ },
1514
+ isError: true,
1515
+ };
1516
+ }
1517
+
1518
+ // Aggregate outputs for {previous}
1519
+ const taskResults: ParallelTaskResult[] = parallelResults.map((r, i) => ({
1520
+ agent: r.agent,
1521
+ taskIndex: i,
1522
+ output: getFinalOutput(r.messages),
1523
+ exitCode: r.exitCode,
1524
+ error: r.error,
1525
+ }));
1526
+ prev = aggregateParallelOutputs(taskResults);
1527
+ } else {
1528
+ // === SEQUENTIAL STEP EXECUTION ===
1529
+ const seqStep = step as SequentialStep;
1530
+ const stepTemplate = stepTemplates as string;
1531
+
1532
+ // Get agent config
1533
+ const agentConfig = agents.find((a) => a.name === seqStep.agent);
1534
+ if (!agentConfig) {
1535
+ removeChainDir(chainDir);
1536
+ return {
1537
+ content: [{ type: "text", text: `Unknown agent: ${seqStep.agent}` }],
1538
+ isError: true,
1539
+ details: { mode: "chain" as const, results: [] },
1540
+ };
1541
+ }
1542
+
1543
+ // Build task string (check if template has {previous} before replacement)
1544
+ const templateHasPrevious = stepTemplate.includes("{previous}");
1545
+ let stepTask = stepTemplate;
1546
+ stepTask = stepTask.replace(/\{task\}/g, originalTask);
1547
+ stepTask = stepTask.replace(/\{previous\}/g, prev);
1548
+ stepTask = stepTask.replace(/\{chain_dir\}/g, chainDir);
1549
+
1550
+ // Resolve behavior (TUI overrides take precedence over step config)
1551
+ const tuiOverride = tuiBehaviorOverrides?.[stepIndex];
1552
+ const stepOverride: StepOverrides = {
1553
+ output: tuiOverride?.output !== undefined ? tuiOverride.output : seqStep.output,
1554
+ reads: tuiOverride?.reads !== undefined ? tuiOverride.reads : seqStep.reads,
1555
+ progress: tuiOverride?.progress !== undefined ? tuiOverride.progress : seqStep.progress,
1556
+ };
1557
+ const behavior = resolveStepBehavior(agentConfig, stepOverride);
1558
+
1559
+ // Determine if this is the first agent to create progress.md
1560
+ const isFirstProgress = behavior.progress && !progressCreated;
1561
+ if (isFirstProgress) {
1562
+ progressCreated = true;
1563
+ }
1564
+
1565
+ // Add chain instructions (include previous summary only if not already in template)
1566
+ stepTask += buildChainInstructions(behavior, chainDir, isFirstProgress, templateHasPrevious ? undefined : prev);
1567
+
1568
+ // Run step
1569
+ const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
1570
+ cwd: seqStep.cwd ?? params.cwd,
1571
+ signal,
1572
+ runId,
1573
+ index: globalTaskIndex,
1574
+ sessionDir: sessionDirForIndex(globalTaskIndex),
1575
+ share: shareEnabled,
1576
+ artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1577
+ artifactConfig,
1578
+ onUpdate: onUpdate
1579
+ ? (p) =>
1580
+ onUpdate({
1581
+ ...p,
1582
+ details: {
1583
+ mode: "chain",
1584
+ results: [...results, ...(p.details?.results || [])],
1585
+ progress: [...allProgress, ...(p.details?.progress || [])],
1586
+ chainAgents,
1587
+ totalSteps,
1588
+ currentStepIndex: stepIndex,
1589
+ },
1590
+ })
1591
+ : undefined,
1592
+ });
1593
+
1594
+ globalTaskIndex++;
1595
+ results.push(r);
1596
+ if (r.progress) allProgress.push(r.progress);
1597
+ if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
1598
+
1599
+ // On failure, leave chain_dir for debugging
1600
+ if (r.exitCode !== 0) {
1601
+ const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
1602
+ index: stepIndex,
1603
+ error: r.error || "Chain failed",
1604
+ });
1605
+ return {
1606
+ content: [{ type: "text", text: summary }],
1607
+ details: {
1608
+ mode: "chain",
1609
+ results,
1610
+ progress: params.includeProgress ? allProgress : undefined,
1611
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1612
+ chainAgents,
1613
+ totalSteps,
1614
+ currentStepIndex: stepIndex,
1615
+ },
1616
+ isError: true,
1617
+ };
1618
+ }
1619
+
1620
+ prev = getFinalOutput(r.messages);
1621
+ }
1622
+ }
1623
+
1624
+ // Chain complete - return summary with paths
1625
+ // Chain dir left for inspection (cleaned up after 24h)
1626
+ const summary = buildChainSummary(chainSteps, results, chainDir, "completed");
1627
+
1628
+ return {
1629
+ content: [{ type: "text", text: summary }],
1630
+ details: {
1631
+ mode: "chain",
1632
+ results,
1633
+ progress: params.includeProgress ? allProgress : undefined,
1634
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1635
+ chainAgents,
1636
+ totalSteps,
1637
+ // currentStepIndex omitted for completed chains
1638
+ },
1639
+ };
1640
+ }
1641
+
1642
+ if (hasTasks && params.tasks) {
1643
+ if (params.tasks.length > MAX_PARALLEL)
1644
+ return {
1645
+ content: [{ type: "text", text: `Max ${MAX_PARALLEL} tasks` }],
1646
+ isError: true,
1647
+ details: { mode: "single" as const, results: [] },
1648
+ };
1649
+ const results = await mapConcurrent(params.tasks, MAX_CONCURRENCY, async (t, i) =>
1650
+ runSync(ctx.cwd, agents, t.agent, t.task, {
1651
+ cwd: t.cwd ?? params.cwd,
1652
+ signal,
1653
+ runId,
1654
+ index: i,
1655
+ sessionDir: sessionDirForIndex(i),
1656
+ share: shareEnabled,
1657
+ artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1658
+ artifactConfig,
1659
+ maxOutput: params.maxOutput,
1660
+ }),
1661
+ );
1662
+
1663
+ for (const r of results) {
1664
+ if (r.progress) allProgress.push(r.progress);
1665
+ if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
1666
+ }
1667
+
1668
+ const ok = results.filter((r) => r.exitCode === 0).length;
1669
+ const downgradeNote = parallelDowngraded ? " (async not supported for parallel)" : "";
1670
+ return {
1671
+ content: [{ type: "text", text: `${ok}/${results.length} succeeded${downgradeNote}` }],
1672
+ details: {
1673
+ mode: "parallel",
1674
+ results,
1675
+ progress: params.includeProgress ? allProgress : undefined,
1676
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1677
+ },
1678
+ };
1679
+ }
1680
+
1681
+ if (hasSingle) {
1682
+ // Look up agent config for output handling
1683
+ const agentConfig = agents.find((a) => a.name === params.agent);
1684
+ // Note: runSync already handles unknown agent, but we need config for output
1685
+
1686
+ let task = params.task!;
1687
+ let outputPath: string | undefined;
1688
+
1689
+ // Check if agent has output and it's not disabled
1690
+ if (agentConfig) {
1691
+ const effectiveOutput =
1692
+ params.output !== undefined ? params.output : agentConfig.output;
1693
+
1694
+ if (effectiveOutput && effectiveOutput !== false) {
1695
+ const outputDir = `/tmp/pi-${agentConfig.name}-${runId}`;
1696
+ fs.mkdirSync(outputDir, { recursive: true });
1697
+ outputPath = `${outputDir}/${effectiveOutput}`;
1698
+
1699
+ // Inject output instruction into task
1700
+ task += `\n\n---\n**Output:** Write your findings to: ${outputPath}`;
1701
+ }
1702
+ }
1703
+
1704
+ const r = await runSync(ctx.cwd, agents, params.agent!, task, {
1705
+ cwd: params.cwd,
1706
+ signal,
1707
+ runId,
1708
+ sessionDir: sessionDirForIndex(0),
1709
+ share: shareEnabled,
1710
+ artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1711
+ artifactConfig,
1712
+ maxOutput: params.maxOutput,
1713
+ onUpdate,
1714
+ });
1715
+
1716
+ if (r.progress) allProgress.push(r.progress);
1717
+ if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
1718
+
1719
+ // Get output and append file path if applicable
1720
+ let output = r.truncation?.text || getFinalOutput(r.messages);
1721
+ if (outputPath && r.exitCode === 0) {
1722
+ output += `\n\n📄 Output saved to: ${outputPath}`;
1723
+ }
1724
+
1725
+ if (r.exitCode !== 0)
1726
+ return {
1727
+ content: [{ type: "text", text: r.error || "Failed" }],
1728
+ details: {
1729
+ mode: "single",
1730
+ results: [r],
1731
+ progress: params.includeProgress ? allProgress : undefined,
1732
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1733
+ truncation: r.truncation,
1734
+ },
1735
+ isError: true,
1736
+ };
1737
+ return {
1738
+ content: [{ type: "text", text: output || "(no output)" }],
1739
+ details: {
1740
+ mode: "single",
1741
+ results: [r],
1742
+ progress: params.includeProgress ? allProgress : undefined,
1743
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1744
+ truncation: r.truncation,
1745
+ },
1746
+ };
1747
+ }
1748
+
1749
+ return {
1750
+ content: [{ type: "text", text: "Invalid params" }],
1751
+ isError: true,
1752
+ details: { mode: "single" as const, results: [] },
1753
+ };
1754
+ },
1755
+
1756
+ renderCall(args, theme) {
1757
+ const isParallel = (args.tasks?.length ?? 0) > 0;
1758
+ const asyncLabel = args.async === true && !isParallel ? theme.fg("warning", " [async]") : "";
1759
+ if (args.chain?.length)
1760
+ return new Text(
1761
+ `${theme.fg("toolTitle", theme.bold("subagent "))}chain (${args.chain.length})${asyncLabel}`,
1762
+ 0,
1763
+ 0,
1764
+ );
1765
+ if (isParallel)
1766
+ return new Text(
1767
+ `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${args.tasks!.length})`,
1768
+ 0,
1769
+ 0,
1770
+ );
1771
+ return new Text(
1772
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent || "?")}${asyncLabel}`,
1773
+ 0,
1774
+ 0,
1775
+ );
1776
+ },
1777
+
1778
+ renderResult(result, { expanded }, theme) {
1779
+ const d = result.details;
1780
+ if (!d || !d.results.length) {
1781
+ const t = result.content[0];
1782
+ return new Text(t?.type === "text" ? t.text : "(no output)", 0, 0);
1783
+ }
1784
+
1785
+ const mdTheme = getMarkdownTheme();
1786
+
1787
+ if (d.mode === "single" && d.results.length === 1) {
1788
+ const r = d.results[0];
1789
+ const isRunning = r.progress?.status === "running";
1790
+ const icon = isRunning
1791
+ ? theme.fg("warning", "...")
1792
+ : r.exitCode === 0
1793
+ ? theme.fg("success", "ok")
1794
+ : theme.fg("error", "X");
1795
+ const output = r.truncation?.text || getFinalOutput(r.messages);
1796
+
1797
+ const progressInfo = isRunning && r.progress
1798
+ ? ` | ${r.progress.toolCount} tools, ${formatTokens(r.progress.tokens)} tok, ${formatDuration(r.progress.durationMs)}`
1799
+ : r.progressSummary
1800
+ ? ` | ${r.progressSummary.toolCount} tools, ${formatTokens(r.progressSummary.tokens)} tokens, ${formatDuration(r.progressSummary.durationMs)}`
1801
+ : "";
1802
+
1803
+ if (expanded) {
1804
+ const c = new Container();
1805
+ c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${progressInfo}`, 0, 0));
1806
+ c.addChild(new Spacer(1));
1807
+ c.addChild(
1808
+ new Text(theme.fg("dim", `Task: ${r.task.slice(0, 100)}${r.task.length > 100 ? "..." : ""}`), 0, 0),
1809
+ );
1810
+ c.addChild(new Spacer(1));
1811
+
1812
+ const items = getDisplayItems(r.messages);
1813
+ for (const item of items) {
1814
+ if (item.type === "tool")
1815
+ c.addChild(new Text(theme.fg("muted", formatToolCall(item.name, item.args)), 0, 0));
1816
+ }
1817
+ if (items.length) c.addChild(new Spacer(1));
1818
+
1819
+ if (output) c.addChild(new Markdown(output, 0, 0, mdTheme));
1820
+ c.addChild(new Spacer(1));
1821
+ c.addChild(new Text(theme.fg("dim", formatUsage(r.usage, r.model)), 0, 0));
1822
+ if (r.sessionFile) {
1823
+ c.addChild(new Text(theme.fg("dim", `Session: ${shortenPath(r.sessionFile)}`), 0, 0));
1824
+ }
1825
+ // Sharing disabled - session file path shown above
1826
+
1827
+ if (r.artifactPaths) {
1828
+ c.addChild(new Spacer(1));
1829
+ c.addChild(new Text(theme.fg("dim", `Artifacts: ${shortenPath(r.artifactPaths.outputPath)}`), 0, 0));
1830
+ }
1831
+ return c;
1832
+ }
1833
+
1834
+ const lines = [`${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${progressInfo}`];
1835
+
1836
+ if (isRunning && r.progress) {
1837
+ if (r.progress.currentTool) {
1838
+ const toolLine = r.progress.currentToolArgs
1839
+ ? `${r.progress.currentTool}: ${r.progress.currentToolArgs.slice(0, 60)}${r.progress.currentToolArgs.length > 60 ? "..." : ""}`
1840
+ : r.progress.currentTool;
1841
+ lines.push(theme.fg("warning", `> ${toolLine}`));
1842
+ }
1843
+ for (const line of r.progress.recentOutput.slice(-3)) {
1844
+ lines.push(theme.fg("dim", ` ${line.slice(0, 80)}${line.length > 80 ? "..." : ""}`));
1845
+ }
1846
+ lines.push(theme.fg("dim", "(ctrl+o to expand)"));
1847
+ } else {
1848
+ const items = getDisplayItems(r.messages).slice(-COLLAPSED_ITEMS);
1849
+ for (const item of items) {
1850
+ if (item.type === "tool") lines.push(theme.fg("muted", formatToolCall(item.name, item.args)));
1851
+ else lines.push(item.text.slice(0, 80) + (item.text.length > 80 ? "..." : ""));
1852
+ }
1853
+ lines.push(theme.fg("dim", formatUsage(r.usage, r.model)));
1854
+ }
1855
+ return new Text(lines.join("\n"), 0, 0);
1856
+ }
1857
+
1858
+ const hasRunning = d.progress?.some((p) => p.status === "running")
1859
+ || d.results.some((r) => r.progress?.status === "running");
1860
+ const ok = d.results.filter((r) => r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running")).length;
1861
+ const icon = hasRunning
1862
+ ? theme.fg("warning", "...")
1863
+ : ok === d.results.length
1864
+ ? theme.fg("success", "ok")
1865
+ : theme.fg("error", "X");
1866
+
1867
+ const totalSummary =
1868
+ d.progressSummary ||
1869
+ d.results.reduce(
1870
+ (acc, r) => {
1871
+ const prog = r.progress || r.progressSummary;
1872
+ if (prog) {
1873
+ acc.toolCount += prog.toolCount;
1874
+ acc.tokens += prog.tokens;
1875
+ acc.durationMs =
1876
+ d.mode === "chain"
1877
+ ? acc.durationMs + prog.durationMs
1878
+ : Math.max(acc.durationMs, prog.durationMs);
1879
+ }
1880
+ return acc;
1881
+ },
1882
+ { toolCount: 0, tokens: 0, durationMs: 0 },
1883
+ );
1884
+
1885
+ const summaryStr =
1886
+ totalSummary.toolCount || totalSummary.tokens
1887
+ ? ` | ${totalSummary.toolCount} tools, ${formatTokens(totalSummary.tokens)} tok, ${formatDuration(totalSummary.durationMs)}`
1888
+ : "";
1889
+
1890
+ const modeLabel = d.mode === "parallel" ? "parallel (no live progress)" : d.mode;
1891
+ // Use chain metadata for accurate step counts
1892
+ const totalStepsCount = d.totalSteps ?? d.results.length;
1893
+ const currentStep = d.currentStepIndex !== undefined ? d.currentStepIndex + 1 : ok + 1;
1894
+ const stepInfo = hasRunning ? ` ${currentStep}/${totalStepsCount}` : ` ${ok}/${totalStepsCount}`;
1895
+
1896
+ // Build chain visualization: "scout → planner" with status icons
1897
+ // Note: Only works correctly for sequential chains. Chains with parallel steps
1898
+ // (indicated by "[agent1+agent2]" format) have multiple results per step,
1899
+ // breaking the 1:1 mapping between chainAgents and results.
1900
+ const hasParallelInChain = d.chainAgents?.some((a) => a.startsWith("["));
1901
+ const chainVis = d.chainAgents?.length && !hasParallelInChain
1902
+ ? d.chainAgents
1903
+ .map((agent, i) => {
1904
+ const result = d.results[i];
1905
+ const isFailed = result && result.exitCode !== 0 && result.progress?.status !== "running";
1906
+ const isComplete = result && result.exitCode === 0 && result.progress?.status !== "running";
1907
+ const isCurrent = i === (d.currentStepIndex ?? d.results.length);
1908
+ const icon = isFailed
1909
+ ? theme.fg("error", "✗")
1910
+ : isComplete
1911
+ ? theme.fg("success", "✓")
1912
+ : isCurrent && hasRunning
1913
+ ? theme.fg("warning", "●")
1914
+ : theme.fg("dim", "○");
1915
+ return `${icon}${agent}`;
1916
+ })
1917
+ .join(theme.fg("dim", " → "))
1918
+ : null;
1919
+
1920
+ if (expanded) {
1921
+ const c = new Container();
1922
+ c.addChild(
1923
+ new Text(
1924
+ `${icon} ${theme.fg("toolTitle", theme.bold(modeLabel))}${stepInfo}${summaryStr}`,
1925
+ 0,
1926
+ 0,
1927
+ ),
1928
+ );
1929
+ // Show chain visualization in expanded view
1930
+ if (chainVis) {
1931
+ c.addChild(new Text(` ${chainVis}`, 0, 0));
1932
+ }
1933
+ for (let i = 0; i < d.results.length; i++) {
1934
+ const r = d.results[i];
1935
+ c.addChild(new Spacer(1));
1936
+ // Check both r.progress and d.progress array for running status
1937
+ const progressFromArray = d.progress?.find((p) => p.index === i);
1938
+ const rProg = r.progress || progressFromArray || r.progressSummary;
1939
+ const rRunning = rProg?.status === "running";
1940
+ const rIcon = rRunning
1941
+ ? theme.fg("warning", "...")
1942
+ : r.exitCode === 0
1943
+ ? theme.fg("success", "ok")
1944
+ : theme.fg("error", "X");
1945
+ const rProgress = rProg
1946
+ ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}`
1947
+ : "";
1948
+ c.addChild(new Text(`${rIcon} ${theme.bold(r.agent)}${rProgress}`, 0, 0));
1949
+
1950
+ if (rRunning && rProg) {
1951
+ if (rProg.currentTool) {
1952
+ const toolLine = rProg.currentToolArgs
1953
+ ? `${rProg.currentTool}: ${rProg.currentToolArgs.slice(0, 50)}${rProg.currentToolArgs.length > 50 ? "..." : ""}`
1954
+ : rProg.currentTool;
1955
+ c.addChild(new Text(theme.fg("warning", ` > ${toolLine}`), 0, 0));
1956
+ }
1957
+ for (const line of rProg.recentOutput.slice(-2)) {
1958
+ c.addChild(new Text(theme.fg("dim", ` ${line.slice(0, 70)}${line.length > 70 ? "..." : ""}`), 0, 0));
1959
+ }
1960
+ } else {
1961
+ const out = r.truncation?.text || getFinalOutput(r.messages);
1962
+ if (out) c.addChild(new Markdown(out, 0, 0, mdTheme));
1963
+ c.addChild(new Text(theme.fg("dim", formatUsage(r.usage, r.model)), 0, 0));
1964
+ if (r.sessionFile) {
1965
+ c.addChild(new Text(theme.fg("dim", `Session: ${shortenPath(r.sessionFile)}`), 0, 0));
1966
+ }
1967
+ // Sharing disabled - session file path shown above
1968
+ }
1969
+ }
1970
+
1971
+ if (d.artifacts) {
1972
+ c.addChild(new Spacer(1));
1973
+ c.addChild(new Text(theme.fg("dim", `Artifacts dir: ${shortenPath(d.artifacts.dir)}`), 0, 0));
1974
+ }
1975
+ return c;
1976
+ }
1977
+
1978
+ const lines = [`${icon} ${theme.fg("toolTitle", theme.bold(modeLabel))}${stepInfo}${summaryStr}`];
1979
+ // Show chain visualization if available
1980
+ if (chainVis) {
1981
+ lines.push(` ${chainVis}`);
1982
+ }
1983
+ // Find running progress from d.progress array (more reliable) or d.results
1984
+ const runningProgress = d.progress?.find((p) => p.status === "running")
1985
+ || d.results.find((r) => r.progress?.status === "running")?.progress;
1986
+ if (runningProgress) {
1987
+ if (runningProgress.currentTool) {
1988
+ const toolLine = runningProgress.currentToolArgs
1989
+ ? `${runningProgress.currentTool}: ${runningProgress.currentToolArgs.slice(0, 50)}${runningProgress.currentToolArgs.length > 50 ? "..." : ""}`
1990
+ : runningProgress.currentTool;
1991
+ lines.push(theme.fg("warning", ` > ${toolLine}`));
1992
+ }
1993
+ for (const line of runningProgress.recentOutput.slice(-2)) {
1994
+ lines.push(theme.fg("dim", ` ${line.slice(0, 70)}${line.length > 70 ? "..." : ""}`));
1995
+ }
1996
+ lines.push(theme.fg("dim", "(ctrl+o to expand)"));
1997
+ } else if (hasRunning) {
1998
+ // Fallback: we know something is running but can't find details
1999
+ lines.push(theme.fg("dim", "(ctrl+o to expand)"));
2000
+ }
2001
+ return new Text(lines.join("\n"), 0, 0);
2002
+ },
2003
+
2004
+ };
2005
+
2006
+ const statusTool: ToolDefinition<typeof StatusParams, Details> = {
2007
+ name: "subagent_status",
2008
+ label: "Subagent Status",
2009
+ description: "Inspect async subagent run status and artifacts",
2010
+ parameters: StatusParams,
2011
+
2012
+ async execute(_id, params) {
2013
+ let asyncDir: string | null = null;
2014
+ let resolvedId = params.id;
2015
+
2016
+ if (params.dir) {
2017
+ asyncDir = path.resolve(params.dir);
2018
+ } else if (params.id) {
2019
+ const direct = path.join(ASYNC_DIR, params.id);
2020
+ if (fs.existsSync(direct)) {
2021
+ asyncDir = direct;
2022
+ } else {
2023
+ const match = findByPrefix(ASYNC_DIR, params.id);
2024
+ if (match) {
2025
+ asyncDir = match;
2026
+ resolvedId = path.basename(match);
2027
+ }
2028
+ }
2029
+ }
2030
+
2031
+ const resultPath =
2032
+ params.id && !asyncDir ? findByPrefix(RESULTS_DIR, params.id, ".json") : null;
2033
+
2034
+ if (!asyncDir && !resultPath) {
2035
+ return {
2036
+ content: [{ type: "text", text: "Async run not found. Provide id or dir." }],
2037
+ isError: true,
2038
+ details: { mode: "single" as const, results: [] },
2039
+ };
2040
+ }
2041
+
2042
+ if (asyncDir) {
2043
+ const status = readStatus(asyncDir);
2044
+ const logPath = path.join(asyncDir, `subagent-log-${resolvedId ?? "unknown"}.md`);
2045
+ const eventsPath = path.join(asyncDir, "events.jsonl");
2046
+ if (status) {
2047
+ const stepsTotal = status.steps?.length ?? 1;
2048
+ const current = status.currentStep !== undefined ? status.currentStep + 1 : undefined;
2049
+ const stepLine =
2050
+ current !== undefined ? `Step: ${current}/${stepsTotal}` : `Steps: ${stepsTotal}`;
2051
+ const started = new Date(status.startedAt).toISOString();
2052
+ const updated = status.lastUpdate ? new Date(status.lastUpdate).toISOString() : "n/a";
2053
+
2054
+ const lines = [
2055
+ `Run: ${status.runId}`,
2056
+ `State: ${status.state}`,
2057
+ `Mode: ${status.mode}`,
2058
+ stepLine,
2059
+ `Started: ${started}`,
2060
+ `Updated: ${updated}`,
2061
+ `Dir: ${asyncDir}`,
2062
+ ];
2063
+ if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
2064
+ // Sharing disabled - session file path shown above
2065
+ if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
2066
+ if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
2067
+
2068
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
2069
+ }
2070
+ }
2071
+
2072
+ if (resultPath) {
2073
+ try {
2074
+ const raw = fs.readFileSync(resultPath, "utf-8");
2075
+ const data = JSON.parse(raw) as { id?: string; success?: boolean; summary?: string };
2076
+ const status = data.success ? "complete" : "failed";
2077
+ const lines = [`Run: ${data.id ?? params.id}`, `State: ${status}`, `Result: ${resultPath}`];
2078
+ if (data.summary) lines.push("", data.summary);
2079
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
2080
+ } catch {}
2081
+ }
2082
+
2083
+ return {
2084
+ content: [{ type: "text", text: "Status file not found." }],
2085
+ isError: true,
2086
+ details: { mode: "single" as const, results: [] },
2087
+ };
2088
+ },
2089
+ };
2090
+
2091
+ pi.registerTool(tool);
2092
+ pi.registerTool(statusTool);
2093
+
2094
+ pi.events.on("subagent:started", (data) => {
2095
+ const info = data as {
2096
+ id?: string;
2097
+ asyncDir?: string;
2098
+ agent?: string;
2099
+ chain?: string[];
2100
+ };
2101
+ if (!info.id) return;
2102
+ const asyncDir = info.asyncDir ?? path.join(ASYNC_DIR, info.id);
2103
+ const agents = info.chain && info.chain.length > 0 ? info.chain : info.agent ? [info.agent] : undefined;
2104
+ const now = Date.now();
2105
+ asyncJobs.set(info.id, {
2106
+ asyncId: info.id,
2107
+ asyncDir,
2108
+ status: "queued",
2109
+ mode: info.chain ? "chain" : "single",
2110
+ agents,
2111
+ stepsTotal: agents?.length,
2112
+ startedAt: now,
2113
+ updatedAt: now,
2114
+ });
2115
+ if (lastUiContext) {
2116
+ renderWidget(lastUiContext, Array.from(asyncJobs.values()));
2117
+ ensurePoller();
2118
+ }
2119
+ });
2120
+
2121
+ pi.events.on("subagent:complete", (data) => {
2122
+ const result = data as { id?: string; success?: boolean; asyncDir?: string };
2123
+ const asyncId = result.id;
2124
+ if (!asyncId) return;
2125
+ const job = asyncJobs.get(asyncId);
2126
+ if (job) {
2127
+ job.status = result.success ? "complete" : "failed";
2128
+ job.updatedAt = Date.now();
2129
+ if (result.asyncDir) job.asyncDir = result.asyncDir;
2130
+ }
2131
+ if (lastUiContext) {
2132
+ renderWidget(lastUiContext, Array.from(asyncJobs.values()));
2133
+ }
2134
+ setTimeout(() => {
2135
+ asyncJobs.delete(asyncId);
2136
+ if (lastUiContext) renderWidget(lastUiContext, Array.from(asyncJobs.values()));
2137
+ }, 10000);
2138
+ });
2139
+
2140
+ pi.on("tool_result", (event, ctx) => {
2141
+ if (event.toolName !== "subagent") return;
2142
+ if (!ctx.hasUI) return;
2143
+ lastUiContext = ctx;
2144
+ if (asyncJobs.size > 0) {
2145
+ renderWidget(ctx, Array.from(asyncJobs.values()));
2146
+ ensurePoller();
2147
+ }
2148
+ });
2149
+
2150
+ pi.on("session_start", (_event, ctx) => {
2151
+ baseCwd = ctx.cwd;
2152
+ currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2153
+ asyncJobs.clear();
2154
+ if (ctx.hasUI) {
2155
+ lastUiContext = ctx;
2156
+ renderWidget(ctx, []);
2157
+ }
2158
+ });
2159
+ pi.on("session_switch", (_event, ctx) => {
2160
+ baseCwd = ctx.cwd;
2161
+ currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2162
+ asyncJobs.clear();
2163
+ if (ctx.hasUI) {
2164
+ lastUiContext = ctx;
2165
+ renderWidget(ctx, []);
2166
+ }
2167
+ });
2168
+ pi.on("session_branch", (_event, ctx) => {
2169
+ baseCwd = ctx.cwd;
2170
+ currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2171
+ asyncJobs.clear();
2172
+ if (ctx.hasUI) {
2173
+ lastUiContext = ctx;
2174
+ renderWidget(ctx, []);
2175
+ }
2176
+ });
2177
+ pi.on("session_shutdown", () => {
2178
+ watcher.close();
2179
+ if (poller) clearInterval(poller);
2180
+ poller = null;
2181
+ asyncJobs.clear();
2182
+ if (lastUiContext?.hasUI) {
2183
+ lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
2184
+ }
2185
+ });
2186
+ }