@bacnh85/pi-subagent 0.10.0 → 0.11.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.
@@ -16,45 +16,45 @@
16
16
  import * as path from "node:path";
17
17
  import { StringEnum } from "@earendil-works/pi-ai";
18
18
  import {
19
- CONFIG_DIR_NAME,
20
- DynamicBorder,
21
- type ExtensionAPI,
22
- type ExtensionContext,
23
- getAgentDir,
24
- getMarkdownTheme,
25
- type ThemeColor,
19
+ CONFIG_DIR_NAME,
20
+ DynamicBorder,
21
+ type ExtensionAPI,
22
+ type ExtensionContext,
23
+ getAgentDir,
24
+ getMarkdownTheme,
25
+ type ThemeColor,
26
26
  } from "@earendil-works/pi-coding-agent";
27
27
  import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
28
28
  import { Type } from "typebox";
29
29
 
30
30
  import { type AgentColor, type AgentConfig, type AgentScope, discoverAgents, formatAgentList, getModelCandidates, invalidateAgentCache } from "./agents.ts";
31
31
  import {
32
- type SubAgentProgress,
33
- type SubAgentResult,
34
- getFinalOutput,
35
- getResultOutput,
36
- isFailedResult,
37
- mapWithConcurrencyLimit,
38
- runSubAgent,
39
- startHeartbeat,
32
+ type SubAgentProgress,
33
+ type SubAgentResult,
34
+ getFinalOutput,
35
+ getResultOutput,
36
+ isFailedResult,
37
+ mapWithConcurrencyLimit,
38
+ runSubAgent,
39
+ startHeartbeat,
40
40
  } from "./runner.ts";
41
41
  import {
42
- isRateLimitError,
43
- normalizeTimeout,
44
- resolveSafeCwd,
45
- validateAgentTools,
46
- truncateParallelOutput,
47
- validateExecutionRequest,
48
- READ_ONLY_TOOLS,
49
- MAX_CONCURRENCY,
50
- MAX_PARALLEL_TASKS,
51
- MAX_CHAIN_LENGTH,
52
- MAX_INSTRUCTIONS_LENGTH,
42
+ isRateLimitError,
43
+ normalizeTimeout,
44
+ resolveSafeCwd,
45
+ validateAgentTools,
46
+ truncateParallelOutput,
47
+ validateExecutionRequest,
48
+ READ_ONLY_TOOLS,
49
+ MAX_CONCURRENCY,
50
+ MAX_PARALLEL_TASKS,
51
+ MAX_CHAIN_LENGTH,
52
+ MAX_INSTRUCTIONS_LENGTH,
53
53
  } from "./security.ts";
54
54
  import {
55
- aggregateUsage,
56
- formatUsageStats,
57
- renderSingleResult,
55
+ aggregateUsage,
56
+ formatUsageStats,
57
+ renderSingleResult,
58
58
  } from "./render.ts";
59
59
  import { type SubagentThread, threadStore } from "./threads.ts";
60
60
  import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
@@ -71,17 +71,17 @@ import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
71
71
 
72
72
  /** Namespace for trusted configuration loaded from pi settings, never from tool params. */
73
73
  function getTrustedConfig(ctx: ExtensionContext): { allowUnconfirmedProjectAgents: boolean; allowExternalCwd: boolean } {
74
- // Use pi's settings infrastructure if available; fall back to env vars for testing.
75
- // The model cannot influence these values.
76
- const settings = (ctx as any).settings ?? {};
77
- return {
78
- allowUnconfirmedProjectAgents:
79
- (settings as Record<string, unknown>).allowUnconfirmedProjectAgents === true ||
80
- process.env.PI_SUBAGENT_ALLOW_UNCONFIRMED_PROJECT_AGENTS === "true",
81
- allowExternalCwd:
82
- (settings as Record<string, unknown>).allowExternalCwd === true ||
83
- process.env.PI_SUBAGENT_ALLOW_EXTERNAL_CWD === "true",
84
- };
74
+ // Use pi's settings infrastructure if available; fall back to env vars for testing.
75
+ // The model cannot influence these values.
76
+ const settings = (ctx as any).settings ?? {};
77
+ return {
78
+ allowUnconfirmedProjectAgents:
79
+ (settings as Record<string, unknown>).allowUnconfirmedProjectAgents === true ||
80
+ process.env.PI_SUBAGENT_ALLOW_UNCONFIRMED_PROJECT_AGENTS === "true",
81
+ allowExternalCwd:
82
+ (settings as Record<string, unknown>).allowExternalCwd === true ||
83
+ process.env.PI_SUBAGENT_ALLOW_EXTERNAL_CWD === "true",
84
+ };
85
85
  }
86
86
 
87
87
 
@@ -90,44 +90,44 @@ function getTrustedConfig(ctx: ExtensionContext): { allowUnconfirmedProjectAgent
90
90
  // ---------------------------------------------------------------------------
91
91
 
92
92
  const TaskItem = Type.Object({
93
- agent: Type.String({ description: "Name of the agent to invoke" }),
94
- task: Type.String({ description: "Task to delegate to the agent" }),
95
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
96
- timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this task; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
93
+ agent: Type.String({ description: "Name of the agent to invoke" }),
94
+ task: Type.String({ description: "Task to delegate to the agent" }),
95
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
96
+ timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this task; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
97
97
  });
98
98
 
99
99
  const ChainItem = Type.Object({
100
- agent: Type.String({ description: "Name of the agent to invoke" }),
101
- task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
102
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
103
- timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this step; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
100
+ agent: Type.String({ description: "Name of the agent to invoke" }),
101
+ task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
102
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
103
+ timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this step; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
104
104
  });
105
105
 
106
106
  const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
107
- description:
108
- 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
109
- default: "user",
107
+ description:
108
+ 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
109
+ default: "user",
110
110
  });
111
111
 
112
112
  const SubagentParams = Type.Object({
113
- agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
114
- task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
115
- tasks: Type.Optional(
116
- Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" }),
117
- ),
118
- chain: Type.Optional(
119
- Type.Array(ChainItem, {
120
- description: "Array of {agent, task} for sequential execution with {previous}",
121
- }),
122
- ),
123
- agentScope: Type.Optional(AgentScopeSchema),
124
- // Security: confirmProjectAgents is NOT exposed as a model-controllable parameter.
125
- // Project-agent confirmation is enforced via trusted configuration.
126
- // See Security model section in README.
127
- cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
128
- timeout: Type.Optional(Type.Number({ description: "Global inactivity timeout in milliseconds (default 3 minutes; real activity resets it; fixed 20-minute absolute cap)" })),
129
- instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
130
- abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
113
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
114
+ task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
115
+ tasks: Type.Optional(
116
+ Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" }),
117
+ ),
118
+ chain: Type.Optional(
119
+ Type.Array(ChainItem, {
120
+ description: "Array of {agent, task} for sequential execution with {previous}",
121
+ }),
122
+ ),
123
+ agentScope: Type.Optional(AgentScopeSchema),
124
+ // Security: confirmProjectAgents is NOT exposed as a model-controllable parameter.
125
+ // Project-agent confirmation is enforced via trusted configuration.
126
+ // See Security model section in README.
127
+ cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
128
+ timeout: Type.Optional(Type.Number({ description: "Global inactivity timeout in milliseconds (default 3 minutes; real activity resets it; fixed 20-minute absolute cap)" })),
129
+ instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
130
+ abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
131
131
  });
132
132
 
133
133
  // ---------------------------------------------------------------------------
