@bacnh85/pi-subagent 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 })),
@@ -290,7 +297,14 @@ export default function (pi: ExtensionAPI) {
290
297
  const agentScope: AgentScope = params.agentScope ?? "user";
291
298
  const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
292
299
  const agents = discovery.agents;
293
- const confirmProjectAgents = params.confirmProjectAgents ?? true;
300
+
301
+ // Trusted configuration — never from tool params.
302
+ const trusted = getTrustedConfig(ctx);
303
+ const confirmProjectAgents = !trusted.allowUnconfirmedProjectAgents;
304
+ const allowExternalCwd = trusted.allowExternalCwd;
305
+
306
+ // Resolve workspace root for cwd validation.
307
+ const workspaceRoot = ctx.cwd;
294
308
 
295
309
  const hasChain = (params.chain?.length ?? 0) > 0;
296
310
  const hasTasks = (params.tasks?.length ?? 0) > 0;
@@ -306,6 +320,23 @@ export default function (pi: ExtensionAPI) {
306
320
  results,
307
321
  });
308
322
 
323
+ // Validate execution request before any processing.
324
+ const validationErrors = validateExecutionRequest({
325
+ agentName: params.agent,
326
+ task: params.task,
327
+ tasks: params.tasks,
328
+ chain: params.chain,
329
+ timeout: params.timeout,
330
+ });
331
+ if (validationErrors.length > 0) {
332
+ const errorMessages = validationErrors.map((e) => ` • ${e.field}: ${e.message}`).join("\n");
333
+ return {
334
+ content: [{ type: "text", text: `Invalid parameters:\n${errorMessages}` }],
335
+ details: makeDetails("single")([]),
336
+ isError: true,
337
+ };
338
+ }
339
+
309
340
  // Validate: exactly one mode
310
341
  if (modeCount !== 1) {
311
342
  const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
@@ -327,6 +358,7 @@ export default function (pi: ExtensionAPI) {
327
358
  }
328
359
 
329
360
  // Handle project-local agent confirmation
361
+ // Security: confirmation policy comes from trusted config, never from tool params.
330
362
  if (agentScope === "project" || agentScope === "both") {
331
363
  const requestedAgentNames = new Set<string>();
332
364
  if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
@@ -337,33 +369,34 @@ export default function (pi: ExtensionAPI) {
337
369
  .map((name) => agents.find((a) => a.name === name))
338
370
  .filter((a): a is AgentConfig => a?.source === "project");
339
371
 
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) {
372
+ if (projectAgentsRequested.length > 0) {
373
+ if (confirmProjectAgents) {
374
+ if (ctx.hasUI) {
375
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
376
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
377
+ const ok = await ctx.ui.confirm(
378
+ "Run project-local agents?",
379
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
380
+ );
381
+ if (!ok) {
382
+ return {
383
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
384
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
385
+ };
386
+ }
387
+ } else {
388
+ // Fail closed in headless sessions.
349
389
  return {
350
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
390
+ content: [{
391
+ type: "text",
392
+ text: "Project agents require explicit user approval. "
393
+ + "Enable the trusted project-agent setting to use them in headless mode.",
394
+ }],
351
395
  details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
352
396
  };
353
397
  }
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
398
  }
399
+ // else: allowUnconfirmedProjectAgents is true — skip confirmation.
367
400
  }
368
401
  }
369
402
 
@@ -382,14 +415,45 @@ export default function (pi: ExtensionAPI) {
382
415
  }
383
416
  }
384
417
 
385
- // Helper: run a single agent via SDK
418
+ // Helper: resolve a safe child working directory.
419
+ function resolveChildCwd(childCwd: string | undefined): string {
420
+ const safe = resolveSafeCwd({ workspaceRoot, childCwd, allowExternalCwd });
421
+ if (safe.error) {
422
+ throw new Error(safe.error);
423
+ }
424
+ return safe.path;
425
+ }
426
+
427
+ // Helper: validate and normalise tools for an agent.
428
+ function resolveChildTools(agentTools: string[] | undefined, readOnly?: boolean): string[] {
429
+ const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
430
+ const rawTools = agentTools ?? defaultTools;
431
+ const result = validateAgentTools({ tools: rawTools, readOnly });
432
+ if (result.errors.length > 0) {
433
+ throw new Error(`Tool validation errors: ${result.errors.join("; ")}`);
434
+ }
435
+ return result.tools;
436
+ }
437
+
438
+ // Helper: normalise timeout.
439
+ function resolveChildTimeout(childTimeout: number | undefined, globalTimeout: number | undefined): number | undefined {
440
+ const effectiveTimeout = childTimeout ?? globalTimeout;
441
+ const result = normalizeTimeout({ requested: effectiveTimeout });
442
+ if (result.error) {
443
+ throw new Error(result.error);
444
+ }
445
+ return result.timeoutMs;
446
+ }
447
+
448
+ // Helper: run a single agent via SDK with security validation
386
449
  async function runOne(
387
450
  agentName: string,
388
451
  task: string,
389
452
  cwd: string | undefined,
390
453
  parentSignal?: AbortSignal,
391
454
  timeoutMs?: number,
392
- onProgress?: (partial: SubAgentResult) => void,
455
+ onProgress?: (partial: SubAgentResult) => void,
456
+ isReadOnly?: boolean,
393
457
  ): Promise<SubAgentResult> {
394
458
  const agent = agents.find((a) => a.name === agentName);
395
459
 
@@ -421,49 +485,46 @@ export default function (pi: ExtensionAPI) {
421
485
  };
422
486
  }
423
487
 
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
-
488
+ // Security: validate tools, timeout, and cwd (wrapped in try/catch).
489
+ let tools: string[];
490
+ let effectiveTimeoutMs: number | undefined;
491
+ let safeCwd: string;
442
492
  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,
493
+ // Inject parent's API key so --api-key and other runtime overrides work
494
+ await injectApiKey(resolved.model);
495
+ tools = resolveChildTools(agent.tools, isReadOnly);
496
+ effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
497
+ safeCwd = resolveChildCwd(cwd);
498
+ } catch (err: unknown) {
499
+ const errorMsg = err instanceof Error ? err.message : String(err);
500
+ return {
501
+ agent: agentName,
448
502
  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);
503
+ exitCode: 1,
504
+ messages: [],
505
+ stderr: `Validation error: ${errorMsg}`,
506
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
507
+ errorMessage: errorMsg,
508
+ };
466
509
  }
