@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.
- package/CHANGELOG.md +56 -0
- package/README.md +127 -4
- package/agent-format.md +12 -2
- package/extensions/agents.ts +90 -10
- package/extensions/index.ts +264 -215
- 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 +2 -126
- package/extensions/threads.ts +1 -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 })),
|
|
@@ -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
|
-
|
|
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
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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: [{
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
//
|
|
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
|
-
|
|
617
|
+
const onParentAbort = () => {
|
|
570
618
|
if (!abortCause) abortCause = "parent";
|
|
571
619
|
parallelController.abort();
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
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
|
// ------------------------------------------------------------------
|
package/extensions/render.ts
CHANGED
|
@@ -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({
|