@@ -135,10 +135,10 @@ const SubagentParams = Type.Object({
135
135
  // ---------------------------------------------------------------------------
136
136
 
137
137
  interface SubagentDetails {
138
- mode: "single" | "parallel" | "chain";
139
- agentScope: AgentScope;
140
- projectAgentsDir: string | null;
141
- results: SubAgentResult[];
138
+ mode: "single" | "parallel" | "chain";
139
+ agentScope: AgentScope;
140
+ projectAgentsDir: string | null;
141
+ results: SubAgentResult[];
142
142
  }
143
143
 
144
144
  // ---------------------------------------------------------------------------
@@ -146,1288 +146,1288 @@ interface SubagentDetails {
146
146
  // ---------------------------------------------------------------------------
147
147
 
148
148
  export default function (pi: ExtensionAPI) {
149
- let currentCtx: ExtensionContext | undefined;
150
-
151
- // Invalidate agent cache + clear thread store on session replacement.
152
- pi.on("session_start", (event, ctx) => {
153
- currentCtx = ctx;
154
- if (event.reason === "reload") invalidateAgentCache();
155
- threadStore.clear();
156
- });
157
-
158
- // Resolve bundled agents directory relative to this extension file
159
- const bundledAgentsDir = path.resolve(__dirname, "../agents");
160
-
161
- // Inject available agent catalog into system prompt for semantic auto-delegation
162
- pi.on("before_agent_start", async (event) => {
163
- const ctx = currentCtx;
164
- const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
165
- const catalog = discovery.agents
166
- .map((agent) => {
167
- const candidates = getModelCandidates(agent);
168
- const modelInfo = candidates.length > 0
169
- ? ` (models: ${candidates.join(" → ")} → parent fallback)`
170
- : " (parent fallback)";
171
- const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
172
- const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
173
- return `- **${agent.name}**: ${agent.description}${modelInfo}${thinkingInfo}${sandboxInfo}`;
174
- })
175
- .join("\n");
176
- return {
177
- systemPrompt:
178
- event.systemPrompt +
179
- `\n\n## Available Subagents\n${catalog}\n\n` +
180
- "The subagent tool can delegate tasks to these specialized agents with isolated context. " +
181
- "Use for read-heavy exploration, parallel analysis, or work that would flood the main context.\n" +
182
- "Prefer **scout** and **tester** for cheap routine work. " +
183
- "Prefer **worker** or **general-purpose** for normal coding. " +
184
- "Prefer **planner** and **reviewer** for consequential reasoning. " +
185
- "Modes: single, parallel (max 8 tasks, 4 concurrent), chain.",
186
- };
187
- });
188
-
189
- // Public one-request/one-response service used by pi-review.
190
- pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
191
- const request = raw as SubagentRunRequest;
192
- const ctx = currentCtx;
193
- if (!ctx || !request?.id || typeof request.respond !== "function") return;
194
- if (request.accept && !request.accept()) return;
195
- const agent = discoverAgents(ctx.cwd, "user", bundledAgentsDir).agents.find((item) => item.name === request.agent);
196
- if (!agent) {
197
- request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
198
- return;
199
- }
200
- const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single", color: agent.color ? AGENT_TO_THEME_COLOR[agent.color as AgentColor] : undefined });
201
- void runNamedAgent({
202
- agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
203
- task: request.task,
204
- cwd: request.cwd ?? ctx.cwd,
205
- ctx,
206
- timeout: request.timeout,
207
- instructions: request.instructions,
208
- signal: request.signal,
209
- onMessage: (result) => threadStore.updateThread(thread.id, { result }),
210
- onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
211
- }).then((result) => {
212
- threadStore.updateThread(thread.id, {
213
- status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
214
- result,
215
- });
216
- try {
217
- if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
218
- else request.respond({ id: request.id, ok: true, result });
219
- } catch { /* respond channel closed */ }
220
- }, (error) => {
221
- threadStore.updateThread(thread.id, { status: "failed" });
222
- try {
223
- request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
224
- } catch { /* respond channel closed */ }
225
- });
226
- });
227
-
228
- // /subagent command — list available agents
229
- pi.registerCommand("subagent", {
230
- description: "List available sub-agents, reload agent definitions, or show agent details",
231
- handler: async (args, ctx) => {
232
- const cmd = args.trim().toLowerCase();
233
- const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
234
-
235
- if (cmd === "reload" || cmd === "refresh") {
236
- invalidateAgentCache();
237
- const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
238
- const list = formatAgentList(fresh.agents, 20);
239
- const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
240
- const dirs = fresh.projectAgentsDir ? `project: ${fresh.projectAgentsDir}` : "no project agents dir";
241
- const diagText = fresh.diagnostics.length > 0
242
- ? "\n\nWarnings:\n" + fresh.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
243
- : "";
244
- pi.sendMessage({
245
- customType: "pi-subagent",
246
- content: `Agent definitions reloaded.\n\nAvailable agents (${fresh.agents.length}):\n ${list.text}${extra}${diagText}\n\nDirectories searched:\n user: ${path.join(getAgentDir(), "agents")}\n ${dirs}\n bundled: ${bundledAgentsDir}`,
247
- display: true,
248
- });
249
- ctx.ui.notify("Agent definitions reloaded", "info");
250
- return;
251
- }
252
-
253
- // Handle listing keywords before agent lookup
254
- if (cmd === "all" || cmd === "list" || cmd === "agents") {
255
- const list = formatAgentList(discovery.agents, 20);
256
- const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
257
- const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
258
- const diagText = discovery.diagnostics.length > 0
259
- ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
260
- : "";
261
- pi.sendMessage({
262
- customType: "pi-subagent",
263
- content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
264
- display: true,
265
- });
266
- return;
267
- }
268
-
269
- if (cmd) {
270
- // Show details for a specific agent
271
- const agent = discovery.agents.find(
272
- (a) => a.name.toLowerCase() === cmd,
273
- );
274
- if (!agent) {
275
- ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent to list all.`, "error");
276
- return;
277
- }
278
- const candidates = getModelCandidates(agent);
279
- pi.sendMessage({
280
- customType: "pi-subagent",
281
- content: [
282
- `Agent: ${agent.name} (${agent.source})`,
283
- `Description: ${agent.description}`,
284
- `Models: ${candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback"}`,
285
- `Thinking: ${agent.thinking || "off"}`,
286
- `Tools: ${agent.tools?.join(", ") || "all default"}`,
287
- `Source file: ${agent.filePath}`,
288
- "",
289
- "--- System Prompt ---",
290
- agent.systemPrompt,
291
- ].join("\n"),
292
- display: true,
293
- });
294
- return;
295
- }
296
-
297
- // List all agents
298
- const list = formatAgentList(discovery.agents, 20);
299
- const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
300
- const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
301
- const diagText = discovery.diagnostics.length > 0
302
- ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
303
- : "";
304
- pi.sendMessage({
305
- customType: "pi-subagent",
306
- content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
307
- display: true,
308
- });
309
- },
310
- });
311
-
312
- /** Map AgentColor (from agent frontmatter) to ThemeColor (for pi TUI). */
313
- const AGENT_TO_THEME_COLOR: Record<AgentColor, ThemeColor> = {
314
- red: "error",
315
- blue: "accent",
316
- green: "success",
317
- yellow: "warning",
318
- purple: "syntaxType",
319
- orange: "syntaxString",
320
- pink: "customMessageLabel",
321
- cyan: "syntaxVariable",
322
- };
323
-
324
- /** Resolve agent-defined color to a valid ThemeColor for thread creation. */
325
- const agentToThemeColor = (agentName: string): ThemeColor | undefined => {
326
- const ctx = currentCtx;
327
- if (!ctx) return undefined;
328
- const agent = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === agentName);
329
- return agent?.color ? AGENT_TO_THEME_COLOR[agent.color] : undefined;
330
- };
331
-
332
- /** Look up agent color by name for TUI rendering. */
333
- const resolveAgentColor = (name: string): ThemeColor => {
334
- const ctx = currentCtx;
335
- if (!ctx) return "accent";
336
- const found = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === name);
337
- return found?.color ? AGENT_TO_THEME_COLOR[found.color] : "accent";
338
- };
339
-
340
- pi.registerTool({
341
- name: "subagent",
342
- label: "Subagent",
343
- description: [
344
- "Delegate tasks to specialized subagents with isolated context (SDK-based, minimal overhead).",
345
- "Modes: single (agent + task), parallel (tasks array, max 8, 4 concurrent), chain (sequential with {previous}).",
346
- `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
347
- `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
348
- ].join(" "),
349
- parameters: SubagentParams,
350
- promptSnippet: "Delegate tasks to specialized sub-agents with automatic role-based model routing",
351
- promptGuidelines: [
352
- "Use subagent to delegate work that would flood the main context with search results or file contents.",
353
- "Modes: single {agent, task}, parallel {tasks: [...]} (max 8, 4 concurrent), chain {chain: [...]} (sequential with {previous}).",
354
- "Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
355
- "Use /subagent to list all available agents or /subagent <name> for agent details.",
356
- ],
357
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
358
- const agentScope: AgentScope = params.agentScope ?? "user";
359
- const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
360
- const agents = discovery.agents;
361
-
362
- // Trusted configuration — never from tool params.
363
- const trusted = getTrustedConfig(ctx);
364
- const confirmProjectAgents = !trusted.allowUnconfirmedProjectAgents;
365
- const allowExternalCwd = trusted.allowExternalCwd;
366
-
367
- // Resolve workspace root for cwd validation.
368
- const workspaceRoot = ctx.cwd;
369
-
370
- const hasChain = (params.chain?.length ?? 0) > 0;
371
- const hasTasks = (params.tasks?.length ?? 0) > 0;
372
- const hasSingle = Boolean(params.agent && params.task);
373
- const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
374
-
375
- const makeDetails =
376
- (mode: "single" | "parallel" | "chain") =>
377
- (results: SubAgentResult[]): SubagentDetails => ({
378
- mode,
379
- agentScope,
380
- projectAgentsDir: discovery.projectAgentsDir,
381
- results,
382
- });
383
-
384
- // Validate execution request before any processing.
385
- const validationErrors = validateExecutionRequest({
386
- agentName: params.agent,
387
- task: params.task,
388
- tasks: params.tasks,
389
- chain: params.chain,
390
- timeout: params.timeout,
391
- });
392
- if (validationErrors.length > 0) {
393
- const errorMessages = validationErrors.map((e) => ` • ${e.field}: ${e.message}`).join("\n");
394
- return {
395
- content: [{ type: "text", text: `Invalid parameters:\n${errorMessages}` }],
396
- details: makeDetails("single")([]),
397
- isError: true,
398
- };
399
- }
400
-
401
- // Validate: exactly one mode
402
- if (modeCount !== 1) {
403
- const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
404
- return {
405
- content: [
406
- {
407
- type: "text",
408
- text: [
409
- "Invalid parameters. Provide exactly one mode:",
410
- " single: { agent, task }",
411
- " parallel: { tasks: [...] }",
412
- " chain: { chain: [...] }",
413
- `Available agents: ${available}`,
414
- ].join("\n"),
415
- },
416
- ],
417
- details: makeDetails("single")([]),
418
- };
419
- }
420
-
421
- // Handle project-local agent confirmation
422
- // Security: confirmation policy comes from trusted config, never from tool params.
423
- if (agentScope === "project" || agentScope === "both") {
424
- const requestedAgentNames = new Set<string>();
425
- if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
426
- if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
427
- if (params.agent) requestedAgentNames.add(params.agent);
428
-
429
- const projectAgentsRequested = Array.from(requestedAgentNames)
430
- .map((name) => agents.find((a) => a.name === name))
431
- .filter((a): a is AgentConfig => a?.source === "project");
432
-
433
- if (projectAgentsRequested.length > 0) {
434
- if (confirmProjectAgents) {
435
- if (ctx.hasUI) {
436
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
437
- const dir = discovery.projectAgentsDir ?? "(unknown)";
438
- const ok = await ctx.ui.confirm(
439
- "Run project-local agents?",
440
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
441
- );
442
- if (!ok) {
443
- return {
444
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
445
- details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
446
- };
447
- }
448
- } else {
449
- // Fail closed in headless sessions.
450
- return {
451
- content: [{
452
- type: "text",
453
- text: "Project agents require explicit user approval. "
454
- + "Enable the trusted project-agent setting to use them in headless mode.",
455
- }],
456
- details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
457
- };
458
- }
459
- }
460
- // else: allowUnconfirmedProjectAgents is true — skip confirmation.
461
- }
462
- }
463
-
464
- const modelRegistry = ctx.modelRegistry;
465
- const modelRuntime = (modelRegistry as any).runtime;
466
- const authStorage = (modelRegistry as any).authStorage;
467
-
468
- // Helper: resolve a safe child working directory.
469
- function resolveChildCwd(childCwd: string | undefined): string {
470
- const safe = resolveSafeCwd({ workspaceRoot, childCwd, allowExternalCwd });
471
- if (safe.error) {
472
- throw new Error(safe.error);
473
- }
474
- return safe.path;
475
- }
476
-
477
- // Helper: validate and normalise tools for an agent.
478
- function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): string[] {
479
- const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
480
- let rawTools = agentTools ?? defaultTools;
481
- // sandbox overrides tools: silently strip mutation tools, not an error
482
- if (sandbox === "read-only") {
483
- rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
484
- if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
485
- }
486
- const effectiveReadOnly = readOnly || sandbox === "read-only";
487
- const result = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly });
488
- if (result.errors.length > 0) {
489
- throw new Error(`Tool validation errors: ${result.errors.join("; ")}`);
490
- }
491
- return result.tools;
492
- }
493
-
494
- // Helper: normalise timeout.
495
- function resolveChildTimeout(childTimeout: number | undefined, globalTimeout: number | undefined): number | undefined {
496
- const effectiveTimeout = childTimeout ?? globalTimeout;
497
- const result = normalizeTimeout({ requested: effectiveTimeout });
498
- if (result.error) {
499
- throw new Error(result.error);
500
- }
501
- return result.timeoutMs;
502
- }
503
-
504
- // Helper: run a single agent via SDK with security validation
505
- async function runOne(
506
- agentName: string,
507
- task: string,
508
- cwd: string | undefined,
509
- parentSignal?: AbortSignal,
510
- timeoutMs?: number,
511
- onProgress?: (partial: SubAgentResult) => void,
512
- onActivity?: (progress: SubAgentProgress) => void,
513
- heartbeatDetails?: () => SubagentDetails,
514
- onHeartbeat?: () => void,
515
- isReadOnly?: boolean,
516
- ): Promise<SubAgentResult> {
517
- const agent = agents.find((a) => a.name === agentName);
518
-
519
- if (!agent) {
520
- const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
521
- return {
522
- agent: agentName,
523
- task,
524
- exitCode: 1,
525
- status: "error",
526
- stopReason: "error",
527
- messages: [],
528
- stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
529
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
530
- errorMessage: `Unknown agent: "${agentName}"`,
531
- };
532
- }
533
-
534
- const resolved = await resolveModel(getModelCandidates(agent), ctx.model, ctx.modelRegistry);
535
- if (!resolved.model) {
536
- const tried = resolved.attempted.join(", ") || "none";
537
- const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
538
- return {
539
- agent: agentName,
540
- task,
541
- exitCode: 1,
542
- status: "error",
543
- stopReason: "error",
544
- messages: [],
545
- stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
546
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
547
- errorMessage: `No model resolved (tried: ${tried})`,
548
- };
549
- }
550
-
551
- // Security: validate tools, timeout, and cwd (wrapped in try/catch).
552
- let tools: string[];
553
- let effectiveTimeoutMs: number | undefined;
554
- let safeCwd: string;
555
- try {
556
- tools = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
557
- effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
558
- safeCwd = resolveChildCwd(cwd);
559
- } catch (err: unknown) {
560
- const errorMsg = err instanceof Error ? err.message : String(err);
561
- return {
562
- agent: agentName,
563
- task,
564
- exitCode: 1,
565
- status: "error",
566
- stopReason: "error",
567
- messages: [],
568
- stderr: `Validation error: ${errorMsg}`,
569
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
570
- errorMessage: errorMsg,
571
- };
572
- }
573
-
574
- // Retry loop: rate-limit model fallback
575
- const candidates = getModelCandidates(agent);
576
- const triedModels: string[] = [];
577
-
578
- const stopHeartbeat = onUpdate ? startHeartbeat(() => {
579
- onHeartbeat?.();
580
- onUpdate({ content: [{ type: "text", text: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
581
- }) : undefined;
582
- try {
583
- const tryWithFallback = async (): Promise<SubAgentResult> => {
584
- const remaining = candidates.filter(m => !triedModels.includes(m));
585
- const isParentFallback = remaining.length === 0;
586
- const fallbackResolved = await resolveModel(remaining, ctx.model, ctx.modelRegistry);
587
- if (!fallbackResolved.model) {
588
- return {
589
- agent: agentName,
590
- task,
591
- exitCode: 1,
592
- status: "error" as const,
593
- stopReason: "error" as const,
594
- messages: [],
595
- stderr: [
596
- `All models rate-limited or unavailable.`,
597
- `Tried: ${triedModels.join(" → ") || "(none)"}.`,
598
- `Remaining candidates: ${remaining.join(", ") || "none"}.`,
599
- `Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
600
- ].join(" "),
601
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
602
- errorMessage: `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`,
603
- };
604
- }
605
- const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
606
- if (triedModels.includes(triedName)) {
607
- // Already tried this model (e.g., all candidates unavailable
608
- // and parent fallback) — no further options.
609
- return {
610
- agent: agentName,
611
- task,
612
- exitCode: 1,
613
- status: "error" as const,
614
- stopReason: "error" as const,
615
- messages: [],
616
- stderr: [
617
- `All available models exhausted.`,
618
- `Tried: ${triedModels.join(" → ")}.`,
619
- ].join(" "),
620
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
621
- errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
622
- };
623
- }
624
- triedModels.push(triedName);
625
- // Also track the raw candidate name so candidates.filter() can
626
- // exclude it even when the agent uses unqualified names.
627
- // Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
628
- if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
629
- triedModels.push(fallbackResolved.matchedCandidate);
630
- }
631
-
632
- const result = await runSubAgent({
633
- cwd: safeCwd,
634
- systemPrompt: params.instructions
635
- ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
636
- : agent.systemPrompt,
637
- task,
638
- tools,
639
- model: fallbackResolved.model,
640
- modelRuntime,
641
- authStorage,
642
- modelRegistry,
643
- signal: parentSignal,
644
- timeoutMs: effectiveTimeoutMs,
645
- agentName,
646
- thinkingLevel: agent.thinking,
647
- onMessage: onProgress,
648
- onProgress: onActivity,
649
- });
650
-
651
- if (result.errorMessage && isRateLimitError(result.errorMessage)) {
652
- // If the model that just rate-limited was the parent fallback
653
- // (no remaining candidates), stop — no further options.
654
- if (isParentFallback) {
655
- return {
656
- agent: agentName,
657
- task,
658
- exitCode: 1,
659
- status: "error" as const,
660
- stopReason: "error" as const,
661
- messages: [],
662
- stderr: [
663
- `All available models exhausted.`,
664
- `Tried: ${triedModels.join(" → ")}.`,
665
- ].join(" "),
666
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
667
- errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
668
- };
669
- }
670
- return tryWithFallback();
671
- }
672
- return result;
673
- };
674
-
675
- return tryWithFallback();
676
- } finally {
677
- stopHeartbeat?.();
678
- }
679
- }
680
-
681
- // --- Chain mode ---
682
- if (params.chain && params.chain.length > 0) {
683
- const results: SubAgentResult[] = [];
684
- let previousOutput = "";
685
-
686
- for (let i = 0; i < params.chain.length; i++) {
687
- const step = params.chain[i];
688
- const taskWithContext = step.task.replace(/\{previous\}/g, () => previousOutput);
689
-
690
- const thread = threadStore.createThread({
691
- agentName: step.agent,
692
- task: taskWithContext,
693
- mode: "chain-step",
694
- toolCallId: _toolCallId,
695
- color: agentToThemeColor(step.agent),
696
- });
697
- const result = await runOne(
698
- step.agent, taskWithContext, step.cwd,
699
- signal, step.timeout ?? params.timeout,
700
- (partial) => threadStore.updateThread(thread.id, { result: partial }),
701
- (progress) => threadStore.updateProgress(thread.id, progress),
702
- () => makeDetails("chain")(results),
703
- () => threadStore.refreshHeartbeat(thread.id),
704
- );
705
- threadStore.updateThread(thread.id, {
706
- status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
707
- result,
708
- });
709
- results.push(result);
710
-
711
- const isError = isFailedResult(result);
712
- if (isError) {
713
- const errorMsg = getResultOutput(result);
714
- if (onUpdate) {
715
- onUpdate({
716
- content: [{ type: "text", text: errorMsg }],
717
- details: makeDetails("chain")(results),
718
- });
719
- }
720
- // Include successful previous step outputs in the error content
721
- const prevCount = i;
722
- let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
723
- if (prevCount > 0) {
724
- const prevSummaries = results
725
- .slice(0, prevCount)
726
- .map((r, j) => {
727
- const out = getResultOutput(r).slice(0, 500);
728
- return `Step ${j + 1} (${r.agent}): ${out}`;
729
- })
730
- .join("\n");
731
- contentText = `Chain stopped at step ${i + 1}/${params.chain.length}. ${prevCount} previous step(s) succeeded:\n\n${prevSummaries}\n\nError at step ${i + 1} (${step.agent}): ${errorMsg}`;
732
- }
733
- return {
734
- content: [{ type: "text", text: contentText }],
735
- details: makeDetails("chain")(results),
736
- isError: true,
737
- };
738
- }
739
-
740
- previousOutput = getFinalOutput(result.messages);
741
-
742
- if (onUpdate) {
743
- onUpdate({
744
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
745
- details: makeDetails("chain")(results),
746
- });
747
- }
748
- }
749
-
750
- const last = results[results.length - 1];
751
- return {
752
- content: [
753
- { type: "text", text: getFinalOutput(last.messages) || "(no output)" },
754
- ],
755
- details: makeDetails("chain")(results),
756
- };
757
- }
758
-
759
- // --- Parallel mode ---
760
- if (params.tasks && params.tasks.length > 0) {
761
- const abortOnFailure = params.abortOnFailure ?? false;
762
- const parallelController = new AbortController();
763
- let abortCause: "parent" | "sibling" | "timeout" | undefined;
764
- let cleanupParentSignal: (() => void) | undefined;
765
-
766
- // Link parent abort into parallelController so queued tasks see aborted state
767
- if (signal) {
768
- if (signal.aborted) {
769
- abortCause = "parent";
770
- parallelController.abort();
771
- } else {
772
- const onParentAbort = () => {
773
- if (!abortCause) abortCause = "parent";
774
- parallelController.abort();
775
- };
776
- signal.addEventListener("abort", onParentAbort, { once: true });
777
- cleanupParentSignal = () => signal.removeEventListener("abort", onParentAbort);
778
- }
779
- }
780
-
781
- // Wrap all remaining setup + execution so cleanupParentSignal always runs.
782
- try {
783
- // Pre-create threads for all parallel tasks
784
- const parallelThreads = params.tasks.map((t) =>
785
- threadStore.createThread({
786
- agentName: t.agent,
787
- task: t.task,
788
- mode: "parallel-task",
789
- toolCallId: _toolCallId,
790
- color: agentToThemeColor(t.agent),
791
- }),
792
- );
793
-
794
- const allResults: SubAgentResult[] = new Array(params.tasks.length);
795
- // Initialize placeholder results for streaming
796
- for (let i = 0; i < params.tasks.length; i++) {
797
- allResults[i] = {
798
- agent: params.tasks[i].agent,
799
- task: params.tasks[i].task,
800
- exitCode: -1,
801
- messages: [],
802
- stderr: "",
803
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
804
- };
805
- }
806
-
807
- const emitParallelUpdate = () => {
808
- if (onUpdate) {
809
- const running = allResults.filter((r) => r.exitCode === -1).length;
810
- const done = allResults.filter((r) => r.exitCode !== -1).length;
811
- onUpdate({
812
- content: [
813
- {
814
- type: "text",
815
- text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
816
- },
817
- ],
818
- details: makeDetails("parallel")([...allResults]),
819
- });
820
- }
821
- };
822
-
823
- const results = await mapWithConcurrencyLimit(
824
- params.tasks,
825
- MAX_CONCURRENCY,
826
- async (t, index) => {
827
- // Skip if already aborted by sibling failure or parent abort
828
- if (parallelController.signal.aborted) {
829
- const skippedResult: SubAgentResult = {
830
- agent: t.agent,
831
- task: t.task,
832
- exitCode: 1,
833
- status: "error",
834
- messages: [],
835
- stderr: "",
836
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
837
- stopReason: "aborted",
838
- errorMessage:
839
- abortCause === "sibling"
840
- ? "Cancelled: sibling task failed"
841
- : abortCause === "timeout"
842
- ? "Cancelled: sibling task timed out"
843
- : "Cancelled: parent operation aborted",
844
- };
845
- allResults[index] = skippedResult;
846
- threadStore.updateThread(parallelThreads[index].id, {
847
- status: "aborted",
848
- result: skippedResult,
849
- });
850
- emitParallelUpdate();
851
- return skippedResult;
852
- }
853
- const result = await runOne(
854
- t.agent, t.task, t.cwd,
855
- parallelController.signal, t.timeout ?? params.timeout,
856
- (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
857
- (progress) => threadStore.updateProgress(parallelThreads[index].id, progress),
858
- () => makeDetails("parallel")([...allResults]),
859
- () => threadStore.refreshHeartbeat(parallelThreads[index].id),
860
- );
861
- allResults[index] = result;
862
- threadStore.updateThread(parallelThreads[index].id, {
863
- status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
864
- result,
865
- });
866
- // Early-abort: if this task failed and abortOnFailure is set
867
- if (abortOnFailure && isFailedResult(result) && !abortCause) {
868
- abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
869
- parallelController.abort();
870
- }
871
- emitParallelUpdate();
872
- return result;
873
- },
874
- );
875
-
876
- const successCount = results.filter((r) => !isFailedResult(r)).length;
877
- const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
878
- const summaries = results.map((r) => {
879
- const output = truncateParallelOutput(getResultOutput(r));
880
- const status = isFailedResult(r)
881
- ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
882
- : "completed";
883
- return `### [${r.agent}] ${status}\n\n${output}`;
884
- });
885
-
886
- let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
887
- if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
888
- return {
889
- content: [
890
- {
891
- type: "text",
892
- text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
893
- },
894
- ],
895
- details: makeDetails("parallel")(results),
896
- };
897
- } finally {
898
- cleanupParentSignal?.();
899
- }
900
- }
901
-
902
- // --- Single mode ---
903
- if (params.agent && params.task) {
904
- const thread = threadStore.createThread({
905
- agentName: params.agent,
906
- task: params.task,
907
- mode: "single",
908
- toolCallId: _toolCallId,
909
- color: agentToThemeColor(params.agent),
910
- });
911
- const result = await runOne(
912
- params.agent, params.task, params.cwd,
913
- signal, params.timeout,
914
- (partial) => threadStore.updateThread(thread.id, { result: partial }),
915
- (progress) => threadStore.updateProgress(thread.id, progress),
916
- () => makeDetails("single")([]),
917
- () => threadStore.refreshHeartbeat(thread.id),
918
- );
919
- threadStore.updateThread(thread.id, {
920
- status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
921
- result,
922
- });
923
- const isError = isFailedResult(result);
924
-
925
- if (onUpdate) {
926
- onUpdate({
927
- content: [
928
- { type: "text", text: getFinalOutput(result.messages) || "(running...)" },
929
- ],
930
- details: makeDetails("single")([result]),
931
- });
932
- }
933
-
934
- if (isError) {
935
- const errorMsg = getResultOutput(result);
936
- return {
937
- content: [
938
- {
939
- type: "text",
940
- text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
941
- },
942
- ],
943
- details: makeDetails("single")([result]),
944
- isError: true,
945
- };
946
- }
947
-
948
- return {
949
- content: [
950
- { type: "text", text: getFinalOutput(result.messages) || "(no output)" },
951
- ],
952
- details: makeDetails("single")([result]),
953
- };
954
- }
955
-
956
- // Exhaustiveness check: the modeCount === 1 validation above ensures
957
- // at least one of the three branches is taken, but TS cannot prove it.
958
- throw new Error("unreachable");
959
- },
960
-
961
- // ------------------------------------------------------------------
962
- // TUI rendering
963
- // ------------------------------------------------------------------
964
-
965
- renderCall(args, theme, _context) {
966
- const scope: AgentScope = args.agentScope ?? "user";
967
- const fg = theme.fg.bind(theme);
968
-
969
- // Chain
970
- if (args.chain && args.chain.length > 0) {
971
- let text =
972
- fg("toolTitle", theme.bold("subagent ")) +
973
- fg("accent", `chain (${args.chain.length} steps)`) +
974
- fg("muted", ` [${scope}]`);
975
- for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
976
- const step = args.chain[i];
977
- const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
978
- const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
979
- text +=
980
- "\n " +
981
- fg("muted", `${i + 1}.`) +
982
- " " +
983
- fg(resolveAgentColor(step.agent), step.agent) +
984
- fg("dim", ` ${preview}`);
985
- }
986
- if (args.chain.length > 3)
987
- text += `\n ${fg("muted", `... +${args.chain.length - 3} more`)}`;
988
- return new Text(text, 0, 0);
989
- }
990
-
991
- // Parallel
992
- if (args.tasks && args.tasks.length > 0) {
993
- let text =
994
- fg("toolTitle", theme.bold("subagent ")) +
995
- fg("accent", `parallel (${args.tasks.length} tasks)`) +
996
- fg("muted", ` [${scope}]`);
997
- for (const t of args.tasks.slice(0, 3)) {
998
- const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
999
- text += `\n ${fg(resolveAgentColor(t.agent), t.agent)}${fg("dim", ` ${preview}`)}`;
1000
- }
1001
- if (args.tasks.length > 3)
1002
- text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
1003
- return new Text(text, 0, 0);
1004
- }
1005
-
1006
- // Single
1007
- const agentName = args.agent || "...";
1008
- const preview = args.task
1009
- ? args.task.length > 60
1010
- ? `${args.task.slice(0, 60)}...`
1011
- : args.task
1012
- : "...";
1013
- let text =
1014
- fg("toolTitle", theme.bold("subagent ")) +
1015
- fg(resolveAgentColor(agentName), agentName) +
1016
- fg("muted", ` [${scope}]`);
1017
- text += `\n ${fg("dim", preview)}`;
1018
- return new Text(text, 0, 0);
1019
- },
1020
-
1021
- renderResult(result, { expanded }, theme, _context) {
1022
- const details = result.details as SubagentDetails | undefined;
1023
- if (!details || details.results.length === 0) {
1024
- const text = result.content[0];
1025
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
1026
- }
1027
-
1028
- const fg = theme.fg.bind(theme);
1029
- const mdTheme = getMarkdownTheme();
1030
-
1031
- // --- Single ---
1032
- if (details.mode === "single" && details.results.length === 1) {
1033
- const r = details.results[0];
1034
- return renderSingleResult(r, expanded, theme, resolveAgentColor(r.agent));
1035
- }
1036
-
1037
- // --- Chain ---
1038
- if (details.mode === "chain") {
1039
- const successCount = details.results.filter((r) => !isFailedResult(r)).length;
1040
- const icon =
1041
- successCount === details.results.length
1042
- ? fg("success", "✓")
1043
- : fg("error", "✗");
1044
-
1045
- if (expanded) {
1046
- const container = new Container();
1047
- container.addChild(
1048
- new Text(
1049
- icon +
1050
- " " +
1051
- fg("toolTitle", theme.bold("chain ")) +
1052
- fg("accent", `${successCount}/${details.results.length} steps`),
1053
- 0,
1054
- 0,
1055
- ),
1056
- );
1057
- for (const r of details.results) {
1058
- container.addChild(new Spacer(1));
1059
- const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
1060
- container.addChild(
1061
- new Text(
1062
- fg("muted", `─── Step ${r.exitCode !== -1 ? "" : "?"}: `) +
1063
- fg(resolveAgentColor(r.agent), r.agent) +
1064
- ` ${stepIcon}`,
1065
- 0,
1066
- 0,
1067
- ),
1068
- );
1069
- if (r.errorMessage) {
1070
- container.addChild(
1071
- new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
1072
- );
1073
- }
1074
- const finalOutput = getResultOutput(r);
1075
- if (finalOutput) {
1076
- container.addChild(new Spacer(1));
1077
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1078
- }
1079
- const usageStr = formatUsageStats(r.usage, r.model);
1080
- if (usageStr)
1081
- container.addChild(new Text(fg("dim", usageStr), 0, 0));
1082
- }
1083
- const totalUsage = formatUsageStats(aggregateUsage(details.results));
1084
- if (totalUsage) {
1085
- container.addChild(new Spacer(1));
1086
- container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
1087
- }
1088
- return container;
1089
- }
1090
-
1091
- let text =
1092
- icon +
1093
- " " +
1094
- fg("toolTitle", theme.bold("chain ")) +
1095
- fg("accent", `${successCount}/${details.results.length} steps`);
1096
- for (const r of details.results) {
1097
- const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
1098
- const color = resolveAgentColor(r.agent);
1099
- text += `\n ${stepIcon} ${fg(color, r.agent)}`;
1100
- }
1101
- const totalUsage = formatUsageStats(aggregateUsage(details.results));
1102
- if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
1103
- text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
1104
- return new Text(text, 0, 0);
1105
- }
1106
-
1107
- // --- Parallel ---
1108
- if (details.mode === "parallel") {
1109
- const running = details.results.filter((r) => r.exitCode === -1).length;
1110
- const successCount = details.results.filter(
1111
- (r) => r.exitCode !== -1 && !isFailedResult(r),
1112
- ).length;
1113
- const failCount = details.results.filter(
1114
- (r) => r.exitCode !== -1 && isFailedResult(r),
1115
- ).length;
1116
- const isRunning = running > 0;
1117
- const icon = isRunning
1118
- ? fg("warning", "⏳")
1119
- : failCount > 0
1120
- ? fg("warning", "◐")
1121
- : fg("success", "✓");
1122
- const status = isRunning
1123
- ? `${successCount + failCount}/${details.results.length} done, ${running} running`
1124
- : `${successCount}/${details.results.length} tasks`;
1125
-
1126
- if (expanded && !isRunning) {
1127
- const container = new Container();
1128
- container.addChild(
1129
- new Text(
1130
- `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`,
1131
- 0,
1132
- 0,
1133
- ),
1134
- );
1135
- for (const r of details.results) {
1136
- container.addChild(new Spacer(1));
1137
- const taskIcon = isFailedResult(r)
1138
- ? fg("error", "✗")
1139
- : fg("success", "✓");
1140
- container.addChild(
1141
- new Text(
1142
- fg("muted", "─── ") + fg(resolveAgentColor(r.agent), r.agent) + ` ${taskIcon}`,
1143
- 0,
1144
- 0,
1145
- ),
1146
- );
1147
- container.addChild(
1148
- new Text(fg("muted", "Task: ") + fg("dim", r.task), 0, 0),
1149
- );
1150
- if (r.errorMessage) {
1151
- container.addChild(
1152
- new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
1153
- );
1154
- }
1155
- const finalOutput = getResultOutput(r);
1156
- if (finalOutput) {
1157
- container.addChild(new Spacer(1));
1158
- container.addChild(
1159
- new Markdown(finalOutput.trim(), 0, 0, mdTheme),
1160
- );
1161
- }
1162
- const taskUsage = formatUsageStats(r.usage, r.model);
1163
- if (taskUsage)
1164
- container.addChild(new Text(fg("dim", taskUsage), 0, 0));
1165
- }
1166
- const totalUsage = formatUsageStats(aggregateUsage(details.results));
1167
- if (totalUsage) {
1168
- container.addChild(new Spacer(1));
1169
- container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
1170
- }
1171
- return container;
1172
- }
1173
-
1174
- let text = `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`;
1175
- for (const r of details.results) {
1176
- const taskIcon =
1177
- r.exitCode === -1
1178
- ? fg("warning", "⏳")
1179
- : isFailedResult(r)
1180
- ? fg("error", "✗")
1181
- : fg("success", "✓");
1182
- text += `\n ${taskIcon} ${fg(resolveAgentColor(r.agent), r.agent)}`;
1183
- }
1184
- if (!isRunning) {
1185
- const totalUsage = formatUsageStats(aggregateUsage(details.results));
1186
- if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
1187
- }
1188
- if (!expanded) text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
1189
- return new Text(text, 0, 0);
1190
- }
1191
-
1192
- const fallback = result.content[0];
1193
- return new Text(fallback?.type === "text" ? fallback.text : "(no output)", 0, 0);
1194
- },
1195
- });
1196
- // /agent command — switch between subagent threads.
1197
- // When a thread is selected, the viewer replaces the main TUI (not overlay).
1198
- pi.registerCommand("agent", {
1199
- description: "Switch to a subagent thread to view its work in isolation",
1200
- handler: async (_args, ctx) => {
1201
- // Show picker overlay
1202
- const selectedId = await showAgentPicker(ctx, buildPickerItems(threadStore.getAllThreads()));
1203
- if (!selectedId) return; // Cancelled — stay in current view
1204
-
1205
- // Main selected — close viewer if active, return to conversation
1206
- if (selectedId === "__main__") {
1207
- if (activeViewerDone) {
1208
- activeViewerDone();
1209
- activeViewerDone = null;
1210
- }
1211
- return;
1212
- }
1213
-
1214
- // Close existing viewer (if any) before opening new one
1215
- if (activeViewerDone) {
1216
- activeViewerDone();
1217
- activeViewerDone = null;
1218
- }
1219
-
1220
- // Show thread viewer (re-resolve against current store)
1221
- const freshThreads = threadStore.getAllThreads();
1222
- const idx = freshThreads.findIndex((t) => t.id === selectedId);
1223
- if (idx === -1) {
1224
- ctx.ui.notify("Selected subagent thread no longer exists.", "warning");
1225
- return;
1226
- }
1227
-
1228
- await showThreadViewer(ctx, freshThreads, idx);
1229
- },
1230
- });
1231
-
1232
- // ---------------------------------------------------------------------------
1233
- // Module-level viewer state (so /agent can close an active viewer)
1234
- // ---------------------------------------------------------------------------
1235
- let activeViewerDone: (() => void) | null = null;
1236
-
1237
- // ---------------------------------------------------------------------------
1238
- // Picker helpers (shared between /agent handler and Ctrl+P in viewer)
1239
- // ---------------------------------------------------------------------------
1240
-
1241
- interface PickerItem { value: string; label: string; description: string }
1242
-
1243
- function buildPickerItems(threads: SubagentThread[]): PickerItem[] {
1244
- const items: PickerItem[] = [
1245
- { value: "__main__", label: "Main [default]", description: "(current)" },
1246
- ];
1247
- for (const t of threads) {
1248
- let statusIcon: string;
1249
- switch (t.status) {
1250
- case "running": statusIcon = "⏳"; break;
1251
- case "completed": statusIcon = "✓"; break;
1252
- case "failed": statusIcon = "✗"; break;
1253
- case "aborted": statusIcon = "✗"; break;
1254
- }
1255
- let modeTag = "";
1256
- if (t.mode === "parallel-task") modeTag = " [parallel]";
1257
- else if (t.mode === "chain-step") modeTag = " [chain]";
1258
- const label = `${statusIcon} ${t.agentName}${modeTag}`;
1259
- const desc = t.task.length > 60 ? `${t.task.slice(0, 57)}...` : t.task;
1260
- items.push({ value: t.id, label, description: desc });
1261
- }
1262
- return items;
1263
- }
1264
-
1265
- async function showAgentPicker(
1266
- ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
1267
- items: PickerItem[],
1268
- ): Promise<string | null> {
1269
- return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
1270
- const container = new Container();
1271
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1272
- container.addChild(new Text(theme.fg("accent", theme.bold("Subagents")), 1, 0));
1273
- container.addChild(new Text(theme.fg("dim", "⌥ + ← previous, ⌥ + → next."), 1, 0));
1274
-
1275
- const selectList = new SelectList(
1276
- items.map((it) => ({ value: it.value, label: it.label, description: it.description })),
1277
- Math.min(items.length + 2, 15),
1278
- {
1279
- selectedPrefix: (t: string) => theme.fg("accent", t),
1280
- selectedText: (t: string) => theme.fg("accent", t),
1281
- description: (t: string) => theme.fg("muted", t),
1282
- scrollInfo: (t: string) => theme.fg("dim", t),
1283
- noMatch: (t: string) => theme.fg("warning", t),
1284
- },
1285
- );
1286
- selectList.onSelect = (item) => done(item.value);
1287
- selectList.onCancel = () => done(null);
1288
- container.addChild(selectList);
1289
-
1290
- container.addChild(new Text(
1291
- `${theme.fg("dim", "↑↓ navigate · enter select · esc back")}`,
1292
- 1, 0,
1293
- ));
1294
-
1295
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1296
-
1297
- return {
1298
- render: (w: number) => container.render(w),
1299
- invalidate: () => container.invalidate(),
1300
- handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); },
1301
- };
1302
- }, { overlay: true });
1303
- }
1304
-
1305
- // Helper: show thread viewer as overlay so editor remains visible.
1306
- // Uses dynamic thread list + store subscriptions for live progress.
1307
- // Ctrl+P opens picker overlay to jump to any thread.
1308
- async function showThreadViewer(
1309
- ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
1310
- _threads: SubagentThread[],
1311
- startIndex: number,
1312
- ): Promise<void> {
1313
- let currentIndex = startIndex;
1314
-
1315
- // Resolve thread list dynamically
1316
- const getThreads = () => threadStore.getAllThreads();
1317
-
1318
- // Overlay mode: viewer appears above editor, Esc dismisses
1319
- await ctx.ui.custom<void>((tui: any, theme: any, _kb: any, done: () => void) => {
1320
- let unsubscribe: (() => void) | undefined;
1321
- let closed = false;
1322
-
1323
- const cleanup = () => {
1324
- if (unsubscribe) {
1325
- unsubscribe();
1326
- unsubscribe = undefined;
1327
- }
1328
- };
1329
-
1330
- const close = () => {
1331
- if (closed) return;
1332
- closed = true;
1333
- cleanup();
1334
- activeViewerDone = null;
1335
- done();
1336
- };
1337
-
1338
- // Track this viewer so /agent can close it before opening a new one
1339
- activeViewerDone = close;
1340
-
1341
- function makeCallbacks(): ThreadViewerCallbacks {
1342
- const list = getThreads();
1343
- return {
1344
- onClose: close,
1345
- onPrev: () => {
1346
- const current = getThreads();
1347
- if (currentIndex > 0) {
1348
- currentIndex--;
1349
- viewer.setThread(current[currentIndex], makeCallbacks());
1350
- tui.requestRender();
1351
- }
1352
- },
1353
- onNext: () => {
1354
- const current = getThreads();
1355
- if (currentIndex < current.length - 1) {
1356
- currentIndex++;
1357
- viewer.setThread(current[currentIndex], makeCallbacks());
1358
- tui.requestRender();
1359
- }
1360
- },
1361
- hasPrev: currentIndex > 0,
1362
- hasNext: currentIndex < list.length - 1,
1363
- };
1364
- }
1365
-
1366
- const list = getThreads();
1367
- if (list.length === 0 || currentIndex < 0 || currentIndex >= list.length) {
1368
- close();
1369
- return {
1370
- render: (_w: number) => [],
1371
- invalidate: () => {},
1372
- handleInput: (_data: string) => {},
1373
- dispose: () => {
1374
- cleanup();
1375
- if (activeViewerDone === close) activeViewerDone = null;
1376
- closed = true;
1377
- },
1378
- };
1379
- }
1380
-
1381
- const viewer = new ThreadViewer(list[currentIndex], makeCallbacks(), theme);
1382
- let pickerOpen = false;
1383
-
1384
- // Subscribe to thread store for live updates (after viewer is created)
1385
- unsubscribe = threadStore.subscribe(() => {
1386
- const current = getThreads();
1387
- if (current.length === 0) {
1388
- close();
1389
- return;
1390
- }
1391
- currentIndex = Math.min(currentIndex, current.length - 1);
1392
- viewer.setThread(current[currentIndex], makeCallbacks());
1393
- tui.requestRender();
1394
- });
1395
-
1396
- return {
1397
- render: (w: number) => viewer.render(w),
1398
- invalidate: () => viewer.invalidate(),
1399
- handleInput: (data: string) => {
1400
- // Ctrl+P opens the picker to jump between threads
1401
- if (data === "\x10") {
1402
- if (!pickerOpen) {
1403
- pickerOpen = true;
1404
- openThreadPicker().finally(() => { pickerOpen = false; });
1405
- }
1406
- return;
1407
- }
1408
- viewer.handleInput(data);
1409
- tui.requestRender();
1410
- },
1411
- dispose: () => {
1412
- cleanup();
1413
- if (activeViewerDone === close) activeViewerDone = null;
1414
- closed = true;
1415
- },
1416
- };
1417
-
1418
- // Opens picker overlay on top of viewer to jump to any thread
1419
- async function openThreadPicker() {
1420
- const items = buildPickerItems(getThreads());
1421
- const selectedId = await showAgentPicker(ctx, items);
1422
- if (!selectedId) return;
1423
- if (selectedId === "__main__") { close(); return; }
1424
- const idx = getThreads().findIndex((t) => t.id === selectedId);
1425
- if (idx >= 0) {
1426
- currentIndex = idx;
1427
- viewer.setThread(getThreads()[currentIndex], makeCallbacks());
1428
- tui.requestRender();
1429
- }
1430
- }
1431
- }, { overlay: true, overlayOptions: { maxHeight: "70%" } }); // Overlay: editor stays visible below
1432
- }
149
+ let currentCtx: ExtensionContext | undefined;
150
+
151
+ // Invalidate agent cache + clear thread store on session replacement.
152
+ pi.on("session_start", (event, ctx) => {
153
+ currentCtx = ctx;
154
+ if (event.reason === "reload") invalidateAgentCache();
155
+ threadStore.clear();
156
+ });
157
+
158
+ // Resolve bundled agents directory relative to this extension file
159
+ const bundledAgentsDir = path.resolve(__dirname, "../agents");
160
+
161
+ // Inject available agent catalog into system prompt for semantic auto-delegation
162
+ pi.on("before_agent_start", async (event) => {
163
+ const ctx = currentCtx;
164
+ const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
165
+ const catalog = discovery.agents
166
+ .map((agent) => {
167
+ const candidates = getModelCandidates(agent);
168
+ const modelInfo = candidates.length > 0
169
+ ? ` (models: ${candidates.join(" → ")} → parent fallback)`
170
+ : " (parent fallback)";
171
+ const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
172
+ const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
173
+ return `- **${agent.name}**: ${agent.description}${modelInfo}${thinkingInfo}${sandboxInfo}`;
174
+ })
175
+ .join("\n");
176
+ return {
177
+ systemPrompt:
178
+ event.systemPrompt +
179
+ `\n\n## Available Subagents\n${catalog}\n\n` +
180
+ "The subagent tool can delegate tasks to these specialized agents with isolated context. " +
181
+ "Use for read-heavy exploration, parallel analysis, or work that would flood the main context.\n" +
182
+ "Prefer **scout** and **tester** for cheap routine work. " +
183
+ "Prefer **worker** or **general-purpose** for normal coding. " +
184
+ "Prefer **planner** and **reviewer** for consequential reasoning. " +
185
+ "Modes: single, parallel (max 8 tasks, 4 concurrent), chain.",
186
+ };
187
+ });
188
+
189
+ // Public one-request/one-response service used by pi-review.
190
+ pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
191
+ const request = raw as SubagentRunRequest;
192
+ const ctx = currentCtx;
193
+ if (!ctx || !request?.id || typeof request.respond !== "function") return;
194
+ if (request.accept && !request.accept()) return;
195
+ const agent = discoverAgents(ctx.cwd, "user", bundledAgentsDir).agents.find((item) => item.name === request.agent);
196
+ if (!agent) {
197
+ request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
198
+ return;
199
+ }
200
+ const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single", color: agent.color ? AGENT_TO_THEME_COLOR[agent.color as AgentColor] : undefined });
201
+ void runNamedAgent({
202
+ agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
203
+ task: request.task,
204
+ cwd: request.cwd ?? ctx.cwd,
205
+ ctx,
206
+ timeout: request.timeout,
207
+ instructions: request.instructions,
208
+ signal: request.signal,
209
+ onMessage: (result) => threadStore.updateThread(thread.id, { result }),
210
+ onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
211
+ }).then((result) => {
212
+ threadStore.updateThread(thread.id, {
213
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
214
+ result,
215
+ });
216
+ try {
217
+ if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
218
+ else request.respond({ id: request.id, ok: true, result });
219
+ } catch { /* respond channel closed */ }
220
+ }, (error) => {
221
+ threadStore.updateThread(thread.id, { status: "failed" });
222
+ try {
223
+ request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
224
+ } catch { /* respond channel closed */ }
225
+ });
226
+ });
227
+
228
+ // /subagent command — list available agents
229
+ pi.registerCommand("subagent", {
230
+ description: "List available sub-agents, reload agent definitions, or show agent details",
231
+ handler: async (args, ctx) => {
232
+ const cmd = args.trim().toLowerCase();
233
+ const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
234
+
235
+ if (cmd === "reload" || cmd === "refresh") {
236
+ invalidateAgentCache();
237
+ const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
238
+ const list = formatAgentList(fresh.agents, 20);
239
+ const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
240
+ const dirs = fresh.projectAgentsDir ? `project: ${fresh.projectAgentsDir}` : "no project agents dir";
241
+ const diagText = fresh.diagnostics.length > 0
242
+ ? "\n\nWarnings:\n" + fresh.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
243
+ : "";
244
+ pi.sendMessage({
245
+ customType: "pi-subagent",
246
+ content: `Agent definitions reloaded.\n\nAvailable agents (${fresh.agents.length}):\n ${list.text}${extra}${diagText}\n\nDirectories searched:\n user: ${path.join(getAgentDir(), "agents")}\n ${dirs}\n bundled: ${bundledAgentsDir}`,
247
+ display: true,
248
+ });
249
+ ctx.ui.notify("Agent definitions reloaded", "info");
250
+ return;
251
+ }
252
+
253
+ // Handle listing keywords before agent lookup
254
+ if (cmd === "all" || cmd === "list" || cmd === "agents") {
255
+ const list = formatAgentList(discovery.agents, 20);
256
+ const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
257
+ const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
258
+ const diagText = discovery.diagnostics.length > 0
259
+ ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
260
+ : "";
261
+ pi.sendMessage({
262
+ customType: "pi-subagent",
263
+ content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
264
+ display: true,
265
+ });
266
+ return;
267
+ }
268
+
269
+ if (cmd) {
270
+ // Show details for a specific agent
271
+ const agent = discovery.agents.find(
272
+ (a) => a.name.toLowerCase() === cmd,
273
+ );
274
+ if (!agent) {
275
+ ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent to list all.`, "error");
276
+ return;
277
+ }
278
+ const candidates = getModelCandidates(agent);
279
+ pi.sendMessage({
280
+ customType: "pi-subagent",
281
+ content: [
282
+ `Agent: ${agent.name} (${agent.source})`,
283
+ `Description: ${agent.description}`,
284
+ `Models: ${candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback"}`,
285
+ `Thinking: ${agent.thinking || "off"}`,
286
+ `Tools: ${agent.tools?.join(", ") || "all default"}`,
287
+ `Source file: ${agent.filePath}`,
288
+ "",
289
+ "--- System Prompt ---",
290
+ agent.systemPrompt,
291
+ ].join("\n"),
292
+ display: true,
293
+ });
294
+ return;
295
+ }
296
+
297
+ // List all agents
298
+ const list = formatAgentList(discovery.agents, 20);
299
+ const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
300
+ const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
301
+ const diagText = discovery.diagnostics.length > 0
302
+ ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
303
+ : "";
304
+ pi.sendMessage({
305
+ customType: "pi-subagent",
306
+ content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
307
+ display: true,
308
+ });
309
+ },
310
+ });
311
+
312
+ /** Map AgentColor (from agent frontmatter) to ThemeColor (for pi TUI). */
313
+ const AGENT_TO_THEME_COLOR: Record<AgentColor, ThemeColor> = {
314
+ red: "error",
315
+ blue: "accent",
316
+ green: "success",
317
+ yellow: "warning",
318
+ purple: "syntaxType",
319
+ orange: "syntaxString",
320
+ pink: "customMessageLabel",
321
+ cyan: "syntaxVariable",
322
+ };
323
+
324
+ /** Resolve agent-defined color to a valid ThemeColor for thread creation. */
325
+ const agentToThemeColor = (agentName: string): ThemeColor | undefined => {
326
+ const ctx = currentCtx;
327
+ if (!ctx) return undefined;
328
+ const agent = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === agentName);
329
+ return agent?.color ? AGENT_TO_THEME_COLOR[agent.color] : undefined;
330
+ };
331
+
332
+ /** Look up agent color by name for TUI rendering. */
333
+ const resolveAgentColor = (name: string): ThemeColor => {
334
+ const ctx = currentCtx;
335
+ if (!ctx) return "accent";
336
+ const found = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === name);
337
+ return found?.color ? AGENT_TO_THEME_COLOR[found.color] : "accent";
338
+ };
339
+
340
+ pi.registerTool({
341
+ name: "subagent",
342
+ label: "Subagent",
343
+ description: [
344
+ "Delegate tasks to specialized subagents with isolated context (SDK-based, minimal overhead).",
345
+ "Modes: single (agent + task), parallel (tasks array, max 8, 4 concurrent), chain (sequential with {previous}).",
346
+ `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
347
+ `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
348
+ ].join(" "),
349
+ parameters: SubagentParams,
350
+ promptSnippet: "Delegate tasks to specialized sub-agents with automatic role-based model routing",
351
+ promptGuidelines: [
352
+ "Use subagent to delegate work that would flood the main context with search results or file contents.",
353
+ "Modes: single {agent, task}, parallel {tasks: [...]} (max 8, 4 concurrent), chain {chain: [...]} (sequential with {previous}).",
354
+ "Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
355
+ "Use /subagent to list all available agents or /subagent <name> for agent details.",
356
+ ],
357
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
358
+ const agentScope: AgentScope = params.agentScope ?? "user";
359
+ const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
360
+ const agents = discovery.agents;
361
+
362
+ // Trusted configuration — never from tool params.
363
+ const trusted = getTrustedConfig(ctx);
364
+ const confirmProjectAgents = !trusted.allowUnconfirmedProjectAgents;
365
+ const allowExternalCwd = trusted.allowExternalCwd;
366
+
367
+ // Resolve workspace root for cwd validation.
368
+ const workspaceRoot = ctx.cwd;
369
+
370
+ const hasChain = (params.chain?.length ?? 0) > 0;
371
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
372
+ const hasSingle = Boolean(params.agent && params.task);
373
+ const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
374
+
375
+ const makeDetails =
376
+ (mode: "single" | "parallel" | "chain") =>
377
+ (results: SubAgentResult[]): SubagentDetails => ({
378
+ mode,
379
+ agentScope,
380
+ projectAgentsDir: discovery.projectAgentsDir,
381
+ results,
382
+ });
383
+
384
+ // Validate execution request before any processing.
385
+ const validationErrors = validateExecutionRequest({
386
+ agentName: params.agent,
387
+ task: params.task,
388
+ tasks: params.tasks,
389
+ chain: params.chain,
390
+ timeout: params.timeout,
391
+ });
392
+ if (validationErrors.length > 0) {
393
+ const errorMessages = validationErrors.map((e) => ` • ${e.field}: ${e.message}`).join("\n");
394
+ return {
395
+ content: [{ type: "text", text: `Invalid parameters:\n${errorMessages}` }],
396
+ details: makeDetails("single")([]),
397
+ isError: true,
398
+ };
399
+ }
400
+
401
+ // Validate: exactly one mode
402
+ if (modeCount !== 1) {
403
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
404
+ return {
405
+ content: [
406
+ {
407
+ type: "text",
408
+ text: [
409
+ "Invalid parameters. Provide exactly one mode:",
410
+ " single: { agent, task }",
411
+ " parallel: { tasks: [...] }",
412
+ " chain: { chain: [...] }",
413
+ `Available agents: ${available}`,
414
+ ].join("\n"),
415
+ },
416
+ ],
417
+ details: makeDetails("single")([]),
418
+ };
419
+ }
420
+
421
+ // Handle project-local agent confirmation
422
+ // Security: confirmation policy comes from trusted config, never from tool params.
423
+ if (agentScope === "project" || agentScope === "both") {
424
+ const requestedAgentNames = new Set<string>();
425
+ if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
426
+ if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
427
+ if (params.agent) requestedAgentNames.add(params.agent);
428
+
429
+ const projectAgentsRequested = Array.from(requestedAgentNames)
430
+ .map((name) => agents.find((a) => a.name === name))
431
+ .filter((a): a is AgentConfig => a?.source === "project");
432
+
433
+ if (projectAgentsRequested.length > 0) {
434
+ if (confirmProjectAgents) {
435
+ if (ctx.hasUI) {
436
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
437
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
438
+ const ok = await ctx.ui.confirm(
439
+ "Run project-local agents?",
440
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
441
+ );
442
+ if (!ok) {
443
+ return {
444
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
445
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
446
+ };
447
+ }
448
+ } else {
449
+ // Fail closed in headless sessions.
450
+ return {
451
+ content: [{
452
+ type: "text",
453
+ text: "Project agents require explicit user approval. "
454
+ + "Enable the trusted project-agent setting to use them in headless mode.",
455
+ }],
456
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
457
+ };
458
+ }
459
+ }
460
+ // else: allowUnconfirmedProjectAgents is true — skip confirmation.
461
+ }
462
+ }
463
+
464
+ const modelRegistry = ctx.modelRegistry;
465
+ const modelRuntime = (modelRegistry as any).runtime;
466
+ const authStorage = (modelRegistry as any).authStorage;
467
+
468
+ // Helper: resolve a safe child working directory.
469
+ function resolveChildCwd(childCwd: string | undefined): string {
470
+ const safe = resolveSafeCwd({ workspaceRoot, childCwd, allowExternalCwd });
471
+ if (safe.error) {
472
+ throw new Error(safe.error);
473
+ }
474
+ return safe.path;
475
+ }
476
+
477
+ // Helper: validate and normalise tools for an agent.
478
+ function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): string[] {
479
+ const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
480
+ let rawTools = agentTools ?? defaultTools;
481
+ // sandbox overrides tools: silently strip mutation tools, not an error
482
+ if (sandbox === "read-only") {
483
+ rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
484
+ if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
485
+ }
486
+ const effectiveReadOnly = readOnly || sandbox === "read-only";
487
+ const result = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly });
488
+ if (result.errors.length > 0) {
489
+ throw new Error(`Tool validation errors: ${result.errors.join("; ")}`);
490
+ }
491
+ return result.tools;
492
+ }
493
+
494
+ // Helper: normalise timeout.
495
+ function resolveChildTimeout(childTimeout: number | undefined, globalTimeout: number | undefined): number | undefined {
496
+ const effectiveTimeout = childTimeout ?? globalTimeout;
497
+ const result = normalizeTimeout({ requested: effectiveTimeout });
498
+ if (result.error) {
499
+ throw new Error(result.error);
500
+ }
501
+ return result.timeoutMs;
502
+ }
503
+
504
+ // Helper: run a single agent via SDK with security validation
505
+ async function runOne(
506
+ agentName: string,
507
+ task: string,
508
+ cwd: string | undefined,
509
+ parentSignal?: AbortSignal,
510
+ timeoutMs?: number,
511
+ onProgress?: (partial: SubAgentResult) => void,
512
+ onActivity?: (progress: SubAgentProgress) => void,
513
+ heartbeatDetails?: () => SubagentDetails,
514
+ onHeartbeat?: () => void,
515
+ isReadOnly?: boolean,
516
+ ): Promise<SubAgentResult> {
517
+ const agent = agents.find((a) => a.name === agentName);
518
+
519
+ if (!agent) {
520
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
521
+ return {
522
+ agent: agentName,
523
+ task,
524
+ exitCode: 1,
525
+ status: "error",
526
+ stopReason: "error",
527
+ messages: [],
528
+ stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
529
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
530
+ errorMessage: `Unknown agent: "${agentName}"`,
531
+ };
532
+ }
533
+
534
+ const resolved = await resolveModel(getModelCandidates(agent), ctx.model, ctx.modelRegistry);
535
+ if (!resolved.model) {
536
+ const tried = resolved.attempted.join(", ") || "none";
537
+ const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
538
+ return {
539
+ agent: agentName,
540
+ task,
541
+ exitCode: 1,
542
+ status: "error",
543
+ stopReason: "error",
544
+ messages: [],
545
+ stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
546
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
547
+ errorMessage: `No model resolved (tried: ${tried})`,
548
+ };
549
+ }
550
+
551
+ // Security: validate tools, timeout, and cwd (wrapped in try/catch).
552
+ let tools: string[];
553
+ let effectiveTimeoutMs: number | undefined;
554
+ let safeCwd: string;
555
+ try {
556
+ tools = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
557
+ effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
558
+ safeCwd = resolveChildCwd(cwd);
559
+ } catch (err: unknown) {
560
+ const errorMsg = err instanceof Error ? err.message : String(err);
561
+ return {
562
+ agent: agentName,
563
+ task,
564
+ exitCode: 1,
565
+ status: "error",
566
+ stopReason: "error",
567
+ messages: [],
568
+ stderr: `Validation error: ${errorMsg}`,
569
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
570
+ errorMessage: errorMsg,
571
+ };
572
+ }
573
+
574
+ // Retry loop: rate-limit model fallback
575
+ const candidates = getModelCandidates(agent);
576
+ const triedModels: string[] = [];
577
+
578
+ const stopHeartbeat = onUpdate ? startHeartbeat(() => {
579
+ onHeartbeat?.();
580
+ onUpdate({ content: [{ type: "text", text: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
581
+ }) : undefined;
582
+ try {
583
+ const tryWithFallback = async (): Promise<SubAgentResult> => {
584
+ const remaining = candidates.filter(m => !triedModels.includes(m));
585
+ const isParentFallback = remaining.length === 0;
586
+ const fallbackResolved = await resolveModel(remaining, ctx.model, ctx.modelRegistry);
587
+ if (!fallbackResolved.model) {
588
+ return {
589
+ agent: agentName,
590
+ task,
591
+ exitCode: 1,
592
+ status: "error" as const,
593
+ stopReason: "error" as const,
594
+ messages: [],
595
+ stderr: [
596
+ `All models rate-limited or unavailable.`,
597
+ `Tried: ${triedModels.join(" → ") || "(none)"}.`,
598
+ `Remaining candidates: ${remaining.join(", ") || "none"}.`,
599
+ `Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
600
+ ].join(" "),
601
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
602
+ errorMessage: `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`,
603
+ };
604
+ }
605
+ const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
606
+ if (triedModels.includes(triedName)) {
607
+ // Already tried this model (e.g., all candidates unavailable
608
+ // and parent fallback) — no further options.
609
+ return {
610
+ agent: agentName,
611
+ task,
612
+ exitCode: 1,
613
+ status: "error" as const,
614
+ stopReason: "error" as const,
615
+ messages: [],
616
+ stderr: [
617
+ `All available models exhausted.`,
618
+ `Tried: ${triedModels.join(" → ")}.`,
619
+ ].join(" "),
620
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
621
+ errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
622
+ };
623
+ }
624
+ triedModels.push(triedName);
625
+ // Also track the raw candidate name so candidates.filter() can
626
+ // exclude it even when the agent uses unqualified names.
627
+ // Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
628
+ if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
629
+ triedModels.push(fallbackResolved.matchedCandidate);
630
+ }
631
+
632
+ const result = await runSubAgent({
633
+ cwd: safeCwd,
634
+ systemPrompt: params.instructions
635
+ ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
636
+ : agent.systemPrompt,
637
+ task,
638
+ tools,
639
+ model: fallbackResolved.model,
640
+ modelRuntime,
641
+ authStorage,
642
+ modelRegistry,
643
+ signal: parentSignal,
644
+ timeoutMs: effectiveTimeoutMs,
645
+ agentName,
646
+ thinkingLevel: agent.thinking,
647
+ onMessage: onProgress,
648
+ onProgress: onActivity,
649
+ });
650
+
651
+ if (result.errorMessage && isRateLimitError(result.errorMessage)) {
652
+ // If the model that just rate-limited was the parent fallback
653
+ // (no remaining candidates), stop — no further options.
654
+ if (isParentFallback) {
655
+ return {
656
+ agent: agentName,
657
+ task,
658
+ exitCode: 1,
659
+ status: "error" as const,
660
+ stopReason: "error" as const,
661
+ messages: [],
662
+ stderr: [
663
+ `All available models exhausted.`,
664
+ `Tried: ${triedModels.join(" → ")}.`,
665
+ ].join(" "),
666
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
667
+ errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
668
+ };
669
+ }
670
+ return tryWithFallback();
671
+ }
672
+ return result;
673
+ };
674
+
675
+ return tryWithFallback();
676
+ } finally {
677
+ stopHeartbeat?.();
678
+ }
679
+ }
680
+
681
+ // --- Chain mode ---
682
+ if (params.chain && params.chain.length > 0) {
683
+ const results: SubAgentResult[] = [];
684
+ let previousOutput = "";
685
+
686
+ for (let i = 0; i < params.chain.length; i++) {
687
+ const step = params.chain[i];
688
+ const taskWithContext = step.task.replace(/\{previous\}/g, () => previousOutput);
689
+
690
+ const thread = threadStore.createThread({
691
+ agentName: step.agent,
692
+ task: taskWithContext,
693
+ mode: "chain-step",
694
+ toolCallId: _toolCallId,
695
+ color: agentToThemeColor(step.agent),
696
+ });
697
+ const result = await runOne(
698
+ step.agent, taskWithContext, step.cwd,
699
+ signal, step.timeout ?? params.timeout,
700
+ (partial) => threadStore.updateThread(thread.id, { result: partial }),
701
+ (progress) => threadStore.updateProgress(thread.id, progress),
702
+ () => makeDetails("chain")(results),
703
+ () => threadStore.refreshHeartbeat(thread.id),
704
+ );
705
+ threadStore.updateThread(thread.id, {
706
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
707
+ result,
708
+ });
709
+ results.push(result);
710
+
711
+ const isError = isFailedResult(result);
712
+ if (isError) {
713
+ const errorMsg = getResultOutput(result);
714
+ if (onUpdate) {
715
+ onUpdate({
716
+ content: [{ type: "text", text: errorMsg }],
717
+ details: makeDetails("chain")(results),
718
+ });
719
+ }
720
+ // Include successful previous step outputs in the error content
721
+ const prevCount = i;
722
+ let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
723
+ if (prevCount > 0) {
724
+ const prevSummaries = results
725
+ .slice(0, prevCount)
726
+ .map((r, j) => {
727
+ const out = getResultOutput(r).slice(0, 500);
728
+ return `Step ${j + 1} (${r.agent}): ${out}`;
729
+ })
730
+ .join("\n");
731
+ contentText = `Chain stopped at step ${i + 1}/${params.chain.length}. ${prevCount} previous step(s) succeeded:\n\n${prevSummaries}\n\nError at step ${i + 1} (${step.agent}): ${errorMsg}`;
732
+ }
733
+ return {
734
+ content: [{ type: "text", text: contentText }],
735
+ details: makeDetails("chain")(results),
736
+ isError: true,
737
+ };
738
+ }
739
+
740
+ previousOutput = getFinalOutput(result.messages);
741
+
742
+ if (onUpdate) {
743
+ onUpdate({
744
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
745
+ details: makeDetails("chain")(results),
746
+ });
747
+ }
748
+ }
749
+
750
+ const last = results[results.length - 1];
751
+ return {
752
+ content: [
753
+ { type: "text", text: getFinalOutput(last.messages) || "(no output)" },
754
+ ],
755
+ details: makeDetails("chain")(results),
756
+ };
757
+ }
758
+
759
+ // --- Parallel mode ---
760
+ if (params.tasks && params.tasks.length > 0) {
761
+ const abortOnFailure = params.abortOnFailure ?? false;
762
+ const parallelController = new AbortController();
763
+ let abortCause: "parent" | "sibling" | "timeout" | undefined;
764
+ let cleanupParentSignal: (() => void) | undefined;
765
+
766
+ // Link parent abort into parallelController so queued tasks see aborted state
767
+ if (signal) {
768
+ if (signal.aborted) {
769
+ abortCause = "parent";
770
+ parallelController.abort();
771
+ } else {
772
+ const onParentAbort = () => {
773
+ if (!abortCause) abortCause = "parent";
774
+ parallelController.abort();
775
+ };
776
+ signal.addEventListener("abort", onParentAbort, { once: true });
777
+ cleanupParentSignal = () => signal.removeEventListener("abort", onParentAbort);
778
+ }
779
+ }
780
+
781
+ // Wrap all remaining setup + execution so cleanupParentSignal always runs.
782
+ try {
783
+ // Pre-create threads for all parallel tasks
784
+ const parallelThreads = params.tasks.map((t) =>
785
+ threadStore.createThread({
786
+ agentName: t.agent,
787
+ task: t.task,
788
+ mode: "parallel-task",
789
+ toolCallId: _toolCallId,
790
+ color: agentToThemeColor(t.agent),
791
+ }),
792
+ );
793
+
794
+ const allResults: SubAgentResult[] = new Array(params.tasks.length);
795
+ // Initialize placeholder results for streaming
796
+ for (let i = 0; i < params.tasks.length; i++) {
797
+ allResults[i] = {
798
+ agent: params.tasks[i].agent,
799
+ task: params.tasks[i].task,
800
+ exitCode: -1,
801
+ messages: [],
802
+ stderr: "",
803
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
804
+ };
805
+ }
806
+
807
+ const emitParallelUpdate = () => {
808
+ if (onUpdate) {
809
+ const running = allResults.filter((r) => r.exitCode === -1).length;
810
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
811
+ onUpdate({
812
+ content: [
813
+ {
814
+ type: "text",
815
+ text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
816
+ },
817
+ ],
818
+ details: makeDetails("parallel")([...allResults]),
819
+ });
820
+ }
821
+ };
822
+
823
+ const results = await mapWithConcurrencyLimit(
824
+ params.tasks,
825
+ MAX_CONCURRENCY,
826
+ async (t, index) => {
827
+ // Skip if already aborted by sibling failure or parent abort
828
+ if (parallelController.signal.aborted) {
829
+ const skippedResult: SubAgentResult = {
830
+ agent: t.agent,
831
+ task: t.task,
832
+ exitCode: 1,
833
+ status: "error",
834
+ messages: [],
835
+ stderr: "",
836
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
837
+ stopReason: "aborted",
838
+ errorMessage:
839
+ abortCause === "sibling"
840
+ ? "Cancelled: sibling task failed"
841
+ : abortCause === "timeout"
842
+ ? "Cancelled: sibling task timed out"
843
+ : "Cancelled: parent operation aborted",
844
+ };
845
+ allResults[index] = skippedResult;
846
+ threadStore.updateThread(parallelThreads[index].id, {
847
+ status: "aborted",
848
+ result: skippedResult,
849
+ });
850
+ emitParallelUpdate();
851
+ return skippedResult;
852
+ }
853
+ const result = await runOne(
854
+ t.agent, t.task, t.cwd,
855
+ parallelController.signal, t.timeout ?? params.timeout,
856
+ (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
857
+ (progress) => threadStore.updateProgress(parallelThreads[index].id, progress),
858
+ () => makeDetails("parallel")([...allResults]),
859
+ () => threadStore.refreshHeartbeat(parallelThreads[index].id),
860
+ );
861
+ allResults[index] = result;
862
+ threadStore.updateThread(parallelThreads[index].id, {
863
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
864
+ result,
865
+ });
866
+ // Early-abort: if this task failed and abortOnFailure is set
867
+ if (abortOnFailure && isFailedResult(result) && !abortCause) {
868
+ abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
869
+ parallelController.abort();
870
+ }
871
+ emitParallelUpdate();
872
+ return result;
873
+ },
874
+ );
875
+
876
+ const successCount = results.filter((r) => !isFailedResult(r)).length;
877
+ const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
878
+ const summaries = results.map((r) => {
879
+ const output = truncateParallelOutput(getResultOutput(r));
880
+ const status = isFailedResult(r)
881
+ ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
882
+ : "completed";
883
+ return `### [${r.agent}] ${status}\n\n${output}`;
884
+ });
885
+
886
+ let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
887
+ if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
888
+ return {
889
+ content: [
890
+ {
891
+ type: "text",
892
+ text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
893
+ },
894
+ ],
895
+ details: makeDetails("parallel")(results),
896
+ };
897
+ } finally {
898
+ cleanupParentSignal?.();
899
+ }
900
+ }
901
+
902
+ // --- Single mode ---
903
+ if (params.agent && params.task) {
904
+ const thread = threadStore.createThread({
905
+ agentName: params.agent,
906
+ task: params.task,
907
+ mode: "single",
908
+ toolCallId: _toolCallId,
909
+ color: agentToThemeColor(params.agent),
910
+ });
911
+ const result = await runOne(
912
+ params.agent, params.task, params.cwd,
913
+ signal, params.timeout,
914
+ (partial) => threadStore.updateThread(thread.id, { result: partial }),
915
+ (progress) => threadStore.updateProgress(thread.id, progress),
916
+ () => makeDetails("single")([]),
917
+ () => threadStore.refreshHeartbeat(thread.id),
918
+ );
919
+ threadStore.updateThread(thread.id, {
920
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
921
+ result,
922
+ });
923
+ const isError = isFailedResult(result);
924
+
925
+ if (onUpdate) {
926
+ onUpdate({
927
+ content: [
928
+ { type: "text", text: getFinalOutput(result.messages) || "(running...)" },
929
+ ],
930
+ details: makeDetails("single")([result]),
931
+ });
932
+ }
933
+
934
+ if (isError) {
935
+ const errorMsg = getResultOutput(result);
936
+ return {
937
+ content: [
938
+ {
939
+ type: "text",
940
+ text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
941
+ },
942
+ ],
943
+ details: makeDetails("single")([result]),
944
+ isError: true,
945
+ };
946
+ }
947
+
948
+ return {
949
+ content: [
950
+ { type: "text", text: getFinalOutput(result.messages) || "(no output)" },
951
+ ],
952
+ details: makeDetails("single")([result]),
953
+ };
954
+ }
955
+
956
+ // Exhaustiveness check: the modeCount === 1 validation above ensures
957
+ // at least one of the three branches is taken, but TS cannot prove it.
958
+ throw new Error("unreachable");
959
+ },
960
+
961
+ // ------------------------------------------------------------------
962
+ // TUI rendering
963
+ // ------------------------------------------------------------------
964
+
965
+ renderCall(args, theme, _context) {
966
+ const scope: AgentScope = args.agentScope ?? "user";
967
+ const fg = theme.fg.bind(theme);
968
+
969
+ // Chain
970
+ if (args.chain && args.chain.length > 0) {
971
+ let text =
972
+ fg("toolTitle", theme.bold("subagent ")) +
973
+ fg("accent", `chain (${args.chain.length} steps)`) +
974
+ fg("muted", ` [${scope}]`);
975
+ for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
976
+ const step = args.chain[i];
977
+ const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
978
+ const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
979
+ text +=
980
+ "\n " +
981
+ fg("muted", `${i + 1}.`) +
982
+ " " +
983
+ fg(resolveAgentColor(step.agent), step.agent) +
984
+ fg("dim", ` ${preview}`);
985
+ }
986
+ if (args.chain.length > 3)
987
+ text += `\n ${fg("muted", `... +${args.chain.length - 3} more`)}`;
988
+ return new Text(text, 0, 0);
989
+ }
990
+
991
+ // Parallel
992
+ if (args.tasks && args.tasks.length > 0) {
993
+ let text =
994
+ fg("toolTitle", theme.bold("subagent ")) +
995
+ fg("accent", `parallel (${args.tasks.length} tasks)`) +
996
+ fg("muted", ` [${scope}]`);
997
+ for (const t of args.tasks.slice(0, 3)) {
998
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
999
+ text += `\n ${fg(resolveAgentColor(t.agent), t.agent)}${fg("dim", ` ${preview}`)}`;
1000
+ }
1001
+ if (args.tasks.length > 3)
1002
+ text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
1003
+ return new Text(text, 0, 0);
1004
+ }
1005
+
1006
+ // Single
1007
+ const agentName = args.agent || "...";
1008
+ const preview = args.task
1009
+ ? args.task.length > 60
1010
+ ? `${args.task.slice(0, 60)}...`
1011
+ : args.task
1012
+ : "...";
1013
+ let text =
1014
+ fg("toolTitle", theme.bold("subagent ")) +
1015
+ fg(resolveAgentColor(agentName), agentName) +
1016
+ fg("muted", ` [${scope}]`);
1017
+ text += `\n ${fg("dim", preview)}`;
1018
+ return new Text(text, 0, 0);
1019
+ },
1020
+
1021
+ renderResult(result, { expanded }, theme, _context) {
1022
+ const details = result.details as SubagentDetails | undefined;
1023
+ if (!details || details.results.length === 0) {
1024
+ const text = result.content[0];
1025
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
1026
+ }
1027
+
1028
+ const fg = theme.fg.bind(theme);
1029
+ const mdTheme = getMarkdownTheme();
1030
+
1031
+ // --- Single ---
1032
+ if (details.mode === "single" && details.results.length === 1) {
1033
+ const r = details.results[0];
1034
+ return renderSingleResult(r, expanded, theme, resolveAgentColor(r.agent));
1035
+ }
1036
+
1037
+ // --- Chain ---
1038
+ if (details.mode === "chain") {
1039
+ const successCount = details.results.filter((r) => !isFailedResult(r)).length;
1040
+ const icon =
1041
+ successCount === details.results.length
1042
+ ? fg("success", "✓")
1043
+ : fg("error", "✗");
1044
+
1045
+ if (expanded) {
1046
+ const container = new Container();
1047
+ container.addChild(
1048
+ new Text(
1049
+ icon +
1050
+ " " +
1051
+ fg("toolTitle", theme.bold("chain ")) +
1052
+ fg("accent", `${successCount}/${details.results.length} steps`),
1053
+ 0,
1054
+ 0,
1055
+ ),
1056
+ );
1057
+ for (const r of details.results) {
1058
+ container.addChild(new Spacer(1));
1059
+ const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
1060
+ container.addChild(
1061
+ new Text(
1062
+ fg("muted", `─── Step ${r.exitCode !== -1 ? "" : "?"}: `) +
1063
+ fg(resolveAgentColor(r.agent), r.agent) +
1064
+ ` ${stepIcon}`,
1065
+ 0,
1066
+ 0,
1067
+ ),
1068
+ );
1069
+ if (r.errorMessage) {
1070
+ container.addChild(
1071
+ new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
1072
+ );
1073
+ }
1074
+ const finalOutput = getResultOutput(r);
1075
+ if (finalOutput) {
1076
+ container.addChild(new Spacer(1));
1077
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1078
+ }
1079
+ const usageStr = formatUsageStats(r.usage, r.model);
1080
+ if (usageStr)
1081
+ container.addChild(new Text(fg("dim", usageStr), 0, 0));
1082
+ }
1083
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
1084
+ if (totalUsage) {
1085
+ container.addChild(new Spacer(1));
1086
+ container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
1087
+ }
1088
+ return container;
1089
+ }
1090
+
1091
+ let text =
1092
+ icon +
1093
+ " " +
1094
+ fg("toolTitle", theme.bold("chain ")) +
1095
+ fg("accent", `${successCount}/${details.results.length} steps`);
1096
+ for (const r of details.results) {
1097
+ const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
1098
+ const color = resolveAgentColor(r.agent);
1099
+ text += `\n ${stepIcon} ${fg(color, r.agent)}`;
1100
+ }
1101
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
1102
+ if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
1103
+ text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
1104
+ return new Text(text, 0, 0);
1105
+ }
1106
+
1107
+ // --- Parallel ---
1108
+ if (details.mode === "parallel") {
1109
+ const running = details.results.filter((r) => r.exitCode === -1).length;
1110
+ const successCount = details.results.filter(
1111
+ (r) => r.exitCode !== -1 && !isFailedResult(r),
1112
+ ).length;
1113
+ const failCount = details.results.filter(
1114
+ (r) => r.exitCode !== -1 && isFailedResult(r),
1115
+ ).length;
1116
+ const isRunning = running > 0;
1117
+ const icon = isRunning
1118
+ ? fg("warning", "⏳")
1119
+ : failCount > 0
1120
+ ? fg("warning", "◐")
1121
+ : fg("success", "✓");
1122
+ const status = isRunning
1123
+ ? `${successCount + failCount}/${details.results.length} done, ${running} running`
1124
+ : `${successCount}/${details.results.length} tasks`;
1125
+
1126
+ if (expanded && !isRunning) {
1127
+ const container = new Container();
1128
+ container.addChild(
1129
+ new Text(
1130
+ `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`,
1131
+ 0,
1132
+ 0,
1133
+ ),
1134
+ );
1135
+ for (const r of details.results) {
1136
+ container.addChild(new Spacer(1));
1137
+ const taskIcon = isFailedResult(r)
1138
+ ? fg("error", "✗")
1139
+ : fg("success", "✓");
1140
+ container.addChild(
1141
+ new Text(
1142
+ fg("muted", "─── ") + fg(resolveAgentColor(r.agent), r.agent) + ` ${taskIcon}`,
1143
+ 0,
1144
+ 0,
1145
+ ),
1146
+ );
1147
+ container.addChild(
1148
+ new Text(fg("muted", "Task: ") + fg("dim", r.task), 0, 0),
1149
+ );
1150
+ if (r.errorMessage) {
1151
+ container.addChild(
1152
+ new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
1153
+ );
1154
+ }
1155
+ const finalOutput = getResultOutput(r);
1156
+ if (finalOutput) {
1157
+ container.addChild(new Spacer(1));
1158
+ container.addChild(
1159
+ new Markdown(finalOutput.trim(), 0, 0, mdTheme),
1160
+ );
1161
+ }
1162
+ const taskUsage = formatUsageStats(r.usage, r.model);
1163
+ if (taskUsage)
1164
+ container.addChild(new Text(fg("dim", taskUsage), 0, 0));
1165
+ }
1166
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
1167
+ if (totalUsage) {
1168
+ container.addChild(new Spacer(1));
1169
+ container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
1170
+ }
1171
+ return container;
1172
+ }
1173
+
1174
+ let text = `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`;
1175
+ for (const r of details.results) {
1176
+ const taskIcon =
1177
+ r.exitCode === -1
1178
+ ? fg("warning", "⏳")
1179
+ : isFailedResult(r)
1180
+ ? fg("error", "✗")
1181
+ : fg("success", "✓");
1182
+ text += `\n ${taskIcon} ${fg(resolveAgentColor(r.agent), r.agent)}`;
1183
+ }
1184
+ if (!isRunning) {
1185
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
1186
+ if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
1187
+ }
1188
+ if (!expanded) text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
1189
+ return new Text(text, 0, 0);
1190
+ }
1191
+
1192
+ const fallback = result.content[0];
1193
+ return new Text(fallback?.type === "text" ? fallback.text : "(no output)", 0, 0);
1194
+ },
1195
+ });
1196
+ // /agent command — switch between subagent threads.
1197
+ // When a thread is selected, the viewer replaces the main TUI (not overlay).
1198
+ pi.registerCommand("agent", {
1199
+ description: "Switch to a subagent thread to view its work in isolation",
1200
+ handler: async (_args, ctx) => {
1201
+ // Show picker overlay
1202
+ const selectedId = await showAgentPicker(ctx, buildPickerItems(threadStore.getAllThreads()));
1203
+ if (!selectedId) return; // Cancelled — stay in current view
1204
+
1205
+ // Main selected — close viewer if active, return to conversation
1206
+ if (selectedId === "__main__") {
1207
+ if (activeViewerDone) {
1208
+ activeViewerDone();
1209
+ activeViewerDone = null;
1210
+ }
1211
+ return;
1212
+ }
1213
+
1214
+ // Close existing viewer (if any) before opening new one
1215
+ if (activeViewerDone) {
1216
+ activeViewerDone();
1217
+ activeViewerDone = null;
1218
+ }
1219
+
1220
+ // Show thread viewer (re-resolve against current store)
1221
+ const freshThreads = threadStore.getAllThreads();
1222
+ const idx = freshThreads.findIndex((t) => t.id === selectedId);
1223
+ if (idx === -1) {
1224
+ ctx.ui.notify("Selected subagent thread no longer exists.", "warning");
1225
+ return;
1226
+ }
1227
+
1228
+ await showThreadViewer(ctx, freshThreads, idx);
1229
+ },
1230
+ });
1231
+
1232
+ // ---------------------------------------------------------------------------
1233
+ // Module-level viewer state (so /agent can close an active viewer)
1234
+ // ---------------------------------------------------------------------------
1235
+ let activeViewerDone: (() => void) | null = null;
1236
+
1237
+ // ---------------------------------------------------------------------------
1238
+ // Picker helpers (shared between /agent handler and Ctrl+P in viewer)
1239
+ // ---------------------------------------------------------------------------
1240
+
1241
+ interface PickerItem { value: string; label: string; description: string }
1242
+
1243
+ function buildPickerItems(threads: SubagentThread[]): PickerItem[] {
1244
+ const items: PickerItem[] = [
1245
+ { value: "__main__", label: "Main [default]", description: "(current)" },
1246
+ ];
1247
+ for (const t of threads) {
1248
+ let statusIcon: string;
1249
+ switch (t.status) {
1250
+ case "running": statusIcon = "⏳"; break;
1251
+ case "completed": statusIcon = "✓"; break;
1252
+ case "failed": statusIcon = "✗"; break;
1253
+ case "aborted": statusIcon = "✗"; break;
1254
+ }
1255
+ let modeTag = "";
1256
+ if (t.mode === "parallel-task") modeTag = " [parallel]";
1257
+ else if (t.mode === "chain-step") modeTag = " [chain]";
1258
+ const label = `${statusIcon} ${t.agentName}${modeTag}`;
1259
+ const desc = t.task.length > 60 ? `${t.task.slice(0, 57)}...` : t.task;
1260
+ items.push({ value: t.id, label, description: desc });
1261
+ }
1262
+ return items;
1263
+ }
1264
+
1265
+ async function showAgentPicker(
1266
+ ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
1267
+ items: PickerItem[],
1268
+ ): Promise<string | null> {
1269
+ return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
1270
+ const container = new Container();
1271
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1272
+ container.addChild(new Text(theme.fg("accent", theme.bold("Subagents")), 1, 0));
1273
+ container.addChild(new Text(theme.fg("dim", "⌥ + ← previous, ⌥ + → next."), 1, 0));
1274
+
1275
+ const selectList = new SelectList(
1276
+ items.map((it) => ({ value: it.value, label: it.label, description: it.description })),
1277
+ Math.min(items.length + 2, 15),
1278
+ {
1279
+ selectedPrefix: (t: string) => theme.fg("accent", t),
1280
+ selectedText: (t: string) => theme.fg("accent", t),
1281
+ description: (t: string) => theme.fg("muted", t),
1282
+ scrollInfo: (t: string) => theme.fg("dim", t),
1283
+ noMatch: (t: string) => theme.fg("warning", t),
1284
+ },
1285
+ );
1286
+ selectList.onSelect = (item) => done(item.value);
1287
+ selectList.onCancel = () => done(null);
1288
+ container.addChild(selectList);
1289
+
1290
+ container.addChild(new Text(
1291
+ `${theme.fg("dim", "↑↓ navigate · enter select · esc back")}`,
1292
+ 1, 0,
1293
+ ));
1294
+
1295
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1296
+
1297
+ return {
1298
+ render: (w: number) => container.render(w),
1299
+ invalidate: () => container.invalidate(),
1300
+ handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); },
1301
+ };
1302
+ }, { overlay: true });
1303
+ }
1304
+
1305
+ // Helper: show thread viewer as overlay so editor remains visible.
1306
+ // Uses dynamic thread list + store subscriptions for live progress.
1307
+ // Ctrl+P opens picker overlay to jump to any thread.
1308
+ async function showThreadViewer(
1309
+ ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
1310
+ _threads: SubagentThread[],
1311
+ startIndex: number,
1312
+ ): Promise<void> {
1313
+ let currentIndex = startIndex;
1314
+
1315
+ // Resolve thread list dynamically
1316
+ const getThreads = () => threadStore.getAllThreads();
1317
+
1318
+ // Overlay mode: viewer appears above editor, Esc dismisses
1319
+ await ctx.ui.custom<void>((tui: any, theme: any, _kb: any, done: () => void) => {
1320
+ let unsubscribe: (() => void) | undefined;
1321
+ let closed = false;
1322
+
1323
+ const cleanup = () => {
1324
+ if (unsubscribe) {
1325
+ unsubscribe();
1326
+ unsubscribe = undefined;
1327
+ }
1328
+ };
1329
+
1330
+ const close = () => {
1331
+ if (closed) return;
1332
+ closed = true;
1333
+ cleanup();
1334
+ activeViewerDone = null;
1335
+ done();
1336
+ };
1337
+
1338
+ // Track this viewer so /agent can close it before opening a new one
1339
+ activeViewerDone = close;
1340
+
1341
+ function makeCallbacks(): ThreadViewerCallbacks {
1342
+ const list = getThreads();
1343
+ return {
1344
+ onClose: close,
1345
+ onPrev: () => {
1346
+ const current = getThreads();
1347
+ if (currentIndex > 0) {
1348
+ currentIndex--;
1349
+ viewer.setThread(current[currentIndex], makeCallbacks());
1350
+ tui.requestRender();
1351
+ }
1352
+ },
1353
+ onNext: () => {
1354
+ const current = getThreads();
1355
+ if (currentIndex < current.length - 1) {
1356
+ currentIndex++;
1357
+ viewer.setThread(current[currentIndex], makeCallbacks());
1358
+ tui.requestRender();
1359
+ }
1360
+ },
1361
+ hasPrev: currentIndex > 0,
1362
+ hasNext: currentIndex < list.length - 1,
1363
+ };
1364
+ }
1365
+
1366
+ const list = getThreads();
1367
+ if (list.length === 0 || currentIndex < 0 || currentIndex >= list.length) {
1368
+ close();
1369
+ return {
1370
+ render: (_w: number) => [],
1371
+ invalidate: () => {},
1372
+ handleInput: (_data: string) => {},
1373
+ dispose: () => {
1374
+ cleanup();
1375
+ if (activeViewerDone === close) activeViewerDone = null;
1376
+ closed = true;
1377
+ },
1378
+ };
1379
+ }
1380
+
1381
+ const viewer = new ThreadViewer(list[currentIndex], makeCallbacks(), theme);
1382
+ let pickerOpen = false;
1383
+
1384
+ // Subscribe to thread store for live updates (after viewer is created)
1385
+ unsubscribe = threadStore.subscribe(() => {
1386
+ const current = getThreads();
1387
+ if (current.length === 0) {
1388
+ close();
1389
+ return;
1390
+ }
1391
+ currentIndex = Math.min(currentIndex, current.length - 1);
1392
+ viewer.setThread(current[currentIndex], makeCallbacks());
1393
+ tui.requestRender();
1394
+ });
1395
+
1396
+ return {
1397
+ render: (w: number) => viewer.render(w),
1398
+ invalidate: () => viewer.invalidate(),
1399
+ handleInput: (data: string) => {
1400
+ // Ctrl+P opens the picker to jump between threads
1401
+ if (data === "\x10") {
1402
+ if (!pickerOpen) {
1403
+ pickerOpen = true;
1404
+ openThreadPicker().finally(() => { pickerOpen = false; });
1405
+ }
1406
+ return;
1407
+ }
1408
+ viewer.handleInput(data);
1409
+ tui.requestRender();
1410
+ },
1411
+ dispose: () => {
1412
+ cleanup();
1413
+ if (activeViewerDone === close) activeViewerDone = null;
1414
+ closed = true;
1415
+ },
1416
+ };
1417
+
1418
+ // Opens picker overlay on top of viewer to jump to any thread
1419
+ async function openThreadPicker() {
1420
+ const items = buildPickerItems(getThreads());
1421
+ const selectedId = await showAgentPicker(ctx, items);
1422
+ if (!selectedId) return;
1423
+ if (selectedId === "__main__") { close(); return; }
1424
+ const idx = getThreads().findIndex((t) => t.id === selectedId);
1425
+ if (idx >= 0) {
1426
+ currentIndex = idx;
1427
+ viewer.setThread(getThreads()[currentIndex], makeCallbacks());
1428
+ tui.requestRender();
1429
+ }
1430
+ }
1431
+ }, { overlay: true, overlayOptions: { maxHeight: "70%" } }); // Overlay: editor stays visible below
1432
+ }
1433
1433
  }