510
+
511
+ const result = await runSubAgent({
512
+ cwd: safeCwd,
513
+ systemPrompt: params.instructions
514
+ ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
515
+ : agent.systemPrompt,
516
+ task,
517
+ tools,
518
+ model: resolved.model,
519
+ authStorage,
520
+ modelRegistry,
521
+ signal: parentSignal,
522
+ timeoutMs: effectiveTimeoutMs,
523
+ agentName,
524
+ thinkingLevel: agent.thinking,
525
+ onMessage: onProgress,
526
+ });
527
+ return result;
467
528
  }
468
529
 
469
530
  // --- Chain mode ---
@@ -542,149 +603,140 @@ export default function (pi: ExtensionAPI) {
542
603
 
543
604
  // --- Parallel mode ---
544
605
  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
606
  const abortOnFailure = params.abortOnFailure ?? false;
558
607
  const parallelController = new AbortController();
559
- let abortCause: "parent" | "sibling" | undefined;
608
+ let abortCause: "parent" | "sibling" | "timeout" | undefined;
609
+ let cleanupParentSignal: (() => void) | undefined;
560
610
 
561
- // Combine parent signal with parallel abort controller
562
- let parallelSignal: AbortSignal = parallelController.signal;
611
+ // Link parent abort into parallelController so queued tasks see aborted state
563
612
  if (signal) {
564
- // Always link parent abort into parallelController so queued tasks see aborted state
565
613
  if (signal.aborted) {
566
614
  abortCause = "parent";
567
615
  parallelController.abort();
568
616
  } else {
569
- signal.addEventListener("abort", () => {
617
+ const onParentAbort = () => {
570
618
  if (!abortCause) abortCause = "parent";
571
619
  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;
620
+ };
621
+ signal.addEventListener("abort", onParentAbort, { once: true });
622
+ cleanupParentSignal = () => signal.removeEventListener("abort", onParentAbort);
578
623
  }
579
624
  }
580
625
 
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
- }
626
+ // Wrap all remaining setup + execution so cleanupParentSignal always runs.
627
+ try {
628
+ // Pre-create threads for all parallel tasks
629
+ const parallelThreads = params.tasks.map((t) =>
630
+ threadStore.createThread({
631
+ agentName: t.agent,
632
+ task: t.task,
633
+ mode: "parallel-task",
634
+ toolCallId: _toolCallId,
635
+ }),
636
+ );
603
637
 
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
- });
638
+ const allResults: SubAgentResult[] = new Array(params.tasks.length);
639
+ // Initialize placeholder results for streaming
640
+ for (let i = 0; i < params.tasks.length; i++) {
641
+ allResults[i] = {
642
+ agent: params.tasks[i].agent,
643
+ task: params.tasks[i].task,
644
+ exitCode: -1,
645
+ messages: [],
646
+ stderr: "",
647
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
648
+ };
617
649
  }
618
- };
619
650
 
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,
651
+ const emitParallelUpdate = () => {
652
+ if (onUpdate) {
653
+ const running = allResults.filter((r) => r.exitCode === -1).length;
654
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
655
+ onUpdate({
656
+ content: [
657
+ {
658
+ type: "text",
659
+ text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
660
+ },
661
+ ],
662
+ details: makeDetails("parallel")([...allResults]),
643
663
  });
644
- emitParallelUpdate();
645
- return skippedResult;
646
664
  }
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 }),
665
+ };
666
+
667
+ const results = await mapWithConcurrencyLimit(
668
+ params.tasks,
669
+ MAX_CONCURRENCY,
670
+ async (t, index) => {
671
+ // Skip if already aborted by sibling failure or parent abort
672
+ if (parallelController.signal.aborted) {
673
+ const skippedResult: SubAgentResult = {
674
+ agent: t.agent,
675
+ task: t.task,
676
+ exitCode: 1,
677
+ messages: [],
678
+ stderr: "",
679
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
680
+ stopReason: "aborted",
681
+ errorMessage:
682
+ abortCause === "sibling"
683
+ ? "Cancelled: sibling task failed"
684
+ : abortCause === "timeout"
685
+ ? "Cancelled: sibling task timed out"
686
+ : "Cancelled: parent operation aborted",
687
+ };
688
+ allResults[index] = skippedResult;
689
+ threadStore.updateThread(parallelThreads[index].id, {
690
+ status: "aborted",
691
+ result: skippedResult,
692
+ });
693
+ emitParallelUpdate();
694
+ return skippedResult;
695
+ }
696
+ const result = await runOne(
697
+ t.agent, t.task, t.cwd,
698
+ parallelController.signal, t.timeout ?? params.timeout,
699
+ (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
700
+ );
701
+ allResults[index] = result;
702
+ threadStore.updateThread(parallelThreads[index].id, {
703
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
704
+ result,
705
+ });
706
+ // Early-abort: if this task failed and abortOnFailure is set
707
+ if (abortOnFailure && isFailedResult(result) && !abortCause) {
708
+ abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
709
+ parallelController.abort();
710
+ }
711
+ emitParallelUpdate();
712
+ return result;
713
+ },
651
714
  );
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
715
 
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
- });
716
+ const successCount = results.filter((r) => !isFailedResult(r)).length;
717
+ const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
718
+ const summaries = results.map((r) => {
719
+ const output = truncateParallelOutput(getResultOutput(r));
720
+ const status = isFailedResult(r)
721
+ ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
722
+ : "completed";
723
+ return `### [${r.agent}] ${status}\n\n${output}`;
724
+ });
676
725
 
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
- };
726
+ let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
727
+ if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
728
+ return {
729
+ content: [
730
+ {
731
+ type: "text",
732
+ text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
733
+ },
734
+ ],
735
+ details: makeDetails("parallel")(results),
736
+ };
737
+ } finally {
738
+ cleanupParentSignal?.();
739
+ }
688
740
  }
