@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/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
- * - All messages collected for expanded view (Ctrl+O)
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 { spawnSubagent, getPiInvocation } from "./spawn.ts";
23
+ import { getPiInvocation } from "./spawn.ts";
20
24
  import {
21
- MAX_OUTPUT_CHARS,
22
- formatTokens,
23
25
  AsyncSemaphore,
24
- isProviderError,
25
- effectiveTimeout,
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 { persistSubagentHistory } from "./history.ts";
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
- // ── Helpers ────────────────────────────────────────────────────
33
-
34
- /** Coalesce bursty progress events so the TUI repaints at most this often. */
35
- const PROGRESS_THROTTLE_MS = 50;
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
- for (const [name, role] of Object.entries(BUILTIN_ROLES)) {
65
- if (!ALLOWLIST || ALLOWLIST.includes(name)) {
66
- availableRoles[name] = role;
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} → delegate(${name})`);
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(` delegate(${name}): ${quotedExamples}`);
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 delegate calls in one turn — they run in parallel.",
108
- "Include ALL necessary contextsubagents have no access to this conversation.",
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
- 'Override the model per-call with the `model` parameter for one-off vision or model-specific jobs.',
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
- // Rebuild from BUILTIN_ROLES (respecting ALLOWLIST) so repeated
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 !== "delegate") return;
175
- if (hasFailedSubagentResult(event.details)) return { isError: true };
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: "delegate",
211
+ name: "subagent_delegate",
180
212
  label: "Delegate to subagent",
181
213
  description:
182
- "Offload work to a specialized subagent to keep your own context clean and focused. Prefer this over doing work yourself when a task would generate many tool calls or verbose output. Subagents have isolated contextinclude all necessary info in the task description.",
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({ description: "Specific task for the subagent" }),
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
- "Extra context to give the subagent (selected code, prior results, file list, etc.). Delivered as a separate channel from the task. Omit if the task alone is enough.",
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
- // Throttle state hoisted to the execute scope so the finally block can clear it.
233
- // (try-body `let` is invisible to catch/finally JS gives each its own block scope.)
234
- let pendingPartial: Partial<SubagentResult> | undefined;
235
- let throttleHandle: ReturnType<typeof setTimeout> | undefined;
236
-
237
- // Flush a terminal onUpdate so the TUI's final render reflects the
238
- // real outcome (✓/✗/⏱/⏲), not a stale "running" ⏳ partial. Without it,
239
- // the last onUpdate the framework saw was an exitCode:-1 progress frame,
240
- // so the finished delegate block can keep showing the hourglass (residue).
241
- // Hoisted to execute scope (not try-body) so catch can flush on abort too.
242
- const emitFinal = (results: SubagentResult[], text: string) => {
243
- if (!onUpdate) return;
244
- if (throttleHandle !== undefined) {
245
- clearTimeout(throttleHandle);
246
- throttleHandle = undefined;
247
- }
248
- pendingPartial = undefined;
249
- onUpdate({
250
- content: [{ type: "text", text }],
251
- details: { mode: "single", results },
252
- });
253
- };
254
- // Emit a queued placeholder only when this call will actually wait.
255
- if (onUpdate && gate.isAtCapacity) {
256
- const queued: SubagentResult = {
257
- role: params.role,
258
- task: params.task,
259
- exitCode: -1,
260
- queued: true,
261
- messages: [],
262
- output: "",
263
- stderr: "",
264
- usage: {
265
- input: 0,
266
- output: 0,
267
- cacheRead: 0,
268
- cacheWrite: 0,
269
- cost: 0,
270
- contextTokens: 0,
271
- turns: 0,
272
+ // A subagent process exits when its task finishes, which would orphan any
273
+ // background run it startedbackground 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
- // Acquire a concurrency slot (abortable while queued)
284
- try {
285
- await gate.acquire(signal);
286
- } catch {
287
- throw new Error(`Subagent (${params.role}) was cancelled while queued.`);
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
- // Resolve model AFTER acquiring so the queued period stays zero-cost
292
- let rolesApi: ModelRolesAPI;
293
- try {
294
- rolesApi = getModelRolesAPI();
295
- } catch {
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
- let modelRef: string;
308
- let thinking: ThinkingLevel | undefined;
309
- if (params.model) {
310
- modelRef = params.model;
311
- } else {
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
- // Emit running placeholder now that we hold a slot
394
- if (onUpdate) {
395
- const placeholder: SubagentResult = {
396
- role: params.role,
397
- task: params.task,
398
- exitCode: -1,
399
- messages: [],
400
- output: "",
401
- stderr: "",
402
- usage: {
403
- input: 0,
404
- output: 0,
405
- cacheRead: 0,
406
- cacheWrite: 0,
407
- cost: 0,
408
- contextTokens: 0,
409
- turns: 0,
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
- // Stamp terminal fields once, after any fallback retry: elapsedMs covers
469
- // the whole delegate span (incl. retry); files/context mirror params for the TUI.
470
- result.files = params.files;
471
- result.context = params.context;
472
- result.elapsedMs = Date.now() - startTime;
473
-
474
- // Compress/truncate oversized output before it reaches the main model or TUI.
475
- // Keep the raw original for the history file (audit), feed the prepared text to LLM + expanded view.
476
- const rawOutput = result.output;
477
- if (result.output.length > MAX_OUTPUT_CHARS) {
478
- const { text, method } = await compressOutput(
479
- rolesApi,
480
- result.output,
481
- params.task,
482
- config.summary,
483
- );
484
- result.output = text;
485
- result.outputMethod = method;
486
- } else {
487
- result.outputMethod = "raw";
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
- // Generate summary for TUI display
491
- if (config.summary.enabled && result.output.trim()) {
492
- result.summary = await generateSummary(rolesApi, result.output, config.summary);
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
- // Persist audit record (best-effort; covers both success and failure).
496
- // History keeps the raw original output even when LLM/TUI saw a compressed/truncated version.
497
- if (config.history.enabled) {
498
- let sessionId: string | undefined;
499
- try {
500
- sessionId = ctx.sessionManager?.getSessionId();
501
- } catch {
502
- /* ignore */
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
- persistSubagentHistory(
505
- sessionId,
506
- _toolCallId,
507
- params.role,
508
- params.task,
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
- if (result.exitCode !== 0 || result.errorMessage) {
515
- const failedText = `Subagent (${params.role}) failed: ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}`;
516
- emitFinal([result], failedText);
517
- return {
518
- content: [{ type: "text", text: failedText }],
519
- details: { mode: "single", results: [result] },
520
- };
521
- }
522
-
523
- // Build concise output for the main model with usage info
524
- const usageParts: string[] = [];
525
- if (result.usage.turns)
526
- usageParts.push(`${result.usage.turns} turn${result.usage.turns > 1 ? "s" : ""}`);
527
- if (result.usage.input) usageParts.push(`\u2191${formatTokens(result.usage.input)}`);
528
- if (result.usage.output) usageParts.push(`\u2193${formatTokens(result.usage.output)}`);
529
- if (result.usage.cost) usageParts.push(`$${result.usage.cost.toFixed(4)}`);
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: finalText }],
537
- details: { mode: "single", results: [result] },
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
- // TUI rendering lives in ./render.ts — call row and result view.
556
- renderCall: renderDelegateCall,
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 executable
574
+ // 1. pi invocation (informational — resolution is only exercised at delegate time)
566
575
  const inv = getPiInvocation(["--version"]);
567
- lines.push(`[\u2713] pi invocation: ${inv.command} ${inv.args.slice(0, 1).join(" ")}`);
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
  }