@bacnh85/pi-subagent 0.5.0 → 0.7.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.
@@ -38,6 +38,17 @@ import {
38
38
  mapWithConcurrencyLimit,
39
39
  runSubAgent,
40
40
  } from "./runner.ts";
41
+ import {
42
+ normalizeTimeout,
43
+ resolveSafeCwd,
44
+ validateAgentTools,
45
+ truncateParallelOutput,
46
+ validateExecutionRequest,
47
+ MAX_CONCURRENCY,
48
+ MAX_PARALLEL_TASKS,
49
+ MAX_CHAIN_LENGTH,
50
+ MAX_INSTRUCTIONS_LENGTH,
51
+ } from "./security.ts";
41
52
  import {
42
53
  aggregateUsage,
43
54
  formatUsageStats,
@@ -45,31 +56,30 @@ import {
45
56
  } from "./render.ts";
46
57
  import { type SubagentThread, threadStore } from "./threads.ts";
47
58
  import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
59
+ import { resolveModel } from "./model.ts";
48
60
  import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
49
61
 
50
62
  // ---------------------------------------------------------------------------
51
63
  // Constants
52
64
  // ---------------------------------------------------------------------------
53
65
 
54
- const MAX_PARALLEL_TASKS = 8;
55
- const MAX_CONCURRENCY = 4;
56
- const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB per parallel task
57
-
58
- import { resolveModel } from "./model.ts";
59
-
60
66
  // ---------------------------------------------------------------------------
61
67
  // Helpers
62
68
  // ---------------------------------------------------------------------------
63
69
 
64
- function truncateParallelOutput(output: string): string {
65
- const byteLength = Buffer.byteLength(output, "utf8");
66
- if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
67
-
68
- let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
69
- while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
70
- truncated = truncated.slice(0, -1);
71
- }
72
- return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
70
+ /** Namespace for trusted configuration loaded from pi settings, never from tool params. */
71
+ function getTrustedConfig(ctx: ExtensionContext): { allowUnconfirmedProjectAgents: boolean; allowExternalCwd: boolean } {
72
+ // Use pi's settings infrastructure if available; fall back to env vars for testing.
73
+ // The model cannot influence these values.
74
+ const settings = (ctx as any).settings ?? {};
75
+ return {
76
+ allowUnconfirmedProjectAgents:
77
+ (settings as Record<string, unknown>).allowUnconfirmedProjectAgents === true ||
78
+ process.env.PI_SUBAGENT_ALLOW_UNCONFIRMED_PROJECT_AGENTS === "true",
79
+ allowExternalCwd:
80
+ (settings as Record<string, unknown>).allowExternalCwd === true ||
81
+ process.env.PI_SUBAGENT_ALLOW_EXTERNAL_CWD === "true",
82
+ };
73
83
  }
74
84
 
75
85
 
@@ -109,13 +119,10 @@ const SubagentParams = Type.Object({
109
119
  }),
110
120
  ),
111
121
  agentScope: Type.Optional(AgentScopeSchema),
112
- confirmProjectAgents: Type.Optional(
113
- Type.Boolean({
114
- description: "Prompt before running project-local agents. Default: true.",
115
- default: true,
116
- }),
117
- ),
118
- cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
122
+ // Security: confirmProjectAgents is NOT exposed as a model-controllable parameter.
123
+ // Project-agent confirmation is enforced via trusted configuration.
124
+ // See Security model section in README.
125
+ cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
119
126
  timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds for all sub-agents (overridden by per-task/step timeouts)" })),
120
127
  instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
121
128
  abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
@@ -146,21 +153,34 @@ export default function (pi: ExtensionAPI) {
146
153
  threadStore.clear();
147
154
  });
148
155
 
149
- // Proactively steer agents toward sub-agent delegation when users mention it
150
- pi.on("before_agent_start", async (event) => {
151
- const prompt = event.prompt.toLowerCase();
152
- if (/\b(delegate to|use a subagent|run in parallel|spawn an agent|scout|review this|chain|worker agent)\b/.test(prompt)) {
153
- return {
154
- systemPrompt:
155
- event.systemPrompt +
156
- "\n\nThe subagent tool is available for delegating tasks to specialized agents with isolated context. Use /subagent to list available agents. Bundled: scout (fast recon), reviewer (code review), worker (implementation), general-purpose (fallback). Modes: single, parallel (max 8), chain.",
157
- };
158
- }
159
- });
160
-
161
156
  // Resolve bundled agents directory relative to this extension file
