@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.
- package/CHANGELOG.md +56 -0
- package/README.md +139 -64
- package/agent-format.md +20 -5
- package/agents/reviewer.md +28 -22
- package/agents/scout.md +3 -3
- package/agents/worker.md +2 -2
- package/{agents.ts → extensions/agents.ts} +95 -11
- package/{index.ts → extensions/index.ts} +314 -265
- package/extensions/model.ts +86 -0
- package/extensions/package.json +3 -0
- package/{render.ts → extensions/render.ts} +9 -9
- package/{runner.ts → extensions/runner.ts} +97 -50
- package/extensions/security.ts +504 -0
- package/extensions/service.ts +90 -0
- package/{thread-viewer.ts → extensions/thread-viewer.ts} +2 -126
- package/{threads.ts → extensions/threads.ts} +1 -11
- package/package.json +47 -27
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
if
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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:
|
|
444
|
-
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:
|
|
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
|
-
//
|
|
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
|
// ------------------------------------------------------------------
|
|
@@ -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
|
|