@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.
- package/CHANGELOG.md +56 -0
- package/README.md +127 -4
- package/agent-format.md +12 -2
- package/agents/general-purpose.md +1 -0
- package/agents/reviewer.md +2 -0
- package/agents/scout.md +2 -0
- package/agents/worker.md +1 -0
- package/extensions/agents.ts +120 -10
- package/extensions/index.ts +309 -235
- package/extensions/render.ts +7 -7
- package/extensions/runner.ts +88 -45
- package/extensions/security.ts +504 -0
- package/extensions/service.ts +29 -18
- package/extensions/thread-viewer.ts +4 -127
- package/extensions/threads.ts +4 -11
- package/package.json +26 -11
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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: [{
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
631
|
+
const onParentAbort = () => {
|
|
570
632
|
if (!abortCause) abortCause = "parent";
|
|
571
633
|
parallelController.abort();
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1041
|
+
text += `\n ${taskIcon} ${fg(resolveAgentColor(r.agent), r.agent)}`;
|
|
968
1042
|
}
|
|
969
1043
|
if (!isRunning) {
|
|
970
1044
|
const totalUsage = formatUsageStats(aggregateUsage(details.results));
|