@arhen/pi-core-subagent 1.0.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/LICENSE +22 -0
- package/README.md +116 -0
- package/package.json +45 -0
- package/src/agents.ts +70 -0
- package/src/child.ts +153 -0
- package/src/index.ts +1173 -0
- package/src/mailbox.ts +42 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* minimalist-subagents — pi extension.
|
|
3
|
+
*
|
|
4
|
+
* Fast in-process subagents (isolated AgentSessions, no process spawn).
|
|
5
|
+
* Modes: single / parallel / chain. Background runs, cancel, intercom
|
|
6
|
+
* (ask/notify/update the leader) and agent↔agent mailbox (send/poll).
|
|
7
|
+
*
|
|
8
|
+
* Context discipline: 6 slim parent tools, one-line catalog injected per
|
|
9
|
+
* request (cached), background completions notify with a 3-line summary
|
|
10
|
+
* instead of full outputs, and run updates are throttled (no per-event
|
|
11
|
+
* deep clones).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AgentSessionEvent, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
17
|
+
import { StringEnum, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
|
18
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
import { lookupAgent } from "./agents.ts";
|
|
22
|
+
import { CHILD_TALK_TOOLS, createChildTools, createWatchdog, type ChildHandlers } from "./child.ts";
|
|
23
|
+
import { createMailbox, type Mailbox } from "./mailbox.ts";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
26
|
+
const MAX_CONCURRENCY = 8;
|
|
27
|
+
const MAX_TASKS = 16;
|
|
28
|
+
const DEFAULT_RUNTIME_MS = 10 * 60 * 1000;
|
|
29
|
+
const DEFAULT_STALL_MS = 90_000;
|
|
30
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
31
|
+
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
32
|
+
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
33
|
+
const FINAL_OUTPUT_CAP = 24 * 1024;
|
|
34
|
+
const WIDGET_THROTTLE_MS = 150;
|
|
35
|
+
|
|
36
|
+
type RunMode = "single" | "parallel" | "chain";
|
|
37
|
+
type TaskStatus = "queued" | "starting" | "running" | "awaiting_parent" | "completed" | "failed" | "aborted";
|
|
38
|
+
type RunStatus = "queued" | "running" | "awaiting_parent" | "completed" | "failed" | "aborted";
|
|
39
|
+
|
|
40
|
+
const TERMINAL: TaskStatus[] = ["completed", "failed", "aborted"];
|
|
41
|
+
|
|
42
|
+
interface UsageStats {
|
|
43
|
+
input: number;
|
|
44
|
+
output: number;
|
|
45
|
+
cacheRead: number;
|
|
46
|
+
cacheWrite: number;
|
|
47
|
+
cost: number;
|
|
48
|
+
turns: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TaskInput {
|
|
52
|
+
id?: string;
|
|
53
|
+
/** Name the leader invents for this subagent (display + mailbox addressing). */
|
|
54
|
+
agent: string;
|
|
55
|
+
task: string;
|
|
56
|
+
/** System prompt the leader writes for this agent. Optional — a minimal default is used. */
|
|
57
|
+
prompt?: string;
|
|
58
|
+
/** true = write toolset; false/omitted = read-only toolset. */
|
|
59
|
+
write?: boolean;
|
|
60
|
+
tools?: string[];
|
|
61
|
+
model?: string;
|
|
62
|
+
thinking?: string;
|
|
63
|
+
maxRuntimeMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface TaskSnapshot {
|
|
67
|
+
id: string;
|
|
68
|
+
runId: string;
|
|
69
|
+
agent: string;
|
|
70
|
+
task: string;
|
|
71
|
+
cwd: string;
|
|
72
|
+
status: TaskStatus;
|
|
73
|
+
sessionId?: string;
|
|
74
|
+
sessionFile?: string;
|
|
75
|
+
model?: string;
|
|
76
|
+
thinking?: string;
|
|
77
|
+
tools?: string[];
|
|
78
|
+
startedAt?: number;
|
|
79
|
+
endedAt?: number;
|
|
80
|
+
toolCalls: number;
|
|
81
|
+
/** Address + sibling roster, injected into the child so mailbox tools can address them. */
|
|
82
|
+
roster?: string;
|
|
83
|
+
lastActivity?: string;
|
|
84
|
+
usage: UsageStats;
|
|
85
|
+
finalText?: string;
|
|
86
|
+
error?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface RunSnapshot {
|
|
90
|
+
id: string;
|
|
91
|
+
mode: RunMode;
|
|
92
|
+
status: RunStatus;
|
|
93
|
+
background: boolean;
|
|
94
|
+
allowIntercom: boolean;
|
|
95
|
+
createdAt: number;
|
|
96
|
+
startedAt?: number;
|
|
97
|
+
endedAt?: number;
|
|
98
|
+
concurrency: number;
|
|
99
|
+
/** True once the parent awaited this run — completion notices are redundant then. */
|
|
100
|
+
awaited?: boolean;
|
|
101
|
+
/** Wake the parent (queued follow-up turn) as each task completes. Default true. */
|
|
102
|
+
notifyPerTask: boolean;
|
|
103
|
+
tasks: TaskSnapshot[];
|
|
104
|
+
aggregateUsage: UsageStats;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface RunDetails {
|
|
108
|
+
run: RunSnapshot;
|
|
109
|
+
background?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface PendingReply {
|
|
113
|
+
resolve: (answer: string) => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function newId(prefix: string): string {
|
|
119
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
120
|
+
}
|
|
121
|
+
function emptyUsage(): UsageStats {
|
|
122
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
123
|
+
}
|
|
124
|
+
function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
|
|
125
|
+
const total = emptyUsage();
|
|
126
|
+
for (const task of tasks) {
|
|
127
|
+
total.input += task.usage.input;
|
|
128
|
+
total.output += task.usage.output;
|
|
129
|
+
total.cacheRead += task.usage.cacheRead;
|
|
130
|
+
total.cacheWrite += task.usage.cacheWrite;
|
|
131
|
+
total.cost += task.usage.cost;
|
|
132
|
+
total.turns += task.usage.turns;
|
|
133
|
+
}
|
|
134
|
+
return total;
|
|
135
|
+
}
|
|
136
|
+
function truncateText(text: string, max = FINAL_OUTPUT_CAP): string {
|
|
137
|
+
if (Buffer.byteLength(text, "utf8") <= max) return text;
|
|
138
|
+
return `${text.slice(0, max)}\n\n[Output truncated. Full child session is available in the session file.]`;
|
|
139
|
+
}
|
|
140
|
+
function getFirstText(message: AssistantMessage): string {
|
|
141
|
+
for (const part of message?.content ?? []) {
|
|
142
|
+
if (part?.type === "text" && typeof part.text === "string") return part.text;
|
|
143
|
+
}
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
function getParentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
147
|
+
try {
|
|
148
|
+
return ctx.sessionManager.getSessionFile?.();
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* pi 0.84 StopReason enum: "stop" is NORMAL completion (was "end" in older pi).
|
|
155
|
+
* Only length/error/aborted/deferred/pending/toolUse-as-final are failures.
|
|
156
|
+
*/
|
|
157
|
+
export function classifyFailure(stopReason: string | undefined, errorMessage?: string): { status: "failed" | "aborted"; message: string } | undefined {
|
|
158
|
+
if (!stopReason || stopReason === "stop" || stopReason === "end") return undefined;
|
|
159
|
+
if (stopReason === "aborted") return { status: "aborted", message: errorMessage || "Subagent was aborted." };
|
|
160
|
+
return { status: "failed", message: errorMessage || `Subagent ended with stopReason "${stopReason}".` };
|
|
161
|
+
}
|
|
162
|
+
function lastAssistantFailure(messages: AssistantMessage[] | undefined): { status: "failed" | "aborted"; message: string } | undefined {
|
|
163
|
+
for (const message of [...(messages ?? [])].reverse()) {
|
|
164
|
+
if (message?.role !== "assistant") continue;
|
|
165
|
+
return classifyFailure(message.stopReason, message.errorMessage);
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
function failureError(failure: { status: "failed" | "aborted"; message: string }): Error {
|
|
170
|
+
const error = new Error(failure.message);
|
|
171
|
+
(error as Error & { subagentStatus?: string }).subagentStatus = failure.status;
|
|
172
|
+
return error;
|
|
173
|
+
}
|
|
174
|
+
function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage): void {
|
|
175
|
+
if (message?.role !== "assistant") return;
|
|
176
|
+
task.usage.turns += 1;
|
|
177
|
+
const usage = message.usage;
|
|
178
|
+
if (!usage) return;
|
|
179
|
+
task.usage.input += usage.input ?? 0;
|
|
180
|
+
task.usage.output += usage.output ?? 0;
|
|
181
|
+
task.usage.cacheRead += usage.cacheRead ?? 0;
|
|
182
|
+
task.usage.cacheWrite += usage.cacheWrite ?? 0;
|
|
183
|
+
task.usage.cost += usage.cost?.total ?? 0;
|
|
184
|
+
if (message.model && !task.model) task.model = message.model;
|
|
185
|
+
}
|
|
186
|
+
function fmtTokens(n: number): string {
|
|
187
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n);
|
|
188
|
+
}
|
|
189
|
+
function formatUsage(usage: UsageStats): string {
|
|
190
|
+
const parts: string[] = [];
|
|
191
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
192
|
+
if (usage.input) parts.push(`↑ ${fmtTokens(usage.input)}`);
|
|
193
|
+
if (usage.output) parts.push(`↓ ${fmtTokens(usage.output)}`);
|
|
194
|
+
if (usage.cost > 0) parts.push(usage.cost >= 0.0001 ? `$${usage.cost.toFixed(4)}` : "$<0.0001");
|
|
195
|
+
return parts.join(" · ");
|
|
196
|
+
}
|
|
197
|
+
function statusIcon(status: TaskStatus | RunStatus): string {
|
|
198
|
+
if (status === "completed") return "✓";
|
|
199
|
+
if (status === "failed") return "✗";
|
|
200
|
+
if (status === "aborted") return "⏹";
|
|
201
|
+
if (status === "awaiting_parent") return "❓";
|
|
202
|
+
if (status === "queued") return "○";
|
|
203
|
+
return "•";
|
|
204
|
+
}
|
|
205
|
+
function fmtDuration(ms: number | undefined): string {
|
|
206
|
+
if (ms === undefined || !Number.isFinite(ms)) return "–";
|
|
207
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
208
|
+
return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`;
|
|
209
|
+
}
|
|
210
|
+
function taskTimer(task: TaskSnapshot): string {
|
|
211
|
+
if (task.startedAt === undefined) return "–";
|
|
212
|
+
const end = task.endedAt ?? Date.now();
|
|
213
|
+
const running = !TERMINAL.includes(task.status);
|
|
214
|
+
return `${running ? "running " : ""}${fmtDuration(end - task.startedAt)}`;
|
|
215
|
+
}
|
|
216
|
+
function taskStatsWithUsage(task: TaskSnapshot): string {
|
|
217
|
+
const stats = `${task.toolCalls ?? 0} tools`;
|
|
218
|
+
const usage = formatUsage(task.usage);
|
|
219
|
+
return `${stats}${usage ? ` · ${usage}` : ""}`;
|
|
220
|
+
}
|
|
221
|
+
function taskLine(task: TaskSnapshot): string {
|
|
222
|
+
return `${statusIcon(task.status)} ${task.agent} · ${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
|
|
223
|
+
}
|
|
224
|
+
function argsSuffix(args: unknown): string {
|
|
225
|
+
try {
|
|
226
|
+
const s = JSON.stringify(args);
|
|
227
|
+
if (!s || s === "{}") return "";
|
|
228
|
+
return ` ${s.length > 60 ? `${s.slice(0, 60)}…` : s}`;
|
|
229
|
+
} catch {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function activitySnippet(text: string): string {
|
|
234
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
235
|
+
return flat.length > 90 ? `${flat.slice(0, 90)}…` : flat;
|
|
236
|
+
}
|
|
237
|
+
/** Static compact lines (tool-result stream, subagent_status, /subagents). */
|
|
238
|
+
function compactLines(run: RunSnapshot): string[] {
|
|
239
|
+
const lines: string[] = [];
|
|
240
|
+
for (const task of run.tasks.slice(0, MAX_TASKS)) {
|
|
241
|
+
lines.push(taskLine(task));
|
|
242
|
+
}
|
|
243
|
+
if (run.tasks.length > MAX_TASKS) lines.push(`… +${run.tasks.length - MAX_TASKS} more`);
|
|
244
|
+
return lines;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Above-editor widget, todo-tree style:
|
|
248
|
+
* ● Subagents (0/1)
|
|
249
|
+
* ├─ • code-sleuth · 4 tools · 12s
|
|
250
|
+
* │ → read src/auth.ts
|
|
251
|
+
* └─ ✓ reviewer · 6 tools · 44s
|
|
252
|
+
* Static icons (no animation); latest activity + tool count + runtime per agent.
|
|
253
|
+
*/
|
|
254
|
+
class SubagentsWidget implements Component {
|
|
255
|
+
constructor(
|
|
256
|
+
private readonly getRun: () => RunSnapshot | undefined,
|
|
257
|
+
private readonly theme: Theme,
|
|
258
|
+
) {}
|
|
259
|
+
|
|
260
|
+
invalidate(): void {
|
|
261
|
+
// no cached strings; render() reads live state
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
render(width: number): string[] {
|
|
265
|
+
const run = this.getRun();
|
|
266
|
+
if (!run || run.tasks.length === 0) return [];
|
|
267
|
+
const done = run.tasks.filter((t) => TERMINAL.includes(t.status)).length;
|
|
268
|
+
const active = !TERMINAL.includes(run.status);
|
|
269
|
+
const head = active ? "accent" : "dim";
|
|
270
|
+
const lines = [truncateToWidth(`${this.theme.fg(head, active ? "●" : "○")} ${this.theme.fg(head, `Subagents (${done}/${run.tasks.length})`)}`, width, "…")];
|
|
271
|
+
const visible = run.tasks.slice(0, MAX_TASKS);
|
|
272
|
+
visible.forEach((task, i) => {
|
|
273
|
+
const last = i === visible.length - 1 && run.tasks.length <= MAX_TASKS;
|
|
274
|
+
const conn = this.theme.fg("dim", last ? "└─" : "├─");
|
|
275
|
+
const activity =
|
|
276
|
+
!TERMINAL.includes(task.status) && task.lastActivity
|
|
277
|
+
? `${this.theme.fg("dim", `→ ${task.lastActivity}`)} · `
|
|
278
|
+
: "";
|
|
279
|
+
const line = `${statusIcon(task.status)} ${task.agent} · ${activity}${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
|
|
280
|
+
lines.push(truncateToWidth(`${conn} ${line}`, width, "…"));
|
|
281
|
+
});
|
|
282
|
+
if (run.tasks.length > MAX_TASKS) lines.push(`${this.theme.fg("dim", "└─")} ${this.theme.fg("dim", `+${run.tasks.length - MAX_TASKS} more`)}`);
|
|
283
|
+
return lines;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** Blocking-call summary: full text, because the model asked for it. */
|
|
287
|
+
function makeSummary(run: RunSnapshot): string {
|
|
288
|
+
const succeeded = run.tasks.filter((t) => t.status === "completed").length;
|
|
289
|
+
const failed = run.tasks.filter((t) => t.status === "failed").length;
|
|
290
|
+
const aborted = run.tasks.filter((t) => t.status === "aborted").length;
|
|
291
|
+
const lines = [`Run ${run.id}: Subagents ${run.mode}${run.background ? " (background)" : ""} finished: ${succeeded}/${run.tasks.length} succeeded${failed ? `, ${failed} failed` : ""}${aborted ? `, ${aborted} aborted` : ""}.`];
|
|
292
|
+
const usage = formatUsage(run.aggregateUsage);
|
|
293
|
+
if (usage) lines.push(`Usage: ${usage}`);
|
|
294
|
+
for (const task of run.tasks) {
|
|
295
|
+
lines.push(`\n## ${task.agent} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}`);
|
|
296
|
+
}
|
|
297
|
+
// Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
|
|
298
|
+
return truncateText(lines.join("\n"));
|
|
299
|
+
}
|
|
300
|
+
/** Per-task notice: one task's outcome, small. Full output stays out of parent context. */
|
|
301
|
+
function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: string): string {
|
|
302
|
+
const detail = task.error ? task.error : truncateText(task.finalText || "(no output)", 200);
|
|
303
|
+
return [
|
|
304
|
+
`Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}`,
|
|
305
|
+
`Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
|
|
306
|
+
].join("\n");
|
|
307
|
+
}
|
|
308
|
+
/** Notification: 3 lines max. Full output stays out of parent context. */
|
|
309
|
+
function makeNotice(run: RunSnapshot, kind: string): string {
|
|
310
|
+
const lines = [`Background subagent run ${run.id} ${kind}: ${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length} succeeded.`];
|
|
311
|
+
for (const task of run.tasks) {
|
|
312
|
+
lines.push(`- ${task.agent}: ${task.status}${task.error ? ` — ${truncateText(task.error, 200)}` : ""}`);
|
|
313
|
+
}
|
|
314
|
+
lines.push(`Use subagent_result(runId: "${run.id}") for full output.`);
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
function cloneRun(run: RunSnapshot): RunSnapshot {
|
|
318
|
+
return JSON.parse(JSON.stringify(run)) as RunSnapshot;
|
|
319
|
+
}
|
|
320
|
+
/** Resolve a child model from the pi model registry.
|
|
321
|
+
* Order: explicit "provider/model-id" or bare id (searched across available
|
|
322
|
+
* models) → agent file model → parent's current model (ctx.model) → undefined
|
|
323
|
+
* (createAgentSession falls back to settings). */
|
|
324
|
+
function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
|
|
325
|
+
if (explicit?.trim()) {
|
|
326
|
+
const ref = explicit.trim();
|
|
327
|
+
const slash = ref.indexOf("/");
|
|
328
|
+
if (slash > 0 && slash < ref.length - 1) {
|
|
329
|
+
const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
330
|
+
if (!model) throw new Error(`Model not found: ${ref}`);
|
|
331
|
+
return model;
|
|
332
|
+
}
|
|
333
|
+
const byId = ctx.modelRegistry.getAvailable().find((m) => m.id === ref);
|
|
334
|
+
if (!byId) throw new Error(`Model not found: ${ref}`);
|
|
335
|
+
return byId;
|
|
336
|
+
}
|
|
337
|
+
return ctx.model; // inherit the parent's active model
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Validate a thinking level against the RESOLVED model's registry entry.
|
|
341
|
+
* thinkingLevelMap: null = unsupported, missing key = provider default,
|
|
342
|
+
* absent map = provider defaults. Non-reasoning models only accept "off". */
|
|
343
|
+
export function validateThinking(model: Model<Api> | undefined, level: string | undefined): void {
|
|
344
|
+
if (!level || level === "off") return;
|
|
345
|
+
if (!model) return;
|
|
346
|
+
const map = model.thinkingLevelMap;
|
|
347
|
+
if (map && level in map && map[level as keyof typeof map] === null) {
|
|
348
|
+
const supported = Object.keys(map).filter((k) => map[k as keyof typeof map] !== null);
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Thinking level "${level}" is not supported by ${model.provider}/${model.id}. Supported: ${supported.length ? supported.join(" | ") : "none — use thinking: \"off\""}.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (!model.reasoning) {
|
|
354
|
+
throw new Error(`Model ${model.provider}/${model.id} does not support thinking. Use thinking: "off".`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Cached catalog removed: agents are defined inline by the leader per call,
|
|
359
|
+
// so there is nothing to inject into the parent context. Zero per-request cost.
|
|
360
|
+
|
|
361
|
+
async function mapWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T, index: number) => Promise<void>): Promise<void> {
|
|
362
|
+
let next = 0;
|
|
363
|
+
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
|
|
364
|
+
while (next < items.length) {
|
|
365
|
+
const index = next++;
|
|
366
|
+
await fn(items[index] as T, index);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
await Promise.all(workers);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── manager ──────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
class SubagentManager {
|
|
375
|
+
private runs = new Map<string, RunSnapshot>();
|
|
376
|
+
private settlers = new Map<string, (run: RunSnapshot) => void>();
|
|
377
|
+
private pendingReplies = new Map<string, PendingReply>();
|
|
378
|
+
private liveChildren = new Map<string, { abort: () => void; dispose: () => void; touchWatchdog: () => void }>();
|
|
379
|
+
private mailboxes: Mailbox = createMailbox();
|
|
380
|
+
private runControllers = new Map<string, AbortController>();
|
|
381
|
+
private widgetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
382
|
+
private widgetRun: RunSnapshot | undefined;
|
|
383
|
+
|
|
384
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
385
|
+
|
|
386
|
+
listRuns(): RunSnapshot[] {
|
|
387
|
+
return Array.from(this.runs.values()).sort((a, b) => b.createdAt - a.createdAt);
|
|
388
|
+
}
|
|
389
|
+
getRun(runId: string | undefined): RunSnapshot | undefined {
|
|
390
|
+
return runId ? this.runs.get(runId) : undefined;
|
|
391
|
+
}
|
|
392
|
+
clearRuns(): void {
|
|
393
|
+
this.runs.clear();
|
|
394
|
+
this.settlers.clear();
|
|
395
|
+
this.pendingReplies.clear();
|
|
396
|
+
this.runControllers.clear();
|
|
397
|
+
this.mailboxes = createMailbox();
|
|
398
|
+
this.widgetTui = null; // force re-registration on the next session
|
|
399
|
+
if (this.widgetTimer) clearTimeout(this.widgetTimer);
|
|
400
|
+
this.widgetTimer = undefined;
|
|
401
|
+
this.widgetRun = undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── persistence (sidecar per parent session) ────────────────────────
|
|
405
|
+
async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
|
|
406
|
+
const parentFile = getParentSessionFile(ctx);
|
|
407
|
+
if (!parentFile) return;
|
|
408
|
+
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
409
|
+
let runs: RunSnapshot[];
|
|
410
|
+
try {
|
|
411
|
+
const { readFileSync, existsSync } = await import("fs");
|
|
412
|
+
if (!existsSync(sidecar)) return;
|
|
413
|
+
const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
|
|
414
|
+
if (!Array.isArray(raw)) return;
|
|
415
|
+
runs = (raw as RunSnapshot[]).map((run) => {
|
|
416
|
+
const interrupted = run.tasks.some((t) => !TERMINAL.includes(t.status));
|
|
417
|
+
return {
|
|
418
|
+
...run,
|
|
419
|
+
status: interrupted ? ("aborted" as RunStatus) : run.status,
|
|
420
|
+
endedAt: interrupted ? Date.now() : run.endedAt,
|
|
421
|
+
tasks: run.tasks.map((t) => (TERMINAL.includes(t.status) ? t : { ...t, status: "aborted" as TaskStatus, error: t.error || "Interrupted by session reload" })),
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
} catch {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
let added = 0;
|
|
428
|
+
for (const run of runs) {
|
|
429
|
+
if (!run?.id || this.runs.has(run.id)) continue;
|
|
430
|
+
this.runs.set(run.id, run);
|
|
431
|
+
added += 1;
|
|
432
|
+
}
|
|
433
|
+
if (added > 0) {
|
|
434
|
+
this.emit("subagent:runs-restored", { count: added });
|
|
435
|
+
this.scheduleWidget(this.listRuns()[0], ctx);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
private persist(ctx: ExtensionContext): void {
|
|
439
|
+
try {
|
|
440
|
+
const parentFile = getParentSessionFile(ctx);
|
|
441
|
+
if (!parentFile) return;
|
|
442
|
+
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
443
|
+
import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().map(cloneRun), null, 2)));
|
|
444
|
+
} catch {
|
|
445
|
+
/* ignore */
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private emit(type: string, payload: Record<string, unknown>): void {
|
|
450
|
+
this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Per-task wake-up: queued follow-up so the parent can interleave responses. */
|
|
454
|
+
private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
|
|
455
|
+
const body = makeTaskNotice(run, task, kind);
|
|
456
|
+
try {
|
|
457
|
+
this.pi.sendUserMessage(body, { deliverAs: "followUp" });
|
|
458
|
+
} catch {
|
|
459
|
+
/* parent mid-stream; consumers can poll subagent_status */
|
|
460
|
+
}
|
|
461
|
+
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Wake the parent with a 3-line notice. Full text stays out of context.
|
|
465
|
+
* deliverAs followUp queues the message if the parent is mid-stream
|
|
466
|
+
* (e.g. inside await_subagent) instead of throwing/aborting. */
|
|
467
|
+
private notifyParent(run: RunSnapshot, kind: "completed" | "failed" | "aborted" | "asked", extra?: { taskId?: string; question?: string }): void {
|
|
468
|
+
if (kind !== "asked" && run.awaited) return; // parent already got the result via await_subagent
|
|
469
|
+
const body = kind === "asked"
|
|
470
|
+
? `A background subagent is asking you a question (task ${extra?.taskId}): ${extra?.question ?? ""}\nReply with reply_subagent(runId: "${run.id}", taskId: "${extra?.taskId}", message: ...).`
|
|
471
|
+
: makeNotice(run, kind);
|
|
472
|
+
try {
|
|
473
|
+
this.pi.sendUserMessage(body, { deliverAs: "followUp" });
|
|
474
|
+
} catch {
|
|
475
|
+
/* parent mid-stream; consumers can poll subagent_status */
|
|
476
|
+
}
|
|
477
|
+
this.emit("subagent:notification", { runId: run.id, kind, body });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Widget: register-once + requestRender (todo-overlay pattern).
|
|
481
|
+
// The component self-animates the spinner via its own 100ms interval;
|
|
482
|
+
// scheduleWidget just throttles status changes into requestRender calls.
|
|
483
|
+
private widgetTui: TUI | null = null;
|
|
484
|
+
private scheduleWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
485
|
+
if (run) this.widgetRun = run;
|
|
486
|
+
if (this.widgetTimer || !this.widgetRun) return;
|
|
487
|
+
this.widgetTimer = setTimeout(() => {
|
|
488
|
+
this.widgetTimer = undefined;
|
|
489
|
+
const target = this.widgetRun; // read at fire: never render a stale run
|
|
490
|
+
if (!target) return;
|
|
491
|
+
if (ctx?.hasUI) {
|
|
492
|
+
ctx.ui.setStatus("subagents", `subagents: ${target.tasks.filter((t) => !TERMINAL.includes(t.status)).length} running`);
|
|
493
|
+
this.ensureWidget(ctx);
|
|
494
|
+
this.widgetTui?.requestRender();
|
|
495
|
+
}
|
|
496
|
+
onUpdate?.({ content: [{ type: "text", text: compactLines(target).join("\n") }] });
|
|
497
|
+
}, WIDGET_THROTTLE_MS);
|
|
498
|
+
}
|
|
499
|
+
private flushWidget(ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
500
|
+
if (this.widgetTimer) {
|
|
501
|
+
clearTimeout(this.widgetTimer);
|
|
502
|
+
this.widgetTimer = undefined;
|
|
503
|
+
}
|
|
504
|
+
const run = this.widgetRun;
|
|
505
|
+
if (!run) return;
|
|
506
|
+
if (ctx?.hasUI) {
|
|
507
|
+
ctx.ui.setStatus("subagents", `subagents: ${run.status}`);
|
|
508
|
+
this.ensureWidget(ctx);
|
|
509
|
+
this.widgetTui?.requestRender();
|
|
510
|
+
}
|
|
511
|
+
onUpdate?.({ content: [{ type: "text", text: compactLines(run).join("\n") }] });
|
|
512
|
+
}
|
|
513
|
+
private ensureWidget(ctx: ExtensionContext): void {
|
|
514
|
+
if (this.widgetTui !== null || !ctx.hasUI) return;
|
|
515
|
+
ctx.ui.setWidget(
|
|
516
|
+
"subagents",
|
|
517
|
+
(tui, theme) => {
|
|
518
|
+
this.widgetTui = tui;
|
|
519
|
+
return new SubagentsWidget(() => this.widgetRun, theme);
|
|
520
|
+
},
|
|
521
|
+
{ placement: "aboveEditor" },
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private updateRun(run: RunSnapshot, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
526
|
+
run.aggregateUsage = aggregateUsage(run.tasks);
|
|
527
|
+
this.runs.set(run.id, run);
|
|
528
|
+
this.emit("subagent:run-updated", { runId: run.id, status: run.status, live: run.tasks.filter((t) => !TERMINAL.includes(t.status)).length });
|
|
529
|
+
this.scheduleWidget(run, ctx, onUpdate);
|
|
530
|
+
}
|
|
531
|
+
private updateTask(run: RunSnapshot, task: TaskSnapshot, patch: Partial<TaskSnapshot>, ctx: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
532
|
+
Object.assign(task, patch);
|
|
533
|
+
this.emit("subagent:task-updated", { runId: run.id, taskId: task.id, status: task.status });
|
|
534
|
+
this.updateRun(run, ctx, onUpdate);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── intercom + mailbox ──────────────────────────────────────────────
|
|
538
|
+
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
539
|
+
return {
|
|
540
|
+
onAskParent: async (_taskId, question) => {
|
|
541
|
+
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
542
|
+
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
543
|
+
this.notifyParent(run, "asked", { taskId: task.id, question });
|
|
544
|
+
const reply = await this.awaitParentReply(run.id, task.id);
|
|
545
|
+
this.updateTask(run, task, { status: "running" }, ctx);
|
|
546
|
+
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
547
|
+
return reply;
|
|
548
|
+
},
|
|
549
|
+
onNotifyParent: (_taskId, message, level) => {
|
|
550
|
+
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
|
|
551
|
+
},
|
|
552
|
+
onSendMessage: (_taskId, to, text) => {
|
|
553
|
+
if (to === "leader") {
|
|
554
|
+
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level: "info", message: text });
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
// Run-scoped keys: sibling ids are run-local; cross-run task_1 can never collide.
|
|
558
|
+
return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
|
|
559
|
+
},
|
|
560
|
+
onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
private awaitParentReply(runId: string, taskId: string): Promise<string> {
|
|
564
|
+
return new Promise<string>((resolve) => {
|
|
565
|
+
this.pendingReplies.set(`${runId}:${taskId}`, { resolve });
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
deliverReply(runId: string, taskId: string, message: string): boolean {
|
|
569
|
+
const pending = this.pendingReplies.get(`${runId}:${taskId}`);
|
|
570
|
+
if (!pending) return false;
|
|
571
|
+
this.pendingReplies.delete(`${runId}:${taskId}`);
|
|
572
|
+
pending.resolve(message);
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── child execution ─────────────────────────────────────────────────
|
|
577
|
+
private async runChild(
|
|
578
|
+
run: RunSnapshot,
|
|
579
|
+
task: TaskSnapshot,
|
|
580
|
+
input: TaskInput,
|
|
581
|
+
ctx: ExtensionContext,
|
|
582
|
+
signal: AbortSignal | undefined,
|
|
583
|
+
onUpdate?: (partial: any) => void,
|
|
584
|
+
): Promise<void> {
|
|
585
|
+
if (TERMINAL.includes(task.status)) return; // canceled while queued
|
|
586
|
+
|
|
587
|
+
// Inline params win; otherwise fall back to an existing agent file
|
|
588
|
+
// (~/.agents, .pi/agents, user dir). Never creates files.
|
|
589
|
+
const fileAgent = input.prompt?.trim() ? undefined : lookupAgent(task.agent, task.cwd);
|
|
590
|
+
const prompt = input.prompt?.trim() || fileAgent?.prompt;
|
|
591
|
+
const thinking = input.thinking ?? fileAgent?.thinking;
|
|
592
|
+
const baseTools = input.tools ?? (input.write ? WRITE_TOOLS : fileAgent?.tools ?? READONLY_TOOLS);
|
|
593
|
+
const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
|
|
594
|
+
|
|
595
|
+
// Model + thinking resolve against the pi model registry; a bad request
|
|
596
|
+
// fails the TASK with a helpful message, not the whole run.
|
|
597
|
+
let model: Model<Api> | undefined;
|
|
598
|
+
try {
|
|
599
|
+
model = resolveChildModel(ctx, input.model ?? fileAgent?.model);
|
|
600
|
+
validateThinking(model, thinking);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
this.updateTask(run, task, {
|
|
603
|
+
status: "failed",
|
|
604
|
+
error: err instanceof Error ? err.message : String(err),
|
|
605
|
+
endedAt: Date.now(),
|
|
606
|
+
}, ctx, onUpdate);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
this.updateTask(run, task, {
|
|
611
|
+
status: "starting",
|
|
612
|
+
startedAt: Date.now(),
|
|
613
|
+
model: input.model ?? fileAgent?.model,
|
|
614
|
+
thinking,
|
|
615
|
+
tools,
|
|
616
|
+
}, ctx, onUpdate);
|
|
617
|
+
|
|
618
|
+
let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
619
|
+
let unsubscribe: (() => void) | undefined;
|
|
620
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
621
|
+
let abortListener: (() => void) | undefined;
|
|
622
|
+
let watchdog = createWatchdog(DEFAULT_STALL_MS, `Subagent ${task.agent}`);
|
|
623
|
+
let pendingFailure: ReturnType<typeof classifyFailure>;
|
|
624
|
+
let failChildEnd: ((error: Error) => void) | undefined;
|
|
625
|
+
let childEndResolve: (() => void) | undefined;
|
|
626
|
+
|
|
627
|
+
const key = `${run.id}:${task.id}`;
|
|
628
|
+
try {
|
|
629
|
+
const subagentInstruction = run.allowIntercom
|
|
630
|
+
? `You are running as a subagent. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets.`
|
|
631
|
+
: "You are running as a subagent. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.";
|
|
632
|
+
|
|
633
|
+
const loader = new DefaultResourceLoader({
|
|
634
|
+
cwd: task.cwd,
|
|
635
|
+
agentDir: getAgentDir(),
|
|
636
|
+
noExtensions: true,
|
|
637
|
+
appendSystemPromptOverride: (base) => [...base, [prompt?.trim(), subagentInstruction].filter(Boolean).join("\n\n")],
|
|
638
|
+
});
|
|
639
|
+
await loader.reload();
|
|
640
|
+
|
|
641
|
+
const customTools: ToolDefinition[] = run.allowIntercom ? createChildTools(task.id, this.makeChildHandlers(run, task, ctx)) : [];
|
|
642
|
+
|
|
643
|
+
const created = await createAgentSession({
|
|
644
|
+
cwd: task.cwd,
|
|
645
|
+
agentDir: getAgentDir(),
|
|
646
|
+
resourceLoader: loader,
|
|
647
|
+
sessionManager: SessionManager.create(task.cwd, undefined, { parentSession: getParentSessionFile(ctx) }),
|
|
648
|
+
model,
|
|
649
|
+
thinkingLevel: thinking as ThinkingLevel | undefined,
|
|
650
|
+
tools,
|
|
651
|
+
customTools,
|
|
652
|
+
});
|
|
653
|
+
child = created.session;
|
|
654
|
+
child.setSessionName?.(`subagent: ${task.agent}`);
|
|
655
|
+
this.updateTask(run, task, { status: "running", sessionId: child.sessionId, sessionFile: child.sessionFile }, ctx, onUpdate);
|
|
656
|
+
|
|
657
|
+
const childFailurePromise = new Promise<never>((_, reject) => {
|
|
658
|
+
failChildEnd = reject;
|
|
659
|
+
});
|
|
660
|
+
const childEndPromise = new Promise<void>((resolve) => {
|
|
661
|
+
childEndResolve = resolve;
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
unsubscribe = child.subscribe((event: AgentSessionEvent) => {
|
|
665
|
+
const active = event.type === "message_end" || event.type === "tool_execution_start" || event.type === "tool_execution_end" || event.type === "agent_settled";
|
|
666
|
+
if (active) {
|
|
667
|
+
watchdog.touch();
|
|
668
|
+
this.emit("subagent:session-event", { runId: run.id, taskId: task.id, seq: eventSeq++, event: { type: event.type } });
|
|
669
|
+
}
|
|
670
|
+
if (event.type === "tool_execution_start") {
|
|
671
|
+
this.updateTask(run, task, { toolCalls: task.toolCalls + 1, lastActivity: `${event.toolName}${argsSuffix(event.args)}` }, ctx, onUpdate);
|
|
672
|
+
} else if (event.type === "tool_execution_end") {
|
|
673
|
+
this.scheduleWidget(run, ctx, onUpdate);
|
|
674
|
+
} else if (event.type === "message_end") {
|
|
675
|
+
const message = event.message as AssistantMessage;
|
|
676
|
+
if (message?.role === "assistant") {
|
|
677
|
+
updateUsageFromMessage(task, message);
|
|
678
|
+
const text = getFirstText(message);
|
|
679
|
+
if (text) {
|
|
680
|
+
task.finalText = truncateText(text);
|
|
681
|
+
task.lastActivity = activitySnippet(text);
|
|
682
|
+
}
|
|
683
|
+
pendingFailure = classifyFailure(message.stopReason, message.errorMessage);
|
|
684
|
+
}
|
|
685
|
+
this.updateRun(run, ctx, onUpdate);
|
|
686
|
+
} else if (event.type === "agent_end") {
|
|
687
|
+
if (event.willRetry) {
|
|
688
|
+
pendingFailure = undefined; // retry in flight — don't trust stale failures
|
|
689
|
+
} else {
|
|
690
|
+
const failure = lastAssistantFailure(event.messages as AssistantMessage[]);
|
|
691
|
+
if (failure) {
|
|
692
|
+
pendingFailure = failure;
|
|
693
|
+
failChildEnd?.(failureError(failure));
|
|
694
|
+
}
|
|
695
|
+
// NOTE: success does NOT resolve childEndPromise here — pi may run a
|
|
696
|
+
// continuation leg (compaction/overflow recovery) that emits another
|
|
697
|
+
// agent_end. Resolve only on agent_settled, after all legs finish.
|
|
698
|
+
}
|
|
699
|
+
} else if (event.type === "agent_settled") {
|
|
700
|
+
childEndResolve?.();
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const abortChild = () => void child?.abort();
|
|
705
|
+
const runController = this.runControllers.get(run.id);
|
|
706
|
+
if (signal) signal.addEventListener("abort", abortChild, { once: true });
|
|
707
|
+
if (runController) runController.signal.addEventListener("abort", abortChild, { once: true });
|
|
708
|
+
abortListener = () => {
|
|
709
|
+
signal?.removeEventListener("abort", abortChild);
|
|
710
|
+
runController?.signal.removeEventListener("abort", abortChild);
|
|
711
|
+
};
|
|
712
|
+
// Cancel may have landed during session creation — honor it before prompting.
|
|
713
|
+
if (run.status === "aborted" || TERMINAL.includes(task.status)) {
|
|
714
|
+
await child.abort();
|
|
715
|
+
throw new Error("Canceled by subagent_cancel");
|
|
716
|
+
}
|
|
717
|
+
this.liveChildren.set(key, { abort: () => void child?.abort(), dispose: () => watchdog.dispose(), touchWatchdog: () => watchdog.touch() });
|
|
718
|
+
|
|
719
|
+
const maxRuntimeMs = input.maxRuntimeMs ?? DEFAULT_RUNTIME_MS;
|
|
720
|
+
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
721
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
722
|
+
timeout = setTimeout(() => reject(new Error(`Subagent timed out after ${maxRuntimeMs}ms`)), maxRuntimeMs);
|
|
723
|
+
});
|
|
724
|
+
await Promise.race([promptPromise, childFailurePromise, childEndPromise, watchdog.promise, timeoutPromise]);
|
|
725
|
+
if (timeout) clearTimeout(timeout);
|
|
726
|
+
|
|
727
|
+
pendingFailure ??= lastAssistantFailure(child.messages as AssistantMessage[]);
|
|
728
|
+
if (pendingFailure) throw failureError(pendingFailure);
|
|
729
|
+
|
|
730
|
+
const finalText = task.finalText || truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
731
|
+
if (task.status !== "aborted") {
|
|
732
|
+
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
733
|
+
}
|
|
734
|
+
} catch (err) {
|
|
735
|
+
if (timeout) clearTimeout(timeout);
|
|
736
|
+
// Cancel is authoritative: parent tool signal OR run/task already marked aborted.
|
|
737
|
+
const aborted = signal?.aborted || run.status === "aborted" || task.status === "aborted";
|
|
738
|
+
const subagentStatus = (err as Error & { subagentStatus?: string })?.subagentStatus;
|
|
739
|
+
try {
|
|
740
|
+
// Unblock a child stuck in ask_parent, then time-box the abort so a
|
|
741
|
+
// wedged session can never hang this catch/finally.
|
|
742
|
+
this.pendingReplies.get(key)?.resolve("(parent unreachable)");
|
|
743
|
+
await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
|
|
744
|
+
} catch {
|
|
745
|
+
/* ignore */
|
|
746
|
+
}
|
|
747
|
+
this.updateTask(run, task, {
|
|
748
|
+
status: aborted ? "aborted" : (subagentStatus as TaskStatus) ?? "failed",
|
|
749
|
+
error: err instanceof Error ? err.message : String(err),
|
|
750
|
+
endedAt: Date.now(),
|
|
751
|
+
}, ctx, onUpdate);
|
|
752
|
+
} finally {
|
|
753
|
+
this.liveChildren.delete(key);
|
|
754
|
+
this.pendingReplies.delete(key);
|
|
755
|
+
abortListener?.();
|
|
756
|
+
unsubscribe?.();
|
|
757
|
+
watchdog.dispose();
|
|
758
|
+
if (timeout) clearTimeout(timeout);
|
|
759
|
+
child?.dispose();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ── run lifecycle ───────────────────────────────────────────────────
|
|
764
|
+
createRun(params: SubagentParamsShape, ctx: ExtensionContext): { run: RunSnapshot; inputs: TaskInput[] } {
|
|
765
|
+
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
766
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
767
|
+
const hasSingle = Boolean(params.agent && params.task);
|
|
768
|
+
if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
769
|
+
throw new Error(`Provide exactly one subagent mode (single, tasks, or chain).`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const mode: RunMode = hasChain ? "chain" : hasTasks ? "parallel" : "single";
|
|
773
|
+
const inputs: TaskInput[] = hasSingle
|
|
774
|
+
? [{ agent: params.agent as string, task: params.task as string, prompt: params.prompt, write: params.write, model: params.model, thinking: params.thinking, tools: params.tools, maxRuntimeMs: params.maxRuntimeMs }]
|
|
775
|
+
: hasTasks
|
|
776
|
+
? params.tasks!
|
|
777
|
+
: params.chain!;
|
|
778
|
+
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
779
|
+
|
|
780
|
+
const run: RunSnapshot = {
|
|
781
|
+
id: newId("run"),
|
|
782
|
+
mode,
|
|
783
|
+
status: "queued",
|
|
784
|
+
background: Boolean(params.background),
|
|
785
|
+
allowIntercom: Boolean(params.allowIntercom),
|
|
786
|
+
notifyPerTask: params.notifyPerTask ?? false,
|
|
787
|
+
createdAt: Date.now(),
|
|
788
|
+
concurrency: Math.max(1, Math.min(params.concurrency ?? DEFAULT_CONCURRENCY, MAX_CONCURRENCY)),
|
|
789
|
+
tasks: inputs.map((input, index) => ({
|
|
790
|
+
id: input.id ?? `task_${index + 1}`,
|
|
791
|
+
runId: "",
|
|
792
|
+
agent: input.agent,
|
|
793
|
+
task: input.task,
|
|
794
|
+
cwd: ctx.cwd,
|
|
795
|
+
status: "queued" as TaskStatus,
|
|
796
|
+
model: input.model,
|
|
797
|
+
thinking: input.thinking,
|
|
798
|
+
tools: input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS),
|
|
799
|
+
toolCalls: 0,
|
|
800
|
+
usage: emptyUsage(),
|
|
801
|
+
})),
|
|
802
|
+
aggregateUsage: emptyUsage(),
|
|
803
|
+
};
|
|
804
|
+
// Roster: each child learns its own address + sibling addresses so
|
|
805
|
+
// send_agent_message/poll_agent_messages can be used reliably.
|
|
806
|
+
const roster = run.tasks.map((t) => `${t.id} (${t.agent})`).join(", ");
|
|
807
|
+
for (const task of run.tasks) {
|
|
808
|
+
task.roster = roster;
|
|
809
|
+
}
|
|
810
|
+
run.tasks.forEach((t) => (t.runId = run.id));
|
|
811
|
+
this.runs.set(run.id, run);
|
|
812
|
+
this.settlers.set(run.id, () => {});
|
|
813
|
+
this.runControllers.set(run.id, new AbortController());
|
|
814
|
+
for (const task of run.tasks) this.mailboxes.open(`${run.id}:${task.id}`);
|
|
815
|
+
this.emit("subagent:run-created", { run: cloneRun(run) });
|
|
816
|
+
return { run, inputs };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private async executeTasks(run: RunSnapshot, inputs: TaskInput[], ctx: ExtensionContext, signal: AbortSignal | undefined, onUpdate?: (partial: any) => void): Promise<void> {
|
|
820
|
+
run.status = "running";
|
|
821
|
+
run.startedAt = Date.now();
|
|
822
|
+
this.updateRun(run, ctx, onUpdate);
|
|
823
|
+
|
|
824
|
+
if (run.mode === "chain") {
|
|
825
|
+
let previous = "";
|
|
826
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
827
|
+
const task = run.tasks[i]!;
|
|
828
|
+
if (TERMINAL.includes(task.status)) continue; // canceled
|
|
829
|
+
const rawTask = inputs[i]!.task;
|
|
830
|
+
let next = rawTask.replace(/\{previous\}/g, () => previous); // replacer fn: no $ corruption
|
|
831
|
+
if (previous === "" && rawTask.includes("{previous}")) {
|
|
832
|
+
next += "\n\n(Note: {previous} was empty — no prior step output existed yet.)";
|
|
833
|
+
}
|
|
834
|
+
const input = { ...inputs[i]!, task: next };
|
|
835
|
+
task.task = input.task;
|
|
836
|
+
await this.runChild(run, task, input, ctx, signal, onUpdate);
|
|
837
|
+
if (run.notifyPerTask && TERMINAL.includes(task.status)) {
|
|
838
|
+
this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
839
|
+
}
|
|
840
|
+
if (task.status !== "completed") break;
|
|
841
|
+
previous = task.finalText ?? "";
|
|
842
|
+
}
|
|
843
|
+
} else {
|
|
844
|
+
await mapWithConcurrency(run.tasks, run.mode === "single" ? 1 : run.concurrency, async (task) => {
|
|
845
|
+
const index = run.tasks.indexOf(task);
|
|
846
|
+
await this.runChild(run, task, inputs[index]!, ctx, signal, onUpdate);
|
|
847
|
+
if (run.notifyPerTask && TERMINAL.includes(task.status)) {
|
|
848
|
+
this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const failed = run.tasks.some((t) => t.status === "failed");
|
|
854
|
+
const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
|
|
855
|
+
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
856
|
+
run.endedAt = Date.now();
|
|
857
|
+
this.flushWidget(ctx, onUpdate);
|
|
858
|
+
this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
|
|
859
|
+
this.settleRun(run.id, run);
|
|
860
|
+
this.runControllers.delete(run.id);
|
|
861
|
+
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
862
|
+
this.persist(ctx);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async runBlocking(params: SubagentParamsShape, signal: AbortSignal | undefined, onUpdate: ((partial: any) => void) | undefined, ctx: ExtensionContext): Promise<RunDetails> {
|
|
866
|
+
const { run, inputs } = this.createRun(params, ctx);
|
|
867
|
+
await this.executeTasks(run, inputs, ctx, signal, onUpdate);
|
|
868
|
+
return { run: cloneRun(run) };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
|
|
872
|
+
const { run, inputs } = this.createRun(params, ctx);
|
|
873
|
+
void this.executeTasks(run, inputs, ctx, undefined, undefined)
|
|
874
|
+
.then(() => {
|
|
875
|
+
this.notifyParent(run, run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed");
|
|
876
|
+
})
|
|
877
|
+
.catch((err) => {
|
|
878
|
+
// Never leave a background run unsettled: mark failed, settle, notify.
|
|
879
|
+
run.status = "failed";
|
|
880
|
+
run.endedAt = Date.now();
|
|
881
|
+
for (const task of run.tasks) {
|
|
882
|
+
if (!TERMINAL.includes(task.status)) {
|
|
883
|
+
task.status = "failed";
|
|
884
|
+
task.error = task.error || String(err instanceof Error ? err.message : err);
|
|
885
|
+
task.endedAt = Date.now();
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
this.settleRun(run.id, run);
|
|
889
|
+
this.runControllers.delete(run.id);
|
|
890
|
+
this.notifyParent(run, "failed");
|
|
891
|
+
});
|
|
892
|
+
return { run: cloneRun(run), background: true };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
cancelRun(runId: string): { aborted: number } {
|
|
896
|
+
const run = this.runs.get(runId);
|
|
897
|
+
if (!run) return { aborted: 0 };
|
|
898
|
+
let aborted = 0;
|
|
899
|
+
this.runControllers.get(runId)?.abort();
|
|
900
|
+
for (const [key, child] of this.liveChildren) {
|
|
901
|
+
if (key.startsWith(`${runId}:`)) {
|
|
902
|
+
child.abort();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
for (const task of run.tasks) {
|
|
906
|
+
if (TERMINAL.includes(task.status)) continue;
|
|
907
|
+
task.status = "aborted";
|
|
908
|
+
task.error = task.error || "Canceled by subagent_cancel";
|
|
909
|
+
task.endedAt = Date.now();
|
|
910
|
+
aborted += 1;
|
|
911
|
+
}
|
|
912
|
+
run.status = "aborted";
|
|
913
|
+
run.endedAt = Date.now();
|
|
914
|
+
this.settleRun(runId, run);
|
|
915
|
+
this.runControllers.delete(runId);
|
|
916
|
+
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
917
|
+
this.emit("subagent:run-completed", { runId: run.id, status: "aborted", run: cloneRun(run) });
|
|
918
|
+
return { aborted };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Settle-and-delete: awaiters resolve once; no leak, no closure chain. */
|
|
922
|
+
private settleRun(runId: string, run: RunSnapshot): void {
|
|
923
|
+
const s = this.settlers.get(runId);
|
|
924
|
+
if (!s) return;
|
|
925
|
+
this.settlers.delete(runId);
|
|
926
|
+
s(cloneRun(run));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
awaitRun(runId: string, timeoutMs?: number): Promise<RunSnapshot | undefined> {
|
|
930
|
+
const run = this.runs.get(runId);
|
|
931
|
+
if (!run) return Promise.resolve(undefined);
|
|
932
|
+
run.awaited = true;
|
|
933
|
+
if (TERMINAL.includes(run.status)) return Promise.resolve(cloneRun(run));
|
|
934
|
+
const settled = new Promise<RunSnapshot | undefined>((resolve) => {
|
|
935
|
+
const prev = this.settlers.get(runId);
|
|
936
|
+
this.settlers.set(runId, (r) => {
|
|
937
|
+
prev?.(r);
|
|
938
|
+
resolve(r);
|
|
939
|
+
});
|
|
940
|
+
// Settle may have run between the terminal check and wiring.
|
|
941
|
+
if (TERMINAL.includes(run.status)) resolve(cloneRun(run));
|
|
942
|
+
});
|
|
943
|
+
if (!timeoutMs) return settled;
|
|
944
|
+
return Promise.race([
|
|
945
|
+
settled,
|
|
946
|
+
new Promise<RunSnapshot | undefined>((resolve) =>
|
|
947
|
+
setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs),
|
|
948
|
+
),
|
|
949
|
+
]);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
let eventSeq = 0;
|
|
954
|
+
|
|
955
|
+
// ── tool schemas (slim: short descriptions, no rarely-used knobs) ────────
|
|
956
|
+
|
|
957
|
+
const TaskItem = Type.Object({
|
|
958
|
+
id: Type.Optional(Type.String({ description: "Optional stable task id" })),
|
|
959
|
+
agent: Type.String({ minLength: 1, description: "Agent name. Invent it (inline prompt below), or reuse a name from .agents/, .pi/agents/, or ~/.pi/agent/agents/ to inherit its prompt and toolset. Never creates files." }),
|
|
960
|
+
task: Type.String({ minLength: 1, description: "Task for this agent" }),
|
|
961
|
+
prompt: Type.Optional(Type.String({ description: "System prompt defining this agent's behavior. Optional — a minimal default is used." })),
|
|
962
|
+
write: Type.Optional(Type.Boolean({ description: "true = write toolset (read, bash, edit, write); default false = read-only (read, grep, find, ls)" })),
|
|
963
|
+
model: Type.Optional(Type.String({ description: "Model override (provider/model-id)" })),
|
|
964
|
+
thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override" })),
|
|
965
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset)" })),
|
|
966
|
+
maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout (ms)" })),
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
type SubagentParamsShape = {
|
|
970
|
+
agent?: string;
|
|
971
|
+
task?: string;
|
|
972
|
+
prompt?: string;
|
|
973
|
+
write?: boolean;
|
|
974
|
+
tasks?: TaskInput[];
|
|
975
|
+
chain?: TaskInput[];
|
|
976
|
+
model?: string;
|
|
977
|
+
thinking?: string;
|
|
978
|
+
tools?: string[];
|
|
979
|
+
concurrency?: number;
|
|
980
|
+
maxRuntimeMs?: number;
|
|
981
|
+
background?: boolean;
|
|
982
|
+
allowIntercom?: boolean;
|
|
983
|
+
notifyPerTask?: boolean;
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
const SubagentParams = Type.Object({
|
|
987
|
+
agent: Type.Optional(Type.String({ minLength: 1, description: "Name you invent for this subagent (single mode)" })),
|
|
988
|
+
task: Type.Optional(Type.String({ minLength: 1, description: "Task (single mode)" })),
|
|
989
|
+
prompt: Type.Optional(Type.String({ description: "System prompt for this agent (single mode)" })),
|
|
990
|
+
write: Type.Optional(Type.Boolean({ description: "true = write toolset; default false = read-only (single mode)" })),
|
|
991
|
+
tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel tasks" })),
|
|
992
|
+
chain: Type.Optional(Type.Array(TaskItem, { description: "Sequential tasks; {previous} = prior output" })),
|
|
993
|
+
model: Type.Optional(Type.String({ description: "Model override (single mode)" })),
|
|
994
|
+
thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override (single mode)" })),
|
|
995
|
+
concurrency: Type.Optional(Type.Number({ description: `Parallel concurrency (default ${DEFAULT_CONCURRENCY}, max ${MAX_CONCURRENCY})` })),
|
|
996
|
+
maxRuntimeMs: Type.Optional(Type.Number({ description: `Per-task timeout, ms (default ${DEFAULT_RUNTIME_MS / 60000} min)` })),
|
|
997
|
+
background: Type.Optional(Type.Boolean({ description: "Fire-and-forget: return immediately with a runId; you'll be notified on completion" })),
|
|
998
|
+
notifyPerTask: Type.Optional(Type.Boolean({ description: "Wake you (queued follow-up turn) as each task completes, even mid-run. Default false." })),
|
|
999
|
+
allowIntercom: Type.Optional(Type.Boolean({ description: "Let children ask you questions, notify you, and message sibling subagents" })),
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const RunIdParam = Type.Object({ runId: Type.String({ description: "Run id from subagent()" }) });
|
|
1003
|
+
const ResultParam = Type.Object({
|
|
1004
|
+
runId: Type.String(),
|
|
1005
|
+
taskId: Type.Optional(Type.String({ description: "Specific task id; defaults to all" })),
|
|
1006
|
+
});
|
|
1007
|
+
const AwaitParam = Type.Object({
|
|
1008
|
+
runId: Type.String(),
|
|
1009
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Max wait (ms); default: until finished" })),
|
|
1010
|
+
});
|
|
1011
|
+
const ReplyParam = Type.Object({
|
|
1012
|
+
runId: Type.String(),
|
|
1013
|
+
taskId: Type.String(),
|
|
1014
|
+
message: Type.String({ description: "Answer for the child" }),
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// ── extension entry ──────────────────────────────────────────────────────
|
|
1018
|
+
|
|
1019
|
+
export default function (pi: ExtensionAPI) {
|
|
1020
|
+
const manager = new SubagentManager(pi);
|
|
1021
|
+
|
|
1022
|
+
pi.registerCommand("subagents", {
|
|
1023
|
+
description: "Show recent subagent runs",
|
|
1024
|
+
handler: async (_args, ctx) => {
|
|
1025
|
+
const runs = manager.listRuns().slice(0, 10);
|
|
1026
|
+
if (runs.length === 0) {
|
|
1027
|
+
ctx.ui.notify("No subagent runs in this session.", "info");
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
ctx.ui.setWidget("subagents", runs.flatMap((run) => compactLines(run).concat("")), { placement: "aboveEditor" });
|
|
1031
|
+
ctx.ui.notify(`Showing ${runs.length} subagent run(s).`, "info");
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1036
|
+
await manager.restoreFromSidecar(ctx);
|
|
1037
|
+
});
|
|
1038
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1039
|
+
if (ctx?.hasUI) {
|
|
1040
|
+
try {
|
|
1041
|
+
ctx.ui.setStatus("subagents", "");
|
|
1042
|
+
ctx.ui.setWidget("subagents", [], { placement: "aboveEditor" });
|
|
1043
|
+
} catch {
|
|
1044
|
+
/* ignore */
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
manager.clearRuns();
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
pi.registerTool<typeof SubagentParams, RunDetails>({
|
|
1051
|
+
name: "subagent",
|
|
1052
|
+
label: "Subagent",
|
|
1053
|
+
description: "Define and run isolated subagents (own context, own session). You invent the agent: name, optional system prompt, toolset (read-only default, write:true for edits). Modes: single, parallel (tasks), chain ({previous}). background:true fire-and-forgets with completion notice. allowIntercom:true lets children ask you questions and message each other.\n\nExamples (copy these shapes):\nSingle: subagent({ agent: \"reviewer\", prompt: \"You review code for correctness\", task: \"Review src/auth.ts\" })\nParallel: subagent({ tasks: [{ agent: \"mapper\", task: \"Map all API routes\" }, { agent: \"critic\", task: \"Review auth for vulnerabilities\" }] })\nChain: subagent({ chain: [{ agent: \"planner\", task: \"Plan the change\" }, { agent: \"doer\", write: true, task: \"Execute: {previous}\" }] })\nBackground: subagent({ agent: \"auditor\", task: \"Audit deps\", background: true })",
|
|
1054
|
+
promptSnippet: "Define and delegate work to specialized subagents.",
|
|
1055
|
+
promptGuidelines: [
|
|
1056
|
+
"Use subagent when independent review, testing, research, or parallel analysis improves quality.",
|
|
1057
|
+
"Decompose parallelizable work: if the request has 2+ independent sub-tasks (separate files, separate concerns, independent research/review), delegate each to its own subagent in one parallel call instead of handling them inline.",
|
|
1058
|
+
"If independent sub-tasks are sequential (each builds on the previous one's output), use chain mode with {previous}.",
|
|
1059
|
+
"Define each subagent yourself: an invented name, a focused system prompt (prompt:), and a toolset — read-only (default) or write (write:true).",
|
|
1060
|
+
"Prefer read-only subagents unless the task explicitly needs edits.",
|
|
1061
|
+
"Use background:true for long-running work; you'll be notified on completion.",
|
|
1062
|
+
"Use allowIntercom:true only when a child may need to ask you something; keep children autonomous otherwise.",
|
|
1063
|
+
],
|
|
1064
|
+
parameters: SubagentParams,
|
|
1065
|
+
executionMode: "sequential",
|
|
1066
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1067
|
+
const typed = params as unknown as SubagentParamsShape;
|
|
1068
|
+
if (typed.background) {
|
|
1069
|
+
const details = manager.startInBackground(typed, ctx);
|
|
1070
|
+
return {
|
|
1071
|
+
content: [{ type: "text", text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nUse subagent_status / subagent_result / await_subagent / reply_subagent / subagent_cancel to interact.` }],
|
|
1072
|
+
details,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
const details = await manager.runBlocking(typed, signal, onUpdate, ctx);
|
|
1076
|
+
return { content: [{ type: "text", text: makeSummary(details.run) }], details };
|
|
1077
|
+
},
|
|
1078
|
+
renderCall(args, theme) {
|
|
1079
|
+
const mode = args.chain?.length ? `chain ${args.chain.length}` : args.tasks?.length ? `parallel ${args.tasks.length}` : `single ${args.agent ?? "?"}`;
|
|
1080
|
+
const flags = [args.background ? "bg" : "", args.allowIntercom ? "talk" : ""].filter(Boolean).join(" ");
|
|
1081
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", mode)}${flags ? ` ${theme.fg("muted", `[${flags}]`)}` : ""}`, 0, 0);
|
|
1082
|
+
},
|
|
1083
|
+
renderResult(result, { expanded }, theme) {
|
|
1084
|
+
const run = result.details?.run;
|
|
1085
|
+
if (!run) return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
|
1086
|
+
const header = `${statusIcon(run.status)} ${theme.fg("toolTitle", theme.bold(`subagents ${run.mode}${run.background ? " (bg)" : ""}`))} ${theme.fg("accent", `${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length}`)} ${theme.fg("muted", run.status)}`;
|
|
1087
|
+
if (!expanded) {
|
|
1088
|
+
const lines = [header, ...run.tasks.map((task) => ` ${taskLine(task)}`)];
|
|
1089
|
+
const usage = formatUsage(run.aggregateUsage);
|
|
1090
|
+
if (usage) lines.push(theme.fg("dim", usage));
|
|
1091
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
1092
|
+
}
|
|
1093
|
+
const lines = [header];
|
|
1094
|
+
for (const task of run.tasks) {
|
|
1095
|
+
lines.push(` ${statusIcon(task.status)} ${theme.fg("accent", task.agent)}${task.sessionId ? ` ${theme.fg("muted", task.sessionId)}` : ""}`);
|
|
1096
|
+
if (task.error) lines.push(` ${theme.fg("error", task.error)}`);
|
|
1097
|
+
else if (task.finalText) lines.push(` ${truncateToWidth(theme.fg("dim", task.finalText.trim()), 120, "…")}`);
|
|
1098
|
+
const usage = formatUsage(task.usage);
|
|
1099
|
+
if (usage) lines.push(` ${theme.fg("dim", usage)}`);
|
|
1100
|
+
}
|
|
1101
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
pi.registerTool<typeof RunIdParam, { run?: RunSnapshot }>({
|
|
1106
|
+
name: "subagent_status",
|
|
1107
|
+
label: "Subagent Status",
|
|
1108
|
+
description: "Live status of a subagent run (non-blocking): per-task state.",
|
|
1109
|
+
promptSnippet: "Check progress of a subagent run.",
|
|
1110
|
+
parameters: RunIdParam,
|
|
1111
|
+
async execute(_id, params) {
|
|
1112
|
+
const { runId } = params as { runId: string };
|
|
1113
|
+
const run = manager.getRun(runId);
|
|
1114
|
+
if (!run) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
|
|
1115
|
+
return { content: [{ type: "text", text: compactLines(run).join("\n") }], details: { run: cloneRun(run) } };
|
|
1116
|
+
},
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
pi.registerTool<typeof ResultParam, { run?: RunSnapshot }>({
|
|
1120
|
+
name: "subagent_result",
|
|
1121
|
+
label: "Subagent Result",
|
|
1122
|
+
description: "Full result (finalText + usage) of a run or one task. Non-blocking.",
|
|
1123
|
+
parameters: ResultParam,
|
|
1124
|
+
async execute(_id, params) {
|
|
1125
|
+
const { runId, taskId } = params as { runId: string; taskId?: string };
|
|
1126
|
+
const run = manager.getRun(runId);
|
|
1127
|
+
if (!run) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
|
|
1128
|
+
const tasks = taskId ? run.tasks.filter((t) => t.id === taskId) : run.tasks;
|
|
1129
|
+
const text = [`Run ${run.id} — ${run.status}`, ...tasks.map((t) => `\n## ${t.agent} ${statusIcon(t.status)}\n${t.error ? `Error: ${t.error}` : t.finalText || "(no output yet)"}\n${formatUsage(t.usage)}`)].join("\n");
|
|
1130
|
+
return { content: [{ type: "text", text: truncateText(text) }], details: { run: cloneRun(run) } };
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
pi.registerTool<typeof AwaitParam, { run?: RunSnapshot }>({
|
|
1135
|
+
name: "await_subagent",
|
|
1136
|
+
label: "Await Subagent",
|
|
1137
|
+
description: "Block until a run finishes (or timeoutMs elapses). Use when you need the result before proceeding.",
|
|
1138
|
+
parameters: AwaitParam,
|
|
1139
|
+
async execute(_id, params) {
|
|
1140
|
+
const { runId, timeoutMs } = params as { runId: string; timeoutMs?: number };
|
|
1141
|
+
const run = await manager.awaitRun(runId, timeoutMs);
|
|
1142
|
+
if (!run) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
|
|
1143
|
+
return { content: [{ type: "text", text: makeSummary(run) }], details: { run } };
|
|
1144
|
+
},
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
pi.registerTool<typeof ReplyParam, { run?: RunSnapshot }>({
|
|
1148
|
+
name: "reply_subagent",
|
|
1149
|
+
label: "Reply Subagent",
|
|
1150
|
+
description: "Answer a child's ask_parent question; resumes its run.",
|
|
1151
|
+
parameters: ReplyParam,
|
|
1152
|
+
async execute(_id, params) {
|
|
1153
|
+
const { runId, taskId, message } = params as { runId: string; taskId: string; message: string };
|
|
1154
|
+
const ok = manager.deliverReply(runId, taskId, message);
|
|
1155
|
+
if (!ok) return { content: [{ type: "text", text: `No pending question for ${runId}/${taskId}.` }], isError: true, details: {} };
|
|
1156
|
+
return { content: [{ type: "text", text: `Reply delivered to ${runId}/${taskId}. The child will resume.` }], details: {} };
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
pi.registerTool<typeof RunIdParam, { aborted?: number }>({
|
|
1161
|
+
name: "subagent_cancel",
|
|
1162
|
+
label: "Subagent Cancel",
|
|
1163
|
+
description: "Abort a running/queued subagent run. Children are killed; run becomes aborted.",
|
|
1164
|
+
promptSnippet: "Cancel a subagent run.",
|
|
1165
|
+
parameters: RunIdParam,
|
|
1166
|
+
async execute(_id, params) {
|
|
1167
|
+
const { runId } = params as { runId: string };
|
|
1168
|
+
const { aborted } = manager.cancelRun(runId);
|
|
1169
|
+
if (aborted === 0 && !manager.getRun(runId)) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
|
|
1170
|
+
return { content: [{ type: "text", text: `Canceled ${aborted} task${aborted === 1 ? "" : "s"} in run ${runId}.` }], details: { aborted } };
|
|
1171
|
+
},
|
|
1172
|
+
});
|
|
1173
|
+
}
|