@bacnh85/pi-subagent 0.4.1 → 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.
@@ -15,13 +15,13 @@
15
15
 
16
16
  import * as path from "node:path";
17
17
  import type { Model } from "@earendil-works/pi-ai";
18
- import { getModel } from "@earendil-works/pi-ai/compat";
19
18
  import { StringEnum } from "@earendil-works/pi-ai";
20
19
  import {
21
20
  AuthStorage,
22
21
  CONFIG_DIR_NAME,
23
22
  DynamicBorder,
24
23
  type ExtensionAPI,
24
+ type ExtensionContext,
25
25
  getAgentDir,
26
26
  getMarkdownTheme,
27
27
  ModelRegistry,
@@ -38,66 +38,50 @@ 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,
44
55
  renderSingleResult,
45
56
  } from "./render.ts";
46
57
  import { type SubagentThread, threadStore } from "./threads.ts";
58
+ import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
59
+ import { resolveModel } from "./model.ts";
47
60
  import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
48
61
 
49
62
  // ---------------------------------------------------------------------------
50
63
  // Constants
51
64
  // ---------------------------------------------------------------------------
52
65
 
53
- const MAX_PARALLEL_TASKS = 8;
54
- const MAX_CONCURRENCY = 4;
55
- const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB per parallel task
56
-
57
66
  // ---------------------------------------------------------------------------
58
67
  // Helpers
59
68
  // ---------------------------------------------------------------------------
60
69
 
61
- function truncateParallelOutput(output: string): string {
62
- const byteLength = Buffer.byteLength(output, "utf8");
63
- if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
64
-
65
- let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
66
- while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
67
- truncated = truncated.slice(0, -1);
68
- }
69
- 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
+ };
70
83
  }
71
84
 
72
- interface ResolvedModel {
73
- model: Model | null;
74
- attempted: string[];
75
- }
76
-
77
- function resolveModel(
78
- modelName: string | undefined,
79
- parentModel: Model | undefined,
80
- ): ResolvedModel {
81
- const attempted: string[] = [];
82
- if (modelName) {
83
- // Try as provider/id first, then fall back to anthropic/id
84
- const parts = modelName.split("/");
85
- if (parts.length === 2) {
86
- attempted.push(modelName);
87
- const found = getModel(parts[0], parts[1]) ?? null;
88
- if (found) return { model: found, attempted };
89
- } else {
90
- // Assume Anthropic shorthand
91
- attempted.push(`anthropic/${modelName}`);
92
- const found = getModel("anthropic", modelName) ?? null;
93
- if (found) return { model: found, attempted };
94
- }
95
- } else if (parentModel) {
96
- attempted.push(`${parentModel.provider}/${parentModel.id}`);
97
- return { model: parentModel, attempted };
98
- }
99
- return { model: null, attempted };
100
- }
101
85
 
102
86
  // ---------------------------------------------------------------------------
103
87
  // Tool parameter schema
@@ -135,14 +119,12 @@ const SubagentParams = Type.Object({
135
119
  }),
136
120
  ),
137
121
  agentScope: Type.Optional(AgentScopeSchema),
138
- confirmProjectAgents: Type.Optional(
139
- Type.Boolean({
140
- description: "Prompt before running project-local agents. Default: true.",
141
- default: true,
142
- }),
143
- ),
144
- 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)" })),
145
126
  timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds for all sub-agents (overridden by per-task/step timeouts)" })),
127
+ instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
146
128
  abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
147
129
  });
148
130
 