689
741
 
690
742
  // --- Single mode ---
@@ -737,12 +789,9 @@ export default function (pi: ExtensionAPI) {
737
789
  };
738
790
  }
739
791
 
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
- };
792
+ // Exhaustiveness check: the modeCount === 1 validation above ensures
793
+ // at least one of the three branches is taken, but TS cannot prove it.
794
+ throw new Error("unreachable");
746
795
  },
747
796
 
748
797
  // ------------------------------------------------------------------
@@ -16,15 +16,15 @@ import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.t
16
16
  // Safe type guards
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
- function asString(value: unknown, fallback = "..."): string {
19
+ export function asString(value: unknown, fallback = "..."): string {
20
20
  return typeof value === "string" ? value : fallback;
21
21
  }
22
22
 
23
- function asNumber(value: unknown): number | undefined {
23
+ export function asNumber(value: unknown): number | undefined {
24
24
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
25
25
  }
26
26
 
27
- function asRecord(value: unknown): Record<string, unknown> {
27
+ export function asRecord(value: unknown): Record<string, unknown> {
28
28
  return value && typeof value === "object" && !Array.isArray(value)
29
29
  ? (value as Record<string, unknown>)
30
30
  : {};
@@ -59,7 +59,7 @@ export function formatUsageStats(
59
59
  return parts.join(" ");
60
60
  }
61
61
 
62
- function formatToolCall(
62
+ export function formatToolCall(
63
63
  toolName: string,
64
64
  args: Record<string, unknown>,
65
65
  themeFg: (color: string, text: string) => string,
@@ -130,16 +130,16 @@ function formatToolCall(
130
130
  }
131
131
  }
132
132
 
133
- type DisplayItem =
133
+ export type DisplayItem =
134
134
  | { type: "text"; text: string }
135
135
  | { type: "toolCall"; name: string; args: Record<string, unknown> };
136
136
 
137
- function getDisplayItems(messages: Message[]): DisplayItem[] {
137
+ export function getDisplayItems(messages: Message[]): DisplayItem[] {
138
138
  const items: DisplayItem[] = [];
139
139
  for (const msg of messages) {
140
140
  if (msg.role === "assistant") {
141
141
  for (const part of msg.content) {
142
- if (part.type === "text") {
142
+ if (part.type === "text" && part.text.trim()) {
143
143
  items.push({ type: "text", text: part.text });
144
144
  } else if (part.type === "toolCall") {
145
145
  items.push({