@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/README.md +1 -0
- package/package.json +54 -46
- package/src/child.ts +16 -14
- package/src/format.ts +237 -0
- package/src/graph.ts +145 -0
- package/src/index.ts +93 -1290
- package/src/manager.ts +1051 -0
- package/src/peek.ts +4 -2
- package/src/schemas.ts +88 -0
- package/src/types.ts +70 -0
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 {
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
1312
|
-
|
|
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 (
|
|
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
|
-
|
|
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: [
|
|
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
|
|
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" : ""]
|
|
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
|
|
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
|
|
1409
|
-
.map((t
|
|
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 ?? "")
|
|
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(
|
|
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(
|
|
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:
|
|
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 = [
|
|
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 = [
|
|
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)
|
|
1499
|
-
|
|
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))
|
|
1513
|
-
|
|
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
|
}
|