@d3ara1n/pi-subagent 0.10.4 → 1.0.1
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 +62 -13
- package/package.json +1 -1
- package/src/history.ts +8 -3
- package/src/index.ts +382 -341
- package/src/output.ts +10 -11
- package/src/render-async.ts +324 -0
- package/src/render.ts +54 -135
- package/src/roles.ts +8 -8
- package/src/run.test.ts +276 -0
- package/src/run.ts +363 -0
- package/src/spawn.ts +26 -26
- package/src/types.ts +55 -7
- package/src/utils.test.ts +321 -49
- package/src/utils.ts +336 -12
package/src/index.ts
CHANGED
|
@@ -2,37 +2,50 @@
|
|
|
2
2
|
* pi-subagent — Role-based subagent orchestration with TUI rendering.
|
|
3
3
|
*
|
|
4
4
|
* Delegates tasks to specialized pi child processes with:
|
|
5
|
+
* - One shared async run engine (./run.ts): foreground delegation is
|
|
6
|
+
* background delegation that the tool call blocks on
|
|
7
|
+
* - `subagent_delegate(background: true)` starts a run and returns an id
|
|
8
|
+
* immediately; `subagent_wait` blocks on ids, `subagent_check` fetches a
|
|
9
|
+
* one-shot snapshot/result
|
|
5
10
|
* - Real-time progress streaming via TUI (tool calls, turns, elapsed time)
|
|
6
11
|
* - AI-generated one-line summary for compact display (configurable role)
|
|
7
|
-
* -
|
|
12
|
+
* - Live activity log (thinking + tool calls) for the TUI, full output on completion
|
|
8
13
|
* - Accurate, concise output for the main model
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
17
|
import { Type } from "typebox";
|
|
13
|
-
import type { ModelRolesAPI, ThinkingLevel } from "@d3ara1n/pi-model-roles";
|
|
14
18
|
import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
15
19
|
import type { SubagentConfig, SubagentResult, SubagentRole } from "./types.ts";
|
|
16
20
|
import { DEFAULT_CONFIG } from "./types.ts";
|
|
17
21
|
import { loadSubagentConfig } from "./config.ts";
|
|
18
22
|
import { BUILTIN_ROLES } from "./roles.ts";
|
|
19
|
-
import {
|
|
23
|
+
import { getPiInvocation } from "./spawn.ts";
|
|
20
24
|
import {
|
|
21
|
-
MAX_OUTPUT_CHARS,
|
|
22
|
-
formatTokens,
|
|
23
25
|
AsyncSemaphore,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
createThrottler,
|
|
27
|
+
describeCurrentActivity,
|
|
28
|
+
formatBudgetNote,
|
|
29
|
+
formatCheckText,
|
|
30
|
+
formatFallbackNote,
|
|
31
|
+
formatTimePart,
|
|
32
|
+
formatUsageFooter,
|
|
33
|
+
freezeFrame,
|
|
26
34
|
hasFailedSubagentResult,
|
|
35
|
+
isFailedResult,
|
|
36
|
+
isWaitTimedOut,
|
|
37
|
+
taskPreview,
|
|
27
38
|
} from "./utils.ts";
|
|
28
|
-
import {
|
|
29
|
-
import { compressOutput, generateSummary } from "./output.ts";
|
|
39
|
+
import { startSubagentRun, type RunHandle } from "./run.ts";
|
|
30
40
|
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
import {
|
|
42
|
+
renderBackgroundDelegateCall,
|
|
43
|
+
renderBackgroundDelegateResult,
|
|
44
|
+
renderCheckCall,
|
|
45
|
+
renderCheckResult,
|
|
46
|
+
renderWaitCall,
|
|
47
|
+
renderWaitResult,
|
|
48
|
+
} from "./render-async.ts";
|
|
36
49
|
|
|
37
50
|
// ── Extension entry ────────────────────────────────────────────────
|
|
38
51
|
|
|
@@ -61,11 +74,25 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
61
74
|
})();
|
|
62
75
|
|
|
63
76
|
const availableRoles: Record<string, SubagentRole> = {};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
// Rebuild available roles from BUILTIN_ROLES, filtered by the child
|
|
78
|
+
// allowlist. Called at init and again in session_start so repeated
|
|
79
|
+
// session_start is idempotent — overrides don't accumulate.
|
|
80
|
+
function refreshAvailableRoles(): void {
|
|
81
|
+
for (const key of Object.keys(availableRoles)) delete availableRoles[key];
|
|
82
|
+
for (const [name, role] of Object.entries(BUILTIN_ROLES)) {
|
|
83
|
+
if (!ALLOWLIST || ALLOWLIST.includes(name)) {
|
|
84
|
+
availableRoles[name] = role;
|
|
85
|
+
}
|
|
67
86
|
}
|
|
68
87
|
}
|
|
88
|
+
refreshAvailableRoles();
|
|
89
|
+
|
|
90
|
+
// ── Background run registry ────────────────────────────────────
|
|
91
|
+
// Process-lifetime map of background runs. Foreground delegate runs are NOT
|
|
92
|
+
// registered — their lifecycle is the tool call itself. Cleared never: ids
|
|
93
|
+
// must stay resolvable so check can fetch results long after wait returned.
|
|
94
|
+
const backgroundRuns = new Map<string, RunHandle>();
|
|
95
|
+
let runCounter = 0;
|
|
69
96
|
|
|
70
97
|
// Mutable guidelines array — rebuilt in session_start to reflect agentOverrides
|
|
71
98
|
const guidelines: string[] = [];
|
|
@@ -77,11 +104,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
77
104
|
|
|
78
105
|
for (const [name, role] of entries) {
|
|
79
106
|
// Decision flow
|
|
80
|
-
decisionLines.push(` ${role.decisionTrigger} →
|
|
107
|
+
decisionLines.push(` ${role.decisionTrigger} → subagent_delegate(${name})`);
|
|
81
108
|
|
|
82
109
|
// Concrete examples — one line per role with comma-separated examples
|
|
83
110
|
const quotedExamples = role.examples.map((e) => `"${e}"`).join(", ");
|
|
84
|
-
exampleLines.push(`
|
|
111
|
+
exampleLines.push(` subagent_delegate(${name}): ${quotedExamples}`);
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
guidelines.length = 0;
|
|
@@ -104,10 +131,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
104
131
|
"",
|
|
105
132
|
...exampleLines,
|
|
106
133
|
"",
|
|
107
|
-
"For multiple independent substantial tasks, emit multiple
|
|
108
|
-
"
|
|
134
|
+
"For multiple independent substantial tasks, emit multiple subagent_delegate calls in one turn — they run in parallel.",
|
|
135
|
+
"Subagents have no access to this conversation — everything they need must come through `task`, `context`, and `files`.",
|
|
109
136
|
'Pass reference files via the `files` parameter (e.g. files: ["src/auth.ts"]) instead of pasting their contents into `context` — the subagent reads them directly without consuming your context window.',
|
|
110
|
-
|
|
137
|
+
"Override the model per-call with the `model` parameter for one-off vision or model-specific jobs.",
|
|
138
|
+
"",
|
|
139
|
+
"BACKGROUND DELEGATION — start runs now, collect results later:",
|
|
140
|
+
"",
|
|
141
|
+
"- subagent_delegate(background: true) starts a run and returns immediately with just an id (sub-N). The run is unaffected by turn cancellation.",
|
|
142
|
+
"- After starting background runs, keep working (or start more); then subagent_wait(ids) blocks until every listed run reaches a final state (omit ids to wait for all of them).",
|
|
143
|
+
"- subagent_wait returns ONLY each run's status (finished/failed, one `id (role): status` line per run) — it never returns results. With timeout_ms it errors if anything is still unfinished.",
|
|
144
|
+
"- subagent_check(id) is the result-fetcher: for a finished run it returns the full output; mid-run it returns a snapshot (queued/running + current activity). One id per call — results can be large.",
|
|
145
|
+
"- Typical flow: subagent_delegate(background: true) ×N → work on something else → subagent_wait([id1, id2, ...]) → subagent_check(id) for each finished run.",
|
|
146
|
+
"- Background delegation works only in the top-level session.",
|
|
111
147
|
);
|
|
112
148
|
}
|
|
113
149
|
|
|
@@ -135,15 +171,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
135
171
|
config = loadSubagentConfig(ctx.cwd);
|
|
136
172
|
concurrencyGate = new AsyncSemaphore(config.maxConcurrency);
|
|
137
173
|
|
|
138
|
-
|
|
139
|
-
// session_start is idempotent — overrides from prior sessions don't accumulate.
|
|
140
|
-
for (const key of Object.keys(availableRoles)) delete availableRoles[key];
|
|
141
|
-
for (const [name, role] of Object.entries(BUILTIN_ROLES)) {
|
|
142
|
-
if (!ALLOWLIST || ALLOWLIST.includes(name)) {
|
|
143
|
-
availableRoles[name] = role;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
174
|
+
refreshAvailableRoles();
|
|
147
175
|
applyAgentOverrides(availableRoles, config.agentOverrides);
|
|
148
176
|
|
|
149
177
|
// Validate custom roles (skip built-in roles — they already have all fields)
|
|
@@ -171,25 +199,32 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
171
199
|
});
|
|
172
200
|
|
|
173
201
|
pi.on("tool_result", (event) => {
|
|
174
|
-
if (event.toolName
|
|
175
|
-
|
|
202
|
+
if (event.toolName === "subagent_delegate" && hasFailedSubagentResult(event.details)) {
|
|
203
|
+
return { isError: true };
|
|
204
|
+
}
|
|
205
|
+
if (event.toolName === "subagent_wait" && isWaitTimedOut(event.details)) {
|
|
206
|
+
return { isError: true };
|
|
207
|
+
}
|
|
176
208
|
});
|
|
177
209
|
|
|
178
210
|
pi.registerTool({
|
|
179
|
-
name: "
|
|
211
|
+
name: "subagent_delegate",
|
|
180
212
|
label: "Delegate to subagent",
|
|
181
213
|
description:
|
|
182
|
-
"
|
|
214
|
+
"Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents have no access to this conversation — everything they need must arrive through the parameters.",
|
|
183
215
|
promptSnippet: "Delegate tasks to specialized subagents",
|
|
184
216
|
promptGuidelines: guidelines,
|
|
185
217
|
|
|
186
218
|
parameters: Type.Object({
|
|
187
219
|
role: Type.String({ description: "Subagent role to use" }),
|
|
188
|
-
task: Type.String({
|
|
220
|
+
task: Type.String({
|
|
221
|
+
description:
|
|
222
|
+
"The work to do. Instructions only — background material belongs in `context`, reference file paths in `files`.",
|
|
223
|
+
}),
|
|
189
224
|
context: Type.Optional(
|
|
190
225
|
Type.String({
|
|
191
226
|
description:
|
|
192
|
-
"
|
|
227
|
+
"Background material for the subagent — prior findings, selected code, file lists; can be long. Delivered as a separate channel from the task. Omit if the task alone is enough.",
|
|
193
228
|
}),
|
|
194
229
|
),
|
|
195
230
|
files: Type.Optional(
|
|
@@ -198,6 +233,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
198
233
|
'Reference file paths for the subagent to read directly (e.g. ["src/auth.ts", "docs/api.md"]). Injected as @file attachments — content stays out of your context window. Prefer this over pasting file contents into context.',
|
|
199
234
|
}),
|
|
200
235
|
),
|
|
236
|
+
background: Type.Optional(
|
|
237
|
+
Type.Boolean({
|
|
238
|
+
description:
|
|
239
|
+
"Run asynchronously: returns an id (sub-N) immediately instead of blocking. Then subagent_wait(ids) to await completion and subagent_check(id) to fetch each result. The run survives turn cancellation.",
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
201
242
|
cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
|
|
202
243
|
model: Type.Optional(
|
|
203
244
|
Type.String({
|
|
@@ -208,7 +249,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
208
249
|
}),
|
|
209
250
|
|
|
210
251
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
211
|
-
const gate = concurrencyGate;
|
|
212
252
|
const roleDef = availableRoles[params.role];
|
|
213
253
|
if (!roleDef) {
|
|
214
254
|
return {
|
|
@@ -229,342 +269,311 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
229
269
|
);
|
|
230
270
|
}
|
|
231
271
|
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
+
// A subagent process exits when its task finishes, which would orphan any
|
|
273
|
+
// background run it started — background delegation is top-level only.
|
|
274
|
+
if (params.background && CURRENT_DEPTH > 0) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
"Background delegation is only available in the top-level session. Delegate in the foreground instead.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const run = startSubagentRun({
|
|
281
|
+
id: `sub-${++runCounter}`,
|
|
282
|
+
toolCallId: _toolCallId,
|
|
283
|
+
role: params.role,
|
|
284
|
+
roleDef,
|
|
285
|
+
task: params.task,
|
|
286
|
+
context: params.context,
|
|
287
|
+
files: params.files,
|
|
288
|
+
cwd: params.cwd ?? ctx.cwd,
|
|
289
|
+
depth: CURRENT_DEPTH + 1,
|
|
290
|
+
// Foreground runs die with the tool call; background runs outlive the turn.
|
|
291
|
+
signal: params.background ? undefined : signal,
|
|
292
|
+
modelOverride: params.model,
|
|
293
|
+
config,
|
|
294
|
+
gate: concurrencyGate,
|
|
295
|
+
getRolesApi: getModelRolesAPI,
|
|
296
|
+
getSessionId: () => ctx.sessionManager?.getSessionId(),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ── Background: return the id immediately; the pipeline keeps running. ──
|
|
300
|
+
if (params.background) {
|
|
301
|
+
backgroundRuns.set(run.id, run);
|
|
302
|
+
return {
|
|
303
|
+
content: [
|
|
304
|
+
{ type: "text", text: `Background subagent started — id: ${run.id} (${params.role}).` },
|
|
305
|
+
],
|
|
306
|
+
details: {
|
|
307
|
+
id: run.id,
|
|
308
|
+
role: params.role,
|
|
309
|
+
task: params.task,
|
|
310
|
+
context: params.context,
|
|
311
|
+
files: params.files,
|
|
272
312
|
},
|
|
273
|
-
activityLog: [],
|
|
274
|
-
files: params.files,
|
|
275
|
-
context: params.context,
|
|
276
313
|
};
|
|
277
|
-
onUpdate({
|
|
278
|
-
content: [{ type: "text", text: `${params.role}: queued...` }],
|
|
279
|
-
details: { mode: "single", results: [queued] },
|
|
280
|
-
});
|
|
281
314
|
}
|
|
282
315
|
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
316
|
+
// ── Foreground: the same async engine, blocked on here. ──
|
|
317
|
+
const emit = (results: SubagentResult[], text: string) => {
|
|
318
|
+
onUpdate?.({
|
|
319
|
+
content: [{ type: "text", text }],
|
|
320
|
+
details: { results },
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
const progressText = (f: SubagentResult): string =>
|
|
324
|
+
`${params.role} ${formatTimePart(f) ?? "0s"} ${f.usage.turns} turn${f.usage.turns !== 1 ? "s" : ""}`;
|
|
325
|
+
|
|
326
|
+
let pendingFrame: SubagentResult | undefined;
|
|
327
|
+
const progressThrottle = createThrottler(() => {
|
|
328
|
+
const f = pendingFrame;
|
|
329
|
+
pendingFrame = undefined;
|
|
330
|
+
if (f) emit([f], progressText(f));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const unsubscribe = run.subscribe(() => {
|
|
334
|
+
if (run.result) return; // the terminal frame is emitted explicitly below
|
|
335
|
+
if (!onUpdate) return;
|
|
336
|
+
pendingFrame = run.snapshot;
|
|
337
|
+
progressThrottle.notify();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Emit a queued placeholder only when this call will actually wait.
|
|
341
|
+
if (onUpdate && concurrencyGate.isAtCapacity) {
|
|
342
|
+
emit([run.snapshot], `${params.role}: queued...`);
|
|
288
343
|
}
|
|
289
344
|
|
|
290
345
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
346
|
+
const result = await run.promise;
|
|
347
|
+
|
|
348
|
+
// Fallback note: the main model must know the answer came from the
|
|
349
|
+
// fallback model, not the role's primary — on success AND failure.
|
|
350
|
+
// Budget note: budget stops are intentional successes, but the model
|
|
351
|
+
// must know the output is partial.
|
|
352
|
+
const fallbackNote = formatFallbackNote(result);
|
|
353
|
+
const budgetNote = formatBudgetNote(result);
|
|
354
|
+
|
|
355
|
+
// Aborts and spawn crashes arrive here too: the engine resolves them
|
|
356
|
+
// into failed results that keep the partial frame (task, activity,
|
|
357
|
+
// output, usage), so the TUI renders them like any failure instead
|
|
358
|
+
// of collapsing to a bare error line.
|
|
359
|
+
if (isFailedResult(result)) {
|
|
360
|
+
const failedText =
|
|
361
|
+
`Subagent (${params.role}) failed: ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}` +
|
|
362
|
+
fallbackNote;
|
|
363
|
+
emit([result], failedText);
|
|
296
364
|
return {
|
|
297
|
-
content: [
|
|
298
|
-
|
|
299
|
-
type: "text",
|
|
300
|
-
text: "pi-model-roles is not initialized. Cannot resolve model for subagent.",
|
|
301
|
-
},
|
|
302
|
-
],
|
|
303
|
-
details: undefined as any,
|
|
365
|
+
content: [{ type: "text", text: failedText }],
|
|
366
|
+
details: { results: [result] },
|
|
304
367
|
};
|
|
305
368
|
}
|
|
306
369
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const resolved = await rolesApi.resolveRoleAsync(roleDef.role);
|
|
313
|
-
if (!resolved.model) {
|
|
314
|
-
return {
|
|
315
|
-
content: [
|
|
316
|
-
{
|
|
317
|
-
type: "text",
|
|
318
|
-
text: `Role "${roleDef.role}" could not be resolved. Model not available.`,
|
|
319
|
-
},
|
|
320
|
-
],
|
|
321
|
-
details: undefined as any,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
modelRef = `${resolved.model.provider}/${resolved.model.id}`;
|
|
325
|
-
thinking = resolved.config.thinking;
|
|
326
|
-
}
|
|
327
|
-
const startTime = Date.now();
|
|
328
|
-
// Total active-time budget for this run (ms). The clock pauses while the
|
|
329
|
-
// child delegates, so this caps *active* time, not wall time.
|
|
330
|
-
const timeoutBudgetMs = effectiveTimeout(roleDef) * 1000;
|
|
331
|
-
const maxTurns = roleDef.maxTurns ?? config.maxTurns;
|
|
332
|
-
const maxCost = roleDef.maxCost ?? config.maxCost;
|
|
333
|
-
|
|
334
|
-
// Throttled progress: coalesces bursty thinking/tool events so the TUI
|
|
335
|
-
// repaints at most ~every PROGRESS_THROTTLE_MS, always keeping the latest state.
|
|
336
|
-
const renderProgress = (partial: Partial<SubagentResult>) => {
|
|
337
|
-
// Wall-clock elapsed (always ticking, even during delegate pauses).
|
|
338
|
-
const realElapsed = Math.round((Date.now() - startTime) / 1000);
|
|
339
|
-
const budgetSec = Math.round(timeoutBudgetMs / 1000);
|
|
340
|
-
const graceMs =
|
|
341
|
-
(partial.graceMs ?? 0) + (partial.pauseStart ? Date.now() - partial.pauseStart : 0);
|
|
342
|
-
const graceSec = Math.round(graceMs / 1000);
|
|
343
|
-
const timeText =
|
|
344
|
-
budgetSec > 0
|
|
345
|
-
? graceSec > 0
|
|
346
|
-
? `${realElapsed}s/${budgetSec}s(+${graceSec}s)`
|
|
347
|
-
: `${realElapsed}s/${budgetSec}s`
|
|
348
|
-
: `${realElapsed}s`;
|
|
349
|
-
const liveResult: SubagentResult = {
|
|
350
|
-
role: params.role,
|
|
351
|
-
task: params.task,
|
|
352
|
-
exitCode: -1,
|
|
353
|
-
messages: partial.messages ?? [],
|
|
354
|
-
output: partial.output ?? "",
|
|
355
|
-
stderr: "",
|
|
356
|
-
usage: partial.usage ?? {
|
|
357
|
-
input: 0,
|
|
358
|
-
output: 0,
|
|
359
|
-
cacheRead: 0,
|
|
360
|
-
cacheWrite: 0,
|
|
361
|
-
cost: 0,
|
|
362
|
-
contextTokens: 0,
|
|
363
|
-
turns: 0,
|
|
364
|
-
},
|
|
365
|
-
model: partial.model,
|
|
366
|
-
stopReason: partial.stopReason,
|
|
367
|
-
activityLog: partial.activityLog ?? [],
|
|
368
|
-
startTime,
|
|
369
|
-
budgetMs: timeoutBudgetMs,
|
|
370
|
-
graceMs: partial.graceMs,
|
|
371
|
-
pauseStart: partial.pauseStart,
|
|
372
|
-
files: params.files,
|
|
373
|
-
context: params.context,
|
|
374
|
-
};
|
|
375
|
-
const statusText = `${params.role} ${timeText} ${liveResult.usage.turns} turn${liveResult.usage.turns !== 1 ? "s" : ""}`;
|
|
376
|
-
onUpdate!({
|
|
377
|
-
content: [{ type: "text", text: statusText }],
|
|
378
|
-
details: { mode: "single", results: [liveResult] },
|
|
379
|
-
});
|
|
380
|
-
};
|
|
381
|
-
const emitProgress = (partial: Partial<SubagentResult>) => {
|
|
382
|
-
if (!onUpdate) return;
|
|
383
|
-
pendingPartial = partial;
|
|
384
|
-
if (throttleHandle !== undefined) return;
|
|
385
|
-
throttleHandle = setTimeout(() => {
|
|
386
|
-
throttleHandle = undefined;
|
|
387
|
-
const p = pendingPartial;
|
|
388
|
-
pendingPartial = undefined;
|
|
389
|
-
if (p) renderProgress(p);
|
|
390
|
-
}, PROGRESS_THROTTLE_MS);
|
|
370
|
+
const finalText = result.output + budgetNote + fallbackNote + formatUsageFooter(result);
|
|
371
|
+
emit([result], finalText);
|
|
372
|
+
return {
|
|
373
|
+
content: [{ type: "text", text: finalText }],
|
|
374
|
+
details: { results: [result] },
|
|
391
375
|
};
|
|
376
|
+
} finally {
|
|
377
|
+
// Cancel any trailing throttled onUpdate regardless of how we exited.
|
|
378
|
+
// A stale "still running" progress event fired after the tool returns
|
|
379
|
+
// corrupts framework tool state and crashes the TUI.
|
|
380
|
+
progressThrottle.cancel();
|
|
381
|
+
unsubscribe();
|
|
382
|
+
}
|
|
383
|
+
},
|
|
392
384
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
},
|
|
411
|
-
activityLog: [],
|
|
412
|
-
startTime,
|
|
413
|
-
files: params.files,
|
|
414
|
-
context: params.context,
|
|
415
|
-
};
|
|
416
|
-
onUpdate({
|
|
417
|
-
content: [{ type: "text", text: `${params.role}: running...` }],
|
|
418
|
-
details: { mode: "single", results: [placeholder] },
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
let result = await spawnSubagent(modelRef, params.task, {
|
|
423
|
-
cwd: params.cwd ?? ctx.cwd,
|
|
424
|
-
thinking,
|
|
425
|
-
tools: roleDef.tools,
|
|
426
|
-
systemPrompt: roleDef.systemPrompt,
|
|
427
|
-
context: params.context,
|
|
428
|
-
contextFiles: params.files,
|
|
429
|
-
subagentRoles: roleDef.subagentRoles,
|
|
430
|
-
timeoutMs: timeoutBudgetMs,
|
|
431
|
-
maxTurns,
|
|
432
|
-
maxCost,
|
|
433
|
-
depth: CURRENT_DEPTH + 1,
|
|
434
|
-
signal,
|
|
435
|
-
onProgress: emitProgress,
|
|
436
|
-
});
|
|
437
|
-
// Keep the stored/displayed task as the user's original (not context-expanded)
|
|
438
|
-
result.task = params.task;
|
|
439
|
-
|
|
440
|
-
// Retry with fallback role on provider errors (quota, auth, timeout, etc.)
|
|
441
|
-
if (
|
|
442
|
-
(result.exitCode !== 0 || result.errorMessage) &&
|
|
443
|
-
roleDef.fallbackRole &&
|
|
444
|
-
isProviderError(result)
|
|
445
|
-
) {
|
|
446
|
-
const fallback = await rolesApi.resolveRoleAsync(roleDef.fallbackRole);
|
|
447
|
-
if (fallback.model) {
|
|
448
|
-
const fbRef = `${fallback.model.provider}/${fallback.model.id}`;
|
|
449
|
-
result = await spawnSubagent(fbRef, params.task, {
|
|
450
|
-
cwd: params.cwd ?? ctx.cwd,
|
|
451
|
-
thinking: fallback.config.thinking,
|
|
452
|
-
tools: roleDef.tools,
|
|
453
|
-
systemPrompt: roleDef.systemPrompt,
|
|
454
|
-
context: params.context,
|
|
455
|
-
contextFiles: params.files,
|
|
456
|
-
subagentRoles: roleDef.subagentRoles,
|
|
457
|
-
timeoutMs: timeoutBudgetMs,
|
|
458
|
-
maxTurns,
|
|
459
|
-
maxCost,
|
|
460
|
-
depth: CURRENT_DEPTH + 1,
|
|
461
|
-
signal,
|
|
462
|
-
onProgress: emitProgress,
|
|
463
|
-
});
|
|
464
|
-
result.task = params.task;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
385
|
+
// TUI rendering lives in ./render.ts (foreground) and ./render-async.ts
|
|
386
|
+
// (background input block) — call row and result view.
|
|
387
|
+
renderCall(args, theme, context) {
|
|
388
|
+
return (args as any).background
|
|
389
|
+
? renderBackgroundDelegateCall(args, theme, context)
|
|
390
|
+
: renderDelegateCall(args, theme, context);
|
|
391
|
+
},
|
|
392
|
+
renderResult(result, options, theme, context) {
|
|
393
|
+
// The background flag is not part of the result — route on details shape:
|
|
394
|
+
// background results carry BackgroundDelegateDetails (id), foreground ones
|
|
395
|
+
// carry SubagentDetails (mode/results, never an id field).
|
|
396
|
+
const details = result.details as { id?: unknown } | undefined;
|
|
397
|
+
return typeof details?.id === "string"
|
|
398
|
+
? renderBackgroundDelegateResult(result, options, theme, context)
|
|
399
|
+
: renderDelegateResult(result, options, theme, context);
|
|
400
|
+
},
|
|
401
|
+
});
|
|
467
402
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
403
|
+
pi.registerTool({
|
|
404
|
+
name: "subagent_wait",
|
|
405
|
+
label: "Wait for background subagents",
|
|
406
|
+
description:
|
|
407
|
+
"Block until one or more background subagents (started via subagent_delegate with background: true) finish. Omit ids to wait for ALL current background runs. Returns ONLY each run's final status — one `id (role): finished/failed` line per run, never the results; fetch them afterwards with subagent_check. If timeout_ms elapses before every run finishes, returns an error listing per-run statuses. Cancelling the wait never cancels the runs.",
|
|
408
|
+
promptSnippet: "Wait for background subagents to finish",
|
|
409
|
+
parameters: Type.Object({
|
|
410
|
+
ids: Type.Optional(
|
|
411
|
+
Type.Array(Type.String(), {
|
|
412
|
+
minItems: 1,
|
|
413
|
+
description:
|
|
414
|
+
"Run ids returned by background delegate calls. Omit to wait for all current background runs.",
|
|
415
|
+
}),
|
|
416
|
+
),
|
|
417
|
+
timeout_ms: Type.Optional(
|
|
418
|
+
Type.Number({
|
|
419
|
+
description:
|
|
420
|
+
"Max time to wait in milliseconds. Omit to wait until all runs finish (each run still has its own role timeout).",
|
|
421
|
+
}),
|
|
422
|
+
),
|
|
423
|
+
}),
|
|
489
424
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
425
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
426
|
+
const ids = params.ids ? [...new Set(params.ids)] : [...backgroundRuns.keys()];
|
|
427
|
+
if (ids.length === 0) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
"No background runs to wait for — start one with subagent_delegate(background: true) first.",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const unknown = ids.filter((id) => !backgroundRuns.has(id));
|
|
433
|
+
if (unknown.length > 0) {
|
|
434
|
+
const known = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
435
|
+
throw new Error(
|
|
436
|
+
`Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.length > 0 ? known.join(", ") : "(none)"}.`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
const runs = ids.map((id) => backgroundRuns.get(id)!);
|
|
440
|
+
const timeoutMs =
|
|
441
|
+
typeof params.timeout_ms === "number" && params.timeout_ms > 0 ? params.timeout_ms : 0;
|
|
442
|
+
|
|
443
|
+
// ── Live mirror: forward combined snapshots into this tool row ──
|
|
444
|
+
const entries = () => runs.map((r) => ({ id: r.id, role: r.role, result: r.snapshot }));
|
|
445
|
+
const emit = () => {
|
|
446
|
+
const counts = { queued: 0, running: 0, finished: 0, failed: 0 };
|
|
447
|
+
for (const r of runs) counts[r.state]++;
|
|
448
|
+
const parts: string[] = [];
|
|
449
|
+
if (counts.running) parts.push(`${counts.running} running`);
|
|
450
|
+
if (counts.queued) parts.push(`${counts.queued} queued`);
|
|
451
|
+
parts.push(`${counts.finished} finished`);
|
|
452
|
+
parts.push(`${counts.failed} failed`);
|
|
453
|
+
onUpdate?.({
|
|
454
|
+
content: [{ type: "text", text: `waiting: ${parts.join(", ")}` }],
|
|
455
|
+
details: { entries: entries() },
|
|
456
|
+
});
|
|
457
|
+
};
|
|
458
|
+
const liveThrottle = createThrottler(emit);
|
|
459
|
+
const unsubscribers = runs.map((r) =>
|
|
460
|
+
r.subscribe(() => {
|
|
461
|
+
if (onUpdate) liveThrottle.notify();
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
// First frame right away so the row shows entries immediately.
|
|
465
|
+
if (onUpdate) emit();
|
|
494
466
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
467
|
+
let timedOut = false;
|
|
468
|
+
let cancelled = false;
|
|
469
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
470
|
+
let onAbort: (() => void) | undefined;
|
|
471
|
+
try {
|
|
472
|
+
await new Promise<void>((resolve, reject) => {
|
|
473
|
+
Promise.all(runs.map((r) => r.promise)).then(() => resolve());
|
|
474
|
+
if (timeoutMs > 0) {
|
|
475
|
+
timeoutHandle = setTimeout(() => {
|
|
476
|
+
timedOut = true;
|
|
477
|
+
reject(new Error("timeout"));
|
|
478
|
+
}, timeoutMs);
|
|
479
|
+
}
|
|
480
|
+
if (signal) {
|
|
481
|
+
onAbort = () => {
|
|
482
|
+
cancelled = true;
|
|
483
|
+
reject(new Error("cancelled"));
|
|
484
|
+
};
|
|
485
|
+
if (signal.aborted) {
|
|
486
|
+
onAbort();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
503
490
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
result,
|
|
510
|
-
rawOutput,
|
|
491
|
+
});
|
|
492
|
+
} catch (err) {
|
|
493
|
+
if (cancelled) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
"wait was cancelled — the watched subagents keep running. Call wait or check again later.",
|
|
511
496
|
);
|
|
512
497
|
}
|
|
498
|
+
if (!timedOut) throw err; // timeout is handled below via the timedOut flag
|
|
499
|
+
} finally {
|
|
500
|
+
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
|
501
|
+
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
|
502
|
+
liveThrottle.cancel();
|
|
503
|
+
for (const u of unsubscribers) u();
|
|
504
|
+
}
|
|
513
505
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if (result.model) usageParts.push(result.model);
|
|
531
|
-
const usageLine = usageParts.length > 0 ? `\n\n--- ${usageParts.join(" ")} ---` : "";
|
|
532
|
-
|
|
533
|
-
const finalText = result.output + usageLine;
|
|
534
|
-
emitFinal([result], finalText);
|
|
506
|
+
// Status line per run — same `id (role): state` shape check uses, so
|
|
507
|
+
// the model can map ids to roles from wait output alone. Budget-stopped
|
|
508
|
+
// runs report "finished" but their output is partial — flag it inline.
|
|
509
|
+
const perId = () =>
|
|
510
|
+
runs
|
|
511
|
+
.map((r) =>
|
|
512
|
+
r.result?.stopReason === "budget_exceeded"
|
|
513
|
+
? `${r.id} (${r.role}): ${r.state} (budget exceeded — output is partial)`
|
|
514
|
+
: `${r.id} (${r.role}): ${r.state}`,
|
|
515
|
+
)
|
|
516
|
+
.join("\n");
|
|
517
|
+
if (timedOut) {
|
|
518
|
+
const unfinished = runs.filter((r) => r.state === "queued" || r.state === "running");
|
|
519
|
+
const text =
|
|
520
|
+
`Timed out after ${Math.round(timeoutMs / 1000)}s — ${unfinished.length} of ${runs.length} subagents not finished. ` +
|
|
521
|
+
`Call wait again later, or check ids individually.\n${perId()}`;
|
|
535
522
|
return {
|
|
536
|
-
content: [{ type: "text", text
|
|
537
|
-
details: {
|
|
523
|
+
content: [{ type: "text", text }],
|
|
524
|
+
details: { entries: entries(), timedOut: true },
|
|
538
525
|
};
|
|
539
|
-
} catch (err: any) {
|
|
540
|
-
const errorText = `Subagent (${params.role}) error: ${err.message || err}`;
|
|
541
|
-
emitFinal([], errorText);
|
|
542
|
-
throw new Error(errorText);
|
|
543
|
-
} finally {
|
|
544
|
-
// Cancel any trailing throttled onUpdate regardless of how we exited
|
|
545
|
-
// (success / fallback / budget / error). A stale "still running" progress
|
|
546
|
-
// event fired after the tool returns corrupts framework tool state and
|
|
547
|
-
// crashes the TUI — notably in delegate chains where a subagent itself
|
|
548
|
-
// delegates (worker → explorer): the inner crash surfaces as TUI escapes.
|
|
549
|
-
if (throttleHandle !== undefined) clearTimeout(throttleHandle);
|
|
550
|
-
pendingPartial = undefined;
|
|
551
|
-
gate.release();
|
|
552
526
|
}
|
|
527
|
+
return {
|
|
528
|
+
content: [{ type: "text", text: perId() }],
|
|
529
|
+
details: { entries: entries() },
|
|
530
|
+
};
|
|
553
531
|
},
|
|
554
532
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
renderResult: renderDelegateResult,
|
|
533
|
+
renderCall: renderWaitCall,
|
|
534
|
+
renderResult: renderWaitResult,
|
|
558
535
|
});
|
|
536
|
+
|
|
537
|
+
pi.registerTool({
|
|
538
|
+
name: "subagent_check",
|
|
539
|
+
label: "Check a background subagent",
|
|
540
|
+
description:
|
|
541
|
+
"Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. One id per call because results can be large.",
|
|
542
|
+
promptSnippet: "Inspect a background subagent run",
|
|
543
|
+
parameters: Type.Object({
|
|
544
|
+
id: Type.String({ description: "Run id returned by a background delegate call" }),
|
|
545
|
+
}),
|
|
546
|
+
|
|
547
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
548
|
+
const run = backgroundRuns.get(params.id);
|
|
549
|
+
if (!run) {
|
|
550
|
+
const known = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
|
|
551
|
+
throw new Error(
|
|
552
|
+
`Unknown subagent id: ${params.id}. Known: ${known.length > 0 ? known.join(", ") : "(none)"}.`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Freeze live frames so the snapshot's elapsed time stays static.
|
|
557
|
+
const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
|
|
558
|
+
return {
|
|
559
|
+
content: [{ type: "text", text: formatCheckText(run.id, run.role, snap) }],
|
|
560
|
+
details: { id: run.id, role: run.role, result: snap },
|
|
561
|
+
};
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
renderCall: renderCheckCall,
|
|
565
|
+
renderResult: renderCheckResult,
|
|
566
|
+
});
|
|
567
|
+
|
|
559
568
|
pi.registerCommand("subagent:doctor", {
|
|
560
569
|
description: "Diagnose pi-subagent configuration and dependencies",
|
|
561
570
|
handler: async (_args, ctx) => {
|
|
562
571
|
const lines: string[] = [];
|
|
563
572
|
let allOk = true;
|
|
564
573
|
|
|
565
|
-
// 1. pi
|
|
574
|
+
// 1. pi invocation (informational — resolution is only exercised at delegate time)
|
|
566
575
|
const inv = getPiInvocation(["--version"]);
|
|
567
|
-
lines.push(`[
|
|
576
|
+
lines.push(`[i] pi invocation: ${inv.command} ${inv.args.slice(0, 2).join(" ")}`);
|
|
568
577
|
|
|
569
578
|
// 2. pi-model-roles
|
|
570
579
|
try {
|
|
@@ -643,4 +652,36 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
643
652
|
ctx.ui.notify(`${summary}\n\n${lines.join("\n")}`, "info");
|
|
644
653
|
},
|
|
645
654
|
});
|
|
655
|
+
|
|
656
|
+
pi.registerCommand("subagent:status", {
|
|
657
|
+
description: "List background subagent runs and their current state",
|
|
658
|
+
handler: async (_args, ctx) => {
|
|
659
|
+
if (backgroundRuns.size === 0) {
|
|
660
|
+
ctx.ui.notify("No background runs.", "info");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const lines: string[] = [];
|
|
664
|
+
for (const run of backgroundRuns.values()) {
|
|
665
|
+
// Freeze live frames so elapsed-dependent details don't drift in the listing.
|
|
666
|
+
const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
|
|
667
|
+
let icon: string;
|
|
668
|
+
let detail: string;
|
|
669
|
+
if (run.state === "failed") {
|
|
670
|
+
icon = "\u2717";
|
|
671
|
+
detail = snap.errorMessage || "unknown error";
|
|
672
|
+
} else if (run.state === "finished") {
|
|
673
|
+
icon = "\u2713";
|
|
674
|
+
detail = snap.summary || taskPreview(snap.output) || "(no output)";
|
|
675
|
+
} else if (run.state === "queued") {
|
|
676
|
+
icon = "\u23F8";
|
|
677
|
+
detail = "queued — waiting for a concurrency slot";
|
|
678
|
+
} else {
|
|
679
|
+
icon = "\u23F3";
|
|
680
|
+
detail = `running — ${describeCurrentActivity(snap)}`;
|
|
681
|
+
}
|
|
682
|
+
lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
|
|
683
|
+
}
|
|
684
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
685
|
+
},
|
|
686
|
+
});
|
|
646
687
|
}
|