@ferris1225/pi-subagents 0.28.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +270 -597
- package/agents/explore.md +1 -0
- package/agents/reviewer.md +1 -0
- package/agents/worker.md +1 -1
- package/package.json +1 -1
- package/src/completion.ts +28 -0
- package/src/config.ts +25 -0
- package/src/dispatch.ts +845 -0
- package/src/format.ts +149 -0
- package/src/index.ts +90 -1386
- package/src/models.ts +13 -0
- package/src/monitor.ts +27 -0
- package/src/prompt.ts +10 -0
- package/src/runtime.ts +145 -0
- package/src/setup.ts +50 -0
- package/src/spawn.ts +189 -68
- package/src/tools.ts +409 -0
- package/src/ui.ts +8 -3
- package/src/widget.ts +182 -0
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `subagent` tool: dispatches explore/worker/reviewer agents as isolated pi
|
|
3
|
+
* child processes, single or parallel. Owns the dispatch pipeline: config load
|
|
4
|
+
* + unavailable-model repair, per-run widget tracking, the auto-fix chain
|
|
5
|
+
* (REVIEW_FAIL → worker → re-review), and completion delivery.
|
|
6
|
+
*
|
|
7
|
+
* Vision: a task flagged `vision: true` runs on the configured vision-capable
|
|
8
|
+
* model (config.visionModel); when none is configured it falls back to the main
|
|
9
|
+
* session's current model. If the configured vision model is no longer
|
|
10
|
+
* available, the user is asked (TUI picker) to pick a replacement, which is
|
|
11
|
+
* persisted; outside the TUI it degrades to the main-session model with a
|
|
12
|
+
* warning.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { rm } from "node:fs/promises";
|
|
17
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
import { discoverAgents, type AgentConfig } from "./agents.ts";
|
|
20
|
+
import {
|
|
21
|
+
completionTriggersTurn,
|
|
22
|
+
type CompletionMessageItem,
|
|
23
|
+
} from "./completion.ts";
|
|
24
|
+
import { loadConfig, saveConfig, type SubagentsConfig } from "./config.ts";
|
|
25
|
+
import {
|
|
26
|
+
dispatchFailedResult,
|
|
27
|
+
failedStartResult,
|
|
28
|
+
formatCompletionBlock,
|
|
29
|
+
formatUsage,
|
|
30
|
+
modelLevelTakeoverNote,
|
|
31
|
+
queuedResult,
|
|
32
|
+
} from "./format.ts";
|
|
33
|
+
import {
|
|
34
|
+
buildFixTaskBrief,
|
|
35
|
+
buildReReviewBrief,
|
|
36
|
+
formatChainSummary,
|
|
37
|
+
shouldTriggerFixLoop,
|
|
38
|
+
summarizeChainResult,
|
|
39
|
+
type ChainStep,
|
|
40
|
+
} from "./fixloop.ts";
|
|
41
|
+
import { availableModelRefs, repairUnavailableModelOverrides, resolveVisionModelRef } from "./models.ts";
|
|
42
|
+
import {
|
|
43
|
+
formatTaskSummary,
|
|
44
|
+
formatToolActivity,
|
|
45
|
+
monitor,
|
|
46
|
+
statusIcon,
|
|
47
|
+
type RunChainMeta,
|
|
48
|
+
} from "./monitor.ts";
|
|
49
|
+
import type { SubagentRuntime } from "./runtime.ts";
|
|
50
|
+
import {
|
|
51
|
+
buildFallbackResumeReason,
|
|
52
|
+
buildResumePrompt,
|
|
53
|
+
getResultOutput,
|
|
54
|
+
isFailedResult,
|
|
55
|
+
isModelLevelFailure,
|
|
56
|
+
reviewVerdict,
|
|
57
|
+
runSingleAgentWithModelFallback,
|
|
58
|
+
type SingleResult,
|
|
59
|
+
type SubagentDetails,
|
|
60
|
+
type SubagentLiveEvent,
|
|
61
|
+
} from "./spawn.ts";
|
|
62
|
+
import { promptSelectOne } from "./ui.ts";
|
|
63
|
+
|
|
64
|
+
const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
|
|
65
|
+
|
|
66
|
+
const VISION_DESCRIPTION =
|
|
67
|
+
"Set true when the task may require viewing images (screenshots, mockups, designs) — the sub-agent then runs on the configured vision-capable model, or the main session's current model when none is configured";
|
|
68
|
+
|
|
69
|
+
const TaskItem = Type.Object({
|
|
70
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
71
|
+
task: Type.String({
|
|
72
|
+
...NON_BLANK_TASK_OPTIONS,
|
|
73
|
+
description: "Self-contained task to delegate (the agent has no memory of this conversation)",
|
|
74
|
+
}),
|
|
75
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
76
|
+
vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const SubagentParams = Type.Object({
|
|
80
|
+
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
|
|
81
|
+
task: Type.Optional(
|
|
82
|
+
Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Self-contained task to delegate (single mode)" }),
|
|
83
|
+
),
|
|
84
|
+
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
|
85
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
86
|
+
vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
|
|
87
|
+
resume: Type.Optional(
|
|
88
|
+
Type.Number({
|
|
89
|
+
description:
|
|
90
|
+
"Resume a handed-back run by its id: continue a sub-agent whose model hit a quota/auth limit, picking up its preserved context without re-scanning. Use the run id from a model-level handback message.",
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** True when any dispatched task carries the vision flag. */
|
|
96
|
+
function hasVisionTask(params: { vision?: boolean; tasks?: Array<{ vision?: boolean }> }): boolean {
|
|
97
|
+
return params.vision === true || (params.tasks ?? []).some((t) => t.vision === true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* When the configured vision model is unavailable, ask the user to pick a
|
|
102
|
+
* replacement (TUI) and persist it; outside the TUI, warn and fall back to the
|
|
103
|
+
* main session's model. Returns the repaired vision model (undefined = use the
|
|
104
|
+
* main-session fallback).
|
|
105
|
+
*/
|
|
106
|
+
async function repairVisionModelForDispatch(
|
|
107
|
+
ctx: ExtensionContext,
|
|
108
|
+
config: SubagentsConfig,
|
|
109
|
+
configPath: string,
|
|
110
|
+
): Promise<string | undefined> {
|
|
111
|
+
const configured = config.visionModel?.trim();
|
|
112
|
+
if (!configured) return undefined;
|
|
113
|
+
const refs = availableModelRefs(ctx);
|
|
114
|
+
if (refs.includes(configured)) return configured;
|
|
115
|
+
|
|
116
|
+
if (ctx.mode === "tui" && refs.length > 0) {
|
|
117
|
+
try {
|
|
118
|
+
const picked = await promptSelectOne(
|
|
119
|
+
ctx,
|
|
120
|
+
`Vision model "${configured}" is unavailable. Pick a replacement?`,
|
|
121
|
+
"Type to filter • ↑/↓ • Enter selects • Esc falls back to the main session's model",
|
|
122
|
+
refs.map((ref) => ({ value: ref, label: ref })),
|
|
123
|
+
);
|
|
124
|
+
if (picked !== undefined) {
|
|
125
|
+
try {
|
|
126
|
+
await saveConfig({ ...config, visionModel: picked }, configPath);
|
|
127
|
+
ctx.ui.notify(`Vision model switched to ${picked}.`, "info");
|
|
128
|
+
} catch {
|
|
129
|
+
/* persistence failure is non-fatal; the pick still applies this dispatch */
|
|
130
|
+
}
|
|
131
|
+
return picked;
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
/* a failed picker must never break the dispatch */
|
|
135
|
+
}
|
|
136
|
+
ctx.ui.notify(`Vision model left as "${configured}"; this dispatch runs without the vision override.`, "warning");
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
ctx.ui.notify(
|
|
140
|
+
`Configured vision model "${configured}" is unavailable; this dispatch uses the main session's model.`,
|
|
141
|
+
"warning",
|
|
142
|
+
);
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
147
|
+
pi.registerTool({
|
|
148
|
+
name: "subagent",
|
|
149
|
+
label: "Subagent",
|
|
150
|
+
description: [
|
|
151
|
+
"Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
|
|
152
|
+
"Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
|
|
153
|
+
"Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
|
|
154
|
+
"Resume: pass { resume: <runId> } to continue a run that was handed back after its model hit a quota/auth limit — it picks up the preserved context without re-scanning.",
|
|
155
|
+
"It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
|
|
156
|
+
"Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
|
|
157
|
+
"Results arrive as wake-up messages automatically — you do NOT need to wait. If you must get a result in-turn, subagent_wait is a non-blocking lookup by default (pass timeoutMs to block).",
|
|
158
|
+
"Vision: set vision: true when the task may require viewing images (screenshots, mockups, design files — e.g. frontend work) — the sub-agent then runs on the vision-capable model configured in /subagents-setup, or the main session's current model when none is configured.",
|
|
159
|
+
].join(" "),
|
|
160
|
+
promptSnippet:
|
|
161
|
+
"Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent. Simple tasks: use direct tools, not subagents.",
|
|
162
|
+
promptGuidelines: [
|
|
163
|
+
"Delegate only when an isolated context genuinely pays: broad exploration, a self-contained implementation, or a review gate. Handle simple lookups and one-line edits inline with direct tools — never spawn a sub-agent for them.",
|
|
164
|
+
"Use subagent with agent 'explore' for broad or open-ended code search before large changes; a targeted 'where is X' is a direct grep/read.",
|
|
165
|
+
"Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
|
|
166
|
+
"Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
|
|
167
|
+
"subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
|
|
168
|
+
"Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
|
|
169
|
+
"NEVER sleep or poll, and do NOT call subagent_wait to hold the turn — subagent ends the turn immediately and the result arrives as a message that wakes you automatically (even mid-turn). Ending your turn is the default and the only correct way to wait.",
|
|
170
|
+
"If you must keep the turn for a result, call subagent_wait with an explicit timeoutMs (non-blocking by default) — never bash sleep/timeout to wait for a sub-agent.",
|
|
171
|
+
"When a delegated task may require viewing images (frontend screenshots, mockups, design comparisons), pass vision: true and give the sub-agent the exact image paths — it reads them with its read tool. The sub-agent then runs on the configured vision-capable model, or the main session's current model when none is configured.",
|
|
172
|
+
"When a sub-agent result arrives it is already shown to the user — do NOT restate, paraphrase, or summarize it; reply with only your own conclusion or next action (often just one line), since duplicating the result wastes tokens for nothing.",
|
|
173
|
+
],
|
|
174
|
+
parameters: SubagentParams,
|
|
175
|
+
|
|
176
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
177
|
+
monitor.beginTurn();
|
|
178
|
+
let config = await loadConfig(runtime.configPath);
|
|
179
|
+
// Pick up concurrency changes from /subagents-setup without a restart.
|
|
180
|
+
runtime.backgroundQueue.setConcurrency(config.maxConcurrency);
|
|
181
|
+
const repairedModels = repairUnavailableModelOverrides(ctx, config.agentModels);
|
|
182
|
+
if (repairedModels.changed) {
|
|
183
|
+
config = { ...config, agentModels: repairedModels.agentModels };
|
|
184
|
+
try {
|
|
185
|
+
await saveConfig(config, runtime.configPath);
|
|
186
|
+
ctx.ui.notify(
|
|
187
|
+
repairedModels.fallbackRef
|
|
188
|
+
? `Unavailable sub-agent models switched to ${repairedModels.fallbackRef} and saved to config.`
|
|
189
|
+
: "Unavailable sub-agent model overrides removed; no main-window model is available.",
|
|
190
|
+
"warning",
|
|
191
|
+
);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
ctx.ui.notify(
|
|
194
|
+
`Could not persist repaired sub-agent model config: ${error instanceof Error ? error.message : String(error)}`,
|
|
195
|
+
"warning",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Finished runs leave the widget immediately. Their final findings are sent
|
|
201
|
+
// back as a custom message that automatically starts a follow-up turn.
|
|
202
|
+
const finishRun = (
|
|
203
|
+
runId: number,
|
|
204
|
+
status: "done" | "failed",
|
|
205
|
+
opts?: { silent?: boolean; retain?: boolean },
|
|
206
|
+
): void => {
|
|
207
|
+
monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
|
|
208
|
+
const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
|
|
209
|
+
if (!run) return; // already finished — stay idempotent
|
|
210
|
+
if (opts?.retain) monitor.setRetained(runId, true);
|
|
211
|
+
if (opts?.silent || !runtime.sessionActive) return;
|
|
212
|
+
const icon = status === "done" ? "✓" : "✗";
|
|
213
|
+
ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Live sub-agent activity → concise one-line status ("thinking",
|
|
217
|
+
// "read src/index.ts", ...), never a raw args blob. The live handler only
|
|
218
|
+
// updates widget status; finishing (removeRun + notify) is owned by the
|
|
219
|
+
// queue task / launchInLoop. That keeps a startup retry — which fires a
|
|
220
|
+
// transient "failed" status before relaunching — from ripping the row out
|
|
221
|
+
// early, and lets the queue task decide between delivering a reviewer's
|
|
222
|
+
// result and starting an auto-fix chain (a triggered chain keeps the
|
|
223
|
+
// parent row annotated until it completes).
|
|
224
|
+
const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
|
|
225
|
+
switch (e.kind) {
|
|
226
|
+
case "status":
|
|
227
|
+
// Only update the widget status here. Finishing (removeRun + notify) is
|
|
228
|
+
// owned by the queue task / launchInLoop so that a startup retry — which
|
|
229
|
+
// fires a transient "failed" status before relaunching the child — never
|
|
230
|
+
// rips the row out from under the retry or emits a premature "✗" toast.
|
|
231
|
+
monitor.setStatus(runId, e.status);
|
|
232
|
+
break;
|
|
233
|
+
case "usage":
|
|
234
|
+
monitor.setUsage(runId, e.usage, e.model);
|
|
235
|
+
break;
|
|
236
|
+
case "tool_start":
|
|
237
|
+
monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
|
|
238
|
+
break;
|
|
239
|
+
case "tool_end":
|
|
240
|
+
monitor.recordToolEnd(runId, e.toolName, e.isError);
|
|
241
|
+
break;
|
|
242
|
+
case "thinking":
|
|
243
|
+
monitor.setActivity(runId, "thinking");
|
|
244
|
+
break;
|
|
245
|
+
case "text":
|
|
246
|
+
// A text delta is model output, not a filesystem write.
|
|
247
|
+
monitor.setActivity(runId, "responding");
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const discovery = discoverAgents(ctx.cwd, {
|
|
252
|
+
scope: config.agentScope,
|
|
253
|
+
enabledNames: config.enabledAgents,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Effective model precedence: setup override > current session model > frontmatter default.
|
|
257
|
+
const sessionRef = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
258
|
+
const agents: AgentConfig[] = discovery.agents.map((agent) => ({
|
|
259
|
+
...agent,
|
|
260
|
+
model: config.agentModels[agent.name] ?? sessionRef ?? agent.model,
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
264
|
+
const hasSingle = Boolean(params.agent) && params.task !== undefined;
|
|
265
|
+
// `resume` is its own exclusive mode (it re-dispatches a handed-back run
|
|
266
|
+
// from its preserved session), so it bypasses the single/parallel check.
|
|
267
|
+
const hasResume = typeof params.resume === "number";
|
|
268
|
+
|
|
269
|
+
const makeDetails =
|
|
270
|
+
(mode: "single" | "parallel", background = false) =>
|
|
271
|
+
(results: SingleResult[]): SubagentDetails => ({ mode, results, background });
|
|
272
|
+
|
|
273
|
+
const catalog = agents.map((a) => a.name).join(", ") || "none";
|
|
274
|
+
|
|
275
|
+
if (!hasResume && Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
276
|
+
return {
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: "text",
|
|
280
|
+
text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
details: makeDetails("single")([]),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (hasTasks) {
|
|
288
|
+
const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1;
|
|
289
|
+
if (blankTaskIndex !== -1) {
|
|
290
|
+
return {
|
|
291
|
+
content: [
|
|
292
|
+
{
|
|
293
|
+
type: "text",
|
|
294
|
+
text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`,
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
details: makeDetails("parallel")([]),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
} else if (params.task?.trim().length === 0) {
|
|
301
|
+
return {
|
|
302
|
+
content: [
|
|
303
|
+
{
|
|
304
|
+
type: "text",
|
|
305
|
+
text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`,
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
details: makeDetails("single")([]),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A vision-flagged dispatch with a stale vision model asks the user for a
|
|
313
|
+
// replacement before spawning (the persisted pick also fixes future runs).
|
|
314
|
+
// Runs only after parameter validation, so an invalid call never pops a picker.
|
|
315
|
+
const visionRequested = hasVisionTask(params);
|
|
316
|
+
let visionModel = config.visionModel;
|
|
317
|
+
if (visionRequested && visionModel !== undefined && !availableModelRefs(ctx).includes(visionModel.trim())) {
|
|
318
|
+
visionModel = await repairVisionModelForDispatch(ctx, config, runtime.configPath);
|
|
319
|
+
}
|
|
320
|
+
// Vision-flagged dispatches run on the configured vision model, else the
|
|
321
|
+
// main session's current model (the documented fallback), else the agent's
|
|
322
|
+
// own model as the last resort.
|
|
323
|
+
const visionRef = resolveVisionModelRef(ctx, visionModel);
|
|
324
|
+
const withVision = (agent: AgentConfig, vision: boolean): AgentConfig =>
|
|
325
|
+
vision && visionRef ? { ...agent, model: visionRef } : agent;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Dispatch one agent inside an auto-fix chain: tracked in the widget with a
|
|
329
|
+
* groupId/relationLabel, but NOT delivered through the completion flow — the
|
|
330
|
+
* chain owner assembles and delivers the whole group at the end.
|
|
331
|
+
*/
|
|
332
|
+
const launchInLoop = async (
|
|
333
|
+
agentName: string,
|
|
334
|
+
task: string,
|
|
335
|
+
signal: AbortSignal,
|
|
336
|
+
meta: RunChainMeta,
|
|
337
|
+
vision = false,
|
|
338
|
+
): Promise<{ runId?: number; result: SingleResult }> => {
|
|
339
|
+
const agent = agents.find((candidate) => candidate.name === agentName);
|
|
340
|
+
if (!agent) return { result: failedStartResult(agentName, task, `Unknown agent: "${agentName}".`) };
|
|
341
|
+
// A vision-flagged chain (e.g. a review of UI screenshots) keeps its rounds
|
|
342
|
+
// on the vision model: the fix worker and re-review re-read the same images.
|
|
343
|
+
const effectiveAgent = withVision(agent, vision);
|
|
344
|
+
const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
|
|
345
|
+
const runId = monitor.addRun(agent.name, task, effectiveAgent.model, thinkingLevel, meta);
|
|
346
|
+
const onLive = makeLiveHandler(runId);
|
|
347
|
+
try {
|
|
348
|
+
const result = await runSingleAgentWithModelFallback(
|
|
349
|
+
{
|
|
350
|
+
defaultCwd: ctx.cwd,
|
|
351
|
+
agent: effectiveAgent,
|
|
352
|
+
agentName,
|
|
353
|
+
task,
|
|
354
|
+
thinkingLevel,
|
|
355
|
+
signal,
|
|
356
|
+
onLive,
|
|
357
|
+
makeDetails: makeDetails("single", true),
|
|
358
|
+
idleTimeoutMs: config.idleTimeoutSec * 1000,
|
|
359
|
+
},
|
|
360
|
+
sessionRef,
|
|
361
|
+
);
|
|
362
|
+
// Keep the finished round visible in the widget while the chain is
|
|
363
|
+
// still running, with a one-line summary of what it did; the whole
|
|
364
|
+
// group is dropped when the chain resolves (see removeChainGroup).
|
|
365
|
+
monitor.setSummary(runId, summarizeChainResult(result));
|
|
366
|
+
finishRun(runId, isFailedResult(result) ? "failed" : "done", { retain: true });
|
|
367
|
+
runtime.registerRunResult(runId, result);
|
|
368
|
+
return { runId, result };
|
|
369
|
+
} catch (error) {
|
|
370
|
+
finishRun(runId, "failed", { retain: true });
|
|
371
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
372
|
+
const crashed = {
|
|
373
|
+
...queuedResult(agent, task, thinkingLevel),
|
|
374
|
+
exitCode: 1,
|
|
375
|
+
stderr: errorMessage,
|
|
376
|
+
stopReason: signal.aborted ? "aborted" : "error",
|
|
377
|
+
errorMessage,
|
|
378
|
+
dispatchFailed: true,
|
|
379
|
+
};
|
|
380
|
+
runtime.registerRunResult(runId, crashed);
|
|
381
|
+
return { runId, result: crashed };
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Run the auto-fix chain in the background: worker (briefed with the review's
|
|
387
|
+
* findings) → reviewer re-review, up to maxFixRounds times. The main agent is
|
|
388
|
+
* not woken mid-loop; the full chain is delivered as one group at the end.
|
|
389
|
+
* Failures short-circuit: a crashed worker skips its re-review and delivers.
|
|
390
|
+
* The triggering reviewer's run stays visible in the widget (annotated) until
|
|
391
|
+
* the chain resolves, so the ↳ rows have an obvious parent.
|
|
392
|
+
*/
|
|
393
|
+
/** Drop every widget row belonging to an auto-fix chain; the retained
|
|
394
|
+
* parent row is removed separately (it does not carry the groupId). */
|
|
395
|
+
const removeChainGroup = (groupId: string): void => {
|
|
396
|
+
for (const run of [...monitor.getRuns()]) {
|
|
397
|
+
if (run.groupId === groupId) monitor.removeRun(run.id);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const startFixLoop = (
|
|
402
|
+
initialReviewerResult: SingleResult,
|
|
403
|
+
parentGroupId: string,
|
|
404
|
+
parentRunId: number,
|
|
405
|
+
vision = false,
|
|
406
|
+
): void => {
|
|
407
|
+
runtime.runControllers.set(parentRunId, runtime.backgroundQueue.enqueue(
|
|
408
|
+
async (signal) => {
|
|
409
|
+
const chain: ChainStep[] = [
|
|
410
|
+
{ runId: parentRunId, result: initialReviewerResult, relation: "initial review" },
|
|
411
|
+
];
|
|
412
|
+
let lastReviewer = initialReviewerResult;
|
|
413
|
+
for (let round = 1; round <= config.maxFixRounds; round++) {
|
|
414
|
+
if (!runtime.sessionActive) break;
|
|
415
|
+
const fixBrief = buildFixTaskBrief(lastReviewer, round, config.maxFixRounds);
|
|
416
|
+
const workerStep = await launchInLoop("worker", fixBrief, signal, {
|
|
417
|
+
groupId: parentGroupId,
|
|
418
|
+
relationLabel: `fix round ${round}`,
|
|
419
|
+
}, vision);
|
|
420
|
+
chain.push({ ...workerStep, relation: `fix round ${round}` });
|
|
421
|
+
if (!runtime.sessionActive || isFailedResult(workerStep.result)) break;
|
|
422
|
+
const reReviewBrief = buildReReviewBrief(lastReviewer, round);
|
|
423
|
+
const reviewStep = await launchInLoop("reviewer", reReviewBrief, signal, {
|
|
424
|
+
groupId: parentGroupId,
|
|
425
|
+
relationLabel: `re-review round ${round}`,
|
|
426
|
+
}, vision);
|
|
427
|
+
chain.push({ ...reviewStep, relation: `re-review round ${round}` });
|
|
428
|
+
lastReviewer = reviewStep.result;
|
|
429
|
+
// A crashed re-review must stop the chain like a crashed worker: its
|
|
430
|
+
// output (if any) is not a verdict, and feeding it to the next fix
|
|
431
|
+
// round would brief the worker from garbage.
|
|
432
|
+
if (!runtime.sessionActive || isFailedResult(reviewStep.result)) break;
|
|
433
|
+
if (reviewVerdict(getResultOutput(reviewStep.result)) === "pass") break;
|
|
434
|
+
}
|
|
435
|
+
// The chain is done (success, exhaustion, or abort): drop the retained
|
|
436
|
+
// parent row and its retained round rows, then deliver one condensed
|
|
437
|
+
// summary. Register the parent's final state (the last chain result)
|
|
438
|
+
// before removal so subagent_wait can resolve it.
|
|
439
|
+
runtime.registerRunResult(parentRunId, chain[chain.length - 1].result);
|
|
440
|
+
runtime.runControllers.delete(parentRunId);
|
|
441
|
+
removeChainGroup(parentGroupId);
|
|
442
|
+
monitor.removeRun(parentRunId);
|
|
443
|
+
if (!runtime.sessionActive) return;
|
|
444
|
+
// One compact message instead of every round's raw output: the summary
|
|
445
|
+
// lines cover each step (verdict + what changed/found), and the final
|
|
446
|
+
// step's full report is appended only when its detail is actionable
|
|
447
|
+
// (a FAIL verdict, a crash, or a model-level failure the main agent
|
|
448
|
+
// must take over). Everything else stays one `subagent_status #id`
|
|
449
|
+
// call away.
|
|
450
|
+
const last = chain[chain.length - 1];
|
|
451
|
+
let block = formatChainSummary(chain);
|
|
452
|
+
if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
|
|
453
|
+
if (last.result.sessionDir && last.result.sessionId) {
|
|
454
|
+
runtime.preservedSessions.set(parentRunId, {
|
|
455
|
+
sessionId: last.result.sessionId,
|
|
456
|
+
sessionDir: last.result.sessionDir,
|
|
457
|
+
agentName: last.result.agent,
|
|
458
|
+
task: last.result.task,
|
|
459
|
+
vision,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(last.result, { runId: parentRunId })}`;
|
|
463
|
+
} else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
|
|
464
|
+
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}`;
|
|
465
|
+
}
|
|
466
|
+
runtime.sendCompletionGroup([
|
|
467
|
+
{
|
|
468
|
+
agent: `auto-fix chain (${last.result.agent})`,
|
|
469
|
+
block,
|
|
470
|
+
triggerTurn: true,
|
|
471
|
+
},
|
|
472
|
+
]);
|
|
473
|
+
runtime.completionBatcher.flush();
|
|
474
|
+
},
|
|
475
|
+
() => {
|
|
476
|
+
// Cancelled before delivery: clean up the retained parent row and
|
|
477
|
+
// every retained chain row (each in-flight chain run was already
|
|
478
|
+
// finished by its launchInLoop path).
|
|
479
|
+
runtime.runControllers.delete(parentRunId);
|
|
480
|
+
removeChainGroup(parentGroupId);
|
|
481
|
+
monitor.removeRun(parentRunId);
|
|
482
|
+
},
|
|
483
|
+
(error) => {
|
|
484
|
+
// A crash inside the chain orchestration (failed runs are caught by
|
|
485
|
+
// launchInLoop and delivered as part of the chain) must not vanish:
|
|
486
|
+
// drop the retained rows, notify, and deliver a failed result
|
|
487
|
+
// so the main agent knows the chain never completed.
|
|
488
|
+
runtime.registerRunResult(parentRunId, initialReviewerResult);
|
|
489
|
+
runtime.runControllers.delete(parentRunId);
|
|
490
|
+
removeChainGroup(parentGroupId);
|
|
491
|
+
monitor.removeRun(parentRunId);
|
|
492
|
+
if (!runtime.sessionActive) return;
|
|
493
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
494
|
+
try {
|
|
495
|
+
ctx.ui.notify(`✗ auto-fix chain dispatch failed: ${errorMessage}`, "error");
|
|
496
|
+
// Keep the triggering review's findings: the chain crashed before any
|
|
497
|
+
// fix round ran, and the main agent needs the review to act on it.
|
|
498
|
+
runtime.sendCompletionGroup([
|
|
499
|
+
{
|
|
500
|
+
agent: initialReviewerResult.agent,
|
|
501
|
+
block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines, ctx.cwd)}\n\nAuto-fix chain crashed before completion: ${errorMessage}. The planned fix rounds did not run; the review above is the triggering reviewer's full output.`,
|
|
502
|
+
triggerTurn: true,
|
|
503
|
+
},
|
|
504
|
+
]);
|
|
505
|
+
runtime.completionBatcher.flush();
|
|
506
|
+
} catch {
|
|
507
|
+
/* a second delivery failure must not throw through the queue */
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
));
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const startBackground = (
|
|
514
|
+
agentName: string,
|
|
515
|
+
task: string,
|
|
516
|
+
cwd?: string,
|
|
517
|
+
vision = false,
|
|
518
|
+
resumeSession?: { sessionId: string; sessionDir: string; preservedRunId: number },
|
|
519
|
+
): SingleResult => {
|
|
520
|
+
const agent = agents.find((candidate) => candidate.name === agentName);
|
|
521
|
+
if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
522
|
+
// A vision-flagged task runs on the configured vision model (or the main
|
|
523
|
+
// session's current model), overriding the agent's own model — the
|
|
524
|
+
// per-agent model may not support images.
|
|
525
|
+
const effectiveAgent = withVision(agent, vision);
|
|
526
|
+
|
|
527
|
+
// Effective strength: config override > agent frontmatter default > global default.
|
|
528
|
+
const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
|
|
529
|
+
const pending = queuedResult(effectiveAgent, task, thinkingLevel);
|
|
530
|
+
const runId = monitor.addRun(agent.name, task, effectiveAgent.model, thinkingLevel);
|
|
531
|
+
// Only a main-agent-dispatched reviewer can trigger an auto-fix chain, so
|
|
532
|
+
// only its finish is deferred to the queue task (see startFixLoop).
|
|
533
|
+
const onLive = makeLiveHandler(runId);
|
|
534
|
+
|
|
535
|
+
runtime.runControllers.set(runId, runtime.backgroundQueue.enqueue(
|
|
536
|
+
async (backgroundSignal) => {
|
|
537
|
+
let result: SingleResult;
|
|
538
|
+
try {
|
|
539
|
+
result = await runSingleAgentWithModelFallback(
|
|
540
|
+
{
|
|
541
|
+
defaultCwd: ctx.cwd,
|
|
542
|
+
agent: effectiveAgent,
|
|
543
|
+
agentName,
|
|
544
|
+
task,
|
|
545
|
+
cwd,
|
|
546
|
+
thinkingLevel,
|
|
547
|
+
signal: backgroundSignal,
|
|
548
|
+
onLive,
|
|
549
|
+
makeDetails: makeDetails("single", true),
|
|
550
|
+
idleTimeoutMs: config.idleTimeoutSec * 1000,
|
|
551
|
+
// A resume reuses a preserved session (handed back after a
|
|
552
|
+
// model-level failure) so it continues in-context instead of
|
|
553
|
+
// re-scanning. The wrapper detects the existing session file and
|
|
554
|
+
// resumes it; the continuation prompt steers the model to pick up.
|
|
555
|
+
...(resumeSession
|
|
556
|
+
? {
|
|
557
|
+
sessionId: resumeSession.sessionId,
|
|
558
|
+
sessionDir: resumeSession.sessionDir,
|
|
559
|
+
stdinText: buildResumePrompt(task, buildFallbackResumeReason()),
|
|
560
|
+
}
|
|
561
|
+
: {}),
|
|
562
|
+
},
|
|
563
|
+
sessionRef,
|
|
564
|
+
);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
567
|
+
result = {
|
|
568
|
+
...pending,
|
|
569
|
+
exitCode: 1,
|
|
570
|
+
stderr: errorMessage,
|
|
571
|
+
stopReason: backgroundSignal.aborted ? "aborted" : "error",
|
|
572
|
+
errorMessage,
|
|
573
|
+
dispatchFailed: true,
|
|
574
|
+
};
|
|
575
|
+
// The dedicated dispatch-failure notification below replaces the generic
|
|
576
|
+
// failure toast for dispatch crashes, so finish silently here.
|
|
577
|
+
finishRun(runId, "failed", { silent: true });
|
|
578
|
+
runtime.registerRunResult(runId, result);
|
|
579
|
+
runtime.runControllers.delete(runId);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (!runtime.sessionActive) return;
|
|
583
|
+
// Auto-fix loop: a REVIEW_FAIL from a main-agent-dispatched reviewer
|
|
584
|
+
// triggers a worker→reviewer chain (up to maxFixRounds) without waking
|
|
585
|
+
// the main agent. Loop-internal re-reviews never reach here (they are
|
|
586
|
+
// awaited inside launchInLoop); the initial review is delivered with
|
|
587
|
+
// the chain at the end. While the chain runs, the triggering review
|
|
588
|
+
// stays in the widget (annotated) so the chain rows have an obvious
|
|
589
|
+
// parent; no premature "done" notification is shown.
|
|
590
|
+
if (shouldTriggerFixLoop(result, config)) {
|
|
591
|
+
// The session is known active here (checked above), so the chain
|
|
592
|
+
// always starts: keep the triggering review in the widget
|
|
593
|
+
// (annotated) without a premature "done" notification, and let
|
|
594
|
+
// startFixLoop deliver the whole chain and drop the parent row.
|
|
595
|
+
finishRun(runId, "done", { silent: true, retain: true });
|
|
596
|
+
monitor.setAnnotation(runId, "auto-fix chain running");
|
|
597
|
+
startFixLoop(result, `fix-${runId}`, runId, vision);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const failed = isFailedResult(result);
|
|
601
|
+
// Model-level failures and dispatch crashes get their own dedicated
|
|
602
|
+
// dispatch-failure notification below, so finishRun's generic failure toast is
|
|
603
|
+
// silenced for them (computed before finishRun for that reason).
|
|
604
|
+
const modelLevel = failed && isModelLevelFailure(result);
|
|
605
|
+
const dispatchFailed = result.dispatchFailed === true;
|
|
606
|
+
finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
|
|
607
|
+
// Register before delivery so a concurrent subagent_wait resolves with
|
|
608
|
+
// the result even though the run row is already gone from the monitor.
|
|
609
|
+
runtime.registerRunResult(runId, result);
|
|
610
|
+
runtime.runControllers.delete(runId);
|
|
611
|
+
// A successful resume consumed the preserved session: reclaim its temp
|
|
612
|
+
// dir and drop the id so it cannot be re-resumed. A failed resume keeps
|
|
613
|
+
// it (still filed under the original preserved run id) for another try.
|
|
614
|
+
if (resumeSession && !failed) {
|
|
615
|
+
runtime.preservedSessions.delete(resumeSession.preservedRunId);
|
|
616
|
+
void rm(resumeSession.sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
617
|
+
}
|
|
618
|
+
if (!runtime.sessionActive) return;
|
|
619
|
+
// A model-level failure that preserved a session (the run did real work
|
|
620
|
+
// before the model quota/auth broke) files it under this run id so a
|
|
621
|
+
// later `subagent({ resume: <runId> })` can continue in-context. Skipped
|
|
622
|
+
// for a resume run — its session is already filed under the original id.
|
|
623
|
+
if (modelLevel && !resumeSession && result.sessionDir && result.sessionId) {
|
|
624
|
+
runtime.preservedSessions.set(runId, {
|
|
625
|
+
sessionId: result.sessionId,
|
|
626
|
+
sessionDir: result.sessionDir,
|
|
627
|
+
agentName: agent.name,
|
|
628
|
+
task,
|
|
629
|
+
vision,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
// Model-level failure: the configured model is unavailable or broke
|
|
633
|
+
// and the resume on the main-window model (when distinct) also failed.
|
|
634
|
+
// Hand the task back; when a session was preserved, steer the main agent
|
|
635
|
+
// to resume it in-context instead of executing it fresh.
|
|
636
|
+
const handbackRunId = resumeSession ? resumeSession.preservedRunId : runId;
|
|
637
|
+
const completion: CompletionMessageItem = {
|
|
638
|
+
agent: result.agent,
|
|
639
|
+
block: modelLevel
|
|
640
|
+
? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result, { runId: handbackRunId })}`
|
|
641
|
+
: formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
|
|
642
|
+
triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
|
|
643
|
+
};
|
|
644
|
+
if (modelLevel) {
|
|
645
|
+
ctx.ui.notify(`✗ ${result.agent} dispatch failed: model unavailable or broken — task handed to the main window`, "error");
|
|
646
|
+
} else if (dispatchFailed) {
|
|
647
|
+
// An exception inside the dispatch layer (spawn infra, temp-file/fs
|
|
648
|
+
// errors, ...): the main agent must know so it can re-dispatch.
|
|
649
|
+
ctx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
|
|
650
|
+
}
|
|
651
|
+
if (failed) {
|
|
652
|
+
// Failures never wait and never hide behind a success turn: deliver
|
|
653
|
+
// first so the wake-up leads with the failure; held successes follow.
|
|
654
|
+
runtime.sendCompletionGroup([completion]);
|
|
655
|
+
runtime.completionBatcher.flush();
|
|
656
|
+
} else {
|
|
657
|
+
runtime.completionBatcher.push(completion);
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
() => {
|
|
661
|
+
runtime.runControllers.delete(runId);
|
|
662
|
+
finishRun(runId, "failed");
|
|
663
|
+
},
|
|
664
|
+
(error) => {
|
|
665
|
+
// The task body converts sub-agent failures into delivered results; an
|
|
666
|
+
// exception escaping it (spawn infra, delivery API, ...) must not
|
|
667
|
+
// vanish: notify the user and deliver a failed result so the main
|
|
668
|
+
// agent knows the dispatch failed and can re-dispatch.
|
|
669
|
+
const crashed = dispatchFailedResult(agent, task, error, thinkingLevel);
|
|
670
|
+
finishRun(runId, "failed", { silent: true });
|
|
671
|
+
runtime.registerRunResult(runId, crashed);
|
|
672
|
+
runtime.runControllers.delete(runId);
|
|
673
|
+
if (!runtime.sessionActive) return;
|
|
674
|
+
try {
|
|
675
|
+
ctx.ui.notify(`✗ ${agent.name} dispatch failed: ${crashed.errorMessage}`, "error");
|
|
676
|
+
runtime.sendCompletionGroup([
|
|
677
|
+
{
|
|
678
|
+
agent: agent.name,
|
|
679
|
+
block: formatCompletionBlock(crashed, config.maxResultLines, ctx.cwd),
|
|
680
|
+
triggerTurn: true,
|
|
681
|
+
},
|
|
682
|
+
]);
|
|
683
|
+
runtime.completionBatcher.flush();
|
|
684
|
+
} catch {
|
|
685
|
+
/* a second delivery failure must not throw through the queue */
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
));
|
|
689
|
+
|
|
690
|
+
return pending;
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
// Resume mode (exclusive): continue a handed-back run in its preserved
|
|
694
|
+
// session on the agent's configured model, picking up the prior context
|
|
695
|
+
// instead of re-scanning. Triggered by a model-level handback that named
|
|
696
|
+
// the run id, after the user has a working model again.
|
|
697
|
+
if (typeof params.resume === "number") {
|
|
698
|
+
const preserved = runtime.preservedSessions.get(params.resume);
|
|
699
|
+
if (!preserved) {
|
|
700
|
+
return {
|
|
701
|
+
content: [
|
|
702
|
+
{
|
|
703
|
+
type: "text",
|
|
704
|
+
text: `No preservable session for run #${params.resume}. It completed normally, was not a model-level handback, or the session has ended.`,
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
details: makeDetails("single")([]),
|
|
708
|
+
isError: true,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
const resumeAgent = agents.find((a) => a.name === preserved.agentName);
|
|
712
|
+
if (!resumeAgent) {
|
|
713
|
+
return {
|
|
714
|
+
content: [
|
|
715
|
+
{
|
|
716
|
+
type: "text",
|
|
717
|
+
text: `Cannot resume run #${params.resume}: agent "${preserved.agentName}" is not enabled. Re-enable it (or run /subagents-setup) and resume again.`,
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
details: makeDetails("single")([]),
|
|
721
|
+
isError: true,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
const pending = startBackground(preserved.agentName, preserved.task, undefined, preserved.vision, {
|
|
725
|
+
sessionId: preserved.sessionId,
|
|
726
|
+
sessionDir: preserved.sessionDir,
|
|
727
|
+
preservedRunId: params.resume,
|
|
728
|
+
});
|
|
729
|
+
if (pending.exitCode !== -1) {
|
|
730
|
+
return {
|
|
731
|
+
content: [{ type: "text", text: getResultOutput(pending) }],
|
|
732
|
+
details: makeDetails("single")([pending]),
|
|
733
|
+
isError: true,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
content: [
|
|
738
|
+
{
|
|
739
|
+
type: "text",
|
|
740
|
+
text: `Resuming ${preserved.agentName} (run #${params.resume}) in the background on its configured model, picking up its preserved context. Its result will automatically resume the main agent when ready.`,
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
details: makeDetails("single", true)([pending]),
|
|
744
|
+
terminate: true,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Sub-agents intentionally detach from the foreground turn. This makes the
|
|
749
|
+
// editor available immediately; completion messages later wake the main agent.
|
|
750
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
751
|
+
if (params.tasks.length > config.maxConcurrency) {
|
|
752
|
+
return {
|
|
753
|
+
content: [
|
|
754
|
+
{
|
|
755
|
+
type: "text",
|
|
756
|
+
text: `Too many parallel tasks (${params.tasks.length}). Max is ${config.maxConcurrency} (configurable via /subagents-setup).`,
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
details: makeDetails("parallel", true)([]),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const results = params.tasks.map((task) => startBackground(task.agent, task.task, task.cwd, task.vision === true));
|
|
764
|
+
const started = results.filter((result) => result.exitCode === -1).length;
|
|
765
|
+
const failures = results.filter((result) => result.exitCode !== -1);
|
|
766
|
+
return {
|
|
767
|
+
content: [
|
|
768
|
+
{
|
|
769
|
+
type: "text",
|
|
770
|
+
text:
|
|
771
|
+
started > 0
|
|
772
|
+
? `Started ${started} background subagent${started === 1 ? "" : "s"}. Results will automatically resume the main agent when ready.`
|
|
773
|
+
: failures.map((result) => getResultOutput(result)).join("\n"),
|
|
774
|
+
},
|
|
775
|
+
],
|
|
776
|
+
details: makeDetails("parallel", true)(results),
|
|
777
|
+
isError: failures.length > 0,
|
|
778
|
+
terminate: true,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const result = startBackground(params.agent as string, params.task as string, params.cwd, params.vision === true);
|
|
783
|
+
if (result.exitCode !== -1) {
|
|
784
|
+
return {
|
|
785
|
+
content: [{ type: "text", text: getResultOutput(result) }],
|
|
786
|
+
details: makeDetails("single")([result]),
|
|
787
|
+
isError: true,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
content: [{ type: "text", text: `Started ${result.agent} in the background. Its result will automatically resume the main agent when ready.` }],
|
|
792
|
+
details: makeDetails("single", true)([result]),
|
|
793
|
+
terminate: true,
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
renderCall(args, theme) {
|
|
799
|
+
if (args.tasks && args.tasks.length > 0) {
|
|
800
|
+
let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
|
|
801
|
+
for (const t of args.tasks.slice(0, 4)) {
|
|
802
|
+
const preview = formatTaskSummary(t.task, 48);
|
|
803
|
+
text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
|
|
804
|
+
}
|
|
805
|
+
if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
|
|
806
|
+
return new Text(text, 0, 0);
|
|
807
|
+
}
|
|
808
|
+
const task: string = args.task ?? "";
|
|
809
|
+
const preview = formatTaskSummary(task, 60);
|
|
810
|
+
return new Text(
|
|
811
|
+
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
|
|
812
|
+
0,
|
|
813
|
+
0,
|
|
814
|
+
);
|
|
815
|
+
},
|
|
816
|
+
|
|
817
|
+
renderResult(result, _options, theme) {
|
|
818
|
+
const details = result.details as SubagentDetails | undefined;
|
|
819
|
+
if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
|
|
820
|
+
|
|
821
|
+
if (details.mode === "single") {
|
|
822
|
+
const r = details.results[0];
|
|
823
|
+
const pending = r.exitCode === -1;
|
|
824
|
+
const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
|
|
825
|
+
const usage = formatUsage(r.usage);
|
|
826
|
+
const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (fell back from ${r.modelFallbackFrom})` : ""}`;
|
|
827
|
+
const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
|
|
828
|
+
return new Text(line, 0, 0);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Parallel mode: header + one compact line per agent
|
|
832
|
+
const lines: string[] = [
|
|
833
|
+
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
|
|
834
|
+
];
|
|
835
|
+
for (const r of details.results) {
|
|
836
|
+
const pending = r.exitCode === -1;
|
|
837
|
+
const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
|
|
838
|
+
const usage = formatUsage(r.usage);
|
|
839
|
+
const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (fell back from ${r.modelFallbackFrom})` : ""}`;
|
|
840
|
+
lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
|
|
841
|
+
}
|
|
842
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
}
|