162
157
  const bundledAgentsDir = path.resolve(__dirname, "../agents");
163
158
 
159
+ // Inject available agent catalog into system prompt for semantic auto-delegation
160
+ pi.on("before_agent_start", async (event) => {
161
+ const ctx = currentCtx;
162
+ const discovery = discoverAgents(event.cwd ?? ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
163
+ const catalog = discovery.agents
164
+ .map((a) => {
165
+ const modelInfo = a.model ? ` (model: ${a.model})` : " (inherits parent)";
166
+ const thinkingInfo = a.thinking ? `, thinking: ${a.thinking}` : "";
167
+ const sandboxInfo = a.sandbox ? `, sandbox: ${a.sandbox}` : "";
168
+ return `- **${a.name}**: ${a.description}${modelInfo}${thinkingInfo}${sandboxInfo}`;
169
+ })
170
+ .join("\n");
171
+ return {
172
+ systemPrompt:
173
+ event.systemPrompt +
174
+ `\n\n## Available Subagents\n${catalog}\n\n` +
175
+ "The subagent tool can delegate tasks to these specialized agents with isolated context. " +
176
+ "Use for read-heavy exploration, parallel analysis, or work that would flood the main context.\n" +
177
+ "Prefer **scout** for fast read-only exploration. " +
178
+ "Prefer **reviewer** for code review (high thinking, read-only). " +
179
+ "Prefer **worker** for implementation (medium thinking, all tools). " +
180
+ "Modes: single, parallel (max 8 tasks, 4 concurrent), chain.",
181
+ };
182
+ });
183
+
164
184
  // Public one-request/one-response service used by pi-review.
165
185
  pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
166
186
  const request = raw as SubagentRunRequest;
@@ -172,7 +192,7 @@ export default function (pi: ExtensionAPI) {
172
192
  request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
173
193
  return;
174
194
  }
175
- const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single" });
195
+ const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single", color: agent.color });
176
196
  void runNamedAgent({
177
197
  agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
178
198
  task: request.task,
@@ -290,7 +310,14 @@ export default function (pi: ExtensionAPI) {
290
310
  const agentScope: AgentScope = params.agentScope ?? "user";
291
311
  const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
292
312
  const agents = discovery.agents;
293
- const confirmProjectAgents = params.confirmProjectAgents ?? true;
313
+
314
+ // Trusted configuration — never from tool params.
315
+ const trusted = getTrustedConfig(ctx);
316
+ const confirmProjectAgents = !trusted.allowUnconfirmedProjectAgents;
317
+ const allowExternalCwd = trusted.allowExternalCwd;
318
+
319
+ // Resolve workspace root for cwd validation.
320
+ const workspaceRoot = ctx.cwd;
294
321
 
295
322
  const hasChain = (params.chain?.length ?? 0) > 0;
296
323
  const hasTasks = (params.tasks?.length ?? 0) > 0;
@@ -306,6 +333,23 @@ export default function (pi: ExtensionAPI) {
306
333
  results,
307
334
  });
308
335
 
336
+ // Validate execution request before any processing.
337
+ const validationErrors = validateExecutionRequest({
338
+ agentName: params.agent,
339
+ task: params.task,
340
+ tasks: params.tasks,
341
+ chain: params.chain,
342
+ timeout: params.timeout,
343
+ });
344
+ if (validationErrors.length > 0) {
345
+ const errorMessages = validationErrors.map((e) => ` • ${e.field}: ${e.message}`).join("\n");
346
+ return {
347
+ content: [{ type: "text", text: `Invalid parameters:\n${errorMessages}` }],
348
+ details: makeDetails("single")([]),
349
+ isError: true,
350
+ };
351
+ }
352
+
309
353
  // Validate: exactly one mode
310
354
  if (modeCount !== 1) {
311
355
  const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
@@ -327,6 +371,7 @@ export default function (pi: ExtensionAPI) {
327
371
  }
328
372
 
329
373
  // Handle project-local agent confirmation
374
+ // Security: confirmation policy comes from trusted config, never from tool params.
330
375
  if (agentScope === "project" || agentScope === "both") {
331
376
  const requestedAgentNames = new Set<string>();
332
377
  if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
@@ -337,33 +382,34 @@ export default function (pi: ExtensionAPI) {
337
382
  .map((name) => agents.find((a) => a.name === name))
338
383
  .filter((a): a is AgentConfig => a?.source === "project");
339
384
 
340
- if (projectAgentsRequested.length > 0 && confirmProjectAgents) {
341
- if (ctx.hasUI) {
342
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
343
- const dir = discovery.projectAgentsDir ?? "(unknown)";
344
- const ok = await ctx.ui.confirm(
345
- "Run project-local agents?",
346
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
347
- );
348
- if (!ok) {
385
+ if (projectAgentsRequested.length > 0) {
386
+ if (confirmProjectAgents) {
387
+ if (ctx.hasUI) {
388
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
389
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
390
+ const ok = await ctx.ui.confirm(
391
+ "Run project-local agents?",
392
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
393
+ );
394
+ if (!ok) {
395
+ return {
396
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
397
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
398
+ };
399
+ }
400
+ } else {
401
+ // Fail closed in headless sessions.
349
402
  return {
350
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
403
+ content: [{
404
+ type: "text",
405
+ text: "Project agents require explicit user approval. "
406
+ + "Enable the trusted project-agent setting to use them in headless mode.",
407
+ }],
351
408
  details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
352
409
  };
353
410
  }
354
- } else {
355
- // ponytail: fail closed in headless sessions — project agent
356
- // prompts and tools run without user oversight.
357
- return {
358
- content: [{
359
- type: "text",
360
- text: "Cannot run project-local agents without UI confirmation. "
361
- + "Set confirmProjectAgents: false to allow in headless sessions, "
362
- + "or use agentScope: 'user' to skip project agents.",
363
- }],
364
- details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
365
- };
366
411
  }
412
+ // else: allowUnconfirmedProjectAgents is true — skip confirmation.
367
413
  }
368
414
  }
369
415
 
@@ -382,14 +428,45 @@ export default function (pi: ExtensionAPI) {
382
428
  }
383
429
  }
384
430
 
385
- // Helper: run a single agent via SDK
431
+ // Helper: resolve a safe child working directory.
432
+ function resolveChildCwd(childCwd: string | undefined): string {
433
+ const safe = resolveSafeCwd({ workspaceRoot, childCwd, allowExternalCwd });
434
+ if (safe.error) {
435
+ throw new Error(safe.error);
436
+ }
437
+ return safe.path;
438
+ }
439
+
440
+ // Helper: validate and normalise tools for an agent.
441
+ function resolveChildTools(agentTools: string[] | undefined, readOnly?: boolean): string[] {
442
+ const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
443
+ const rawTools = agentTools ?? defaultTools;
444
+ const result = validateAgentTools({ tools: rawTools, readOnly });
445
+ if (result.errors.length > 0) {
446
+ throw new Error(`Tool validation errors: ${result.errors.join("; ")}`);
447
+ }
448
+ return result.tools;
449
+ }
450
+
451
+ // Helper: normalise timeout.
452
+ function resolveChildTimeout(childTimeout: number | undefined, globalTimeout: number | undefined): number | undefined {
453
+ const effectiveTimeout = childTimeout ?? globalTimeout;
454
+ const result = normalizeTimeout({ requested: effectiveTimeout });
455
+ if (result.error) {
456
+ throw new Error(result.error);
457
+ }
458
+ return result.timeoutMs;
459
+ }
460
+
461
+ // Helper: run a single agent via SDK with security validation
386
462
  async function runOne(
387
463
  agentName: string,
388
464
  task: string,
389
465
  cwd: string | undefined,
390
466
  parentSignal?: AbortSignal,
391
467
  timeoutMs?: number,
392
- onProgress?: (partial: SubAgentResult) => void,
468
+ onProgress?: (partial: SubAgentResult) => void,
469
+ isReadOnly?: boolean,
393
470
  ): Promise<SubAgentResult> {
394
471
  const agent = agents.find((a) => a.name === agentName);
395
472
 
@@ -421,49 +498,46 @@ export default function (pi: ExtensionAPI) {
421
498
  };
422
499
  }
423
500
 
424
- // Inject parent's API key so --api-key and other runtime overrides work
425
- await injectApiKey(resolved.model);
426
-
427
- // Resolve tools; strip "subagent" to prevent accidental recursion.
428
- // Sub-agents cannot spawn further sub-agents (one level of delegation only).
429
- const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
430
- let tools = agent.tools ?? defaultTools;
431
- tools = tools.filter((t) => t !== "subagent");
432
-
433
- const timeoutController = timeoutMs && timeoutMs > 0 ? new AbortController() : undefined;
434
- const timeoutId = timeoutController ? setTimeout(() => timeoutController.abort(), timeoutMs) : undefined;
435
- const signals = [parentSignal, timeoutController?.signal].filter((value): value is AbortSignal => Boolean(value));
436
- const combinedSignal = signals.length > 1
437
- ? typeof (AbortSignal as any).any === "function"
438
- ? (AbortSignal as any).any(signals)
439
- : signals[0]
440
- : signals[0];
441
-
501
+ // Security: validate tools, timeout, and cwd (wrapped in try/catch).
502
+ let tools: string[];
503
+ let effectiveTimeoutMs: number | undefined;
504
+ let safeCwd: string;
442
505
  try {
443
- const result = await runSubAgent({
444
- cwd: cwd ?? ctx.cwd,
445
- systemPrompt: params.instructions
446
- ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, 16 * 1024)}`
447
- : agent.systemPrompt,
506
+ // Inject parent's API key so --api-key and other runtime overrides work
507
+ await injectApiKey(resolved.model);
508
+ tools = resolveChildTools(agent.tools, isReadOnly);
509
+ effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
510
+ safeCwd = resolveChildCwd(cwd);
511
+ } catch (err: unknown) {
512
+ const errorMsg = err instanceof Error ? err.message : String(err);
513
+ return {
514
+ agent: agentName,
448
515
  task,
449
- tools,
450
- model: resolved.model,
451
- authStorage,
452
- modelRegistry,
453
- signal: combinedSignal,
454
- agentName,
455
- thinkingLevel: agent.thinking,
456
- onMessage: onProgress,
457
- });
458
- if (timeoutController?.signal.aborted && !parentSignal?.aborted) {
459
- result.exitCode = 1;
460
- result.stopReason = "timeout";
461
- result.errorMessage ||= `Timeout after ${timeoutMs}ms`;
462
- }
463
- return result;
464
- } finally {
465
- if (timeoutId) clearTimeout(timeoutId);
516
+ exitCode: 1,
517
+ messages: [],
518
+ stderr: `Validation error: ${errorMsg}`,
519
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
520
+ errorMessage: errorMsg,
521
+ };
466
522
  }
523
+
524
+ const result = await runSubAgent({
525
+ cwd: safeCwd,
526
+ systemPrompt: params.instructions
527
+ ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
528
+ : agent.systemPrompt,
529
+ task,
530
+ tools,
531
+ model: resolved.model,
532
+ authStorage,
533
+ modelRegistry,
534
+ signal: parentSignal,
535
+ timeoutMs: effectiveTimeoutMs,
536
+ agentName,
537
+ thinkingLevel: agent.thinking,
538
+ onMessage: onProgress,
539
+ });
540
+ return result;
467
541
  }
468
542
 
469
543
  // --- Chain mode ---
@@ -480,6 +554,7 @@ export default function (pi: ExtensionAPI) {
480
554
  task: taskWithContext,
481
555
  mode: "chain-step",
482
556
  toolCallId: _toolCallId,
557
+ color: agents.find(a => a.name === step.agent)?.color,
483
558
  });
484
559
  const result = await runOne(
485
560
  step.agent, taskWithContext, step.cwd,
@@ -542,149 +617,141 @@ export default function (pi: ExtensionAPI) {
542
617
 
543
618
  // --- Parallel mode ---
544
619
  if (params.tasks && params.tasks.length > 0) {
545
- if (params.tasks.length > MAX_PARALLEL_TASKS) {
546
- return {
547
- content: [
548
- {
549
- type: "text",
550
- text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
551
- },
552
- ],
553
- details: makeDetails("parallel")([]),
554
- };
555
- }
556
-
557
620
  const abortOnFailure = params.abortOnFailure ?? false;
558
621
  const parallelController = new AbortController();
559
- let abortCause: "parent" | "sibling" | undefined;
622
+ let abortCause: "parent" | "sibling" | "timeout" | undefined;
623
+ let cleanupParentSignal: (() => void) | undefined;
560
624
 
561
- // Combine parent signal with parallel abort controller
562
- let parallelSignal: AbortSignal = parallelController.signal;
625
+ // Link parent abort into parallelController so queued tasks see aborted state
563
626
  if (signal) {
564
- // Always link parent abort into parallelController so queued tasks see aborted state
565
627
  if (signal.aborted) {
566
628
  abortCause = "parent";
567
629
  parallelController.abort();
568
630
  } else {
569
- signal.addEventListener("abort", () => {
631
+ const onParentAbort = () => {
570
632
  if (!abortCause) abortCause = "parent";
571
633
  parallelController.abort();
572
- }, { once: true });
573
- }
574
- if (typeof (AbortSignal as any).any === "function") {
575
- parallelSignal = (AbortSignal as any).any([signal, parallelController.signal]);
576
- } else {
577
- parallelSignal = parallelController.signal;
634
+ };
635
+ signal.addEventListener("abort", onParentAbort, { once: true });
636
+ cleanupParentSignal = () => signal.removeEventListener("abort", onParentAbort);
578
637
  }
579
638
  }
580
639
 
581
- // Pre-create threads for all parallel tasks
582
- const parallelThreads = params.tasks.map((t) =>
583
- threadStore.createThread({
584
- agentName: t.agent,
585
- task: t.task,
586
- mode: "parallel-task",
587
- toolCallId: _toolCallId,
588
- }),
589
- );
590
-
591
- const allResults: SubAgentResult[] = new Array(params.tasks.length);
592
- // Initialize placeholder results for streaming
593
- for (let i = 0; i < params.tasks.length; i++) {
594
- allResults[i] = {
595
- agent: params.tasks[i].agent,
596
- task: params.tasks[i].task,
597
- exitCode: -1,
598
- messages: [],
599
- stderr: "",
600
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
601
- };
602
- }
640
+ // Wrap all remaining setup + execution so cleanupParentSignal always runs.
641
+ try {
642
+ // Pre-create threads for all parallel tasks
643
+ const parallelThreads = params.tasks.map((t) =>
644
+ threadStore.createThread({
645
+ agentName: t.agent,
646
+ task: t.task,
647
+ mode: "parallel-task",
648
+ toolCallId: _toolCallId,
649
+ color: agents.find(a => a.name === t.agent)?.color,
650
+ }),
651
+ );
603
652
 
604
- const emitParallelUpdate = () => {
605
- if (onUpdate) {
606
- const running = allResults.filter((r) => r.exitCode === -1).length;
607
- const done = allResults.filter((r) => r.exitCode !== -1).length;
608
- onUpdate({
609
- content: [
610
- {
611
- type: "text",
612
- text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
613
- },
614
- ],
615
- details: makeDetails("parallel")([...allResults]),
616
- });
653
+ const allResults: SubAgentResult[] = new Array(params.tasks.length);
654
+ // Initialize placeholder results for streaming
655
+ for (let i = 0; i < params.tasks.length; i++) {
656
+ allResults[i] = {
657
+ agent: params.tasks[i].agent,
658
+ task: params.tasks[i].task,
659
+ exitCode: -1,
660
+ messages: [],
661
+ stderr: "",
662
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
663
+ };
617
664
  }
618
- };
619
665
 
620
- const results = await mapWithConcurrencyLimit(
621
- params.tasks,
622
- MAX_CONCURRENCY,
623
- async (t, index) => {
624
- // Skip if already aborted by sibling failure or parent abort
625
- if (parallelSignal.aborted || parallelController.signal.aborted) {
626
- const skippedResult: SubAgentResult = {
627
- agent: t.agent,
628
- task: t.task,
629
- exitCode: 1,
630
- messages: [],
631
- stderr: "",
632
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
633
- stopReason: "aborted",
634
- errorMessage:
635
- abortCause === "sibling"
636
- ? "Cancelled: sibling task failed"
637
- : "Cancelled: parent operation aborted",
638
- };
639
- allResults[index] = skippedResult;
640
- threadStore.updateThread(parallelThreads[index].id, {
641
- status: "aborted",
642
- result: skippedResult,
666
+ const emitParallelUpdate = () => {
667
+ if (onUpdate) {
668
+ const running = allResults.filter((r) => r.exitCode === -1).length;
669
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
670
+ onUpdate({
671
+ content: [
672
+ {
673
+ type: "text",
674
+ text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
675
+ },
676
+ ],
677
+ details: makeDetails("parallel")([...allResults]),
643
678
  });
644
- emitParallelUpdate();
645
- return skippedResult;
646
679
  }
647
- const result = await runOne(
648
- t.agent, t.task, t.cwd,
649
- parallelSignal, t.timeout ?? params.timeout,
650
- (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
680
+ };
681
+
682
+ const results = await mapWithConcurrencyLimit(
683
+ params.tasks,
684
+ MAX_CONCURRENCY,
685
+ async (t, index) => {
686
+ // Skip if already aborted by sibling failure or parent abort
687
+ if (parallelController.signal.aborted) {
688
+ const skippedResult: SubAgentResult = {
689
+ agent: t.agent,
690
+ task: t.task,
691
+ exitCode: 1,
692
+ messages: [],
693
+ stderr: "",
694
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
695
+ stopReason: "aborted",
696
+ errorMessage:
697
+ abortCause === "sibling"
698
+ ? "Cancelled: sibling task failed"
699
+ : abortCause === "timeout"
700
+ ? "Cancelled: sibling task timed out"
701
+ : "Cancelled: parent operation aborted",
702
+ };
703
+ allResults[index] = skippedResult;
704
+ threadStore.updateThread(parallelThreads[index].id, {
705
+ status: "aborted",
706
+ result: skippedResult,
707
+ });
708
+ emitParallelUpdate();
709
+ return skippedResult;
710
+ }
711
+ const result = await runOne(
712
+ t.agent, t.task, t.cwd,
713
+ parallelController.signal, t.timeout ?? params.timeout,
714
+ (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
715
+ );
716
+ allResults[index] = result;
717
+ threadStore.updateThread(parallelThreads[index].id, {
718
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
719
+ result,
720
+ });
721
+ // Early-abort: if this task failed and abortOnFailure is set
722
+ if (abortOnFailure && isFailedResult(result) && !abortCause) {
723
+ abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
724
+ parallelController.abort();
725
+ }
726
+ emitParallelUpdate();
727
+ return result;
728
+ },
651
729
  );
652
- allResults[index] = result;
653
- threadStore.updateThread(parallelThreads[index].id, {
654
- status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
655
- result,
656
- });
657
- // Early-abort: if this task failed and abortOnFailure is set
658
- if (abortOnFailure && isFailedResult(result)) {
659
- abortCause = "sibling";
660
- parallelController.abort();
661
- }
662
- emitParallelUpdate();
663
- return result;
664
- },
665
- );
666
730
 
667
- const successCount = results.filter((r) => !isFailedResult(r)).length;
668
- const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
669
- const summaries = results.map((r) => {
670
- const output = truncateParallelOutput(getResultOutput(r));
671
- const status = isFailedResult(r)
672
- ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
673
- : "completed";
674
- return `### [${r.agent}] ${status}\n\n${output}`;
675
- });
731
+ const successCount = results.filter((r) => !isFailedResult(r)).length;
732
+ const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
733
+ const summaries = results.map((r) => {
734
+ const output = truncateParallelOutput(getResultOutput(r));
735
+ const status = isFailedResult(r)
736
+ ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
737
+ : "completed";
738
+ return `### [${r.agent}] ${status}\n\n${output}`;
739
+ });
676
740
 
677
- let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
678
- if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
679
- return {
680
- content: [
681
- {
682
- type: "text",
683
- text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
684
- },
685
- ],
686
- details: makeDetails("parallel")(results),
687
- };
741
+ let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
742
+ if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
743
+ return {
744
+ content: [
745
+ {
746
+ type: "text",
747
+ text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
748
+ },
749
+ ],
750
+ details: makeDetails("parallel")(results),
751
+ };
752
+ } finally {
753
+ cleanupParentSignal?.();
754
+ }
688
755
  }
689
756
 
690
757
  // --- Single mode ---
@@ -694,6 +761,7 @@ export default function (pi: ExtensionAPI) {
694
761
  task: params.task,
695
762
  mode: "single",
696
763
  toolCallId: _toolCallId,
764
+ color: agents.find(a => a.name === params.agent)?.color,
697
765
  });
698
766
  const result = await runOne(
699
767
  params.agent, params.task, params.cwd,
@@ -737,18 +805,23 @@ export default function (pi: ExtensionAPI) {
737
805
  };
738
806
  }
739
807
 
740
- // Should not reach here due to validation above
741
- const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
742
- return {
743
- content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
744
- details: makeDetails("single")([]),
745
- };
808
+ // Exhaustiveness check: the modeCount === 1 validation above ensures
809
+ // at least one of the three branches is taken, but TS cannot prove it.
810
+ throw new Error("unreachable");
746
811
  },
747
812
 
748
813
  // ------------------------------------------------------------------
749
814
  // TUI rendering
750
815
  // ------------------------------------------------------------------
751
816
 
817
+ /** Look up agent color by name for TUI rendering. */
818
+ const resolveAgentColor = (name: string): string => {
819
+ const ctx = currentCtx;
820
+ if (!ctx) return "accent";
821
+ const found = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === name);
822
+ return found?.color ?? "accent";
823
+ };
824
+
752
825
  renderCall(args, theme, _context) {
753
826
  const scope: AgentScope = args.agentScope ?? "user";
754
827
  const fg = theme.fg.bind(theme);
@@ -767,7 +840,7 @@ export default function (pi: ExtensionAPI) {
767
840
  "\n " +
768
841
  fg("muted", `${i + 1}.`) +
769
842
  " " +
770
- fg("accent", step.agent) +
843
+ fg(resolveAgentColor(step.agent), step.agent) +
771
844
  fg("dim", ` ${preview}`);
772
845
  }
773
846
  if (args.chain.length > 3)
@@ -783,7 +856,7 @@ export default function (pi: ExtensionAPI) {
783
856
  fg("muted", ` [${scope}]`);
784
857
  for (const t of args.tasks.slice(0, 3)) {
785
858
  const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
786
- text += `\n ${fg("accent", t.agent)}${fg("dim", ` ${preview}`)}`;
859
+ text += `\n ${fg(resolveAgentColor(t.agent), t.agent)}${fg("dim", ` ${preview}`)}`;
787
860
  }
788
861
  if (args.tasks.length > 3)
789
862
  text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
@@ -799,7 +872,7 @@ export default function (pi: ExtensionAPI) {
799
872
  : "...";
800
873
  let text =
801
874
  fg("toolTitle", theme.bold("subagent ")) +
802
- fg("accent", agentName) +
875
+ fg(resolveAgentColor(agentName), agentName) +
803
876
  fg("muted", ` [${scope}]`);
804
877
  text += `\n ${fg("dim", preview)}`;
805
878
  return new Text(text, 0, 0);
@@ -846,7 +919,7 @@ export default function (pi: ExtensionAPI) {
846
919
  container.addChild(
847
920
  new Text(
848
921
  fg("muted", `─── Step ${r.exitCode !== -1 ? "" : "?"}: `) +
849
- fg("accent", r.agent) +
922
+ fg(resolveAgentColor(r.agent), r.agent) +
850
923
  ` ${stepIcon}`,
851
924
  0,
852
925
  0,
@@ -881,7 +954,8 @@ export default function (pi: ExtensionAPI) {
881
954
  fg("accent", `${successCount}/${details.results.length} steps`);
882
955
  for (const r of details.results) {
883
956
  const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
884
- text += `\n ${stepIcon} ${fg("accent", r.agent)}`;
957
+ const color = resolveAgentColor(r.agent);
958
+ text += `\n ${stepIcon} ${fg(color, r.agent)}`;
885
959
  }
886
960
  const totalUsage = formatUsageStats(aggregateUsage(details.results));
887
961
  if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
@@ -924,7 +998,7 @@ export default function (pi: ExtensionAPI) {
924
998
  : fg("success", "✓");
925
999
  container.addChild(
926
1000
  new Text(
927
- fg("muted", "─── ") + fg("accent", r.agent) + ` ${taskIcon}`,
1001
+ fg("muted", "─── ") + fg(resolveAgentColor(r.agent), r.agent) + ` ${taskIcon}`,
928
1002
  0,
929
1003
  0,
930
1004
  ),
@@ -964,7 +1038,7 @@ export default function (pi: ExtensionAPI) {
964
1038
  : isFailedResult(r)
965
1039
  ? fg("error", "✗")
966
1040
  : fg("success", "✓");
967
- text += `\n ${taskIcon} ${fg("accent", r.agent)}`;
1041
+ text += `\n ${taskIcon} ${fg(resolveAgentColor(r.agent), r.agent)}`;
968
1042
  }
969
1043
  if (!isRunning) {
970
1044
  const totalUsage = formatUsageStats(aggregateUsage(details.results));