@ferris1225/pi-subagents 0.27.0 → 0.29.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 +265 -587
- 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/config.ts +25 -0
- package/src/dispatch.ts +729 -0
- package/src/format.ts +142 -0
- package/src/index.ts +90 -1382
- package/src/models.ts +13 -0
- package/src/prompt.ts +11 -3
- package/src/runtime.ts +110 -0
- package/src/setup.ts +50 -0
- package/src/tools.ts +406 -0
- package/src/ui.ts +8 -3
- package/src/widget.ts +178 -0
package/src/index.ts
CHANGED
|
@@ -1,1382 +1,90 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-subagents — focused sub-agent delegation for pi.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from "./
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
stderr: "",
|
|
92
|
-
usage: emptyUsage(),
|
|
93
|
-
model: agent.model,
|
|
94
|
-
...(thinking ? { thinking } : {}),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
|
|
99
|
-
return {
|
|
100
|
-
agent: agentName,
|
|
101
|
-
agentSource: "unknown",
|
|
102
|
-
task,
|
|
103
|
-
exitCode: 1,
|
|
104
|
-
messages: [],
|
|
105
|
-
stderr: errorMessage,
|
|
106
|
-
usage: emptyUsage(),
|
|
107
|
-
errorMessage,
|
|
108
|
-
dispatchFailed: true,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Failed result for a background task that crashed with an exception (spawn
|
|
113
|
-
* infra, delivery API, ...) instead of returning a normal result. */
|
|
114
|
-
function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
|
|
115
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
116
|
-
return {
|
|
117
|
-
...queuedResult(agent, task, thinking),
|
|
118
|
-
exitCode: 1,
|
|
119
|
-
stderr: errorMessage,
|
|
120
|
-
stopReason: "error",
|
|
121
|
-
errorMessage,
|
|
122
|
-
dispatchFailed: true,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function aggregateUsage(results: SingleResult[]): UsageStats {
|
|
127
|
-
const total = emptyUsage();
|
|
128
|
-
for (const r of results) {
|
|
129
|
-
total.input += r.usage.input;
|
|
130
|
-
total.output += r.usage.output;
|
|
131
|
-
total.cacheRead += r.usage.cacheRead;
|
|
132
|
-
total.cacheWrite += r.usage.cacheWrite;
|
|
133
|
-
total.cost += r.usage.cost;
|
|
134
|
-
total.turns += r.usage.turns;
|
|
135
|
-
}
|
|
136
|
-
return total;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function formatTokens(count: number): string {
|
|
140
|
-
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
141
|
-
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
142
|
-
return String(count);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function formatUsage(usage: UsageStats): string {
|
|
146
|
-
const parts: string[] = [];
|
|
147
|
-
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
148
|
-
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
149
|
-
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
150
|
-
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
151
|
-
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
152
|
-
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
153
|
-
return parts.join(" ");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd?: string): string {
|
|
157
|
-
const failed = isFailedResult(result);
|
|
158
|
-
const failedTools = result.failedTools ?? [];
|
|
159
|
-
const status = failed
|
|
160
|
-
? "failed"
|
|
161
|
-
: failedTools.length > 0
|
|
162
|
-
? `completed with ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}`
|
|
163
|
-
: "completed";
|
|
164
|
-
const usage = formatUsage(result.usage);
|
|
165
|
-
const output = getResultOutput(result);
|
|
166
|
-
const { text, truncated } = truncateResultOutput(output, maxResultLines);
|
|
167
|
-
const fallbackNote = result.modelFallbackFrom
|
|
168
|
-
? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
|
|
169
|
-
: "";
|
|
170
|
-
const startupRetryNote = result.startupRetries
|
|
171
|
-
? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
|
|
172
|
-
: "";
|
|
173
|
-
const modelRetryNote = result.modelRetries
|
|
174
|
-
? ` (recovered after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on a transient provider error)`
|
|
175
|
-
: "";
|
|
176
|
-
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
|
|
177
|
-
// A run can exit cleanly while its last tools failed (e.g. a build that broke):
|
|
178
|
-
// the final text alone may claim more than the tools achieved, so surface the
|
|
179
|
-
// failures explicitly and tell the main agent to verify before relying on it.
|
|
180
|
-
if (!failed && failedTools.length > 0) {
|
|
181
|
-
const shown = failedTools.slice(0, 3);
|
|
182
|
-
const more = failedTools.length - shown.length;
|
|
183
|
-
lines.push(
|
|
184
|
-
"",
|
|
185
|
-
`⚠ ${failedTools.length} tool call${failedTools.length === 1 ? "" : "s"} failed during this run — the final text above may not reflect a working state:`,
|
|
186
|
-
...shown.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
|
|
187
|
-
);
|
|
188
|
-
if (more > 0) lines.push(`- … and ${more} more`);
|
|
189
|
-
lines.push("Verify the actual artifacts before relying on this report.");
|
|
190
|
-
}
|
|
191
|
-
if (truncated) {
|
|
192
|
-
// The full text lives on disk so the main agent can read it on demand.
|
|
193
|
-
lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
|
|
194
|
-
}
|
|
195
|
-
return lines.join("\n");
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Instruction appended to a model-level failure: the sub-agent's provider never
|
|
199
|
-
* produced usable output (or the run stalled), so the task is handed back to the
|
|
200
|
-
* main window instead of being left as a dead failure. */
|
|
201
|
-
function modelLevelTakeoverNote(result: SingleResult): string {
|
|
202
|
-
const sameModel = result.modelRetries
|
|
203
|
-
? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
|
|
204
|
-
: "";
|
|
205
|
-
const retry = result.modelFallbackFrom ? ", and the retry with the main-window model also failed" : "";
|
|
206
|
-
return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}. Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Resolve a run-id request to actual ids: an exact numeric match always wins
|
|
210
|
-
* (so "1" never fans out to 10, 11, …); only when no exact match exists does a
|
|
211
|
-
* prefix match run, as a convenience for partial ids. Keeps single-digit lookups
|
|
212
|
-
* from returning — or, for subagent_stop, acting on — a whole prefix family. */
|
|
213
|
-
export function matchRunIds(ids: number[], requested: string): number[] {
|
|
214
|
-
const exact = ids.filter((id) => String(id) === requested);
|
|
215
|
-
if (exact.length > 0) return exact;
|
|
216
|
-
return ids.filter((id) => String(id).startsWith(requested));
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export default function (pi: ExtensionAPI): void {
|
|
220
|
-
const configPath = getConfigPath(getAgentDir());
|
|
221
|
-
// Init-time decisions need the config synchronously; the full (migrating)
|
|
222
|
-
// async load runs per tool call.
|
|
223
|
-
const initialConfig = loadConfigSync(configPath);
|
|
224
|
-
const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
|
|
225
|
-
let sessionActive = true;
|
|
226
|
-
const sendCompletionGroup = (items: CompletionMessageItem[]): void => {
|
|
227
|
-
if (!sessionActive || items.length === 0) return;
|
|
228
|
-
const message = {
|
|
229
|
-
customType: "subagent-result",
|
|
230
|
-
content: formatCompletionMessage(items),
|
|
231
|
-
display: true,
|
|
232
|
-
};
|
|
233
|
-
if (completionGroupTriggersTurn(items)) {
|
|
234
|
-
// steer: the result is injected after the current tool call even mid-turn, or
|
|
235
|
-
// starts a new turn when idle. followUp would sit in the queue until the whole
|
|
236
|
-
// turn ends — a main agent waiting for the result (sleep/poll) would never see
|
|
237
|
-
// it delivered, which is exactly the "returned but never woken" failure mode.
|
|
238
|
-
pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
|
|
239
|
-
} else {
|
|
240
|
-
// No-wake delivery: nextTurn rides along with the next user turn and can
|
|
241
|
-
// never start a continuation by itself. followUp would auto-continue
|
|
242
|
-
// whenever pi is already streaming, defeating the opt-out.
|
|
243
|
-
pi.sendMessage(message, { deliverAs: "nextTurn" });
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
const completionBatcher = createCompletionBatcher<CompletionMessageItem>({ emit: sendCompletionGroup });
|
|
247
|
-
|
|
248
|
-
// Abort controllers per active run, so subagent_stop can cancel a run in-turn.
|
|
249
|
-
const runControllers = new Map<number, AbortController>();
|
|
250
|
-
|
|
251
|
-
// Final results keyed by run id, so `subagent_wait` can hand the model the
|
|
252
|
-
// actual result in-turn instead of it sleeping/polling for a wake-up message.
|
|
253
|
-
const settledRuns = new Map<number, SingleResult>();
|
|
254
|
-
const settledListeners = new Map<number, Set<(result: SingleResult) => void>>();
|
|
255
|
-
const registerRunResult = (runId: number, result: SingleResult): void => {
|
|
256
|
-
settledRuns.set(runId, result);
|
|
257
|
-
const listeners = settledListeners.get(runId);
|
|
258
|
-
if (listeners) {
|
|
259
|
-
settledListeners.delete(runId);
|
|
260
|
-
for (const listener of listeners) {
|
|
261
|
-
try {
|
|
262
|
-
listener(result);
|
|
263
|
-
} catch {
|
|
264
|
-
/* listener errors must never break settling */
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
// Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
|
|
271
|
-
// excluded from their toolset at spawn (--exclude-tools); this check is defense
|
|
272
|
-
// in depth so a child can never expose the tool back to its model, even if
|
|
273
|
-
// another extension ignores the depth marker.
|
|
274
|
-
if (currentSubagentDepth() >= 1) {
|
|
275
|
-
pi.registerCommand("subagents-setup", {
|
|
276
|
-
description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
|
|
277
|
-
handler: async (_args, ctx) => {
|
|
278
|
-
ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
|
|
279
|
-
},
|
|
280
|
-
});
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
|
|
285
|
-
new Text(
|
|
286
|
-
`${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
|
|
287
|
-
0,
|
|
288
|
-
0,
|
|
289
|
-
),
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
pi.on("session_shutdown", () => {
|
|
293
|
-
sessionActive = false;
|
|
294
|
-
completionBatcher.dispose();
|
|
295
|
-
backgroundQueue.cancelAll();
|
|
296
|
-
settledRuns.clear();
|
|
297
|
-
settledListeners.clear();
|
|
298
|
-
runControllers.clear();
|
|
299
|
-
// Clear the monitor so stale runs from this session never leak into the
|
|
300
|
-
// next one (the module-level singleton survives across sessions).
|
|
301
|
-
monitor.clear();
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
pi.registerTool({
|
|
305
|
-
name: "subagent",
|
|
306
|
-
label: "Subagent",
|
|
307
|
-
description: [
|
|
308
|
-
"Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
|
|
309
|
-
"Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
|
|
310
|
-
"Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
|
|
311
|
-
"It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
|
|
312
|
-
"Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
|
|
313
|
-
"To get a result in-turn without sleeping, use the subagent_wait tool."
|
|
314
|
-
].join(" "),
|
|
315
|
-
promptSnippet:
|
|
316
|
-
"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.",
|
|
317
|
-
promptGuidelines: [
|
|
318
|
-
"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.",
|
|
319
|
-
"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.",
|
|
320
|
-
"Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
|
|
321
|
-
"Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
|
|
322
|
-
"subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
|
|
323
|
-
"Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
|
|
324
|
-
"NEVER sleep, poll, or call other tools alongside subagent — it ends the turn immediately. The main agent is auto-resumed when results arrive; manual waiting only blocks the turn and delays delivery. The one exception is subagent_wait (below): only when you must stay in the turn.",
|
|
325
|
-
"If you must keep the turn for a result, call subagent_wait (blocks in-tool and returns the result) — never bash sleep/timeout to wait for a sub-agent.",
|
|
326
|
-
],
|
|
327
|
-
parameters: SubagentParams,
|
|
328
|
-
|
|
329
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
330
|
-
monitor.beginTurn();
|
|
331
|
-
let config = await loadConfig(configPath);
|
|
332
|
-
// Pick up concurrency changes from /subagents-setup without a restart.
|
|
333
|
-
backgroundQueue.setConcurrency(config.maxConcurrency);
|
|
334
|
-
const repairedModels = repairUnavailableModelOverrides(ctx, config.agentModels);
|
|
335
|
-
if (repairedModels.changed) {
|
|
336
|
-
config = { ...config, agentModels: repairedModels.agentModels };
|
|
337
|
-
try {
|
|
338
|
-
await saveConfig(config, configPath);
|
|
339
|
-
ctx.ui.notify(
|
|
340
|
-
repairedModels.fallbackRef
|
|
341
|
-
? `Unavailable sub-agent models switched to ${repairedModels.fallbackRef} and saved to config.`
|
|
342
|
-
: "Unavailable sub-agent model overrides removed; no main-window model is available.",
|
|
343
|
-
"warning",
|
|
344
|
-
);
|
|
345
|
-
} catch (error) {
|
|
346
|
-
ctx.ui.notify(
|
|
347
|
-
`Could not persist repaired sub-agent model config: ${error instanceof Error ? error.message : String(error)}`,
|
|
348
|
-
"warning",
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Finished runs leave the widget immediately. Their final findings are sent
|
|
354
|
-
// back as a custom message that automatically starts a follow-up turn.
|
|
355
|
-
const finishRun = (
|
|
356
|
-
runId: number,
|
|
357
|
-
status: "done" | "failed",
|
|
358
|
-
opts?: { silent?: boolean; retain?: boolean },
|
|
359
|
-
): void => {
|
|
360
|
-
monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
|
|
361
|
-
const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
|
|
362
|
-
if (!run) return; // already finished — stay idempotent
|
|
363
|
-
if (opts?.retain) monitor.setRetained(runId, true);
|
|
364
|
-
if (opts?.silent || !sessionActive) return;
|
|
365
|
-
const icon = status === "done" ? "✓" : "✗";
|
|
366
|
-
ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
// Live sub-agent activity → concise one-line status ("thinking",
|
|
370
|
-
// "read src/index.ts", ...), never a raw args blob. The live handler only
|
|
371
|
-
// updates widget status; finishing (removeRun + notify) is owned by the
|
|
372
|
-
// queue task / launchInLoop. That keeps a startup retry — which fires a
|
|
373
|
-
// transient "failed" status before relaunching — from ripping the row out
|
|
374
|
-
// early, and lets the queue task decide between delivering a reviewer's
|
|
375
|
-
// result and starting an auto-fix chain (a triggered chain keeps the
|
|
376
|
-
// parent row annotated until it completes).
|
|
377
|
-
const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
|
|
378
|
-
switch (e.kind) {
|
|
379
|
-
case "status":
|
|
380
|
-
// Only update the widget status here. Finishing (removeRun + notify) is
|
|
381
|
-
// owned by the queue task / launchInLoop so that a startup retry — which
|
|
382
|
-
// fires a transient "failed" status before relaunching the child — never
|
|
383
|
-
// rips the row out from under the retry or emits a premature "✗" toast.
|
|
384
|
-
monitor.setStatus(runId, e.status);
|
|
385
|
-
break;
|
|
386
|
-
case "usage":
|
|
387
|
-
monitor.setUsage(runId, e.usage, e.model);
|
|
388
|
-
break;
|
|
389
|
-
case "tool_start":
|
|
390
|
-
monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
|
|
391
|
-
break;
|
|
392
|
-
case "tool_end":
|
|
393
|
-
monitor.recordToolEnd(runId, e.toolName, e.isError);
|
|
394
|
-
break;
|
|
395
|
-
case "thinking":
|
|
396
|
-
monitor.setActivity(runId, "thinking");
|
|
397
|
-
break;
|
|
398
|
-
case "text":
|
|
399
|
-
// A text delta is model output, not a filesystem write.
|
|
400
|
-
monitor.setActivity(runId, "responding");
|
|
401
|
-
break;
|
|
402
|
-
}
|
|
403
|
-
};
|
|
404
|
-
const discovery = discoverAgents(ctx.cwd, {
|
|
405
|
-
scope: config.agentScope,
|
|
406
|
-
enabledNames: config.enabledAgents,
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
// Effective model precedence: setup override > current session model > frontmatter default.
|
|
410
|
-
const sessionRef = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
411
|
-
const agents: AgentConfig[] = discovery.agents.map((agent) => ({
|
|
412
|
-
...agent,
|
|
413
|
-
model: config.agentModels[agent.name] ?? sessionRef ?? agent.model,
|
|
414
|
-
}));
|
|
415
|
-
|
|
416
|
-
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
417
|
-
const hasSingle = Boolean(params.agent) && params.task !== undefined;
|
|
418
|
-
|
|
419
|
-
const makeDetails =
|
|
420
|
-
(mode: "single" | "parallel", background = false) =>
|
|
421
|
-
(results: SingleResult[]): SubagentDetails => ({ mode, results, background });
|
|
422
|
-
|
|
423
|
-
const catalog = agents.map((a) => a.name).join(", ") || "none";
|
|
424
|
-
|
|
425
|
-
if (Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
426
|
-
return {
|
|
427
|
-
content: [
|
|
428
|
-
{
|
|
429
|
-
type: "text",
|
|
430
|
-
text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
|
|
431
|
-
},
|
|
432
|
-
],
|
|
433
|
-
details: makeDetails("single")([]),
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (hasTasks) {
|
|
438
|
-
const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1;
|
|
439
|
-
if (blankTaskIndex !== -1) {
|
|
440
|
-
return {
|
|
441
|
-
content: [
|
|
442
|
-
{
|
|
443
|
-
type: "text",
|
|
444
|
-
text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`,
|
|
445
|
-
},
|
|
446
|
-
],
|
|
447
|
-
details: makeDetails("parallel")([]),
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
} else if (params.task?.trim().length === 0) {
|
|
451
|
-
return {
|
|
452
|
-
content: [
|
|
453
|
-
{
|
|
454
|
-
type: "text",
|
|
455
|
-
text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`,
|
|
456
|
-
},
|
|
457
|
-
],
|
|
458
|
-
details: makeDetails("single")([]),
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Dispatch one agent inside an auto-fix chain: tracked in the widget with a
|
|
464
|
-
* groupId/relationLabel, but NOT delivered through the completion flow — the
|
|
465
|
-
* chain owner assembles and delivers the whole group at the end.
|
|
466
|
-
*/
|
|
467
|
-
const launchInLoop = async (
|
|
468
|
-
agentName: string,
|
|
469
|
-
task: string,
|
|
470
|
-
signal: AbortSignal,
|
|
471
|
-
meta: RunChainMeta,
|
|
472
|
-
): Promise<{ runId?: number; result: SingleResult }> => {
|
|
473
|
-
const agent = agents.find((candidate) => candidate.name === agentName);
|
|
474
|
-
if (!agent) return { result: failedStartResult(agentName, task, `Unknown agent: "${agentName}".`) };
|
|
475
|
-
const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
|
|
476
|
-
const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel, meta);
|
|
477
|
-
const onLive = makeLiveHandler(runId);
|
|
478
|
-
try {
|
|
479
|
-
const result = await runSingleAgentWithModelFallback(
|
|
480
|
-
{
|
|
481
|
-
defaultCwd: ctx.cwd,
|
|
482
|
-
agent,
|
|
483
|
-
agentName,
|
|
484
|
-
task,
|
|
485
|
-
thinkingLevel,
|
|
486
|
-
signal,
|
|
487
|
-
onLive,
|
|
488
|
-
makeDetails: makeDetails("single", true),
|
|
489
|
-
idleTimeoutMs: config.idleTimeoutSec * 1000,
|
|
490
|
-
},
|
|
491
|
-
sessionRef,
|
|
492
|
-
);
|
|
493
|
-
// Keep the finished round visible in the widget while the chain is
|
|
494
|
-
// still running, with a one-line summary of what it did; the whole
|
|
495
|
-
// group is dropped when the chain resolves (see removeChainGroup).
|
|
496
|
-
monitor.setSummary(runId, summarizeChainResult(result));
|
|
497
|
-
finishRun(runId, isFailedResult(result) ? "failed" : "done", { retain: true });
|
|
498
|
-
registerRunResult(runId, result);
|
|
499
|
-
return { runId, result };
|
|
500
|
-
} catch (error) {
|
|
501
|
-
finishRun(runId, "failed", { retain: true });
|
|
502
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
503
|
-
const crashed = {
|
|
504
|
-
...queuedResult(agent, task, thinkingLevel),
|
|
505
|
-
exitCode: 1,
|
|
506
|
-
stderr: errorMessage,
|
|
507
|
-
stopReason: signal.aborted ? "aborted" : "error",
|
|
508
|
-
errorMessage,
|
|
509
|
-
dispatchFailed: true,
|
|
510
|
-
};
|
|
511
|
-
registerRunResult(runId, crashed);
|
|
512
|
-
return { runId, result: crashed };
|
|
513
|
-
}
|
|
514
|
-
};
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Run the auto-fix chain in the background: worker (briefed with the review's
|
|
518
|
-
* findings) → reviewer re-review, up to maxFixRounds times. The main agent is
|
|
519
|
-
* not woken mid-loop; the full chain is delivered as one group at the end.
|
|
520
|
-
* Failures short-circuit: a crashed worker skips its re-review and delivers.
|
|
521
|
-
* The triggering reviewer's run stays visible in the widget (annotated) until
|
|
522
|
-
* the chain resolves, so the ↳ rows have an obvious parent.
|
|
523
|
-
*/
|
|
524
|
-
/** Drop every widget row belonging to an auto-fix chain; the retained
|
|
525
|
-
* parent row is removed separately (it does not carry the groupId). */
|
|
526
|
-
const removeChainGroup = (groupId: string): void => {
|
|
527
|
-
for (const run of [...monitor.getRuns()]) {
|
|
528
|
-
if (run.groupId === groupId) monitor.removeRun(run.id);
|
|
529
|
-
}
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string, parentRunId: number): void => {
|
|
533
|
-
runControllers.set(parentRunId, backgroundQueue.enqueue(
|
|
534
|
-
async (signal) => {
|
|
535
|
-
const chain: ChainStep[] = [
|
|
536
|
-
{ runId: parentRunId, result: initialReviewerResult, relation: "initial review" },
|
|
537
|
-
];
|
|
538
|
-
let lastReviewer = initialReviewerResult;
|
|
539
|
-
for (let round = 1; round <= config.maxFixRounds; round++) {
|
|
540
|
-
if (!sessionActive) break;
|
|
541
|
-
const fixBrief = buildFixTaskBrief(lastReviewer, round, config.maxFixRounds);
|
|
542
|
-
const workerStep = await launchInLoop("worker", fixBrief, signal, {
|
|
543
|
-
groupId: parentGroupId,
|
|
544
|
-
relationLabel: `fix round ${round}`,
|
|
545
|
-
});
|
|
546
|
-
chain.push({ ...workerStep, relation: `fix round ${round}` });
|
|
547
|
-
if (!sessionActive || isFailedResult(workerStep.result)) break;
|
|
548
|
-
const reReviewBrief = buildReReviewBrief(lastReviewer, round);
|
|
549
|
-
const reviewStep = await launchInLoop("reviewer", reReviewBrief, signal, {
|
|
550
|
-
groupId: parentGroupId,
|
|
551
|
-
relationLabel: `re-review round ${round}`,
|
|
552
|
-
});
|
|
553
|
-
chain.push({ ...reviewStep, relation: `re-review round ${round}` });
|
|
554
|
-
lastReviewer = reviewStep.result;
|
|
555
|
-
// A crashed re-review must stop the chain like a crashed worker: its
|
|
556
|
-
// output (if any) is not a verdict, and feeding it to the next fix
|
|
557
|
-
// round would brief the worker from garbage.
|
|
558
|
-
if (!sessionActive || isFailedResult(reviewStep.result)) break;
|
|
559
|
-
if (reviewVerdict(getResultOutput(reviewStep.result)) === "pass") break;
|
|
560
|
-
}
|
|
561
|
-
// The chain is done (success, exhaustion, or abort): drop the retained
|
|
562
|
-
// parent row and its retained round rows, then deliver one condensed
|
|
563
|
-
// summary. Register the parent's final state (the last chain result)
|
|
564
|
-
// before removal so subagent_wait can resolve it.
|
|
565
|
-
registerRunResult(parentRunId, chain[chain.length - 1].result);
|
|
566
|
-
runControllers.delete(parentRunId);
|
|
567
|
-
removeChainGroup(parentGroupId);
|
|
568
|
-
monitor.removeRun(parentRunId);
|
|
569
|
-
if (!sessionActive) return;
|
|
570
|
-
// One compact message instead of every round's raw output: the summary
|
|
571
|
-
// lines cover each step (verdict + what changed/found), and the final
|
|
572
|
-
// step's full report is appended only when its detail is actionable
|
|
573
|
-
// (a FAIL verdict, a crash, or a model-level failure the main agent
|
|
574
|
-
// must take over). Everything else stays one `subagent_status #id`
|
|
575
|
-
// call away.
|
|
576
|
-
const last = chain[chain.length - 1];
|
|
577
|
-
let block = formatChainSummary(chain);
|
|
578
|
-
if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
|
|
579
|
-
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(last.result)}`;
|
|
580
|
-
} else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
|
|
581
|
-
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}`;
|
|
582
|
-
}
|
|
583
|
-
sendCompletionGroup([
|
|
584
|
-
{
|
|
585
|
-
agent: `auto-fix chain (${last.result.agent})`,
|
|
586
|
-
block,
|
|
587
|
-
triggerTurn: true,
|
|
588
|
-
},
|
|
589
|
-
]);
|
|
590
|
-
completionBatcher.flush();
|
|
591
|
-
},
|
|
592
|
-
() => {
|
|
593
|
-
// Cancelled before delivery: clean up the retained parent row and
|
|
594
|
-
// every retained chain row (each in-flight chain run was already
|
|
595
|
-
// finished by its launchInLoop path).
|
|
596
|
-
runControllers.delete(parentRunId);
|
|
597
|
-
removeChainGroup(parentGroupId);
|
|
598
|
-
monitor.removeRun(parentRunId);
|
|
599
|
-
},
|
|
600
|
-
(error) => {
|
|
601
|
-
// A crash inside the chain orchestration (failed runs are caught by
|
|
602
|
-
// launchInLoop and delivered as part of the chain) must not vanish:
|
|
603
|
-
// drop the retained rows, notify, and deliver a failed result
|
|
604
|
-
// so the main agent knows the chain never completed.
|
|
605
|
-
registerRunResult(parentRunId, initialReviewerResult);
|
|
606
|
-
runControllers.delete(parentRunId);
|
|
607
|
-
removeChainGroup(parentGroupId);
|
|
608
|
-
monitor.removeRun(parentRunId);
|
|
609
|
-
if (!sessionActive) return;
|
|
610
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
611
|
-
try {
|
|
612
|
-
ctx.ui.notify(`✗ auto-fix chain 派发失败: ${errorMessage}`, "error");
|
|
613
|
-
// Keep the triggering review's findings: the chain crashed before any
|
|
614
|
-
// fix round ran, and the main agent needs the review to act on it.
|
|
615
|
-
sendCompletionGroup([
|
|
616
|
-
{
|
|
617
|
-
agent: initialReviewerResult.agent,
|
|
618
|
-
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.`,
|
|
619
|
-
triggerTurn: true,
|
|
620
|
-
},
|
|
621
|
-
]);
|
|
622
|
-
completionBatcher.flush();
|
|
623
|
-
} catch {
|
|
624
|
-
/* a second delivery failure must not throw through the queue */
|
|
625
|
-
}
|
|
626
|
-
},
|
|
627
|
-
));
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
const startBackground = (agentName: string, task: string, cwd?: string): SingleResult => {
|
|
631
|
-
const agent = agents.find((candidate) => candidate.name === agentName);
|
|
632
|
-
if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
633
|
-
|
|
634
|
-
// Effective strength: config override > agent frontmatter default > global default.
|
|
635
|
-
const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
|
|
636
|
-
const pending = queuedResult(agent, task, thinkingLevel);
|
|
637
|
-
const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel);
|
|
638
|
-
// Only a main-agent-dispatched reviewer can trigger an auto-fix chain, so
|
|
639
|
-
// only its finish is deferred to the queue task (see startFixLoop).
|
|
640
|
-
const onLive = makeLiveHandler(runId);
|
|
641
|
-
|
|
642
|
-
runControllers.set(runId, backgroundQueue.enqueue(
|
|
643
|
-
async (backgroundSignal) => {
|
|
644
|
-
let result: SingleResult;
|
|
645
|
-
try {
|
|
646
|
-
result = await runSingleAgentWithModelFallback(
|
|
647
|
-
{
|
|
648
|
-
defaultCwd: ctx.cwd,
|
|
649
|
-
agent,
|
|
650
|
-
agentName,
|
|
651
|
-
task,
|
|
652
|
-
cwd,
|
|
653
|
-
thinkingLevel,
|
|
654
|
-
signal: backgroundSignal,
|
|
655
|
-
onLive,
|
|
656
|
-
makeDetails: makeDetails("single", true),
|
|
657
|
-
idleTimeoutMs: config.idleTimeoutSec * 1000,
|
|
658
|
-
},
|
|
659
|
-
sessionRef,
|
|
660
|
-
);
|
|
661
|
-
} catch (error) {
|
|
662
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
663
|
-
result = {
|
|
664
|
-
...pending,
|
|
665
|
-
exitCode: 1,
|
|
666
|
-
stderr: errorMessage,
|
|
667
|
-
stopReason: backgroundSignal.aborted ? "aborted" : "error",
|
|
668
|
-
errorMessage,
|
|
669
|
-
dispatchFailed: true,
|
|
670
|
-
};
|
|
671
|
-
// The dedicated dispatch-failure notification below replaces the generic
|
|
672
|
-
// failure toast for dispatch crashes, so finish silently here.
|
|
673
|
-
finishRun(runId, "failed", { silent: true });
|
|
674
|
-
registerRunResult(runId, result);
|
|
675
|
-
runControllers.delete(runId);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
if (!sessionActive) return;
|
|
679
|
-
// Auto-fix loop: a REVIEW_FAIL from a main-agent-dispatched reviewer
|
|
680
|
-
// triggers a worker→reviewer chain (up to maxFixRounds) without waking
|
|
681
|
-
// the main agent. Loop-internal re-reviews never reach here (they are
|
|
682
|
-
// awaited inside launchInLoop); the initial review is delivered with
|
|
683
|
-
// the chain at the end. While the chain runs, the triggering review
|
|
684
|
-
// stays in the widget (annotated) so the chain rows have an obvious
|
|
685
|
-
// parent; no premature "done" notification is shown.
|
|
686
|
-
if (shouldTriggerFixLoop(result, config)) {
|
|
687
|
-
// The session is known active here (checked above), so the chain
|
|
688
|
-
// always starts: keep the triggering review in the widget
|
|
689
|
-
// (annotated) without a premature "done" notification, and let
|
|
690
|
-
// startFixLoop deliver the whole chain and drop the parent row.
|
|
691
|
-
finishRun(runId, "done", { silent: true, retain: true });
|
|
692
|
-
monitor.setAnnotation(runId, "auto-fix chain running");
|
|
693
|
-
startFixLoop(result, `fix-${runId}`, runId);
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
const failed = isFailedResult(result);
|
|
697
|
-
// Model-level failures and dispatch crashes get their own dedicated
|
|
698
|
-
// dispatch-failure notification below, so finishRun's generic failure toast is
|
|
699
|
-
// silenced for them (computed before finishRun for that reason).
|
|
700
|
-
const modelLevel = failed && isModelLevelFailure(result);
|
|
701
|
-
const dispatchFailed = result.dispatchFailed === true;
|
|
702
|
-
finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
|
|
703
|
-
// Register before delivery so a concurrent subagent_wait resolves with
|
|
704
|
-
// the result even though the run row is already gone from the monitor.
|
|
705
|
-
registerRunResult(runId, result);
|
|
706
|
-
runControllers.delete(runId);
|
|
707
|
-
if (!sessionActive) return;
|
|
708
|
-
// Model-level failure: the configured model is unavailable or broke
|
|
709
|
-
// and the retry with the main-window model (when distinct) also
|
|
710
|
-
// failed. Instead of leaving a dead failure, hand the task to the
|
|
711
|
-
// main window — the main agent executes it itself with its own tools.
|
|
712
|
-
const completion: CompletionMessageItem = {
|
|
713
|
-
agent: result.agent,
|
|
714
|
-
block: modelLevel
|
|
715
|
-
? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result)}`
|
|
716
|
-
: formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
|
|
717
|
-
triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
|
|
718
|
-
};
|
|
719
|
-
if (modelLevel) {
|
|
720
|
-
ctx.ui.notify(`✗ ${result.agent} 派发失败: 模型不可用或出错,任务已交由主窗口执行`, "error");
|
|
721
|
-
} else if (dispatchFailed) {
|
|
722
|
-
// An exception inside the dispatch layer (spawn infra, temp-file/fs
|
|
723
|
-
// errors, ...): the main agent must know so it can re-dispatch.
|
|
724
|
-
ctx.ui.notify(`✗ ${result.agent} 派发失败: ${result.errorMessage ?? "dispatch crashed"}`, "error");
|
|
725
|
-
}
|
|
726
|
-
if (failed) {
|
|
727
|
-
// Failures never wait and never hide behind a success turn: deliver
|
|
728
|
-
// first so the wake-up leads with the failure; held successes follow.
|
|
729
|
-
sendCompletionGroup([completion]);
|
|
730
|
-
completionBatcher.flush();
|
|
731
|
-
} else {
|
|
732
|
-
completionBatcher.push(completion);
|
|
733
|
-
}
|
|
734
|
-
},
|
|
735
|
-
() => {
|
|
736
|
-
runControllers.delete(runId);
|
|
737
|
-
finishRun(runId, "failed");
|
|
738
|
-
},
|
|
739
|
-
(error) => {
|
|
740
|
-
// The task body converts sub-agent failures into delivered results; an
|
|
741
|
-
// exception escaping it (spawn infra, delivery API, ...) must not
|
|
742
|
-
// vanish: notify the user and deliver a failed result so the main
|
|
743
|
-
// agent knows the dispatch failed and can re-dispatch.
|
|
744
|
-
const crashed = dispatchFailedResult(agent, task, error, thinkingLevel);
|
|
745
|
-
finishRun(runId, "failed", { silent: true });
|
|
746
|
-
registerRunResult(runId, crashed);
|
|
747
|
-
runControllers.delete(runId);
|
|
748
|
-
if (!sessionActive) return;
|
|
749
|
-
try {
|
|
750
|
-
ctx.ui.notify(`✗ ${agent.name} 派发失败: ${crashed.errorMessage}`, "error");
|
|
751
|
-
sendCompletionGroup([
|
|
752
|
-
{
|
|
753
|
-
agent: agent.name,
|
|
754
|
-
block: formatCompletionBlock(crashed, config.maxResultLines, ctx.cwd),
|
|
755
|
-
triggerTurn: true,
|
|
756
|
-
},
|
|
757
|
-
]);
|
|
758
|
-
completionBatcher.flush();
|
|
759
|
-
} catch {
|
|
760
|
-
/* a second delivery failure must not throw through the queue */
|
|
761
|
-
}
|
|
762
|
-
},
|
|
763
|
-
));
|
|
764
|
-
|
|
765
|
-
return pending;
|
|
766
|
-
};
|
|
767
|
-
|
|
768
|
-
// Sub-agents intentionally detach from the foreground turn. This makes the
|
|
769
|
-
// editor available immediately; completion messages later wake the main agent.
|
|
770
|
-
if (params.tasks && params.tasks.length > 0) {
|
|
771
|
-
if (params.tasks.length > config.maxConcurrency) {
|
|
772
|
-
return {
|
|
773
|
-
content: [
|
|
774
|
-
{
|
|
775
|
-
type: "text",
|
|
776
|
-
text: `Too many parallel tasks (${params.tasks.length}). Max is ${config.maxConcurrency} (configurable via /subagents-setup).`,
|
|
777
|
-
},
|
|
778
|
-
],
|
|
779
|
-
details: makeDetails("parallel", true)([]),
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
const results = params.tasks.map((task) => startBackground(task.agent, task.task, task.cwd));
|
|
784
|
-
const started = results.filter((result) => result.exitCode === -1).length;
|
|
785
|
-
const failures = results.filter((result) => result.exitCode !== -1);
|
|
786
|
-
return {
|
|
787
|
-
content: [
|
|
788
|
-
{
|
|
789
|
-
type: "text",
|
|
790
|
-
text:
|
|
791
|
-
started > 0
|
|
792
|
-
? `Started ${started} background subagent${started === 1 ? "" : "s"}. Results will automatically resume the main agent when ready.`
|
|
793
|
-
: failures.map((result) => getResultOutput(result)).join("\n"),
|
|
794
|
-
},
|
|
795
|
-
],
|
|
796
|
-
details: makeDetails("parallel", true)(results),
|
|
797
|
-
isError: failures.length > 0,
|
|
798
|
-
terminate: true,
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
const result = startBackground(params.agent as string, params.task as string, params.cwd);
|
|
803
|
-
if (result.exitCode !== -1) {
|
|
804
|
-
return {
|
|
805
|
-
content: [{ type: "text", text: getResultOutput(result) }],
|
|
806
|
-
details: makeDetails("single")([result]),
|
|
807
|
-
isError: true,
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
return {
|
|
811
|
-
content: [{ type: "text", text: `Started ${result.agent} in the background. Its result will automatically resume the main agent when ready.` }],
|
|
812
|
-
details: makeDetails("single", true)([result]),
|
|
813
|
-
terminate: true,
|
|
814
|
-
};
|
|
815
|
-
|
|
816
|
-
},
|
|
817
|
-
|
|
818
|
-
renderCall(args, theme) {
|
|
819
|
-
if (args.tasks && args.tasks.length > 0) {
|
|
820
|
-
let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
|
|
821
|
-
for (const t of args.tasks.slice(0, 4)) {
|
|
822
|
-
const preview = formatTaskSummary(t.task, 48);
|
|
823
|
-
text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
|
|
824
|
-
}
|
|
825
|
-
if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
|
|
826
|
-
return new Text(text, 0, 0);
|
|
827
|
-
}
|
|
828
|
-
const task: string = args.task ?? "";
|
|
829
|
-
const preview = formatTaskSummary(task, 60);
|
|
830
|
-
return new Text(
|
|
831
|
-
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
|
|
832
|
-
0,
|
|
833
|
-
0,
|
|
834
|
-
);
|
|
835
|
-
},
|
|
836
|
-
|
|
837
|
-
renderResult(result, _options, theme) {
|
|
838
|
-
const details = result.details as SubagentDetails | undefined;
|
|
839
|
-
if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
|
|
840
|
-
|
|
841
|
-
if (details.mode === "single") {
|
|
842
|
-
const r = details.results[0];
|
|
843
|
-
const pending = r.exitCode === -1;
|
|
844
|
-
const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
|
|
845
|
-
const usage = formatUsage(r.usage);
|
|
846
|
-
const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (fell back from ${r.modelFallbackFrom})` : ""}`;
|
|
847
|
-
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}` : ""}`)}`;
|
|
848
|
-
return new Text(line, 0, 0);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// Parallel mode: header + one compact line per agent
|
|
852
|
-
const lines: string[] = [
|
|
853
|
-
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
|
|
854
|
-
];
|
|
855
|
-
for (const r of details.results) {
|
|
856
|
-
const pending = r.exitCode === -1;
|
|
857
|
-
const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
|
|
858
|
-
const usage = formatUsage(r.usage);
|
|
859
|
-
const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (fell back from ${r.modelFallbackFrom})` : ""}`;
|
|
860
|
-
lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
|
|
861
|
-
}
|
|
862
|
-
return new Text(lines.join("\n"), 0, 0);
|
|
863
|
-
},
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
// Blocking wait: keeps the turn alive until the targeted run(s) settle, then
|
|
867
|
-
// returns the actual result(s) to the model in-turn. Without it, a model that
|
|
868
|
-
// must stay in the turn falls back to bash sleep/poll — blocking the turn and
|
|
869
|
-
// delaying the very wake-up it is waiting for. Ending the turn and letting the
|
|
870
|
-
// steer-delivered completion wake it is still the preferred path; this tool is
|
|
871
|
-
// for when the result is needed NOW (sequential dependent steps).
|
|
872
|
-
const SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
873
|
-
|
|
874
|
-
const SubagentWaitParams = Type.Object({
|
|
875
|
-
id: Type.Optional(
|
|
876
|
-
Type.String({
|
|
877
|
-
description: "Run id or prefix shown in the subagent widget (#id). Omit to wait for all active runs in this session.",
|
|
878
|
-
}),
|
|
879
|
-
),
|
|
880
|
-
timeoutMs: Type.Optional(
|
|
881
|
-
Type.Number({
|
|
882
|
-
description: `Give up after this many milliseconds and report the still-running runs (default ${SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS}).`,
|
|
883
|
-
}),
|
|
884
|
-
),
|
|
885
|
-
});
|
|
886
|
-
|
|
887
|
-
pi.registerTool({
|
|
888
|
-
name: "subagent_wait",
|
|
889
|
-
label: "Subagent Wait",
|
|
890
|
-
description: [
|
|
891
|
-
"Block the current turn until background sub-agent run(s) finish, then return their results.",
|
|
892
|
-
"Use ONLY when you must stay in the turn and act on the result immediately (sequential dependent steps).",
|
|
893
|
-
"Prefer ending your turn after subagent — the result arrives automatically and wakes you.",
|
|
894
|
-
"NEVER sleep, poll, or wait with bash to get a sub-agent result: end the turn, or call this tool.",
|
|
895
|
-
"The same result is also delivered as a completion message that resumes the main agent, so you may see it twice (once here, once as a wake-up) — that is expected, not a duplicate.",
|
|
896
|
-
].join(" "),
|
|
897
|
-
promptSnippet: "Wait for a background subagent to finish and get its result in-turn (id: run id from the widget; omit for all).",
|
|
898
|
-
promptGuidelines: [
|
|
899
|
-
"Call subagent_wait only when you must keep the turn and need the result now — e.g. the next step depends on it.",
|
|
900
|
-
"After dispatching via subagent, prefer ending the turn: the completion message wakes you automatically (no waiting).",
|
|
901
|
-
"Never use bash sleep/timeout/polling to wait for a sub-agent — it blocks the turn and delays result delivery.",
|
|
902
|
-
"If subagent_wait times out, call it again with a longer timeoutMs or end the turn and wait for the wake-up message.",
|
|
903
|
-
],
|
|
904
|
-
parameters: SubagentWaitParams,
|
|
905
|
-
|
|
906
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
907
|
-
const config = await loadConfig(configPath);
|
|
908
|
-
// A non-finite or negative timeout would produce a nonsensical note
|
|
909
|
-
// ("timed out after Infinitys") or an instant "timeout" that was never
|
|
910
|
-
// asked for; fall back to the default. Zero is honored as an immediate
|
|
911
|
-
// give-up (clamped to 1ms below).
|
|
912
|
-
const timeoutMs =
|
|
913
|
-
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
|
|
914
|
-
? params.timeoutMs
|
|
915
|
-
: SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
|
|
916
|
-
const isActive = (run: { status: string; retained?: boolean }): boolean =>
|
|
917
|
-
run.status === "queued" || run.status === "running" || run.retained === true;
|
|
918
|
-
|
|
919
|
-
const requested = params.id?.trim();
|
|
920
|
-
// A run that already settled resolves immediately with its result.
|
|
921
|
-
if (requested) {
|
|
922
|
-
const settledIds = matchRunIds([...settledRuns.keys()], requested);
|
|
923
|
-
if (settledIds.length > 0) {
|
|
924
|
-
return {
|
|
925
|
-
content: [
|
|
926
|
-
{ type: "text", text: settledIds.map((id) => formatCompletionBlock(settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
|
|
927
|
-
],
|
|
928
|
-
details: {},
|
|
929
|
-
};
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
const activeRuns = monitor.getRuns().filter(isActive);
|
|
934
|
-
const targetIds = requested ? matchRunIds(activeRuns.map((run) => run.id), requested) : activeRuns.map((run) => run.id);
|
|
935
|
-
const targets = activeRuns.filter((run) => targetIds.includes(run.id));
|
|
936
|
-
if (targets.length === 0) {
|
|
937
|
-
const activeList = activeRuns.map((run) => `#${run.id} ${run.agent}`).join(", ");
|
|
938
|
-
return {
|
|
939
|
-
content: [
|
|
940
|
-
{
|
|
941
|
-
type: "text",
|
|
942
|
-
text: requested
|
|
943
|
-
? `No active subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
|
|
944
|
-
: `No active subagent runs${activeList ? ` (active: ${activeList})` : " right now"}.`,
|
|
945
|
-
},
|
|
946
|
-
],
|
|
947
|
-
details: {},
|
|
948
|
-
};
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
|
|
952
|
-
const already = settledRuns.get(runId);
|
|
953
|
-
if (already) return Promise.resolve({ result: already });
|
|
954
|
-
return new Promise((resolve) => {
|
|
955
|
-
let done = false;
|
|
956
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
957
|
-
let unsub: (() => void) | undefined;
|
|
958
|
-
const cleanup = (): void => {
|
|
959
|
-
if (timer) clearTimeout(timer);
|
|
960
|
-
if (unsub) unsub();
|
|
961
|
-
signal?.removeEventListener("abort", onAbort);
|
|
962
|
-
const listeners = settledListeners.get(runId);
|
|
963
|
-
if (listeners) {
|
|
964
|
-
listeners.delete(onSettled);
|
|
965
|
-
if (listeners.size === 0) settledListeners.delete(runId);
|
|
966
|
-
}
|
|
967
|
-
};
|
|
968
|
-
const finish = (outcome: { result?: SingleResult; note?: string }): void => {
|
|
969
|
-
if (done) return;
|
|
970
|
-
done = true;
|
|
971
|
-
cleanup();
|
|
972
|
-
resolve(outcome);
|
|
973
|
-
};
|
|
974
|
-
const onSettled = (result: SingleResult): void => finish({ result });
|
|
975
|
-
const onMonitor = (): void => {
|
|
976
|
-
const current = settledRuns.get(runId);
|
|
977
|
-
if (current) {
|
|
978
|
-
finish({ result: current });
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
|
-
if (!monitor.findRun(runId)) {
|
|
982
|
-
// Removal is followed synchronously by registerRunResult in the
|
|
983
|
-
// finishing task; re-check on the next tick so the result wins.
|
|
984
|
-
setTimeout(() => {
|
|
985
|
-
const late = settledRuns.get(runId);
|
|
986
|
-
if (late) finish({ result: late });
|
|
987
|
-
else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
|
|
988
|
-
}, 0);
|
|
989
|
-
}
|
|
990
|
-
};
|
|
991
|
-
const onAbort = (): void => finish({ note: "wait aborted" });
|
|
992
|
-
let listeners = settledListeners.get(runId);
|
|
993
|
-
if (!listeners) {
|
|
994
|
-
listeners = new Set();
|
|
995
|
-
settledListeners.set(runId, listeners);
|
|
996
|
-
}
|
|
997
|
-
listeners.add(onSettled);
|
|
998
|
-
unsub = monitor.subscribe(onMonitor);
|
|
999
|
-
timer = setTimeout(
|
|
1000
|
-
() =>
|
|
1001
|
-
finish({
|
|
1002
|
-
note: `wait timed out after ${Math.round(timeoutMs / 1000)}s — run #${runId} is still active; call subagent_wait again or end the turn (the result will wake you when ready)`,
|
|
1003
|
-
}),
|
|
1004
|
-
Math.max(1, timeoutMs),
|
|
1005
|
-
);
|
|
1006
|
-
if (signal?.aborted) onAbort();
|
|
1007
|
-
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
1008
|
-
});
|
|
1009
|
-
};
|
|
1010
|
-
|
|
1011
|
-
const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
|
|
1012
|
-
const blocks = outcomes.map((outcome) =>
|
|
1013
|
-
outcome.result ? formatCompletionBlock(outcome.result, config.maxResultLines, ctx.cwd) : (outcome.note ?? "(no outcome)"),
|
|
1014
|
-
);
|
|
1015
|
-
return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
|
|
1016
|
-
},
|
|
1017
|
-
|
|
1018
|
-
renderCall(args, theme) {
|
|
1019
|
-
const target = args.id ? `#${args.id}` : "all";
|
|
1020
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", target)}`, 0, 0);
|
|
1021
|
-
},
|
|
1022
|
-
|
|
1023
|
-
renderResult(result, _options, theme) {
|
|
1024
|
-
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
1025
|
-
const text = parts
|
|
1026
|
-
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
1027
|
-
.join(" ")
|
|
1028
|
-
.trim();
|
|
1029
|
-
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
1030
|
-
return new Text(
|
|
1031
|
-
`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
|
|
1032
|
-
0,
|
|
1033
|
-
0,
|
|
1034
|
-
);
|
|
1035
|
-
},
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
// Status overview: what is running right now and what finished this session,
|
|
1039
|
-
// with per-run details (id, agent, model, usage, elapsed, activity) so the
|
|
1040
|
-
// main agent can decide whether to wait, stop, or re-dispatch. Learned from
|
|
1041
|
-
// nicobailon/pi-subagents ({action:"status"} + status files): inspect before
|
|
1042
|
-
// you act, and report run ids when handing off.
|
|
1043
|
-
const SubagentStatusParams = Type.Object({
|
|
1044
|
-
id: Type.Optional(
|
|
1045
|
-
Type.String({
|
|
1046
|
-
description: "Run id or prefix to show the full result for (must already be finished; use subagent_wait to block on an active run).",
|
|
1047
|
-
}),
|
|
1048
|
-
),
|
|
1049
|
-
});
|
|
1050
|
-
|
|
1051
|
-
pi.registerTool({
|
|
1052
|
-
name: "subagent_status",
|
|
1053
|
-
label: "Subagent Status",
|
|
1054
|
-
description: [
|
|
1055
|
-
"List active background sub-agent runs (id, agent, model, usage, elapsed, current activity) and recently finished results.",
|
|
1056
|
-
"Pass id to read the full result of a finished run; pass no id for the overview.",
|
|
1057
|
-
"Use it to decide whether to subagent_wait, subagent_stop, or re-dispatch — never to poll: results arrive by themselves.",
|
|
1058
|
-
].join(" "),
|
|
1059
|
-
promptSnippet: "Inspect background subagents: active runs, finished results, full result by id.",
|
|
1060
|
-
promptGuidelines: [
|
|
1061
|
-
"Call subagent_status to see what is running and what already finished; the widget shows the same live state.",
|
|
1062
|
-
"Never poll subagent_status in a loop to wait for a run: end the turn (you will be woken) or call subagent_wait.",
|
|
1063
|
-
"A finished run's id stays available for the session; its full result is one subagent_status call away.",
|
|
1064
|
-
],
|
|
1065
|
-
parameters: SubagentStatusParams,
|
|
1066
|
-
|
|
1067
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1068
|
-
const config = await loadConfig(configPath);
|
|
1069
|
-
const requested = params.id?.trim();
|
|
1070
|
-
|
|
1071
|
-
if (requested) {
|
|
1072
|
-
const settledIds = matchRunIds([...settledRuns.keys()], requested);
|
|
1073
|
-
if (settledIds.length > 0) {
|
|
1074
|
-
return {
|
|
1075
|
-
content: [
|
|
1076
|
-
{ type: "text", text: settledIds.map((id) => formatCompletionBlock(settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
|
|
1077
|
-
],
|
|
1078
|
-
details: {},
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
const runs = monitor.getRuns();
|
|
1082
|
-
const activeId = matchRunIds(runs.map((run) => run.id), requested)[0];
|
|
1083
|
-
const active = activeId === undefined ? undefined : runs.find((run) => run.id === activeId);
|
|
1084
|
-
if (active) {
|
|
1085
|
-
return {
|
|
1086
|
-
content: [
|
|
1087
|
-
{
|
|
1088
|
-
type: "text",
|
|
1089
|
-
text: `Run #${active.id} ${active.agent} is still active (${active.activity ?? statusLabel(active.status)}). Use subagent_wait to block for its result, or subagent_stop to cancel it.`,
|
|
1090
|
-
},
|
|
1091
|
-
],
|
|
1092
|
-
details: {},
|
|
1093
|
-
};
|
|
1094
|
-
}
|
|
1095
|
-
return { content: [{ type: "text", text: `No subagent run matches "${requested}".` }], details: {} };
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
const now = Date.now();
|
|
1099
|
-
const activeRuns = monitor.getRuns().filter(
|
|
1100
|
-
(run) => run.status === "queued" || run.status === "running" || run.retained,
|
|
1101
|
-
);
|
|
1102
|
-
const activeLines = activeRuns.map((run) => {
|
|
1103
|
-
const parts = [
|
|
1104
|
-
`#${run.id} ${run.agent}`,
|
|
1105
|
-
run.model ?? "?",
|
|
1106
|
-
formatUsageCompact(run.usage),
|
|
1107
|
-
formatElapsed(run, now),
|
|
1108
|
-
].filter(Boolean);
|
|
1109
|
-
return `- ${parts.join(" · ")} · ${run.activity ?? statusLabel(run.status)}`;
|
|
1110
|
-
});
|
|
1111
|
-
const completed = [...settledRuns.entries()].slice(-5);
|
|
1112
|
-
const completedLines = completed.map(([id, result]) => {
|
|
1113
|
-
const usage = formatUsage(result.usage);
|
|
1114
|
-
return `- #${id} ${result.agent} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
const sections: string[] = [];
|
|
1118
|
-
sections.push(`### Active subagent runs (${activeRuns.length})`);
|
|
1119
|
-
sections.push(activeLines.length > 0 ? activeLines.join("\n") : "(none)");
|
|
1120
|
-
sections.push(`### Finished this session (${settledRuns.size})`);
|
|
1121
|
-
sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
|
|
1122
|
-
sections.push("Pass a run id to subagent_status for the full result, or subagent_wait to block for an active run.");
|
|
1123
|
-
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
1124
|
-
},
|
|
1125
|
-
|
|
1126
|
-
renderCall(args, theme) {
|
|
1127
|
-
return new Text(
|
|
1128
|
-
`${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("accent", args.id ? `#${args.id}` : "overview")}`,
|
|
1129
|
-
0,
|
|
1130
|
-
0,
|
|
1131
|
-
);
|
|
1132
|
-
},
|
|
1133
|
-
|
|
1134
|
-
renderResult(result, _options, theme) {
|
|
1135
|
-
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
1136
|
-
const text = parts
|
|
1137
|
-
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
1138
|
-
.join(" ")
|
|
1139
|
-
.trim();
|
|
1140
|
-
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
1141
|
-
return new Text(
|
|
1142
|
-
`${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
|
|
1143
|
-
0,
|
|
1144
|
-
0,
|
|
1145
|
-
);
|
|
1146
|
-
},
|
|
1147
|
-
});
|
|
1148
|
-
|
|
1149
|
-
// Cancel one or more active runs: aborts the queue controller, which
|
|
1150
|
-
// terminates the child and delivers an aborted result (with whatever partial
|
|
1151
|
-
// output it produced) so the main agent always knows the run stopped.
|
|
1152
|
-
const SubagentStopParams = Type.Object({
|
|
1153
|
-
id: Type.Optional(
|
|
1154
|
-
Type.String({
|
|
1155
|
-
description: "Run id or prefix to stop (see the widget or subagent_status).",
|
|
1156
|
-
}),
|
|
1157
|
-
),
|
|
1158
|
-
all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
pi.registerTool({
|
|
1162
|
-
name: "subagent_stop",
|
|
1163
|
-
label: "Subagent Stop",
|
|
1164
|
-
description: [
|
|
1165
|
-
"Cancel one or more active background sub-agent runs: the child process is terminated and an aborted result (with partial output) is delivered.",
|
|
1166
|
-
"Pass id (run id or prefix) to stop one run, or all: true to stop every active run.",
|
|
1167
|
-
].join(" "),
|
|
1168
|
-
promptSnippet: "Stop a running background subagent (id from the widget/subagent_status; or all: true).",
|
|
1169
|
-
promptGuidelines: [
|
|
1170
|
-
"Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens.",
|
|
1171
|
-
"A stopped run reports as failed with 'aborted' and its partial output, so the next step knows it did not complete.",
|
|
1172
|
-
],
|
|
1173
|
-
parameters: SubagentStopParams,
|
|
1174
|
-
|
|
1175
|
-
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
1176
|
-
const targets =
|
|
1177
|
-
params.all === true
|
|
1178
|
-
? [...runControllers.keys()]
|
|
1179
|
-
: params.id !== undefined && params.id.trim() !== ""
|
|
1180
|
-
? matchRunIds([...runControllers.keys()], params.id!.trim())
|
|
1181
|
-
: [];
|
|
1182
|
-
|
|
1183
|
-
if (targets.length === 0) {
|
|
1184
|
-
const activeList = [...runControllers.keys()].map((id) => `#${id}`).join(", ");
|
|
1185
|
-
return {
|
|
1186
|
-
content: [
|
|
1187
|
-
{
|
|
1188
|
-
type: "text",
|
|
1189
|
-
text:
|
|
1190
|
-
params.all === true
|
|
1191
|
-
? "No active subagent runs to stop."
|
|
1192
|
-
: `No active subagent run matches "${params.id}".${activeList ? ` Active runs: ${activeList}.` : ""}`,
|
|
1193
|
-
},
|
|
1194
|
-
],
|
|
1195
|
-
details: {},
|
|
1196
|
-
};
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const stopped: string[] = [];
|
|
1200
|
-
for (const runId of targets) {
|
|
1201
|
-
const run = monitor.findRun(runId);
|
|
1202
|
-
if (!run) {
|
|
1203
|
-
runControllers.delete(runId);
|
|
1204
|
-
continue;
|
|
1205
|
-
}
|
|
1206
|
-
// Abort before registering the synthetic result: abort() only marks the
|
|
1207
|
-
// queue entry (drain delivers the cancellation callback later), so the
|
|
1208
|
-
// has() re-check right after it distinguishes an entry that never ran
|
|
1209
|
-
// from one whose task already started under a stale "queued" status —
|
|
1210
|
-
// a started task owns its own (real, partial-output) result.
|
|
1211
|
-
const controller = runControllers.get(runId);
|
|
1212
|
-
controller?.abort();
|
|
1213
|
-
// A queued run never reaches the child-spawn code path, so its abort
|
|
1214
|
-
// goes through the queue's cancelled callback with no result object;
|
|
1215
|
-
// register a synthetic aborted result so subagent_wait resolves.
|
|
1216
|
-
if (run.status === "queued" && runControllers.has(runId)) {
|
|
1217
|
-
registerRunResult(runId, {
|
|
1218
|
-
agent: run.agent,
|
|
1219
|
-
agentSource: "builtin",
|
|
1220
|
-
task: run.task,
|
|
1221
|
-
exitCode: 1,
|
|
1222
|
-
messages: [],
|
|
1223
|
-
stderr: "Stopped by subagent_stop before the run started.",
|
|
1224
|
-
usage: emptyUsage(),
|
|
1225
|
-
model: run.model,
|
|
1226
|
-
thinking: run.thinking,
|
|
1227
|
-
stopReason: "aborted",
|
|
1228
|
-
errorMessage: "Stopped by subagent_stop before the run started.",
|
|
1229
|
-
});
|
|
1230
|
-
}
|
|
1231
|
-
stopped.push(`#${runId} ${run.agent}${run.status === "queued" ? " (queued)" : ""}`);
|
|
1232
|
-
}
|
|
1233
|
-
return {
|
|
1234
|
-
content: [
|
|
1235
|
-
{
|
|
1236
|
-
type: "text",
|
|
1237
|
-
text: `Stopped ${stopped.length} run${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. An aborted result (with partial output) is delivered.`,
|
|
1238
|
-
},
|
|
1239
|
-
],
|
|
1240
|
-
details: {},
|
|
1241
|
-
};
|
|
1242
|
-
},
|
|
1243
|
-
|
|
1244
|
-
renderCall(args, theme) {
|
|
1245
|
-
return new Text(
|
|
1246
|
-
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
|
|
1247
|
-
0,
|
|
1248
|
-
0,
|
|
1249
|
-
);
|
|
1250
|
-
},
|
|
1251
|
-
|
|
1252
|
-
renderResult(result, _options, theme) {
|
|
1253
|
-
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
1254
|
-
const text = parts
|
|
1255
|
-
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
1256
|
-
.join(" ")
|
|
1257
|
-
.trim();
|
|
1258
|
-
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
1259
|
-
return new Text(
|
|
1260
|
-
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("dim", firstLine.slice(0, 60))}`,
|
|
1261
|
-
0,
|
|
1262
|
-
0,
|
|
1263
|
-
);
|
|
1264
|
-
},
|
|
1265
|
-
});
|
|
1266
|
-
|
|
1267
|
-
pi.registerCommand("subagents-setup", {
|
|
1268
|
-
description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
|
|
1269
|
-
handler: async (_args, ctx) => {
|
|
1270
|
-
await runSetup(ctx, configPath);
|
|
1271
|
-
},
|
|
1272
|
-
});
|
|
1273
|
-
|
|
1274
|
-
// Persistent widget above the editor showing live sub-agent status.
|
|
1275
|
-
pi.on("session_start", (_e, ctx) => {
|
|
1276
|
-
if (ctx.mode !== "tui") return;
|
|
1277
|
-
ctx.ui.setWidget(
|
|
1278
|
-
"pi-subagents",
|
|
1279
|
-
(tui, theme) => {
|
|
1280
|
-
const unsub = monitor.subscribe(() => tui.requestRender());
|
|
1281
|
-
// Tick once a second so elapsed time stays live while runs are active.
|
|
1282
|
-
const timer = setInterval(() => {
|
|
1283
|
-
if (monitor.getRuns().some((r) => r.status === "queued" || r.status === "running")) {
|
|
1284
|
-
tui.requestRender();
|
|
1285
|
-
}
|
|
1286
|
-
}, 1000);
|
|
1287
|
-
return {
|
|
1288
|
-
render(width: number): string[] {
|
|
1289
|
-
const runs = monitor.getRuns();
|
|
1290
|
-
if (runs.length === 0) return [];
|
|
1291
|
-
const now = Date.now();
|
|
1292
|
-
const lines: string[] = [];
|
|
1293
|
-
// Tree layout: each top-level agent is a root whose title/activity hang
|
|
1294
|
-
// off it as branches; auto-fix chain runs (groupId) become child nodes
|
|
1295
|
-
// under their parent root, with a "│" continuation while more siblings
|
|
1296
|
-
// follow. Blank lines separate agent blocks so parallel runs don't blur
|
|
1297
|
-
// into one wall of text.
|
|
1298
|
-
const dim = (t: string): string => theme.fg("dim", t);
|
|
1299
|
-
for (let idx = 0; idx < runs.length; idx++) {
|
|
1300
|
-
const r = runs[idx];
|
|
1301
|
-
const isChain = Boolean(r.groupId);
|
|
1302
|
-
const chainContinues = isChain && runs[idx + 1]?.groupId === r.groupId;
|
|
1303
|
-
const activity =
|
|
1304
|
-
r.activity && (r.status === "running" || r.status === "queued") ? r.activity : undefined;
|
|
1305
|
-
const hasActivity = activity !== undefined;
|
|
1306
|
-
const icon = statusIcon(r.status, theme);
|
|
1307
|
-
// Chain-internal runs (auto-fix worker/reviewer) are child nodes under
|
|
1308
|
-
// their parent reviewer. Their relationLabel ("fix round 1") is more
|
|
1309
|
-
// distinguishing than the repeated worker/reviewer name.
|
|
1310
|
-
const name = isChain ? (r.relationLabel ?? r.agent) : r.agent;
|
|
1311
|
-
// Two lines per run: the header row (icon, run id, agent name) and the
|
|
1312
|
-
// live activity branch below. The task summary is deliberately not
|
|
1313
|
-
// shown — the task lives in the tool result, and the agent name plus
|
|
1314
|
-
// what it is doing right now is enough to tell runs apart. The header
|
|
1315
|
-
// stays exactly as it was (accent name, dim stats), matching the
|
|
1316
|
-
// referenced sub-agent widgets (tintinweb): the running indicator
|
|
1317
|
-
// uses the accent color, everything else is quiet.
|
|
1318
|
-
if (!isChain && lines.length > 0) lines.push("");
|
|
1319
|
-
const nodeBranch = isChain ? (chainContinues ? "├─ " : "└─ ") : "";
|
|
1320
|
-
const left = `${dim(nodeBranch)}${icon} ${dim(`#${r.id}`)} ${isChain ? name : theme.fg("accent", theme.bold(name))}`;
|
|
1321
|
-
|
|
1322
|
-
// Right side: full model ref (provider/model), token usage (in/out +
|
|
1323
|
-
// cache read/write), tool count, elapsed, and the soft activity-state
|
|
1324
|
-
// annotation (idle / long-running). Trailing the header with a single
|
|
1325
|
-
// " · " chain keeps the row compact (no center gap); compactLine
|
|
1326
|
-
// clips on overflow, never the right side on its own.
|
|
1327
|
-
const model = r.model ?? "?";
|
|
1328
|
-
const usage = formatUsageCompact(r.usage);
|
|
1329
|
-
const tools = r.toolCount ? `${r.toolCount} tool${r.toolCount === 1 ? "" : "s"}` : "";
|
|
1330
|
-
const elapsed = formatElapsed(r, now);
|
|
1331
|
-
// The round outcome summary leads the metadata so a finished chain
|
|
1332
|
-
// row reads as what it did ("fail · src/index.ts · render()",
|
|
1333
|
-
// "pass", "src/index.ts · tests/monitor.test.ts").
|
|
1334
|
-
const metaParts = [r.summary, model, usage, tools, elapsed].filter(Boolean);
|
|
1335
|
-
// Running is conveyed by the icon + elapsed; spell out the label only for
|
|
1336
|
-
// the other states (ready / done / stopped) so they are unambiguous.
|
|
1337
|
-
if (r.status !== "running") metaParts.push(statusLabel(r.status));
|
|
1338
|
-
const state = deriveActivityState(r, now);
|
|
1339
|
-
if (state) metaParts.push(activityStateLabel(state));
|
|
1340
|
-
if (r.annotation) metaParts.push(r.annotation);
|
|
1341
|
-
// Metadata trails the header in dim — quiet, never competing with the
|
|
1342
|
-
// accent agent name (the same restraint the referenced widgets use).
|
|
1343
|
-
// Trailing with a single " · " chain keeps the row compact (no center
|
|
1344
|
-
// gap); compactLine clips on overflow, never the right side on its own.
|
|
1345
|
-
const right = metaParts.length ? dim(` · ${metaParts.join(" · ")}`) : "";
|
|
1346
|
-
lines.push(compactLine(left, right, width));
|
|
1347
|
-
|
|
1348
|
-
// Current activity ("read src/index.ts", "bash npm test") is the only
|
|
1349
|
-
// branch: gray, so it never competes with the agent name or pi's own
|
|
1350
|
-
// UI. Chain nodes that still have siblings carry a "│" continuation
|
|
1351
|
-
// down to the last one.
|
|
1352
|
-
if (hasActivity) {
|
|
1353
|
-
const continuation = isChain ? (chainContinues ? "│ " : " ") : "";
|
|
1354
|
-
lines.push(truncateToWidth(`${continuation}${dim("└─ ")}${dim(activity)}`, width));
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
return lines;
|
|
1358
|
-
},
|
|
1359
|
-
invalidate() {},
|
|
1360
|
-
dispose() {
|
|
1361
|
-
unsub();
|
|
1362
|
-
clearInterval(timer);
|
|
1363
|
-
},
|
|
1364
|
-
};
|
|
1365
|
-
},
|
|
1366
|
-
{ placement: "aboveEditor" },
|
|
1367
|
-
);
|
|
1368
|
-
});
|
|
1369
|
-
|
|
1370
|
-
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
1371
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
1372
|
-
const config = await loadConfig(configPath);
|
|
1373
|
-
if (!config.proactiveInjection) return undefined;
|
|
1374
|
-
const { agents } = discoverAgents(ctx.cwd, {
|
|
1375
|
-
scope: config.agentScope,
|
|
1376
|
-
enabledNames: config.enabledAgents,
|
|
1377
|
-
});
|
|
1378
|
-
const directive = buildDelegationDirective(agents);
|
|
1379
|
-
if (!directive) return undefined;
|
|
1380
|
-
return { systemPrompt: `${event.systemPrompt}\n${directive}` };
|
|
1381
|
-
});
|
|
1382
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagents — focused sub-agent delegation for pi.
|
|
3
|
+
*
|
|
4
|
+
* Assembly point: builds the shared runtime and registers everything.
|
|
5
|
+
* The heavy lifting lives in focused modules:
|
|
6
|
+
* - dispatch.ts — the `subagent` tool (spawn, auto-fix chain, vision model)
|
|
7
|
+
* - tools.ts — subagent_wait / subagent_status / subagent_stop
|
|
8
|
+
* - widget.ts — session_start widget + one-time feature announcements
|
|
9
|
+
* - runtime.ts — shared per-session state
|
|
10
|
+
*
|
|
11
|
+
* Also registers the `/subagents-setup` command and a `before_agent_start` hook
|
|
12
|
+
* that injects a delegation directive into the parent system prompt so the main
|
|
13
|
+
* model uses the tool proactively.
|
|
14
|
+
*
|
|
15
|
+
* The tool is not registered inside child sub-agent processes, which prevents
|
|
16
|
+
* runaway recursion and keeps child context windows clean.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
21
|
+
import { discoverAgents } from "./agents.ts";
|
|
22
|
+
import { getConfigPath, loadConfig } from "./config.ts";
|
|
23
|
+
import { registerSubagentTool } from "./dispatch.ts";
|
|
24
|
+
import { matchRunIds } from "./format.ts";
|
|
25
|
+
import { buildDelegationDirective } from "./prompt.ts";
|
|
26
|
+
import { createRuntime } from "./runtime.ts";
|
|
27
|
+
import { runSetup } from "./setup.ts";
|
|
28
|
+
import { currentSubagentDepth } from "./spawn.ts";
|
|
29
|
+
import { registerLookupTools } from "./tools.ts";
|
|
30
|
+
import { registerWidget } from "./widget.ts";
|
|
31
|
+
|
|
32
|
+
export { matchRunIds };
|
|
33
|
+
|
|
34
|
+
export default function (pi: ExtensionAPI): void {
|
|
35
|
+
const configPath = getConfigPath(getAgentDir());
|
|
36
|
+
const runtime = createRuntime(pi, configPath);
|
|
37
|
+
|
|
38
|
+
// Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
|
|
39
|
+
// excluded from their toolset at spawn (--exclude-tools); this check is defense
|
|
40
|
+
// in depth so a child can never expose the tool back to its model, even if
|
|
41
|
+
// another extension ignores the depth marker.
|
|
42
|
+
if (currentSubagentDepth() >= 1) {
|
|
43
|
+
pi.registerCommand("subagents-setup", {
|
|
44
|
+
description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
|
|
45
|
+
handler: async (_args, ctx) => {
|
|
46
|
+
ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
|
|
53
|
+
new Text(
|
|
54
|
+
`${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
|
|
55
|
+
0,
|
|
56
|
+
0,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
pi.on("session_shutdown", () => {
|
|
61
|
+
runtime.shutdown();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
registerSubagentTool(pi, runtime);
|
|
65
|
+
registerLookupTools(pi, runtime);
|
|
66
|
+
|
|
67
|
+
pi.registerCommand("subagents-setup", {
|
|
68
|
+
description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
|
|
69
|
+
handler: async (_args, ctx) => {
|
|
70
|
+
await runSetup(ctx, configPath);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Persistent widget above the editor showing live sub-agent status, plus
|
|
75
|
+
// one-time feature announcements after updates.
|
|
76
|
+
registerWidget(pi, runtime);
|
|
77
|
+
|
|
78
|
+
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
79
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
80
|
+
const config = await loadConfig(configPath);
|
|
81
|
+
if (!config.proactiveInjection) return undefined;
|
|
82
|
+
const { agents } = discoverAgents(ctx.cwd, {
|
|
83
|
+
scope: config.agentScope,
|
|
84
|
+
enabledNames: config.enabledAgents,
|
|
85
|
+
});
|
|
86
|
+
const directive = buildDelegationDirective(agents);
|
|
87
|
+
if (!directive) return undefined;
|
|
88
|
+
return { systemPrompt: `${event.systemPrompt}\n${directive}` };
|
|
89
|
+
});
|
|
90
|
+
}
|