@arhen/pi-core-subagent 1.3.3 → 1.3.4

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/src/index.ts CHANGED
@@ -9,1279 +9,34 @@
9
9
  * request (cached), background completions notify with a 3-line summary
10
10
  * instead of full outputs, and run updates are throttled (no per-event
11
11
  * deep clones).
12
+ *
13
+ * Layout: schemas → schemas.ts, scheduler/graph → graph.ts, rendering →
14
+ * format.ts, run lifecycle → manager.ts, this file = entry + registrations.
12
15
  */
13
16
 
14
- import type { AgentSessionEvent, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
15
- import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRuntime, 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";
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
19
- import type { Component, TUI } from "@earendil-works/pi-tui";
20
- import { Type, type Static } from "typebox";
21
- import { join } from "node:path";
22
- import { CHILD_TALK_TOOLS, createChildTools, createWatchdog, type ChildHandlers } from "./child.ts";
23
- import { createMailbox, type Mailbox } from "./mailbox.ts";
19
+ import {
20
+ compactLines,
21
+ formatUsage,
22
+ makeSummary,
23
+ statusIcon,
24
+ taskLine,
25
+ themedTaskLine,
26
+ truncateText,
27
+ } from "./format.ts";
28
+ import { waveNotation } from "./graph.ts";
29
+ import { cloneRun, SubagentManager } from "./manager.ts";
24
30
  import { createPeekPane, type PeekTask } from "./peek.ts";
25
-
26
- const DEFAULT_CONCURRENCY = 3;
27
- const MAX_CONCURRENCY = 8;
28
- const MAX_TASKS = 16;
29
- /** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
30
- const DEFAULT_RUNTIME_MS = 0;
31
- const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit message_update, not message_end
32
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
33
- const READONLY_TOOLS = ["read", "grep", "find", "ls"];
34
- const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
35
- const FINAL_OUTPUT_CAP = 24 * 1024;
36
- const WIDGET_THROTTLE_MS = 150;
37
-
38
- type RunMode = "single" | "parallel" | "chain";
39
- type TaskStatus = "queued" | "starting" | "running" | "awaiting_parent" | "completed" | "failed" | "aborted";
40
- type RunStatus = "queued" | "running" | "awaiting_parent" | "completed" | "failed" | "aborted";
41
-
42
- const TERMINAL: TaskStatus[] = ["completed", "failed", "aborted"];
43
-
44
- interface UsageStats {
45
- input: number;
46
- output: number;
47
- cacheRead: number;
48
- cacheWrite: number;
49
- cost: number;
50
- turns: number;
51
- }
52
-
53
- interface TaskSnapshot {
54
- id: string;
55
- runId: string;
56
- agent: string;
57
- task: string;
58
- cwd: string;
59
- status: TaskStatus;
60
- /** Resolved dependency edges (task ids). Empty/absent = wave 1. */
61
- needs?: string[];
62
- sessionId?: string;
63
- sessionFile?: string;
64
- model?: string;
65
- thinking?: string;
66
- tools?: string[];
67
- startedAt?: number;
68
- endedAt?: number;
69
- toolCalls: number;
70
- /** Address + sibling roster, injected into the child so mailbox tools can address them. */
71
- roster?: string;
72
- lastActivity?: string;
73
- usage: UsageStats;
74
- finalText?: string;
75
- error?: string;
76
- }
77
-
78
- interface RunSnapshot {
79
- id: string;
80
- mode: RunMode;
81
- status: RunStatus;
82
- background: boolean;
83
- allowIntercom: boolean;
84
- createdAt: number;
85
- startedAt?: number;
86
- endedAt?: number;
87
- concurrency: number;
88
- /** True once the parent awaited this run — completion notices are redundant then. */
89
- awaited?: boolean;
90
- /** Wake the parent (queued follow-up turn) as each task completes. Default false. */
91
- notifyPerTask: boolean;
92
- tasks: TaskSnapshot[];
93
- aggregateUsage: UsageStats;
94
- }
95
-
96
- interface RunDetails {
97
- run: RunSnapshot;
98
- background?: boolean;
99
- }
100
-
101
- interface PendingReply {
102
- resolve: (answer: string) => void;
103
- }
104
-
105
- // ── helpers ──────────────────────────────────────────────────────────────
106
-
107
- function newId(prefix: string): string {
108
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
109
- }
110
- function emptyUsage(): UsageStats {
111
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
112
- }
113
- function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
114
- const total = emptyUsage();
115
- for (const task of tasks) {
116
- total.input += task.usage.input;
117
- total.output += task.usage.output;
118
- total.cacheRead += task.usage.cacheRead;
119
- total.cacheWrite += task.usage.cacheWrite;
120
- total.cost += task.usage.cost;
121
- total.turns += task.usage.turns;
122
- }
123
- return total;
124
- }
125
- function truncateText(text: string, max = FINAL_OUTPUT_CAP): string {
126
- if (Buffer.byteLength(text, "utf8") <= max) return text;
127
- let out = text.slice(0, max);
128
- while (Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1); // multibyte-safe
129
- return `${out}\n\n[Output truncated. Full child session is available in the session file.]`;
130
- }
131
- function getFirstText(message: AssistantMessage): string {
132
- for (const part of message?.content ?? []) {
133
- if (part?.type === "text" && typeof part.text === "string") return part.text;
134
- }
135
- return "";
136
- }
137
- function getParentSessionFile(ctx: ExtensionContext): string | undefined {
138
- try {
139
- return ctx.sessionManager.getSessionFile?.();
140
- } catch {
141
- return undefined;
142
- }
143
- }
144
- /**
145
- * pi 0.84 StopReason enum: "stop" is NORMAL completion (was "end" in older pi).
146
- * Only length/error/aborted/deferred/pending/toolUse-as-final are failures.
147
- */
148
- export function classifyFailure(stopReason: string | undefined, errorMessage?: string): { status: "failed" | "aborted"; message: string } | undefined {
149
- if (!stopReason || stopReason === "stop" || stopReason === "end") return undefined;
150
- if (stopReason === "aborted") return { status: "aborted", message: errorMessage || "Subagent was aborted." };
151
- return { status: "failed", message: errorMessage || `Subagent ended with stopReason "${stopReason}".` };
152
- }
153
- function lastAssistantFailure(messages: AssistantMessage[] | undefined): { status: "failed" | "aborted"; message: string } | undefined {
154
- for (const message of [...(messages ?? [])].reverse()) {
155
- if (message?.role !== "assistant") continue;
156
- return classifyFailure(message.stopReason, message.errorMessage);
157
- }
158
- return undefined;
159
- }
160
- function failureError(failure: { status: "failed" | "aborted"; message: string }): Error {
161
- const error = new Error(failure.message);
162
- (error as Error & { subagentStatus?: string }).subagentStatus = failure.status;
163
- return error;
164
- }
165
- function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage): void {
166
- if (message?.role !== "assistant") return;
167
- task.usage.turns += 1;
168
- const usage = message.usage;
169
- if (!usage) return;
170
- task.usage.input += usage.input ?? 0;
171
- task.usage.output += usage.output ?? 0;
172
- task.usage.cacheRead += usage.cacheRead ?? 0;
173
- task.usage.cacheWrite += usage.cacheWrite ?? 0;
174
- task.usage.cost += usage.cost?.total ?? 0;
175
- if (message.model && !task.model) task.model = message.model;
176
- }
177
- function fmtTokens(n: number): string {
178
- return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n);
179
- }
180
- function formatUsage(usage: UsageStats): string {
181
- const parts: string[] = [];
182
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
183
- if (usage.input) parts.push(`↑ ${fmtTokens(usage.input)}`);
184
- if (usage.output) parts.push(`↓ ${fmtTokens(usage.output)}`);
185
- if (usage.cost > 0) parts.push(usage.cost >= 0.0001 ? `$${usage.cost.toFixed(4)}` : "$<0.0001");
186
- return parts.join(" · ");
187
- }
188
- function statusIcon(status: TaskStatus | RunStatus): string {
189
- if (status === "completed") return "✓";
190
- if (status === "failed") return "✗";
191
- if (status === "aborted") return "⏹";
192
- if (status === "awaiting_parent") return "❓";
193
- if (status === "queued") return "○";
194
- return "•";
195
- }
196
- function fmtDuration(ms: number | undefined): string {
197
- if (ms === undefined || !Number.isFinite(ms)) return "–";
198
- const s = Math.max(0, Math.round(ms / 1000));
199
- return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`;
200
- }
201
- function taskTimer(task: TaskSnapshot): string {
202
- if (task.startedAt === undefined) return "–";
203
- const end = task.endedAt ?? Date.now();
204
- const running = !TERMINAL.includes(task.status);
205
- return `${running ? "running " : ""}${fmtDuration(end - task.startedAt)}`;
206
- }
207
- function taskStatsWithUsage(task: TaskSnapshot): string {
208
- const stats = `${task.toolCalls ?? 0} tools`;
209
- const usage = formatUsage(task.usage);
210
- return `${stats}${usage ? ` · ${usage}` : ""}`;
211
- }
212
- function taskLine(task: TaskSnapshot): string {
213
- return `${statusIcon(task.status)} ${task.agent} · ${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
214
- }
215
- /**
216
- * Numbers take the theme's number color, everything else stays muted — like the footer.
217
- * Must run on RAW text: styling an already-colored string rewrites the digits
218
- * inside the ANSI escape codes themselves ("38;2;139;136;122m16 tools").
219
- */
220
- export function colorNums(text: string, theme: Theme): string {
221
- // A value keeps its unit: "460.6k" and "2m30s" each color as one token, not digit-by-digit.
222
- return text.replace(/((?:\d+(?:\.\d+)?[a-zA-Z]*)+)|([^\d]+)/g, (_m, num?: string, rest?: string) => (num ? theme.fg("syntaxNumber", num) : theme.fg("muted", rest ?? "")));
223
- }
224
- /**
225
- * Themed one-liner. Finished tasks dim entirely (stats included); live tasks
226
- * keep the agent name readable with themed numbers.
227
- */
228
- function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string {
229
- const tail = `${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
230
- // Queued task with unmet needs: show the gate it's waiting on instead of empty stats.
231
- const gate = task.status === "queued" && task.needs?.length ? `${theme.fg("muted", `↳ waits ${task.needs.join(",")}`)} · ` : "";
232
- if (TERMINAL.includes(task.status)) {
233
- return theme.fg("dim", `${statusIcon(task.status)} ${task.agent} · ${tail}`);
234
- }
235
- return `${statusIcon(task.status)} ${task.agent} · ${gate}${activity}${colorNums(tail, theme)}`;
236
- }
237
- /**
238
- * Human-readable activity line: "Read src/index.ts", "Grep wrapSingleLine".
239
- * ponytail: picks the first interesting string arg instead of a per-tool table —
240
- * unknown/custom tools then read fine too. Add a case only if one reads badly.
241
- */
242
- // Order matters: the most specific arg wins (grep's pattern beats its path).
243
- const ARG_KEYS = ["pattern", "query", "command", "path", "file_path", "filePath", "url", "name", "subject", "task"];
244
- export function describeCall(toolName: string, args: unknown, cwd?: string): string {
245
- const verb = toolName.charAt(0).toUpperCase() + toolName.slice(1);
246
- const obj = args && typeof args === "object" ? (args as Record<string, unknown>) : undefined;
247
- if (!obj) return verb;
248
- let value = ARG_KEYS.map((k) => obj[k]).find((v) => typeof v === "string" && v.trim() !== "") as string | undefined;
249
- if (value === undefined) {
250
- value = Object.values(obj).find((v) => typeof v === "string" && v.trim() !== "") as string | undefined;
251
- }
252
- if (value === undefined) return verb;
253
- let text = value.replace(/\s+/g, " ").trim();
254
- if (cwd && text.startsWith(`${cwd}/`)) text = text.slice(cwd.length + 1); // absolute paths inside the task cwd read as noise
255
- return `${verb} ${text.length > 60 ? `${text.slice(0, 60)}…` : text}`;
256
- }
257
- function activitySnippet(text: string): string {
258
- const flat = text.replace(/\s+/g, " ").trim();
259
- return flat.length > 90 ? `${flat.slice(0, 90)}…` : flat;
260
- }
261
- /** Static compact lines (tool-result stream, subagent_status, /subagents). */
262
- function compactLines(run: RunSnapshot): string[] {
263
- const lines: string[] = [];
264
- for (const task of run.tasks.slice(0, MAX_TASKS)) {
265
- lines.push(taskLine(task));
266
- }
267
- if (run.tasks.length > MAX_TASKS) lines.push(`… +${run.tasks.length - MAX_TASKS} more`);
268
- return lines;
269
- }
270
- /**
271
- * Above-editor widget, todo-tree style:
272
- * ● Subagents (0/1)
273
- * ├─ • code-sleuth · 4 tools · 12s
274
- * │ → read src/auth.ts
275
- * └─ ✓ reviewer · 6 tools · 44s
276
- * Static icons (no animation); latest activity + tool count + runtime per agent.
277
- */
278
- const WIDGET_MAX_LINES = 10;
279
-
280
- class SubagentsWidget implements Component {
281
- constructor(
282
- private readonly getRuns: () => RunSnapshot[],
283
- private readonly theme: Theme,
284
- ) {}
285
-
286
- invalidate(): void {
287
- // no cached strings; render() reads live state
288
- }
289
-
290
- render(width: number): string[] {
291
- // ONE flat tree: every run's tasks concatenated under a single heading.
292
- // Whether the model spawned N runs or one tasks[] call, the pane reads the same.
293
- const runs = this.getRuns().filter((r) => r.tasks.length > 0);
294
- if (runs.length === 0) return [];
295
- const total = runs.reduce((n, r) => n + r.tasks.length, 0);
296
- const done = runs.reduce((n, r) => n + r.tasks.filter((t) => TERMINAL.includes(t.status)).length, 0);
297
- const live = total - done;
298
- const head = live > 0 ? "accent" : "dim";
299
- const lines = [truncateToWidth(`${this.theme.fg(head, live > 0 ? "●" : "○")} ${this.theme.fg(head, `Subagents (${done}/${total})`)}`, width, "…")];
300
- const budget = WIDGET_MAX_LINES - 1;
301
- let shown = 0;
302
- outer: for (const run of runs) {
303
- for (const task of run.tasks) {
304
- if (shown >= budget) break outer;
305
- shown += 1;
306
- const activity = task.lastActivity ? `${this.theme.fg("dim", `→ ${task.lastActivity}`)} · ` : "";
307
- // Per-TASK status drives dimming: a finished agent stays dim even while siblings run.
308
- lines.push(truncateToWidth(`${this.theme.fg("dim", "├─")} ${themedTaskLine(task, this.theme, activity)}`, width, "…"));
309
- }
310
- }
311
- const hidden = total - shown;
312
- if (hidden > 0) {
313
- lines.push(`${this.theme.fg("dim", "└─")} ${this.theme.fg("dim", `+${hidden} more`)}`);
314
- } else if (lines.length > 1) {
315
- lines[lines.length - 1] = lines[lines.length - 1]!.replace("├─", "└─");
316
- }
317
- return lines;
318
- }
319
- }
320
- /** Blocking-call summary: full text, because the model asked for it. */
321
- function makeSummary(run: RunSnapshot): string {
322
- const succeeded = run.tasks.filter((t) => t.status === "completed").length;
323
- const failed = run.tasks.filter((t) => t.status === "failed").length;
324
- const aborted = run.tasks.filter((t) => t.status === "aborted").length;
325
- const done = TERMINAL.includes(run.status) ? "finished" : "running";
326
- const lines = [`Run ${run.id}: Subagents ${run.mode}${run.background ? " (background)" : ""} ${done}: ${succeeded}/${run.tasks.length} succeeded${failed ? `, ${failed} failed` : ""}${aborted ? `, ${aborted} aborted` : ""}.`];
327
- const usage = formatUsage(run.aggregateUsage);
328
- if (usage) lines.push(`Usage: ${usage}`);
329
- for (const task of run.tasks) {
330
- // Edges are named so the leader can compare what it delegated against what came back.
331
- const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
332
- lines.push(`\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}`);
333
- }
334
- // Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
335
- return truncateText(lines.join("\n"));
336
- }
337
- /** Per-task notice: one task's outcome, small. Full output stays out of parent context. */
338
- function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: string): string {
339
- const detail = task.error ? task.error : truncateText(task.finalText || "(no output)", 200);
340
- return [
341
- `Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}`,
342
- `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`,
343
- ].join("\n");
344
- }
345
- /** Notification: 3 lines max. Full output stays out of parent context. */
346
- function makeNotice(run: RunSnapshot, kind: string): string {
347
- const lines = [`Background subagent run ${run.id} ${kind}: ${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length} succeeded.`];
348
- for (const task of run.tasks) {
349
- lines.push(`- ${task.agent}: ${task.status}${task.error ? ` — ${truncateText(task.error, 200)}` : ""}`);
350
- }
351
- lines.push(`Use subagent_result(runId: "${run.id}") for full output.`);
352
- return lines.join("\n");
353
- }
354
- function cloneRun(run: RunSnapshot): RunSnapshot {
355
- return JSON.parse(JSON.stringify(run)) as RunSnapshot;
356
- }
357
- /** Resolve a child model from the pi model registry.
358
- * Order: explicit "provider/model-id" or bare id (searched across available
359
- * models) → agent file model → parent's current model (ctx.model) → undefined
360
- * (createAgentSession falls back to settings). */
361
- export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
362
- if (!explicit?.trim()) return ctx.model; // inherit the parent's active model
363
- const ref = explicit.trim();
364
- const available = ctx.modelRegistry.getAvailable();
365
- // Model ids can contain slashes (e.g. 9router/cc/claude-opus-5), so a bare id
366
- // match and every provider/id split point must be tried, not just the first.
367
- const byId = available.find((m) => m.id === ref);
368
- if (byId) return byId;
369
- for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
370
- const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
371
- if (model) return model;
372
- }
373
- throw new Error(`Model not found: ${ref}`);
374
- }
375
-
376
- /** Extension-registered providers (e.g. 9router) live only in the parent's
377
- * in-memory runtime. A child builds its runtime from disk and would lose them,
378
- * so replay the parent's registrations before the child resolves auth. */
379
- async function createChildModelRuntime(ctx: ExtensionContext) {
380
- const ids = ctx.modelRegistry.getRegisteredProviderIds?.() ?? [];
381
- if (ids.length === 0) return undefined; // no extension providers: disk runtime is enough
382
- const agentDir = getAgentDir();
383
- const runtime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
384
- for (const id of ids) {
385
- const native = ctx.modelRegistry.getRegisteredNativeProvider?.(id);
386
- if (native) {
387
- runtime.registerNativeProvider(native);
388
- continue;
389
- }
390
- const config = ctx.modelRegistry.getRegisteredProviderConfig?.(id);
391
- if (config) runtime.registerProvider(id, config);
392
- }
393
- await runtime.refresh({ allowNetwork: false });
394
- return runtime;
395
- }
396
-
397
- /** Validate a thinking level against the RESOLVED model's registry entry.
398
- * thinkingLevelMap: null = unsupported, missing key = provider default,
399
- * absent map = provider defaults. Non-reasoning models only accept "off". */
400
- export function validateThinking(model: Model<Api> | undefined, level: string | undefined): void {
401
- if (!level || level === "off") return;
402
- if (!model) return;
403
- const map = model.thinkingLevelMap;
404
- if (map && level in map && map[level as keyof typeof map] === null) {
405
- const supported = Object.keys(map).filter((k) => map[k as keyof typeof map] !== null);
406
- throw new Error(
407
- `Thinking level "${level}" is not supported by ${model.provider}/${model.id}. Supported: ${supported.length ? supported.join(" | ") : "none — use thinking: \"off\""}.`,
408
- );
409
- }
410
- if (!model.reasoning) {
411
- throw new Error(`Model ${model.provider}/${model.id} does not support thinking. Use thinking: "off".`);
412
- }
413
- }
414
-
415
- // Cached catalog removed: agents are defined inline by the leader per call,
416
- // so there is nothing to inject into the parent context. Zero per-request cost.
417
-
418
- /**
419
- * Resolve dependency edges (Graph Protocol §2). Returns one id list per task,
420
- * in input order. Chain mode is just `needs: [previous]`, so both modes run
421
- * through the same wave scheduler.
422
- *
423
- * Throws on unknown ids, self-edges, and cycles — a bad graph must fail before
424
- * any child is spawned, never halfway through a run.
425
- */
426
- export function resolveNeeds(inputs: { id?: string; needs?: string[] }[], mode: RunMode): string[][] {
427
- const ids = inputs.map((input, index) => input.id ?? `task_${index + 1}`);
428
- const known = new Set(ids);
429
- const edges = inputs.map((input, index) => {
430
- if (mode === "chain") return index === 0 ? [] : [ids[index - 1] as string];
431
- const needs = input.needs ?? [];
432
- for (const need of needs) {
433
- if (!known.has(need)) throw new Error(`Task ${ids[index]} needs unknown task id: ${need}`);
434
- if (need === ids[index]) throw new Error(`Task ${ids[index]} cannot need itself.`);
435
- }
436
- return [...new Set(needs)];
437
- });
438
- // Kahn's algorithm: if any task never becomes ready, the remainder is a cycle.
439
- const done = new Set<string>();
440
- let progress = true;
441
- while (progress) {
442
- progress = false;
443
- for (const [index, id] of ids.entries()) {
444
- if (done.has(id)) continue;
445
- if ((edges[index] as string[]).every((need) => done.has(need))) {
446
- done.add(id);
447
- progress = true;
448
- }
449
- }
450
- }
451
- if (done.size !== ids.length) {
452
- throw new Error(`Cycle in subagent needs: ${ids.filter((id) => !done.has(id)).join(", ")}`);
453
- }
454
- return edges;
455
- }
456
-
457
- /**
458
- * Graph Protocol §2 notation: `wave1[api ∥ db] → gate → wave2[doc]`.
459
- *
460
- * Tolerates half-streamed args: a need pointing at an id that has not arrived yet
461
- * keeps its task out of the ready set, so the layout settles as the model types.
462
- * Returns "" when there are no edges — flat fan-out gets no graph vocabulary.
463
- */
464
- export function waveNotation(tasks: { id?: string; needs?: string[] }[]): string {
465
- if (!tasks.some((t) => t.needs?.length)) return "";
466
- const ids = tasks.map((t, i) => t.id ?? `task_${i + 1}`);
467
- const settled = new Set<string>();
468
- let remaining = tasks.map((t, i) => ({ id: ids[i] as string, needs: t.needs ?? [] }));
469
- const waves: string[][] = [];
470
- while (remaining.length > 0) {
471
- const ready = remaining.filter((t) => t.needs.every((n) => settled.has(n)));
472
- if (ready.length === 0) break; // cycle, or an upstream id not typed yet
473
- waves.push(ready.map((t) => t.id));
474
- for (const t of ready) settled.add(t.id);
475
- remaining = remaining.filter((t) => !settled.has(t.id));
476
- }
477
- if (remaining.length > 0) waves.push(remaining.map((t) => t.id)); // show them rather than drop them
478
- if (waves.length < 2) return "";
479
- const full = waves.map((w, i) => `wave${i + 1}[${w.join(" ∥ ")}]`).join(" → gate → ");
480
- // Long graphs: keep the shape, drop the names.
481
- return full.length <= 100 ? full : waves.map((w, i) => `wave${i + 1}[${w.length}]`).join(" → gate → ");
482
- }
483
-
484
- /**
485
- * Graph Protocol §6: the edge carries the upstream output, not just ordering.
486
- * Upstream results are prepended verbatim; `{previous}` stays supported so old
487
- * chain prompts keep working (it expands to the first need's output).
488
- */
489
- export function applyUpstream(task: string, needs: string[], outputs: Map<string, string>): string {
490
- if (needs.length === 0) {
491
- return task.includes("{previous}")
492
- ? `${task.replace(/\{previous\}/g, () => "")}\n\n(Note: {previous} was empty — no prior step output existed yet.)`
493
- : task;
494
- }
495
- const first = outputs.get(needs[0] as string) ?? "";
496
- const body = task.replace(/\{previous\}/g, () => first); // replacer fn: no $ corruption
497
- const blocks = needs.map((need) => `## Output of ${need}\n${outputs.get(need) ?? "(no output)"}`);
498
- return `${blocks.join("\n\n")}\n\n---\n\n${body}`;
499
- }
500
-
501
- async function mapWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T, index: number) => Promise<void>): Promise<void> {
502
- let next = 0;
503
- const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
504
- while (next < items.length) {
505
- const index = next++;
506
- await fn(items[index] as T, index);
507
- }
508
- });
509
- await Promise.all(workers);
510
- }
511
-
512
- // ── manager ──────────────────────────────────────────────────────────────
513
-
514
- class SubagentManager {
515
- private runs = new Map<string, RunSnapshot>();
516
- private settlers = new Map<string, (run: RunSnapshot) => void>();
517
- private pendingReplies = new Map<string, PendingReply>();
518
- private liveChildren = new Map<string, { abort: () => void; dispose: () => void; touchWatchdog: () => void }>();
519
- private mailboxes: Mailbox = createMailbox();
520
- private runControllers = new Map<string, AbortController>();
521
- private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
522
- private widgetRuns: RunSnapshot[] = [];
523
-
524
- turnActivity = false;
525
-
526
- constructor(private readonly pi: ExtensionAPI) {}
527
-
528
- /** Any run still has queued/running tasks? */
529
- hasActiveRun(): boolean {
530
- for (const run of this.runs.values()) {
531
- if (run.tasks.some((t) => !TERMINAL.includes(t.status))) return true;
532
- }
533
- return false;
534
- }
535
-
536
- /** Hide the widget + clear the footer status entry. */
537
- clearWidget(ctx: ExtensionContext): void {
538
- this.widgetRuns = [];
539
- this.widgetTui = null;
540
- if (ctx.hasUI) {
541
- try {
542
- ctx.ui.setWidget("subagents", undefined);
543
- } catch {
544
- /* ignore */
545
- }
546
- }
547
- }
548
-
549
- listRuns(): RunSnapshot[] {
550
- return Array.from(this.runs.values()).sort((a, b) => b.createdAt - a.createdAt);
551
- }
552
- getRun(runId: string | undefined): RunSnapshot | undefined {
553
- return runId ? this.runs.get(runId) : undefined;
554
- }
555
- clearRuns(): void {
556
- for (const child of this.liveChildren.values()) {
557
- child.abort();
558
- child.dispose();
559
- }
560
- this.liveChildren.clear();
561
- this.runs.clear();
562
- this.settlers.clear();
563
- this.pendingReplies.clear();
564
- this.runControllers.clear();
565
- this.mailboxes = createMailbox();
566
- this.widgetTui = null; // force re-registration on the next session
567
- for (const t of this.widgetTimers.values()) clearTimeout(t);
568
- this.widgetTimers.clear();
569
- this.widgetRuns = [];
570
- }
571
-
572
- // ── persistence (sidecar per parent session) ────────────────────────
573
- async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
574
- const parentFile = getParentSessionFile(ctx);
575
- if (!parentFile) return;
576
- const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
577
- let runs: RunSnapshot[];
578
- try {
579
- const { readFileSync, existsSync } = await import("fs");
580
- if (!existsSync(sidecar)) return;
581
- const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
582
- if (!Array.isArray(raw)) return;
583
- runs = (raw as RunSnapshot[]).map((run) => {
584
- const interrupted = run.tasks.some((t) => !TERMINAL.includes(t.status));
585
- // A persisted "running" run whose tasks are all terminal (crash between
586
- // task end and run end) must not stay "running" forever.
587
- let status = interrupted ? ("aborted" as RunStatus) : run.status;
588
- if (!TERMINAL.includes(status)) {
589
- const anyFailed = run.tasks.some((t) => t.status === "failed");
590
- const anyAborted = run.tasks.some((t) => t.status === "aborted");
591
- status = anyFailed ? "failed" : anyAborted ? "aborted" : "completed";
592
- }
593
- return {
594
- ...run,
595
- status,
596
- endedAt: interrupted ? Date.now() : run.endedAt,
597
- tasks: run.tasks.map((t) => (TERMINAL.includes(t.status) ? t : { ...t, status: "aborted" as TaskStatus, error: t.error || "Interrupted by session reload" })),
598
- };
599
- });
600
- } catch {
601
- return;
602
- }
603
- let added = 0;
604
- for (const run of runs) {
605
- if (!run?.id || this.runs.has(run.id)) continue;
606
- this.runs.set(run.id, run);
607
- added += 1;
608
- }
609
- if (added > 0) {
610
- this.emit("subagent:runs-restored", { count: added });
611
- this.scheduleWidget(this.listRuns()[0], ctx);
612
- }
613
- }
614
- private persist(ctx: ExtensionContext): void {
615
- try {
616
- const parentFile = getParentSessionFile(ctx);
617
- if (!parentFile) return;
618
- const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
619
- import("fs")
620
- .then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)))
621
- .catch(() => {}); // never surface as an unhandled rejection
622
- } catch {
623
- /* ignore */
624
- }
625
- }
626
-
627
- private emit(type: string, payload: Record<string, unknown>): void {
628
- this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
629
- }
630
-
631
- /** Per-task wake-up: queued follow-up so the parent can interleave responses. */
632
- private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
633
- const body = makeTaskNotice(run, task, kind);
634
- try {
635
- this.pi.sendUserMessage(body, { deliverAs: "followUp" });
636
- } catch {
637
- /* parent mid-stream; consumers can poll subagent_status */
638
- }
639
- this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
640
- }
641
-
642
- /** Wake the parent with a 3-line notice. Full text stays out of context.
643
- * deliverAs followUp queues the message if the parent is mid-stream
644
- * (e.g. inside await_subagent) instead of throwing/aborting. */
645
- private notifyParent(run: RunSnapshot, kind: "completed" | "failed" | "aborted" | "asked", extra?: { taskId?: string; question?: string }): void {
646
- if (kind !== "asked" && run.awaited) return; // parent already got the result via await_subagent
647
- const body = kind === "asked"
648
- ? `A background subagent is asking you a question (task ${extra?.taskId}): ${extra?.question ?? ""}\nReply with reply_subagent(runId: "${run.id}", taskId: "${extra?.taskId}", message: ...).`
649
- : makeNotice(run, kind);
650
- try {
651
- this.pi.sendUserMessage(body, { deliverAs: "followUp" });
652
- } catch {
653
- /* parent mid-stream; consumers can poll subagent_status */
654
- }
655
- this.emit("subagent:notification", { runId: run.id, kind, body });
656
- }
657
-
658
- // Widget: register-once + requestRender (todo-overlay pattern).
659
- // scheduleWidget throttles status changes into requestRender calls.
660
- private widgetTui: TUI | null = null;
661
- /** Upsert a run into the widget's visible set (all runs, not just the latest). */
662
- private upsertWidgetRun(run: RunSnapshot | undefined): void {
663
- if (!run) return;
664
- const idx = this.widgetRuns.findIndex((r) => r.id === run.id);
665
- if (idx >= 0) this.widgetRuns[idx] = run;
666
- else this.widgetRuns.push(run);
667
- }
668
- private scheduleWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
669
- this.upsertWidgetRun(run);
670
- if (!run || this.widgetTimers.has(run.id)) return;
671
- this.widgetTimers.set(run.id, setTimeout(() => {
672
- this.widgetTimers.delete(run.id);
673
- if (ctx?.hasUI) {
674
- this.ensureWidget(ctx);
675
- this.widgetTui?.requestRender();
676
- }
677
- }, WIDGET_THROTTLE_MS));
678
- }
679
- private flushWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
680
- if (run) {
681
- const t = this.widgetTimers.get(run.id);
682
- if (t) {
683
- clearTimeout(t);
684
- this.widgetTimers.delete(run.id);
685
- }
686
- }
687
- if (!run || this.widgetRuns.length === 0) return;
688
- if (ctx?.hasUI) {
689
- this.ensureWidget(ctx);
690
- this.widgetTui?.requestRender();
691
- }
692
- // Transcript gets one status line only — the live per-task view is the widget's job.
693
- onUpdate?.({ content: [{ type: "text", text: `${run.tasks.filter((t) => TERMINAL.includes(t.status)).length}/${run.tasks.length} done · ${run.status}` }] });
694
- }
695
- private ensureWidget(ctx: ExtensionContext): void {
696
- if (this.widgetTui !== null || !ctx.hasUI) return;
697
- ctx.ui.setWidget(
698
- "subagents",
699
- (tui, theme) => {
700
- this.widgetTui = tui;
701
- return new SubagentsWidget(() => [...this.widgetRuns], theme);
702
- },
703
- { placement: "aboveEditor" },
704
- );
705
- }
706
-
707
- private updateRun(run: RunSnapshot, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
708
- run.aggregateUsage = aggregateUsage(run.tasks);
709
- this.runs.set(run.id, run);
710
- this.emit("subagent:run-updated", { runId: run.id, status: run.status, live: run.tasks.filter((t) => !TERMINAL.includes(t.status)).length });
711
- this.scheduleWidget(run, ctx, onUpdate);
712
- }
713
- private updateTask(run: RunSnapshot, task: TaskSnapshot, patch: Partial<TaskSnapshot>, ctx: ExtensionContext, onUpdate?: (partial: any) => void): void {
714
- Object.assign(task, patch);
715
- this.emit("subagent:task-updated", { runId: run.id, taskId: task.id, status: task.status });
716
- this.updateRun(run, ctx, onUpdate);
717
- }
718
-
719
- // ── intercom + mailbox ──────────────────────────────────────────────
720
- private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
721
- return {
722
- onAskParent: async (_taskId, question) => {
723
- this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
724
- this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
725
- // A blocking run's parent can't reply mid-tool (followUp only fires after the
726
- // tool returns) — only background runs can truly wait for the answer.
727
- if (!run.background) {
728
- this.updateTask(run, task, { status: "running" }, ctx);
729
- this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
730
- return "Parent cannot answer while this run is blocking. Continue autonomously with your best judgment.";
731
- }
732
- this.notifyParent(run, "asked", { taskId: task.id, question });
733
- // M3: a waiting child is not stalled — keep the watchdog fed until the reply.
734
- const keepAlive = setInterval(() => this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog(), 30_000);
735
- try {
736
- const reply = await this.awaitParentReply(run.id, task.id);
737
- this.updateTask(run, task, { status: "running" }, ctx);
738
- this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
739
- return reply;
740
- } finally {
741
- clearInterval(keepAlive);
742
- }
743
- },
744
- onNotifyParent: (_taskId, message, level) => {
745
- this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
746
- if (!run.awaited) {
747
- try {
748
- this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
749
- } catch {
750
- /* parent mid-stream */
751
- }
752
- }
753
- },
754
- onSendMessage: (_taskId, to, text) => {
755
- if (to === "leader") {
756
- this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level: "info", message: text });
757
- if (!run.awaited) {
758
- try {
759
- this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
760
- } catch {
761
- /* parent mid-stream */
762
- }
763
- }
764
- return true;
765
- }
766
- // Run-scoped keys: sibling ids are run-local; cross-run task_1 can never collide.
767
- return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
768
- },
769
- onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
770
- };
771
- }
772
- private awaitParentReply(runId: string, taskId: string): Promise<string> {
773
- return new Promise<string>((resolve) => {
774
- this.pendingReplies.set(`${runId}:${taskId}`, { resolve });
775
- });
776
- }
777
- deliverReply(runId: string, taskId: string, message: string): boolean {
778
- const pending = this.pendingReplies.get(`${runId}:${taskId}`);
779
- if (!pending) return false;
780
- this.pendingReplies.delete(`${runId}:${taskId}`);
781
- pending.resolve(message);
782
- return true;
783
- }
784
-
785
- // ── child execution ─────────────────────────────────────────────────
786
- private async runChild(
787
- run: RunSnapshot,
788
- task: TaskSnapshot,
789
- input: TaskInput,
790
- ctx: ExtensionContext,
791
- signal: AbortSignal | undefined,
792
- onUpdate?: (partial: any) => void,
793
- ): Promise<void> {
794
- if (TERMINAL.includes(task.status)) return; // canceled while queued
795
-
796
- // Inline params win; otherwise fall back to an existing agent file
797
- // (~/.agents, .pi/agents, user dir). Never creates files.
798
- const prompt = input.prompt?.trim();
799
- const thinking = input.thinking;
800
- const baseTools = input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS);
801
- const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
802
-
803
- // Model + thinking resolve against the pi model registry; a bad request
804
- // fails the TASK with a helpful message, not the whole run.
805
- let model: Model<Api> | undefined;
806
- try {
807
- model = resolveChildModel(ctx, input.model);
808
- validateThinking(model, thinking);
809
- } catch (err) {
810
- this.updateTask(run, task, {
811
- status: "failed",
812
- error: err instanceof Error ? err.message : String(err),
813
- endedAt: Date.now(),
814
- }, ctx, onUpdate);
815
- return;
816
- }
817
-
818
- this.updateTask(run, task, {
819
- status: "starting",
820
- startedAt: Date.now(),
821
- // Upstream outputs were spliced in by the scheduler; the snapshot must show
822
- // the prompt the child actually receives.
823
- task: input.task,
824
- model: input.model,
825
- thinking,
826
- tools,
827
- }, ctx, onUpdate);
828
-
829
- let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
830
- let unsubscribe: (() => void) | undefined;
831
- let timeout: ReturnType<typeof setTimeout> | undefined;
832
- let abortListener: (() => void) | undefined;
833
- let watchdog = createWatchdog(DEFAULT_STALL_MS, `Subagent ${task.agent}`);
834
- let pendingFailure: ReturnType<typeof classifyFailure>;
835
- let failChildEnd: ((error: Error) => void) | undefined;
836
- let childEndResolve: (() => void) | undefined;
837
-
838
- const key = `${run.id}:${task.id}`;
839
- try {
840
- const subagentInstruction = run.allowIntercom
841
- ? `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.`
842
- : "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.";
843
-
844
- const loader = new DefaultResourceLoader({
845
- cwd: task.cwd,
846
- agentDir: getAgentDir(),
847
- noExtensions: true,
848
- appendSystemPromptOverride: (base) => [...base, [prompt?.trim(), subagentInstruction].filter(Boolean).join("\n\n")],
849
- });
850
- await loader.reload();
851
-
852
- const customTools: ToolDefinition[] = run.allowIntercom ? createChildTools(task.id, this.makeChildHandlers(run, task, ctx)) : [];
853
-
854
- const created = await createAgentSession({
855
- cwd: task.cwd,
856
- agentDir: getAgentDir(),
857
- modelRuntime: await createChildModelRuntime(ctx),
858
- resourceLoader: loader,
859
- sessionManager: SessionManager.create(task.cwd, undefined, { parentSession: getParentSessionFile(ctx) }),
860
- model,
861
- thinkingLevel: thinking as ThinkingLevel | undefined,
862
- tools,
863
- customTools,
864
- });
865
- child = created.session;
866
- child.setSessionName?.(`subagent: ${task.agent}`);
867
- this.updateTask(run, task, { status: "running", sessionId: child.sessionId, sessionFile: child.sessionFile }, ctx, onUpdate);
868
-
869
- const childFailurePromise = new Promise<never>((_, reject) => {
870
- failChildEnd = reject;
871
- });
872
- const childEndPromise = new Promise<void>((resolve) => {
873
- childEndResolve = resolve;
874
- });
875
-
876
- unsubscribe = child.subscribe((event: AgentSessionEvent) => {
877
- const active = event.type === "message_update" || event.type === "message_end" || event.type === "tool_execution_start" || event.type === "tool_execution_update" || event.type === "tool_execution_end" || event.type === "bash_execution_update" || event.type === "agent_settled";
878
- if (active) {
879
- watchdog.touch();
880
- this.emit("subagent:session-event", { runId: run.id, taskId: task.id, seq: eventSeq++, event: { type: event.type } });
881
- }
882
- if (event.type === "tool_execution_start") {
883
- this.updateTask(run, task, { toolCalls: task.toolCalls + 1, lastActivity: describeCall(event.toolName, event.args, task.cwd) }, ctx, onUpdate);
884
- } else if (event.type === "tool_execution_end") {
885
- this.scheduleWidget(run, ctx, onUpdate);
886
- } else if (event.type === "message_end") {
887
- const message = event.message as AssistantMessage;
888
- if (message?.role === "assistant") {
889
- updateUsageFromMessage(task, message);
890
- const text = getFirstText(message);
891
- if (text) {
892
- task.finalText = truncateText(text);
893
- task.lastActivity = activitySnippet(text);
894
- }
895
- pendingFailure = classifyFailure(message.stopReason, message.errorMessage);
896
- }
897
- this.updateRun(run, ctx, onUpdate);
898
- } else if (event.type === "agent_end") {
899
- if (event.willRetry) {
900
- pendingFailure = undefined; // retry in flight — don't trust stale failures
901
- } else {
902
- const failure = lastAssistantFailure(event.messages as AssistantMessage[]);
903
- if (failure) {
904
- pendingFailure = failure;
905
- failChildEnd?.(failureError(failure));
906
- }
907
- // NOTE: success does NOT resolve childEndPromise here — pi may run a
908
- // continuation leg (compaction/overflow recovery) that emits another
909
- // agent_end. Resolve only on agent_settled, after all legs finish.
910
- }
911
- } else if (event.type === "agent_settled") {
912
- childEndResolve?.();
913
- }
914
- });
915
-
916
- const abortChild = () => {
917
- void child?.abort();
918
- this.runControllers.get(run.id)?.abort(); // parent abort kills ALL siblings, not just this child
919
- };
920
- const runController = this.runControllers.get(run.id);
921
- if (signal) signal.addEventListener("abort", abortChild, { once: true });
922
- if (runController) runController.signal.addEventListener("abort", abortChild, { once: true });
923
- abortListener = () => {
924
- signal?.removeEventListener("abort", abortChild);
925
- runController?.signal.removeEventListener("abort", abortChild);
926
- };
927
- // Cancel may have landed during session creation — honor it before prompting.
928
- if (run.status === "aborted" || TERMINAL.includes(task.status) || signal?.aborted) {
929
- await child.abort();
930
- throw new Error("Canceled by subagent_cancel");
931
- }
932
- this.liveChildren.set(key, { abort: () => void child?.abort(), dispose: () => watchdog.dispose(), touchWatchdog: () => watchdog.touch() });
933
-
934
- const maxRuntimeMs = input.maxRuntimeMs ?? DEFAULT_RUNTIME_MS;
935
- const promptPromise = child.prompt(task.task, { source: "extension" });
936
- const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
937
- if (maxRuntimeMs > 0) {
938
- races.push(
939
- new Promise<never>((_, reject) => {
940
- timeout = setTimeout(() => reject(new Error(`Subagent timed out after ${maxRuntimeMs}ms`)), maxRuntimeMs);
941
- }),
942
- );
943
- }
944
- await Promise.race(races);
945
- if (timeout) clearTimeout(timeout);
946
-
947
- pendingFailure ??= lastAssistantFailure(child.messages as AssistantMessage[]);
948
- if (pendingFailure) throw failureError(pendingFailure);
949
-
950
- const finalText = task.finalText || truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
951
- if (task.status !== "aborted") {
952
- this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
953
- }
954
- } catch (err) {
955
- if (timeout) clearTimeout(timeout);
956
- // Cancel is authoritative: parent tool signal OR run/task already marked aborted.
957
- const aborted = signal?.aborted || run.status === "aborted" || task.status === "aborted";
958
- const subagentStatus = (err as Error & { subagentStatus?: string })?.subagentStatus;
959
- try {
960
- // Unblock a child stuck in ask_parent, then time-box the abort so a
961
- // wedged session can never hang this catch/finally.
962
- this.pendingReplies.get(key)?.resolve("(parent unreachable)");
963
- await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
964
- } catch {
965
- /* ignore */
966
- }
967
- this.updateTask(run, task, {
968
- status: aborted ? "aborted" : (subagentStatus as TaskStatus) ?? "failed",
969
- error: err instanceof Error ? err.message : String(err),
970
- endedAt: Date.now(),
971
- }, ctx, onUpdate);
972
- } finally {
973
- this.liveChildren.delete(key);
974
- this.pendingReplies.delete(key);
975
- abortListener?.();
976
- unsubscribe?.();
977
- watchdog.dispose();
978
- if (timeout) clearTimeout(timeout);
979
- child?.dispose();
980
- }
981
- }
982
-
983
- // ── run lifecycle ───────────────────────────────────────────────────
984
- createRun(params: SubagentParamsShape, ctx: ExtensionContext): { run: RunSnapshot; inputs: TaskInput[] } {
985
- const hasChain = (params.chain?.length ?? 0) > 0;
986
- const hasTasks = (params.tasks?.length ?? 0) > 0;
987
- const hasSingle = Boolean(params.agent && params.task);
988
- if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
989
- throw new Error(`Provide exactly one subagent mode (single, tasks, or chain).`);
990
- }
991
-
992
- const mode: RunMode = hasChain ? "chain" : hasTasks ? "parallel" : "single";
993
- const inputs: TaskInput[] = hasSingle
994
- ? [{ agent: params.agent as string, task: params.task as string, prompt: params.prompt, write: params.write, model: params.model, thinking: params.thinking, cwd: params.cwd, tools: params.tools, maxRuntimeMs: params.maxRuntimeMs }]
995
- : hasTasks
996
- ? params.tasks!
997
- : params.chain!;
998
- if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
999
- const ids = new Set<string>();
1000
- for (const input of inputs) {
1001
- if (input.id !== undefined) {
1002
- if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
1003
- ids.add(input.id);
1004
- }
1005
- }
1006
- const edges = resolveNeeds(inputs, mode);
1007
-
1008
- const run: RunSnapshot = {
1009
- id: newId("run"),
1010
- mode,
1011
- status: "queued",
1012
- background: Boolean(params.background),
1013
- allowIntercom: Boolean(params.allowIntercom),
1014
- notifyPerTask: params.notifyPerTask ?? false,
1015
- createdAt: Date.now(),
1016
- concurrency: Math.max(1, Math.min(params.concurrency ?? DEFAULT_CONCURRENCY, MAX_CONCURRENCY)),
1017
- tasks: inputs.map((input, index) => ({
1018
- id: input.id ?? `task_${index + 1}`,
1019
- runId: "",
1020
- agent: input.agent,
1021
- task: input.task,
1022
- cwd: input.cwd ?? ctx.cwd,
1023
- status: "queued" as TaskStatus,
1024
- needs: edges[index],
1025
- model: input.model,
1026
- thinking: input.thinking,
1027
- tools: input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS),
1028
- toolCalls: 0,
1029
- usage: emptyUsage(),
1030
- })),
1031
- aggregateUsage: emptyUsage(),
1032
- };
1033
- // Roster: each child learns its own address + sibling addresses so
1034
- // send_agent_message/poll_agent_messages can be used reliably.
1035
- const roster = run.tasks.map((t) => `${t.id} (${t.agent})`).join(", ");
1036
- for (const task of run.tasks) {
1037
- task.roster = roster;
1038
- }
1039
- run.tasks.forEach((t) => (t.runId = run.id));
1040
- this.turnActivity = true;
1041
- this.runs.set(run.id, run);
1042
- this.settlers.set(run.id, () => {});
1043
- this.runControllers.set(run.id, new AbortController());
1044
- for (const task of run.tasks) this.mailboxes.open(`${run.id}:${task.id}`);
1045
- this.emit("subagent:run-created", { run: cloneRun(run) });
1046
- return { run, inputs };
1047
- }
1048
-
1049
- private async executeTasks(run: RunSnapshot, inputs: TaskInput[], ctx: ExtensionContext, signal: AbortSignal | undefined, onUpdate?: (partial: any) => void): Promise<void> {
1050
- run.status = "running";
1051
- run.startedAt = Date.now();
1052
- this.updateRun(run, ctx, onUpdate);
1053
-
1054
- // One wave scheduler for every mode. A wave is the set of tasks whose needs
1055
- // are all satisfied; the loop boundary between waves IS the gate. Chain mode
1056
- // reaches here as needs: [previous], so it needs no special case.
1057
- const outputs = new Map<string, string>();
1058
- const settled = new Set<string>();
1059
- let remaining = run.tasks.filter((t) => !TERMINAL.includes(t.status));
1060
- for (const task of run.tasks) {
1061
- if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
1062
- }
1063
-
1064
- while (remaining.length > 0) {
1065
- const ready = remaining.filter((t) => (t.needs ?? []).every((need) => settled.has(need)));
1066
- // resolveNeeds() rejects cycles up front, so an empty frontier here means every
1067
- // remaining task is downstream of one that never settled (canceled mid-run).
1068
- if (ready.length === 0) break;
1069
-
1070
- await mapWithConcurrency(ready, run.mode === "single" ? 1 : run.concurrency, async (task) => {
1071
- const index = run.tasks.indexOf(task);
1072
- const input = inputs[index]!;
1073
- const needs = task.needs ?? [];
1074
- // An upstream failure means this task's input never existed. Running it anyway
1075
- // burns a full child session on a prompt with a hole in it.
1076
- const broken = needs.filter((need) => !outputs.has(need));
1077
- if (broken.length > 0) {
1078
- this.updateTask(run, task, { status: "aborted", error: `Skipped: upstream task(s) did not complete: ${broken.join(", ")}`, endedAt: Date.now() }, ctx, onUpdate);
1079
- } else {
1080
- await this.runChild(run, task, { ...input, task: applyUpstream(input.task, needs, outputs) }, ctx, signal, onUpdate);
1081
- }
1082
- if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
1083
- this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
1084
- }
1085
- });
1086
-
1087
- for (const task of ready) {
1088
- settled.add(task.id);
1089
- if (task.status === "completed") outputs.set(task.id, task.finalText ?? "");
1090
- }
1091
- remaining = remaining.filter((t) => !settled.has(t.id));
1092
- }
1093
-
1094
- const failed = run.tasks.some((t) => t.status === "failed");
1095
- const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
1096
- run.status = aborted ? "aborted" : failed ? "failed" : "completed";
1097
- run.endedAt = Date.now();
1098
- this.flushWidget(run, ctx, onUpdate);
1099
- // Finished runs (including aborted ones) stay on screen so the outcome is readable.
1100
- // The agent_start handler clears them on the next turn that spawns nothing.
1101
- const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
1102
- if (live) this.scheduleWidget(live, ctx, onUpdate);
1103
- // L7: cancelRun already emitted + settled — don't double-report.
1104
- if (this.settlers.has(run.id)) {
1105
- this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
1106
- this.settleRun(run.id, run);
1107
- }
1108
- this.runControllers.delete(run.id);
1109
- for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
1110
- this.persist(ctx);
1111
- }
1112
-
1113
- async runBlocking(params: SubagentParamsShape, signal: AbortSignal | undefined, onUpdate: ((partial: any) => void) | undefined, ctx: ExtensionContext): Promise<RunDetails> {
1114
- const { run, inputs } = this.createRun(params, ctx);
1115
- await this.executeTasks(run, inputs, ctx, signal, onUpdate);
1116
- return { run: cloneRun(run) };
1117
- }
1118
-
1119
- startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
1120
- const { run, inputs } = this.createRun(params, ctx);
1121
- void this.executeTasks(run, inputs, ctx, undefined, undefined)
1122
- .then(() => {
1123
- this.notifyParent(run, run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed");
1124
- })
1125
- .catch((err) => {
1126
- // Never leave a background run unsettled: mark failed, settle, notify.
1127
- run.status = "failed";
1128
- run.endedAt = Date.now();
1129
- for (const task of run.tasks) {
1130
- if (!TERMINAL.includes(task.status)) {
1131
- task.status = "failed";
1132
- task.error = task.error || String(err instanceof Error ? err.message : err);
1133
- task.endedAt = Date.now();
1134
- }
1135
- }
1136
- this.settleRun(run.id, run);
1137
- this.runControllers.delete(run.id);
1138
- for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
1139
- this.emit("subagent:run-completed", { runId: run.id, status: "failed", run: cloneRun(run) });
1140
- this.notifyParent(run, "failed");
1141
- this.persist(ctx);
1142
- });
1143
- return { run: cloneRun(run), background: true };
1144
- }
1145
-
1146
- /** Abort ONE task; siblings keep running. Returns false when unknown or already finished. */
1147
- cancelTask(runId: string, taskId: string, ctx?: ExtensionContext): boolean {
1148
- const run = this.runs.get(runId);
1149
- const task = run?.tasks.find((t) => t.id === taskId);
1150
- if (!run || !task || TERMINAL.includes(task.status)) return false;
1151
- // Mark first: runChild's catch reads task.status to classify the outcome as aborted.
1152
- task.status = "aborted";
1153
- task.error = task.error || "Canceled from peek";
1154
- task.endedAt = Date.now();
1155
- this.liveChildren.get(`${runId}:${taskId}`)?.abort();
1156
- this.mailboxes.close(`${runId}:${taskId}`);
1157
- if (ctx) this.flushWidget(run, ctx);
1158
- this.emit("subagent:task-aborted", { runId, taskId });
1159
- return true;
1160
- }
1161
-
1162
- cancelRun(runId: string): { aborted: number } {
1163
- const run = this.runs.get(runId);
1164
- if (!run) return { aborted: 0 };
1165
- if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
1166
- let aborted = 0;
1167
- this.runControllers.get(runId)?.abort();
1168
- for (const [key, child] of this.liveChildren) {
1169
- if (key.startsWith(`${runId}:`)) {
1170
- child.abort();
1171
- }
1172
- }
1173
- for (const task of run.tasks) {
1174
- if (TERMINAL.includes(task.status)) continue;
1175
- task.status = "aborted";
1176
- task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
1177
- task.endedAt = Date.now();
1178
- aborted += 1;
1179
- }
1180
- run.status = "aborted";
1181
- run.endedAt = Date.now();
1182
- this.settleRun(runId, run);
1183
- this.runControllers.delete(runId);
1184
- for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
1185
- this.emit("subagent:run-completed", { runId: run.id, status: "aborted", run: cloneRun(run) });
1186
- return { aborted };
1187
- }
1188
-
1189
- /** Settle-and-delete: awaiters resolve once; no leak, no closure chain. */
1190
- private settleRun(runId: string, run: RunSnapshot): void {
1191
- const s = this.settlers.get(runId);
1192
- if (!s) return;
1193
- this.settlers.delete(runId);
1194
- s(cloneRun(run));
1195
- }
1196
-
1197
- awaitRun(runId: string, timeoutMs?: number): Promise<RunSnapshot | undefined> {
1198
- const run = this.runs.get(runId);
1199
- if (!run) return Promise.resolve(undefined);
1200
- if (TERMINAL.includes(run.status)) {
1201
- run.awaited = true;
1202
- return Promise.resolve(cloneRun(run));
1203
- }
1204
- const settled = new Promise<RunSnapshot | undefined>((resolve) => {
1205
- const prev = this.settlers.get(runId);
1206
- this.settlers.set(runId, (r) => {
1207
- prev?.(r);
1208
- resolve(r);
1209
- });
1210
- });
1211
- if (timeoutMs) {
1212
- return Promise.race([
1213
- settled,
1214
- new Promise<RunSnapshot | undefined>((resolve) => {
1215
- const timer = setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs);
1216
- settled.then(() => clearTimeout(timer));
1217
- }),
1218
- ]);
1219
- }
1220
- // Awaiting to completion: parent gets the real result, so suppress the
1221
- // completion notice. On timeout we resolve a snapshot and leave awaited
1222
- // unset, so the parent still receives the completion notification.
1223
- run.awaited = true;
1224
- return settled;
1225
- }
1226
- }
1227
-
1228
- let eventSeq = 0;
1229
-
1230
- // ── tool schemas (slim: short descriptions, no rarely-used knobs) ────────
1231
-
1232
- const TaskItem = Type.Object({
1233
- id: Type.Optional(Type.String({ description: "Optional stable task id" })),
1234
- agent: Type.String({ minLength: 1, description: "Agent name you invent. Always define the agent inline: prompt (system prompt) + toolset (write: true for write access). Never create agent files." }),
1235
- task: Type.String({ minLength: 1, description: "Task for this agent" }),
1236
- prompt: Type.Optional(Type.String({ description: "System prompt defining this agent's behavior. Optional — a minimal default is used." })),
1237
- write: Type.Optional(Type.Boolean({ description: "true = write toolset (read, bash, edit, write); default false = read-only (read, grep, find, ls)" })),
1238
- model: Type.Optional(Type.String({ description: "Model override (provider/model-id)" })),
1239
- thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override" })),
1240
- cwd: Type.Optional(Type.String({ description: "Working directory for this task. Default: current project." })),
1241
- tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset)" })),
1242
- maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout (ms)" })),
1243
- needs: Type.Optional(Type.Array(Type.String(), { description: "Ids of tasks this one waits for; their outputs are prepended to this prompt." })),
1244
- });
1245
-
1246
-
1247
- const SubagentParams = Type.Object({
1248
- agent: Type.Optional(Type.String({ minLength: 1, description: "Name you invent for this subagent (single mode)" })),
1249
- task: Type.Optional(Type.String({ minLength: 1, description: "Task (single mode)" })),
1250
- prompt: Type.Optional(Type.String({ description: "System prompt for this agent (single mode)" })),
1251
- write: Type.Optional(Type.Boolean({ description: "true = write toolset; default false = read-only (single mode)" })),
1252
- tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset) (single mode)" })),
1253
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel tasks" })),
1254
- chain: Type.Optional(Type.Array(TaskItem, { description: "Sequential tasks; {previous} = prior output" })),
1255
- model: Type.Optional(Type.String({ description: "Model override (single mode)" })),
1256
- thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override (single mode)" })),
1257
- cwd: Type.Optional(Type.String({ description: "Working directory (single mode). Default: current project." })),
1258
- concurrency: Type.Optional(Type.Number({ description: `Parallel concurrency (default ${DEFAULT_CONCURRENCY}, max ${MAX_CONCURRENCY})` })),
1259
- maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout, ms. Omit for no cap (default): tasks run until done, stalled, or user-aborted." })),
1260
- background: Type.Optional(Type.Boolean({ description: "Fire-and-forget: return immediately with a runId; you'll be notified on completion" })),
1261
- notifyPerTask: Type.Optional(Type.Boolean({ description: "Wake you (queued follow-up turn) as each task completes — background runs only, since blocking runs can't be woken mid-tool. Default false." })),
1262
- allowIntercom: Type.Optional(Type.Boolean({ description: "Let children ask you questions, notify you, and message sibling subagents" })),
1263
- });
1264
-
1265
- /** Derived from the schemas — single source of truth, no hand-maintained mirror. */
1266
- type TaskInput = Static<typeof TaskItem>;
1267
- type SubagentParamsShape = Static<typeof SubagentParams>;
1268
-
1269
- const RunIdParam = Type.Object({ runId: Type.String({ description: "Run id from subagent()" }) });
1270
- const ResultParam = Type.Object({
1271
- runId: Type.String(),
1272
- taskId: Type.Optional(Type.String({ description: "Specific task id; defaults to all" })),
1273
- });
1274
- const AwaitParam = Type.Object({
1275
- runId: Type.String(),
1276
- timeoutMs: Type.Optional(Type.Number({ description: "Max wait (ms); default: until finished" })),
1277
- });
1278
- const ReplyParam = Type.Object({
1279
- runId: Type.String(),
1280
- taskId: Type.String(),
1281
- message: Type.String({ description: "Answer for the child" }),
1282
- });
1283
-
1284
- // ── extension entry ──────────────────────────────────────────────────────
31
+ import {
32
+ AwaitParam,
33
+ ReplyParam,
34
+ ResultParam,
35
+ RunIdParam,
36
+ SubagentParams,
37
+ type SubagentParamsShape,
38
+ } from "./schemas.ts";
39
+ import { type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
1285
40
 
1286
41
  export default function (pi: ExtensionAPI) {
1287
42
  const manager = new SubagentManager(pi);
@@ -1308,16 +63,27 @@ export default function (pi: ExtensionAPI) {
1308
63
  }
1309
64
  await ctx.ui.custom<void>(
1310
65
  (tui, theme, _keybindings, done) =>
1311
- createPeekPane(getTasks, theme, () => tui.requestRender(), () => done(undefined), (t) => {
1312
- if (manager.cancelTask(t.runId, t.taskId, ctx)) ctx.ui.notify(`Aborted subagent ${t.agent}.`, "warning");
1313
- }),
66
+ createPeekPane(
67
+ getTasks,
68
+ theme,
69
+ () => tui.requestRender(),
70
+ () => done(undefined),
71
+ (t) => {
72
+ if (manager.cancelTask(t.runId, t.taskId, ctx)) ctx.ui.notify(`Aborted subagent ${t.agent}.`, "warning");
73
+ },
74
+ ),
1314
75
  { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "70%", margin: 2 } },
1315
76
  );
1316
77
  };