@@ -162,8 +144,11 @@ interface SubagentDetails {
162
144
  // ---------------------------------------------------------------------------
163
145
 
164
146
  export default function (pi: ExtensionAPI) {
165
- // Invalidate agent cache + clear thread store on reload
166
- pi.on("session_start", (event) => {
147
+ let currentCtx: ExtensionContext | undefined;
148
+
149
+ // Invalidate agent cache + clear thread store on session replacement.
150
+ pi.on("session_start", (event, ctx) => {
151
+ currentCtx = ctx;
167
152
  if (event.reason === "reload") invalidateAgentCache();
168
153
  threadStore.clear();
169
154
  });
@@ -180,22 +165,43 @@ export default function (pi: ExtensionAPI) {
180
165
  }
181
166
  });
182
167
 
183
- // Inject available agent list into system prompt on every session
184
- pi.on("before_agent_start", async (event, ctx) => {
185
- const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
186
- if (discovery.agents.length > 0) {
187
- const names = discovery.agents.map(a => a.name).join(", ");
188
- return {
189
- systemPrompt:
190
- event.systemPrompt +
191
- `\n\nAvailable sub-agents: ${names}. Use /subagent for details.`,
192
- };
168
+ // Resolve bundled agents directory relative to this extension file
169
+ const bundledAgentsDir = path.resolve(__dirname, "../agents");
170
+
171
+ // Public one-request/one-response service used by pi-review.
172
+ pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
173
+ const request = raw as SubagentRunRequest;
174
+ const ctx = currentCtx;
175
+ if (!ctx || !request?.id || typeof request.respond !== "function") return;
176
+ if (request.accept && !request.accept()) return;
177
+ const agent = discoverAgents(ctx.cwd, "user", bundledAgentsDir).agents.find((item) => item.name === request.agent);
178
+ if (!agent) {
179
+ request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
180
+ return;
193
181
  }
182
+ const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single" });
183
+ void runNamedAgent({
184
+ agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
185
+ task: request.task,
186
+ cwd: request.cwd ?? ctx.cwd,
187
+ ctx,
188
+ timeout: request.timeout,
189
+ instructions: request.instructions,
190
+ signal: request.signal,
191
+ onMessage: (result) => threadStore.updateThread(thread.id, { result }),
192
+ }).then((result) => {
193
+ threadStore.updateThread(thread.id, {
194
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
195
+ result,
196
+ });
197
+ if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
198
+ else request.respond({ id: request.id, ok: true, result });
199
+ }, (error) => {
200
+ threadStore.updateThread(thread.id, { status: "failed" });
201
+ request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
202
+ });
194
203
  });
195
204
 
196
- // Resolve bundled agents directory relative to this extension file
197
- const bundledAgentsDir = path.resolve(__dirname, "agents");
198
-
199
205
  // /subagent command — list available agents
200
206
  pi.registerCommand("subagent", {
201
207
  description: "List available sub-agents, reload agent definitions, or show agent details",
@@ -246,6 +252,7 @@ export default function (pi: ExtensionAPI) {
246
252
  `Agent: ${agent.name} (${agent.source})`,
247
253
  `Description: ${agent.description}`,
248
254
  `Model: ${agent.model || "inherits from parent"}`,
255
+ `Thinking: ${agent.thinking || "off"}`,
249
256
  `Tools: ${agent.tools?.join(", ") || "all default"}`,
250
257
  `Source file: ${agent.filePath}`,
251
258
  "",
@@ -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";
@@ -326,12 +357,9 @@ export default function (pi: ExtensionAPI) {
326
357
  };
327
358
  }
328
359
 
329
- // Confirm project-local agents
330
- if (
331
- (agentScope === "project" || agentScope === "both") &&
332
- confirmProjectAgents &&
333
- ctx.hasUI
334
- ) {
360
+ // Handle project-local agent confirmation
361
+ // Security: confirmation policy comes from trusted config, never from tool params.
362
+ if (agentScope === "project" || agentScope === "both") {
335
363
  const requestedAgentNames = new Set<string>();
336
364
  if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
337
365
  if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
@@ -342,41 +370,90 @@ export default function (pi: ExtensionAPI) {
342
370
  .filter((a): a is AgentConfig => a?.source === "project");
343
371
 
344
372
  if (projectAgentsRequested.length > 0) {
345
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
346
- const dir = discovery.projectAgentsDir ?? "(unknown)";
347
- const ok = await ctx.ui.confirm(
348
- "Run project-local agents?",
349
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
350
- );
351
- if (!ok) {
352
- return {
353
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
354
- details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
355
- };
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.
389
+ return {
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
+ }],
395
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
396
+ };
397
+ }
356
398
  }
399
+ // else: allowUnconfirmedProjectAgents is true — skip confirmation.
357
400
  }
358
401
  }
359
402
 
360
403
  // Shared auth/model setup for SDK sessions
361
- const authStorage = AuthStorage.create();
362
- const modelRegistry = ModelRegistry.create(authStorage);
404
+ // ponytail: reuse parent modelRegistry instead of a fresh copy — avoids
405
+ // internal API casts (storeModelHeaders) and preserves env/headers/OAuth.
406
+ const authStorage = AuthStorage.inMemory();
407
+ const modelRegistry = ctx.modelRegistry;
363
408
 
364
409
  // Helper: inject parent's API key into child auth storage
365
- async function injectApiKey(model: Model): Promise<void> {
410
+ async function injectApiKey(model: Model<any>): Promise<void> {
366
411
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
367
- if (auth.ok && auth.apiKey) {
368
- authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
412
+ if (auth.ok) {
413
+ if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
414
+ // ponytail: headers/env stay on the parent registry — no copy needed.
415
+ }
416
+ }
417
+
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);
369
444
  }
445
+ return result.timeoutMs;
370
446
  }
371
447
 
372
- // Helper: run a single agent via SDK
448
+ // Helper: run a single agent via SDK with security validation
373
449
  async function runOne(
374
450
  agentName: string,
375
451
  task: string,
376
452
  cwd: string | undefined,
377
453
  parentSignal?: AbortSignal,
378
454
  timeoutMs?: number,
379
- onProgress?: (partial: SubAgentResult) => void,
455
+ onProgress?: (partial: SubAgentResult) => void,
456
+ isReadOnly?: boolean,
380
457
  ): Promise<SubAgentResult> {
381
458
  const agent = agents.find((a) => a.name === agentName);
382
459
 
@@ -393,7 +470,7 @@ export default function (pi: ExtensionAPI) {
393
470
  };
394
471
  }
395
472
 
396
- const resolved = resolveModel(agent.model, ctx.model);
473
+ const resolved = resolveModel(agent.model, ctx.model, ctx.modelRegistry);
397
474
  if (!resolved.model) {
398
475
  const tried = resolved.attempted.join(", ") || "none";
399
476
  const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
@@ -408,61 +485,45 @@ export default function (pi: ExtensionAPI) {
408
485
  };
409
486
  }
410
487
 
411
- // Inject parent's API key so --api-key and other runtime overrides work
412
- await injectApiKey(resolved.model);
413
-
414
- // Resolve tools; strip "subagent" to prevent accidental recursion.
415
- // Sub-agents cannot spawn further sub-agents (one level of delegation only).
416
- const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
417
- let tools = agent.tools ?? defaultTools;
418
- tools = tools.filter((t) => t !== "subagent");
419
-
420
- // Build timeout + parent signal into a combined AbortSignal
421
- let combinedSignal = parentSignal;
422
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
423
- let timeoutController: AbortController | undefined;
424
- if (timeoutMs && timeoutMs > 0) {
425
- timeoutController = new AbortController();
426
- timeoutId = setTimeout(() => {
427
- timeoutController!.abort();
428
- }, timeoutMs);
429
- // Combine with parent signal if present (Node 20+ AbortSignal.any)
430
- if (parentSignal && typeof (AbortSignal as any).any === "function") {
431
- combinedSignal = (AbortSignal as any).any([parentSignal, timeoutController.signal]);
432
- } else if (parentSignal) {
433
- combinedSignal = timeoutController.signal;
434
- // Link parent to timeout: if parent aborts, also abort our timeout controller
435
- if (parentSignal.aborted) timeoutController.abort();
436
- else parentSignal.addEventListener("abort", () => timeoutController!.abort(), { once: true });
437
- } else {
438
- combinedSignal = timeoutController.signal;
439
- }
488
+ // Security: validate tools, timeout, and cwd (wrapped in try/catch).
489
+ let tools: string[];
490
+ let effectiveTimeoutMs: number | undefined;
491
+ let safeCwd: string;
492
+ try {
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,
502
+ task,
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
+ };
440
509
  }
441
510
 
442
511
  const result = await runSubAgent({
443
- cwd: cwd ?? ctx.cwd,
444
- systemPrompt: agent.systemPrompt,
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,
445
516
  task,
446
517
  tools,
447
518
  model: resolved.model,
448
519
  authStorage,
449
520
  modelRegistry,
450
- signal: combinedSignal,
521
+ signal: parentSignal,
522
+ timeoutMs: effectiveTimeoutMs,
451
523
  agentName,
524
+ thinkingLevel: agent.thinking,
452
525
  onMessage: onProgress,
453
526
  });
454
-
455
- // Clean up timeout
456
- if (timeoutId) clearTimeout(timeoutId);
457
-
458
- // Detect timeout: our timeout controller fired, not the parent
459
- const timedOut = timeoutController?.signal.aborted && !parentSignal?.aborted;
460
- if (timedOut) {
461
- result.exitCode = 1;
462
- result.stopReason = "timeout";
463
- if (!result.errorMessage) result.errorMessage = `Timeout after ${timeoutMs}ms`;
464
- }
465
-
466
527
  return result;
467
528
  }
468
529
 
@@ -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
  // ------------------------------------------------------------------
@@ -1051,7 +1100,7 @@ export default function (pi: ExtensionAPI) {
1051
1100
  ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
1052
1101
  items: PickerItem[],
1053
1102
  ): Promise<string | null> {
1054
- return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1103
+ return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
1055
1104
  const container = new Container();
1056
1105
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1057
1106
  container.addChild(new Text(theme.fg("accent", theme.bold("Subagents")), 1, 0));
@@ -1101,7 +1150,7 @@ export default function (pi: ExtensionAPI) {
1101
1150
  const getThreads = () => threadStore.getAllThreads();
1102
1151
 
1103
1152
  // Overlay mode: viewer appears above editor, Esc dismisses
1104
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1153
+ await ctx.ui.custom<void>((tui: any, theme: any, _kb: any, done: () => void) => {
1105
1154
  let unsubscribe: (() => void) | undefined;
1106
1155
  let closed = false;
1107
1156