@bacnh85/pi-subagent 0.1.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/README.md +88 -0
- package/agents/reviewer.md +30 -0
- package/agents/scout.md +44 -0
- package/agents/worker.md +15 -0
- package/agents.ts +174 -0
- package/index.ts +701 -0
- package/package.json +46 -0
- package/render.ts +250 -0
- package/runner.ts +258 -0
package/index.ts
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagent — Minimal-overhead sub-agent extension for pi.
|
|
3
|
+
*
|
|
4
|
+
* Provides a `subagent` tool that delegates tasks to specialized agents
|
|
5
|
+
* running in isolated in-process SDK sessions. Supports three modes:
|
|
6
|
+
*
|
|
7
|
+
* - Single: { agent: "scout", task: "find auth code" }
|
|
8
|
+
* - Parallel: { tasks: [{ agent: "scout", task: "..." }, ...] }
|
|
9
|
+
* - Chain: { chain: [{ agent: "scout", task: "..." }, ...] }
|
|
10
|
+
*
|
|
11
|
+
* Compared to process-spawning, this saves ~4-11K tokens per sub-agent
|
|
12
|
+
* by using the pi SDK directly with a minimal system prompt, no AGENTS.md,
|
|
13
|
+
* no extensions, no skills, no thinking, and no compaction.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
18
|
+
import { getModel } from "@earendil-works/pi-ai/compat";
|
|
19
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
20
|
+
import {
|
|
21
|
+
AuthStorage,
|
|
22
|
+
CONFIG_DIR_NAME,
|
|
23
|
+
type ExtensionAPI,
|
|
24
|
+
getAgentDir,
|
|
25
|
+
getMarkdownTheme,
|
|
26
|
+
ModelRegistry,
|
|
27
|
+
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
29
|
+
import { Type } from "typebox";
|
|
30
|
+
|
|
31
|
+
import { type AgentConfig, type AgentScope, discoverAgents, invalidateAgentCache } from "./agents.ts";
|
|
32
|
+
import {
|
|
33
|
+
type SubAgentResult,
|
|
34
|
+
getFinalOutput,
|
|
35
|
+
getResultOutput,
|
|
36
|
+
isFailedResult,
|
|
37
|
+
mapWithConcurrencyLimit,
|
|
38
|
+
runSubAgent,
|
|
39
|
+
} from "./runner.ts";
|
|
40
|
+
import {
|
|
41
|
+
aggregateUsage,
|
|
42
|
+
formatUsageStats,
|
|
43
|
+
renderSingleResult,
|
|
44
|
+
} from "./render.ts";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Constants
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const MAX_PARALLEL_TASKS = 8;
|
|
51
|
+
const MAX_CONCURRENCY = 4;
|
|
52
|
+
const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB per parallel task
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function truncateParallelOutput(output: string): string {
|
|
59
|
+
const byteLength = Buffer.byteLength(output, "utf8");
|
|
60
|
+
if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
|
|
61
|
+
|
|
62
|
+
let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
|
|
63
|
+
while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
|
|
64
|
+
truncated = truncated.slice(0, -1);
|
|
65
|
+
}
|
|
66
|
+
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveModel(
|
|
70
|
+
modelName: string | undefined,
|
|
71
|
+
parentModel: Model | undefined,
|
|
72
|
+
): Model | null {
|
|
73
|
+
if (modelName) {
|
|
74
|
+
// Try as provider/id first, then fall back to anthropic/id
|
|
75
|
+
const parts = modelName.split("/");
|
|
76
|
+
if (parts.length === 2) {
|
|
77
|
+
return getModel(parts[0], parts[1]) ?? null;
|
|
78
|
+
}
|
|
79
|
+
// Assume Anthropic shorthand
|
|
80
|
+
return getModel("anthropic", modelName) ?? null;
|
|
81
|
+
}
|
|
82
|
+
return parentModel ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Tool parameter schema
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
const TaskItem = Type.Object({
|
|
90
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
91
|
+
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
92
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const ChainItem = Type.Object({
|
|
96
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
97
|
+
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
98
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
102
|
+
description:
|
|
103
|
+
'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
|
|
104
|
+
default: "user",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const SubagentParams = Type.Object({
|
|
108
|
+
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
|
|
109
|
+
task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
|
|
110
|
+
tasks: Type.Optional(
|
|
111
|
+
Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" }),
|
|
112
|
+
),
|
|
113
|
+
chain: Type.Optional(
|
|
114
|
+
Type.Array(ChainItem, {
|
|
115
|
+
description: "Array of {agent, task} for sequential execution with {previous}",
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
agentScope: Type.Optional(AgentScopeSchema),
|
|
119
|
+
confirmProjectAgents: Type.Optional(
|
|
120
|
+
Type.Boolean({
|
|
121
|
+
description: "Prompt before running project-local agents. Default: true.",
|
|
122
|
+
default: true,
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Details type
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
interface SubagentDetails {
|
|
133
|
+
mode: "single" | "parallel" | "chain";
|
|
134
|
+
agentScope: AgentScope;
|
|
135
|
+
projectAgentsDir: string | null;
|
|
136
|
+
results: SubAgentResult[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Extension entry point
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export default function (pi: ExtensionAPI) {
|
|
144
|
+
// Invalidate agent cache on reload so edited agent files take effect
|
|
145
|
+
pi.on("session_start", (event) => {
|
|
146
|
+
if (event.reason === "reload") invalidateAgentCache();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Resolve bundled agents directory relative to this extension file
|
|
150
|
+
const bundledAgentsDir = path.resolve(__dirname, "agents");
|
|
151
|
+
|
|
152
|
+
pi.registerTool({
|
|
153
|
+
name: "subagent",
|
|
154
|
+
label: "Subagent",
|
|
155
|
+
description: [
|
|
156
|
+
"Delegate tasks to specialized subagents with isolated context (SDK-based, minimal overhead).",
|
|
157
|
+
"Modes: single (agent + task), parallel (tasks array, max 8, 4 concurrent), chain (sequential with {previous}).",
|
|
158
|
+
`Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
|
|
159
|
+
`To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
|
|
160
|
+
].join(" "),
|
|
161
|
+
parameters: SubagentParams,
|
|
162
|
+
|
|
163
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
164
|
+
const agentScope: AgentScope = params.agentScope ?? "user";
|
|
165
|
+
const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
|
|
166
|
+
const agents = discovery.agents;
|
|
167
|
+
const confirmProjectAgents = params.confirmProjectAgents ?? true;
|
|
168
|
+
|
|
169
|
+
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
170
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
171
|
+
const hasSingle = Boolean(params.agent && params.task);
|
|
172
|
+
const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
|
|
173
|
+
|
|
174
|
+
const makeDetails =
|
|
175
|
+
(mode: "single" | "parallel" | "chain") =>
|
|
176
|
+
(results: SubAgentResult[]): SubagentDetails => ({
|
|
177
|
+
mode,
|
|
178
|
+
agentScope,
|
|
179
|
+
projectAgentsDir: discovery.projectAgentsDir,
|
|
180
|
+
results,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Validate: exactly one mode
|
|
184
|
+
if (modeCount !== 1) {
|
|
185
|
+
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
186
|
+
return {
|
|
187
|
+
content: [
|
|
188
|
+
{
|
|
189
|
+
type: "text",
|
|
190
|
+
text: [
|
|
191
|
+
"Invalid parameters. Provide exactly one mode:",
|
|
192
|
+
" single: { agent, task }",
|
|
193
|
+
" parallel: { tasks: [...] }",
|
|
194
|
+
" chain: { chain: [...] }",
|
|
195
|
+
`Available agents: ${available}`,
|
|
196
|
+
].join("\n"),
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
details: makeDetails("single")([]),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Confirm project-local agents
|
|
204
|
+
if (
|
|
205
|
+
(agentScope === "project" || agentScope === "both") &&
|
|
206
|
+
confirmProjectAgents &&
|
|
207
|
+
ctx.hasUI
|
|
208
|
+
) {
|
|
209
|
+
const requestedAgentNames = new Set<string>();
|
|
210
|
+
if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
|
|
211
|
+
if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
|
|
212
|
+
if (params.agent) requestedAgentNames.add(params.agent);
|
|
213
|
+
|
|
214
|
+
const projectAgentsRequested = Array.from(requestedAgentNames)
|
|
215
|
+
.map((name) => agents.find((a) => a.name === name))
|
|
216
|
+
.filter((a): a is AgentConfig => a?.source === "project");
|
|
217
|
+
|
|
218
|
+
if (projectAgentsRequested.length > 0) {
|
|
219
|
+
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
|
220
|
+
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
|
221
|
+
const ok = await ctx.ui.confirm(
|
|
222
|
+
"Run project-local agents?",
|
|
223
|
+
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
|
224
|
+
);
|
|
225
|
+
if (!ok) {
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
|
|
228
|
+
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Shared auth/model setup for SDK sessions
|
|
235
|
+
const authStorage = AuthStorage.create();
|
|
236
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
237
|
+
|
|
238
|
+
// Helper: inject parent's API key into child auth storage
|
|
239
|
+
async function injectApiKey(model: Model): Promise<void> {
|
|
240
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
241
|
+
if (auth.ok && auth.apiKey) {
|
|
242
|
+
authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Helper: run a single agent via SDK
|
|
247
|
+
async function runOne(
|
|
248
|
+
agentName: string,
|
|
249
|
+
task: string,
|
|
250
|
+
cwd: string | undefined,
|
|
251
|
+
): Promise<SubAgentResult> {
|
|
252
|
+
const agent = agents.find((a) => a.name === agentName);
|
|
253
|
+
|
|
254
|
+
if (!agent) {
|
|
255
|
+
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
|
|
256
|
+
return {
|
|
257
|
+
agent: agentName,
|
|
258
|
+
task,
|
|
259
|
+
exitCode: 1,
|
|
260
|
+
messages: [],
|
|
261
|
+
stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
|
|
262
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
263
|
+
errorMessage: `Unknown agent: "${agentName}"`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const model = resolveModel(agent.model, ctx.model);
|
|
268
|
+
if (!model) {
|
|
269
|
+
return {
|
|
270
|
+
agent: agentName,
|
|
271
|
+
task,
|
|
272
|
+
exitCode: 1,
|
|
273
|
+
messages: [],
|
|
274
|
+
stderr: `No model resolved for agent "${agentName}". Configure a model in the agent definition or select one in the parent session.`,
|
|
275
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
276
|
+
errorMessage: "No model resolved",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Inject parent's API key so --api-key and other runtime overrides work
|
|
281
|
+
await injectApiKey(model);
|
|
282
|
+
|
|
283
|
+
const tools = agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
284
|
+
|
|
285
|
+
return runSubAgent({
|
|
286
|
+
cwd: cwd ?? ctx.cwd,
|
|
287
|
+
systemPrompt: agent.systemPrompt,
|
|
288
|
+
task,
|
|
289
|
+
tools,
|
|
290
|
+
model,
|
|
291
|
+
authStorage,
|
|
292
|
+
modelRegistry,
|
|
293
|
+
signal,
|
|
294
|
+
agentName,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- Chain mode ---
|
|
299
|
+
if (params.chain && params.chain.length > 0) {
|
|
300
|
+
const results: SubAgentResult[] = [];
|
|
301
|
+
let previousOutput = "";
|
|
302
|
+
|
|
303
|
+
for (let i = 0; i < params.chain.length; i++) {
|
|
304
|
+
const step = params.chain[i];
|
|
305
|
+
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
|
306
|
+
|
|
307
|
+
const result = await runOne(step.agent, taskWithContext, step.cwd);
|
|
308
|
+
results.push(result);
|
|
309
|
+
|
|
310
|
+
const isError = isFailedResult(result);
|
|
311
|
+
if (isError) {
|
|
312
|
+
const errorMsg = getResultOutput(result);
|
|
313
|
+
if (onUpdate) {
|
|
314
|
+
onUpdate({
|
|
315
|
+
content: [{ type: "text", text: errorMsg }],
|
|
316
|
+
details: makeDetails("chain")(results),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
content: [
|
|
321
|
+
{
|
|
322
|
+
type: "text",
|
|
323
|
+
text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`,
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
details: makeDetails("chain")(results),
|
|
327
|
+
isError: true,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
previousOutput = getFinalOutput(result.messages);
|
|
332
|
+
|
|
333
|
+
if (onUpdate) {
|
|
334
|
+
onUpdate({
|
|
335
|
+
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
|
336
|
+
details: makeDetails("chain")(results),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const last = results[results.length - 1];
|
|
342
|
+
return {
|
|
343
|
+
content: [
|
|
344
|
+
{ type: "text", text: getFinalOutput(last.messages) || "(no output)" },
|
|
345
|
+
],
|
|
346
|
+
details: makeDetails("chain")(results),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- Parallel mode ---
|
|
351
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
352
|
+
if (params.tasks.length > MAX_PARALLEL_TASKS) {
|
|
353
|
+
return {
|
|
354
|
+
content: [
|
|
355
|
+
{
|
|
356
|
+
type: "text",
|
|
357
|
+
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
details: makeDetails("parallel")([]),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const allResults: SubAgentResult[] = new Array(params.tasks.length);
|
|
365
|
+
// Initialize placeholder results for streaming
|
|
366
|
+
for (let i = 0; i < params.tasks.length; i++) {
|
|
367
|
+
allResults[i] = {
|
|
368
|
+
agent: params.tasks[i].agent,
|
|
369
|
+
task: params.tasks[i].task,
|
|
370
|
+
exitCode: -1,
|
|
371
|
+
messages: [],
|
|
372
|
+
stderr: "",
|
|
373
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const emitParallelUpdate = () => {
|
|
378
|
+
if (onUpdate) {
|
|
379
|
+
const running = allResults.filter((r) => r.exitCode === -1).length;
|
|
380
|
+
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
|
381
|
+
onUpdate({
|
|
382
|
+
content: [
|
|
383
|
+
{
|
|
384
|
+
type: "text",
|
|
385
|
+
text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
details: makeDetails("parallel")([...allResults]),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const results = await mapWithConcurrencyLimit(
|
|
394
|
+
params.tasks,
|
|
395
|
+
MAX_CONCURRENCY,
|
|
396
|
+
async (t, index) => {
|
|
397
|
+
const result = await runOne(t.agent, t.task, t.cwd);
|
|
398
|
+
allResults[index] = result;
|
|
399
|
+
emitParallelUpdate();
|
|
400
|
+
return result;
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const successCount = results.filter((r) => !isFailedResult(r)).length;
|
|
405
|
+
const summaries = results.map((r) => {
|
|
406
|
+
const output = truncateParallelOutput(getResultOutput(r));
|
|
407
|
+
const status = isFailedResult(r)
|
|
408
|
+
? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
|
|
409
|
+
: "completed";
|
|
410
|
+
return `### [${r.agent}] ${status}\n\n${output}`;
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
content: [
|
|
415
|
+
{
|
|
416
|
+
type: "text",
|
|
417
|
+
text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
details: makeDetails("parallel")(results),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// --- Single mode ---
|
|
425
|
+
if (params.agent && params.task) {
|
|
426
|
+
const result = await runOne(params.agent, params.task, params.cwd);
|
|
427
|
+
const isError = isFailedResult(result);
|
|
428
|
+
|
|
429
|
+
if (onUpdate) {
|
|
430
|
+
onUpdate({
|
|
431
|
+
content: [
|
|
432
|
+
{ type: "text", text: getFinalOutput(result.messages) || "(running...)" },
|
|
433
|
+
],
|
|
434
|
+
details: makeDetails("single")([result]),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (isError) {
|
|
439
|
+
const errorMsg = getResultOutput(result);
|
|
440
|
+
return {
|
|
441
|
+
content: [
|
|
442
|
+
{
|
|
443
|
+
type: "text",
|
|
444
|
+
text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
details: makeDetails("single")([result]),
|
|
448
|
+
isError: true,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
content: [
|
|
454
|
+
{ type: "text", text: getFinalOutput(result.messages) || "(no output)" },
|
|
455
|
+
],
|
|
456
|
+
details: makeDetails("single")([result]),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Should not reach here due to validation above
|
|
461
|
+
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
462
|
+
return {
|
|
463
|
+
content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
|
|
464
|
+
details: makeDetails("single")([]),
|
|
465
|
+
};
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
// ------------------------------------------------------------------
|
|
469
|
+
// TUI rendering
|
|
470
|
+
// ------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
renderCall(args, theme, _context) {
|
|
473
|
+
const scope: AgentScope = args.agentScope ?? "user";
|
|
474
|
+
const fg = theme.fg.bind(theme);
|
|
475
|
+
|
|
476
|
+
// Chain
|
|
477
|
+
if (args.chain && args.chain.length > 0) {
|
|
478
|
+
let text =
|
|
479
|
+
fg("toolTitle", theme.bold("subagent ")) +
|
|
480
|
+
fg("accent", `chain (${args.chain.length} steps)`) +
|
|
481
|
+
fg("muted", ` [${scope}]`);
|
|
482
|
+
for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
|
|
483
|
+
const step = args.chain[i];
|
|
484
|
+
const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
|
|
485
|
+
const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
|
|
486
|
+
text +=
|
|
487
|
+
"\n " +
|
|
488
|
+
fg("muted", `${i + 1}.`) +
|
|
489
|
+
" " +
|
|
490
|
+
fg("accent", step.agent) +
|
|
491
|
+
fg("dim", ` ${preview}`);
|
|
492
|
+
}
|
|
493
|
+
if (args.chain.length > 3)
|
|
494
|
+
text += `\n ${fg("muted", `... +${args.chain.length - 3} more`)}`;
|
|
495
|
+
return new Text(text, 0, 0);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Parallel
|
|
499
|
+
if (args.tasks && args.tasks.length > 0) {
|
|
500
|
+
let text =
|
|
501
|
+
fg("toolTitle", theme.bold("subagent ")) +
|
|
502
|
+
fg("accent", `parallel (${args.tasks.length} tasks)`) +
|
|
503
|
+
fg("muted", ` [${scope}]`);
|
|
504
|
+
for (const t of args.tasks.slice(0, 3)) {
|
|
505
|
+
const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
|
|
506
|
+
text += `\n ${fg("accent", t.agent)}${fg("dim", ` ${preview}`)}`;
|
|
507
|
+
}
|
|
508
|
+
if (args.tasks.length > 3)
|
|
509
|
+
text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
|
|
510
|
+
return new Text(text, 0, 0);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Single
|
|
514
|
+
const agentName = args.agent || "...";
|
|
515
|
+
const preview = args.task
|
|
516
|
+
? args.task.length > 60
|
|
517
|
+
? `${args.task.slice(0, 60)}...`
|
|
518
|
+
: args.task
|
|
519
|
+
: "...";
|
|
520
|
+
let text =
|
|
521
|
+
fg("toolTitle", theme.bold("subagent ")) +
|
|
522
|
+
fg("accent", agentName) +
|
|
523
|
+
fg("muted", ` [${scope}]`);
|
|
524
|
+
text += `\n ${fg("dim", preview)}`;
|
|
525
|
+
return new Text(text, 0, 0);
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
renderResult(result, { expanded }, theme, _context) {
|
|
529
|
+
const details = result.details as SubagentDetails | undefined;
|
|
530
|
+
if (!details || details.results.length === 0) {
|
|
531
|
+
const text = result.content[0];
|
|
532
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const fg = theme.fg.bind(theme);
|
|
536
|
+
const mdTheme = getMarkdownTheme();
|
|
537
|
+
|
|
538
|
+
// --- Single ---
|
|
539
|
+
if (details.mode === "single" && details.results.length === 1) {
|
|
540
|
+
return renderSingleResult(details.results[0], expanded, theme);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// --- Chain ---
|
|
544
|
+
if (details.mode === "chain") {
|
|
545
|
+
const successCount = details.results.filter((r) => !isFailedResult(r)).length;
|
|
546
|
+
const icon =
|
|
547
|
+
successCount === details.results.length
|
|
548
|
+
? fg("success", "✓")
|
|
549
|
+
: fg("error", "✗");
|
|
550
|
+
|
|
551
|
+
if (expanded) {
|
|
552
|
+
const container = new Container();
|
|
553
|
+
container.addChild(
|
|
554
|
+
new Text(
|
|
555
|
+
icon +
|
|
556
|
+
" " +
|
|
557
|
+
fg("toolTitle", theme.bold("chain ")) +
|
|
558
|
+
fg("accent", `${successCount}/${details.results.length} steps`),
|
|
559
|
+
0,
|
|
560
|
+
0,
|
|
561
|
+
),
|
|
562
|
+
);
|
|
563
|
+
for (const r of details.results) {
|
|
564
|
+
container.addChild(new Spacer(1));
|
|
565
|
+
const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
|
|
566
|
+
container.addChild(
|
|
567
|
+
new Text(
|
|
568
|
+
fg("muted", `─── Step ${r.exitCode !== -1 ? "" : "?"}: `) +
|
|
569
|
+
fg("accent", r.agent) +
|
|
570
|
+
` ${stepIcon}`,
|
|
571
|
+
0,
|
|
572
|
+
0,
|
|
573
|
+
),
|
|
574
|
+
);
|
|
575
|
+
if (r.errorMessage) {
|
|
576
|
+
container.addChild(
|
|
577
|
+
new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
const finalOutput = getResultOutput(r);
|
|
581
|
+
if (finalOutput) {
|
|
582
|
+
container.addChild(new Spacer(1));
|
|
583
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
584
|
+
}
|
|
585
|
+
const usageStr = formatUsageStats(r.usage, r.model);
|
|
586
|
+
if (usageStr)
|
|
587
|
+
container.addChild(new Text(fg("dim", usageStr), 0, 0));
|
|
588
|
+
}
|
|
589
|
+
const totalUsage = formatUsageStats(aggregateUsage(details.results));
|
|
590
|
+
if (totalUsage) {
|
|
591
|
+
container.addChild(new Spacer(1));
|
|
592
|
+
container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
|
|
593
|
+
}
|
|
594
|
+
return container;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
let text =
|
|
598
|
+
icon +
|
|
599
|
+
" " +
|
|
600
|
+
fg("toolTitle", theme.bold("chain ")) +
|
|
601
|
+
fg("accent", `${successCount}/${details.results.length} steps`);
|
|
602
|
+
for (const r of details.results) {
|
|
603
|
+
const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
|
|
604
|
+
text += `\n ${stepIcon} ${fg("accent", r.agent)}`;
|
|
605
|
+
}
|
|
606
|
+
const totalUsage = formatUsageStats(aggregateUsage(details.results));
|
|
607
|
+
if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
|
|
608
|
+
text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
|
|
609
|
+
return new Text(text, 0, 0);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// --- Parallel ---
|
|
613
|
+
if (details.mode === "parallel") {
|
|
614
|
+
const running = details.results.filter((r) => r.exitCode === -1).length;
|
|
615
|
+
const successCount = details.results.filter(
|
|
616
|
+
(r) => r.exitCode !== -1 && !isFailedResult(r),
|
|
617
|
+
).length;
|
|
618
|
+
const failCount = details.results.filter(
|
|
619
|
+
(r) => r.exitCode !== -1 && isFailedResult(r),
|
|
620
|
+
).length;
|
|
621
|
+
const isRunning = running > 0;
|
|
622
|
+
const icon = isRunning
|
|
623
|
+
? fg("warning", "⏳")
|
|
624
|
+
: failCount > 0
|
|
625
|
+
? fg("warning", "◐")
|
|
626
|
+
: fg("success", "✓");
|
|
627
|
+
const status = isRunning
|
|
628
|
+
? `${successCount + failCount}/${details.results.length} done, ${running} running`
|
|
629
|
+
: `${successCount}/${details.results.length} tasks`;
|
|
630
|
+
|
|
631
|
+
if (expanded && !isRunning) {
|
|
632
|
+
const container = new Container();
|
|
633
|
+
container.addChild(
|
|
634
|
+
new Text(
|
|
635
|
+
`${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`,
|
|
636
|
+
0,
|
|
637
|
+
0,
|
|
638
|
+
),
|
|
639
|
+
);
|
|
640
|
+
for (const r of details.results) {
|
|
641
|
+
container.addChild(new Spacer(1));
|
|
642
|
+
const taskIcon = isFailedResult(r)
|
|
643
|
+
? fg("error", "✗")
|
|
644
|
+
: fg("success", "✓");
|
|
645
|
+
container.addChild(
|
|
646
|
+
new Text(
|
|
647
|
+
fg("muted", "─── ") + fg("accent", r.agent) + ` ${taskIcon}`,
|
|
648
|
+
0,
|
|
649
|
+
0,
|
|
650
|
+
),
|
|
651
|
+
);
|
|
652
|
+
container.addChild(
|
|
653
|
+
new Text(fg("muted", "Task: ") + fg("dim", r.task), 0, 0),
|
|
654
|
+
);
|
|
655
|
+
if (r.errorMessage) {
|
|
656
|
+
container.addChild(
|
|
657
|
+
new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
const finalOutput = getResultOutput(r);
|
|
661
|
+
if (finalOutput) {
|
|
662
|
+
container.addChild(new Spacer(1));
|
|
663
|
+
container.addChild(
|
|
664
|
+
new Markdown(finalOutput.trim(), 0, 0, mdTheme),
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
const taskUsage = formatUsageStats(r.usage, r.model);
|
|
668
|
+
if (taskUsage)
|
|
669
|
+
container.addChild(new Text(fg("dim", taskUsage), 0, 0));
|
|
670
|
+
}
|
|
671
|
+
const totalUsage = formatUsageStats(aggregateUsage(details.results));
|
|
672
|
+
if (totalUsage) {
|
|
673
|
+
container.addChild(new Spacer(1));
|
|
674
|
+
container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
|
|
675
|
+
}
|
|
676
|
+
return container;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
let text = `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`;
|
|
680
|
+
for (const r of details.results) {
|
|
681
|
+
const taskIcon =
|
|
682
|
+
r.exitCode === -1
|
|
683
|
+
? fg("warning", "⏳")
|
|
684
|
+
: isFailedResult(r)
|
|
685
|
+
? fg("error", "✗")
|
|
686
|
+
: fg("success", "✓");
|
|
687
|
+
text += `\n ${taskIcon} ${fg("accent", r.agent)}`;
|
|
688
|
+
}
|
|
689
|
+
if (!isRunning) {
|
|
690
|
+
const totalUsage = formatUsageStats(aggregateUsage(details.results));
|
|
691
|
+
if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
|
|
692
|
+
}
|
|
693
|
+
if (!expanded) text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
|
|
694
|
+
return new Text(text, 0, 0);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const fallback = result.content[0];
|
|
698
|
+
return new Text(fallback?.type === "text" ? fallback.text : "(no output)", 0, 0);
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
}
|