1317
78
  pi.registerCommand("subagents", {
1318
79
  description: "List subagent runs. `/subagents peek` opens the browsable pane.",
1319
80
  handler: async (args, ctx) => {
1320
- if (String(args ?? "").trim().toLowerCase() === "peek") return openPeek(ctx);
81
+ if (
82
+ String(args ?? "")
83
+ .trim()
84
+ .toLowerCase() === "peek"
85
+ )
86
+ return openPeek(ctx);
1321
87
  const runs = manager.listRuns().slice(0, 10);
1322
88
  if (runs.length === 0) {
1323
89
  ctx.ui.notify("No subagent runs in this session.", "info");
@@ -1354,7 +120,7 @@ export default function (pi: ExtensionAPI) {
1354
120
  // ponytail: this string is billed on every request. One example — the graph one —
1355
121
  // covers ids, needs, write and Verify; the simpler shapes are subsets of it.
1356
122
  description:
1357
- "Run isolated subagents (own context, own session). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. background:true returns immediately; allowIntercom:true lets children talk to you and each other.\n\nsubagent({ tasks: [{ id: \"api\", agent: \"api-mapper\", task: \"Map API routes\" }, { id: \"db\", agent: \"db-mapper\", task: \"Map DB schema\" }, { id: \"doc\", agent: \"writer\", needs: [\"api\", \"db\"], write: true, task: \"Write ARCHITECTURE.md. Verify: test -s ARCHITECTURE.md\" }] })",
123
+ 'Run isolated subagents (own context, own session). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. background:true returns immediately; allowIntercom:true lets children talk to you and each other.\n\nsubagent({ tasks: [{ id: "api", agent: "api-mapper", task: "Map API routes" }, { id: "db", agent: "db-mapper", task: "Map DB schema" }, { id: "doc", agent: "writer", needs: ["api", "db"], write: true, task: "Write ARCHITECTURE.md. Verify: test -s ARCHITECTURE.md" }] })',
1358
124
  promptSnippet: "Define and delegate work to specialized subagents.",
1359
125
  promptGuidelines: [
1360
126
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
@@ -1371,7 +137,12 @@ export default function (pi: ExtensionAPI) {
1371
137
  if (typed.background) {
1372
138
  const details = manager.startInBackground(typed, ctx);
1373
139
  return {
1374
- 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.` }],
140
+ content: [
141
+ {
142
+ type: "text",
143
+ 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.`,
144
+ },
145
+ ],
1375
146
  details,
1376
147
  };
1377
148
  }
@@ -1380,7 +151,7 @@ export default function (pi: ExtensionAPI) {
1380
151
  },
1381
152
  renderCall(args, theme) {
1382
153
  // ponytail: args stream in partially, so mode is unknowable until JSON closes. Show "preparing…" instead of a wrong "single ?".
1383
- const hasEdges = args.tasks?.some((t: any) => t.needs?.length);
154
+ const hasEdges = args.tasks?.some((t) => t.needs?.length);
1384
155
  const mode = args.chain?.length
1385
156
  ? `chain ${args.chain.length}`
1386
157
  : args.tasks?.length
@@ -1388,10 +159,12 @@ export default function (pi: ExtensionAPI) {
1388
159
  : args.agent
1389
160
  ? `single ${args.agent}`
1390
161
  : "preparing…";
1391
- const flags = [args.background ? "background" : "", args.allowIntercom ? "can ask" : ""].filter(Boolean).join(", ");
162
+ const flags = [args.background ? "background" : "", args.allowIntercom ? "can ask" : ""]
163
+ .filter(Boolean)
164
+ .join(", ");
1392
165
  // Params used, dimmed: model, thinking, toolset, per-task write count.
1393
166
  const tasks = args.tasks ?? args.chain ?? [];
1394
- const writeCount = tasks.filter((t: any) => t.write).length;
167
+ const writeCount = tasks.filter((t) => t.write).length;
1395
168
  const parts: string[] = [];
1396
169
  if (args.model) parts.push(args.model);
1397
170
  if (args.thinking) parts.push(args.thinking);
@@ -1405,18 +178,24 @@ export default function (pi: ExtensionAPI) {
1405
178
  // The plan the model actually wrote: ids, edges, toolset. Streams in as args arrive,
1406
179
  // so a graph is visible before the first child spawns.
1407
180
  const plan = tasks
1408
- .filter((t: any) => t.agent || t.id)
1409
- .map((t: any, i: number) => {
181
+ .filter((t) => t.agent || t.id)
182
+ .map((t, i: number) => {
1410
183
  const id = t.id ?? `task_${i + 1}`;
1411
184
  const edge = t.needs?.length ? theme.fg("muted", ` ← ${t.needs.join(", ")}`) : "";
1412
185
  const mark = t.write ? theme.fg("warning", " ✎") : "";
1413
186
  // Plain clip, not truncateText — that one appends a multi-line session-file notice.
1414
- const flat = String(t.task ?? "").replace(/\s+/g, " ").trim();
187
+ const flat = String(t.task ?? "")
188
+ .replace(/\s+/g, " ")
189
+ .trim();
1415
190
  const what = flat ? theme.fg("dim", ` ${flat.length > 64 ? `${flat.slice(0, 64)}…` : flat}`) : "";
1416
191
  return `\n ${theme.fg("muted", id)} ${theme.fg("accent", t.agent ?? "…")}${mark}${edge}${what}`;
1417
192
  })
1418
193
  .join("");
1419
- return new Text(`${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", mode)}${flags ? ` ${theme.fg("muted", `[${flags}]`)}` : ""}${params}${graphLine}${plan}`, 0, 0);
194
+ return new Text(
195
+ `${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", mode)}${flags ? ` ${theme.fg("muted", `[${flags}]`)}` : ""}${params}${graphLine}${plan}`,
196
+ 0,
197
+ 0,
198
+ );
1420
199
  },
1421
200
  renderResult(result, { expanded }, theme) {
1422
201
  const run = result.details?.run;
@@ -1431,7 +210,9 @@ export default function (pi: ExtensionAPI) {
1431
210
  }
1432
211
  const lines = [header];
1433
212
  for (const task of run.tasks) {
1434
- lines.push(` ${statusIcon(task.status)} ${theme.fg("accent", task.agent)}${task.sessionId ? ` ${theme.fg("muted", task.sessionId)}` : ""}`);
213
+ lines.push(
214
+ ` ${statusIcon(task.status)} ${theme.fg("accent", task.agent)}${task.sessionId ? ` ${theme.fg("muted", task.sessionId)}` : ""}`,
215
+ );
1435
216
  if (task.error) lines.push(` ${theme.fg("error", task.error)}`);
1436
217
  else if (task.finalText) lines.push(` ${truncateToWidth(theme.fg("dim", task.finalText.trim()), 120, "…")}`);
1437
218
  const usage = formatUsage(task.usage);
@@ -1444,7 +225,8 @@ export default function (pi: ExtensionAPI) {
1444
225
  pi.registerTool<typeof RunIdParam, { run?: RunSnapshot }>({
1445
226
  name: "subagent_status",
1446
227
  label: "Subagent Status",
1447
- description: "Live status of a subagent run (non-blocking): per-task state, plus each child's session file path (JSONL) so you can tail it from outside — e.g. in a terminal multiplexer pane.",
228
+ description:
229
+ "Live status of a subagent run (non-blocking): per-task state, plus each child's session file path (JSONL) so you can tail it from outside — e.g. in a terminal multiplexer pane.",
1448
230
  promptSnippet: "Check progress of a subagent run.",
1449
231
  parameters: RunIdParam,
1450
232
  async execute(_id, params) {
@@ -1454,7 +236,10 @@ export default function (pi: ExtensionAPI) {
1454
236
  // Session file paths are the one primitive an outside tool needs: `tail -f` it in a
1455
237
  // multiplexer pane, a log viewer, anything. Cheaper than owning a pane integration.
1456
238
  const files = run.tasks.filter((t) => t.sessionFile).map((t) => `${t.id} (${t.agent}): ${t.sessionFile}`);
1457
- const text = [compactLines(run).join("\n"), ...(files.length > 0 ? ["", "Live session files (tail -f to watch):", ...files] : [])].join("\n");
239
+ const text = [
240
+ compactLines(run).join("\n"),
241
+ ...(files.length > 0 ? ["", "Live session files (tail -f to watch):", ...files] : []),
242
+ ].join("\n");
1458
243
  return { content: [{ type: "text", text }], details: { run: cloneRun(run) } };
1459
244
  },
1460
245
  });
@@ -1469,7 +254,13 @@ export default function (pi: ExtensionAPI) {
1469
254
  const run = manager.getRun(runId);
1470
255
  if (!run) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
1471
256
  const tasks = taskId ? run.tasks.filter((t) => t.id === taskId) : run.tasks;
1472
- 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");
257
+ const text = [
258
+ `Run ${run.id} — ${run.status}`,
259
+ ...tasks.map(
260
+ (t) =>
261
+ `\n## ${t.agent} ${statusIcon(t.status)}\n${t.error ? `Error: ${t.error}` : t.finalText || "(no output yet)"}\n${formatUsage(t.usage)}`,
262
+ ),
263
+ ].join("\n");
1473
264
  return { content: [{ type: "text", text: truncateText(text) }], details: { run: cloneRun(run) } };
1474
265
  },
1475
266
  });
@@ -1495,8 +286,16 @@ export default function (pi: ExtensionAPI) {
1495
286
  async execute(_id, params) {
1496
287
  const { runId, taskId, message } = params as { runId: string; taskId: string; message: string };
1497
288
  const ok = manager.deliverReply(runId, taskId, message);
1498
- if (!ok) return { content: [{ type: "text", text: `No pending question for ${runId}/${taskId}.` }], isError: true, details: {} };
1499
- return { content: [{ type: "text", text: `Reply delivered to ${runId}/${taskId}. The child will resume.` }], details: {} };
289
+ if (!ok)
290
+ return {
291
+ content: [{ type: "text", text: `No pending question for ${runId}/${taskId}.` }],
292
+ isError: true,
293
+ details: {},
294
+ };
295
+ return {
296
+ content: [{ type: "text", text: `Reply delivered to ${runId}/${taskId}. The child will resume.` }],
297
+ details: {},
298
+ };
1500
299
  },
1501
300
  });
1502
301
 
@@ -1509,8 +308,12 @@ export default function (pi: ExtensionAPI) {
1509
308
  async execute(_id, params) {
1510
309
  const { runId } = params as { runId: string };
1511
310
  const { aborted } = manager.cancelRun(runId);
1512
- if (aborted === 0 && !manager.getRun(runId)) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
1513
- return { content: [{ type: "text", text: `Canceled ${aborted} task${aborted === 1 ? "" : "s"} in run ${runId}.` }], details: { aborted } };
311
+ if (aborted === 0 && !manager.getRun(runId))
312
+ return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
313
+ return {
314
+ content: [{ type: "text", text: `Canceled ${aborted} task${aborted === 1 ? "" : "s"} in run ${runId}.` }],
315
+ details: { aborted },
316
+ };
1514
317
  },
1515
318
  });
1516
319
  }