@cr1ms0n/pi-subagent 0.8.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/LICENSE +21 -0
  3. package/README.md +543 -0
  4. package/docs/ARCHITECTURE.md +125 -0
  5. package/docs/COST-ACCOUNTING.md +66 -0
  6. package/docs/PLAN.md +325 -0
  7. package/docs/RELEASING.md +32 -0
  8. package/docs/ROADMAP.md +252 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/UI-OVERHAUL.md +186 -0
  11. package/docs/UX.md +141 -0
  12. package/extensions/subagent.ts +1 -0
  13. package/package.json +58 -0
  14. package/skills/subagent/SKILL.md +103 -0
  15. package/src/agents.ts +285 -0
  16. package/src/backend.ts +146 -0
  17. package/src/backends/claude.ts +384 -0
  18. package/src/backends/codex.ts +330 -0
  19. package/src/backends/index.ts +26 -0
  20. package/src/backends/pi.ts +94 -0
  21. package/src/btw.ts +34 -0
  22. package/src/config.ts +254 -0
  23. package/src/distill.ts +222 -0
  24. package/src/extension.ts +1527 -0
  25. package/src/format.ts +365 -0
  26. package/src/index.ts +60 -0
  27. package/src/launch.ts +120 -0
  28. package/src/maintenance.ts +6 -0
  29. package/src/model-policy.ts +157 -0
  30. package/src/notifications.ts +106 -0
  31. package/src/orchestrator.ts +247 -0
  32. package/src/output.ts +124 -0
  33. package/src/persistence.ts +334 -0
  34. package/src/policy.ts +500 -0
  35. package/src/process-lock.ts +687 -0
  36. package/src/protocol.ts +290 -0
  37. package/src/registry.ts +632 -0
  38. package/src/runner.ts +850 -0
  39. package/src/schema.ts +166 -0
  40. package/src/semaphore.ts +123 -0
  41. package/src/structured.ts +169 -0
  42. package/src/transcript.ts +360 -0
  43. package/src/types.ts +197 -0
  44. package/src/ui.ts +545 -0
  45. package/src/usage.ts +274 -0
  46. package/src/worktree.ts +753 -0
@@ -0,0 +1,1527 @@
1
+ import { Buffer } from "node:buffer";
2
+ import * as fs from "node:fs/promises";
3
+ import { constants as fsConstants } from "node:fs";
4
+ import * as path from "node:path";
5
+ import type { ExtensionAPI, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
6
+ import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
7
+ import type { Usage } from "@earendil-works/pi-ai";
8
+ import { Value } from "typebox/value";
9
+ import { defaultConfig, loadConfig, readConfigFile, type SubagentConfig } from "./config.js";
10
+ import {
11
+ formatDuration,
12
+ formatStatusPreview,
13
+ formatTokens,
14
+ isActiveState,
15
+ oneLine,
16
+ renderCallLine,
17
+ renderRunLines,
18
+ SPINNERS,
19
+ stateGlyph,
20
+ type InlineRunView,
21
+ } from "./format.js";
22
+ import { createGetPiCommand, getLaunchResolution } from "./launch.js";
23
+ import { abortAsPromise } from "./maintenance.js";
24
+ import { sweepSessionsLifecycle } from "./distill.js";
25
+ import { runTasks } from "./orchestrator.js";
26
+ import { OutputManager } from "./output.js";
27
+ import { parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type ResolvedTask } from "./policy.js";
28
+ import type { ChildRunner } from "./runner.js";
29
+ import { ProcessLockManager } from "./process-lock.js";
30
+ import { SessionScopedRunRegistry, snapshotFromLiveRun } from "./registry.js";
31
+ import { SubagentParamsSchema, SubagentWaitParamsSchema, type SubagentParams, type SubagentWaitParams } from "./schema.js";
32
+ import { BTW_ENTRY_TYPE, btwLabel, type BtwEntry } from "./btw.js";
33
+ import { Semaphore } from "./semaphore.js";
34
+ import type { RunSnapshot, TaskResult, TaskSpec, UsageStats } from "./types.js";
35
+ import { emptyUsage } from "./types.js";
36
+ import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, toPiUsage, type UsageLedger } from "./usage.js";
37
+ import { resolveBackendSessionFilePath, resolveSessionFilePath } from "./transcript.js";
38
+ import { CompletionBatcher, COMPLETION_MESSAGE_TYPE, type CompletionDetails, type CompletionDetailsRun, type CompletionDetailsTask } from "./notifications.js";
39
+ import { describeCatalog, discoverAgents, type AgentDefinition } from "./agents.js";
40
+ import { createSubagentsOverlay, FooterStatusModel, type SubagentAdapter } from "./ui.js";
41
+ import { WorktreeManager } from "./worktree.js";
42
+ import { formatModelPolicyPrompt, readModelPolicyFile, resolveModelRoute, validateModelRequest, type ModelPolicySnapshot } from "./model-policy.js";
43
+
44
+ interface SessionRuntime {
45
+ key: string;
46
+ ctx: ExtensionContext;
47
+ config: SubagentConfig;
48
+ registry: SessionScopedRunRegistry;
49
+ output: OutputManager;
50
+ semaphore: Semaphore;
51
+ worktrees: WorktreeManager;
52
+ locks: ProcessLockManager;
53
+ getPiCommand: ReturnType<typeof createGetPiCommand>;
54
+ /** Live per-run child runners, for mid-run steering. runId → task index → runner. */
55
+ liveRunners: Map<string, Map<number, ChildRunner>>;
56
+ /** Run ids started with async:true — the only runs that notify on completion. */
57
+ asyncRuns: Set<string>;
58
+ completions?: CompletionBatcher;
59
+ widgetTimer?: NodeJS.Timeout;
60
+ /** Named agent catalog (project/shared/global .md files). Refreshed lazily. */
61
+ agents: Map<string, AgentDefinition>;
62
+ agentsLoadedAt: number;
63
+ footer?: FooterStatusModel;
64
+ unsubscribe?: () => void;
65
+ unsubscribeLedger?: () => void;
66
+ pendingRootMessages: import("@earendil-works/pi-ai").Message[];
67
+ /** Memoized usage ledger; recomputed only after usage-affecting events. */
68
+ ledgerValue?: UsageLedger;
69
+ ledgerDirty: boolean;
70
+ closed: boolean;
71
+ depth: number;
72
+ }
73
+
74
+ function sessionKey(ctx: ExtensionContext): string {
75
+ return ctx.sessionManager.getSessionFile() || ctx.sessionManager.getSessionId() || `ephemeral:${ctx.cwd}`;
76
+ }
77
+
78
+ function activeEntries(runtime: SessionRuntime): readonly unknown[] {
79
+ return runtime.ctx.sessionManager.getBranch();
80
+ }
81
+
82
+ /**
83
+ * Ledger computation folds the whole active branch, so it is memoized and
84
+ * invalidated by registry events / new root messages instead of being rebuilt
85
+ * on every footer refresh or live-text tick.
86
+ */
87
+ function ledger(runtime: SessionRuntime): UsageLedger {
88
+ if (!runtime.ledgerDirty && runtime.ledgerValue) return runtime.ledgerValue;
89
+ const entries = activeEntries(runtime);
90
+ runtime.ledgerValue = buildUsageLedger(
91
+ entries,
92
+ [
93
+ ...runtime.registry.getLiveRuns(runtime.key).map(snapshotFromLiveRun),
94
+ ...runtime.registry.getSnapshots(runtime.key),
95
+ ],
96
+ runtime.pendingRootMessages,
97
+ );
98
+ runtime.ledgerDirty = false;
99
+ return runtime.ledgerValue;
100
+ }
101
+
102
+ function makeAdapter(runtime: SessionRuntime): SubagentAdapter {
103
+ return {
104
+ getActiveRuns: () => runtime.registry.getLiveRuns(runtime.key).map(snapshotFromLiveRun),
105
+ getCompletedRuns: () => runtime.registry.getSnapshots(runtime.key),
106
+ getRunById(id) {
107
+ const found = runtime.registry.lookup(id, runtime.key);
108
+ if (found.status !== "found" || !found.run) return null;
109
+ return "controller" in found.run ? snapshotFromLiveRun(found.run) : found.run;
110
+ },
111
+ cancelRun(id) {
112
+ const found = runtime.registry.lookup(id, runtime.key);
113
+ if (found.status === "found" && found.run && "controller" in found.run) found.run.controller.abort();
114
+ },
115
+ dismissRun: (id) => { runtime.registry.markDismissed(id, runtime.key); },
116
+ async resumeRun(id) {
117
+ const run = this.getRunById(id);
118
+ const session = run?.results.find((result) => result.sessionId)?.sessionId;
119
+ if (!session) {
120
+ runtime.ctx.ui.notify("No resumable child session is available", "warning");
121
+ return;
122
+ }
123
+ runtime.ctx.ui.setEditorText(`Continue the subagent session ${session}. Ask me for the follow-up task, then use the subagent tool with resume: "${session}".`);
124
+ runtime.ctx.ui.notify("Prepared a resume request in the editor", "info");
125
+ },
126
+ showOutput(id) {
127
+ const run = this.getRunById(id);
128
+ const pointers = run?.results.flatMap((result) => [result.outputFile, result.worktree?.cwd, result.sessionId]).filter(Boolean) as string[] | undefined;
129
+ if (!pointers?.length) runtime.ctx.ui.notify("No output artifact, worktree, or session pointer", "warning");
130
+ else {
131
+ runtime.ctx.ui.setEditorText(pointers.join("\n"));
132
+ runtime.ctx.ui.notify("Output pointers copied to the editor", "info");
133
+ }
134
+ },
135
+ getReadyCount: () => runtime.registry.getSnapshots(runtime.key).filter((run) => !run.delivered).length,
136
+ getUsageSummary: () => formatLedger(ledger(runtime)),
137
+ async steerRun(id) {
138
+ const runners = runtime.liveRunners.get(id) ?? [...runtime.liveRunners.entries()].find(([key]) => key.startsWith(id))?.[1];
139
+ if (!runners?.size) return runtime.ctx.ui.notify("Run has no steerable child (still queued or already finished)", "warning");
140
+ const message = await runtime.ctx.ui.input("Steering message", "guidance for the running child…");
141
+ if (!message?.trim()) return;
142
+ let sent = 0;
143
+ for (const runner of runners.values()) if (runner.steer(message)) sent++;
144
+ runtime.ctx.ui.notify(sent ? `Steering queued for ${sent} task(s); delivered after the current turn` : "Child is no longer accepting input", sent ? "info" : "warning");
145
+ },
146
+ async applyWorktree(id) {
147
+ const run = this.getRunById(id);
148
+ const changed = run?.results.filter((result) => result.worktree?.changed) ?? [];
149
+ if (!changed.length) return runtime.ctx.ui.notify("No changed worktree on this run", "warning");
150
+ if (changed.length > 1) {
151
+ runtime.ctx.ui.setEditorText(`Apply one of the worktrees from run ${id} with the subagent tool: { action: "apply", id: "${id}", index: <task index> }`);
152
+ return runtime.ctx.ui.notify("Multiple changed worktrees; pick one via the tool (prompt prepared)", "info");
153
+ }
154
+ const tree = changed[0]!.worktree!;
155
+ const ok = await runtime.ctx.ui.confirm("Apply worktree changes?", `Applies branch ${tree.branch} onto ${runtime.ctx.cwd} as uncommitted changes.`);
156
+ if (!ok) return;
157
+ try {
158
+ const applied = await runtime.worktrees.apply({ cwd: tree.cwd, baseCommit: tree.baseCommit }, runtime.ctx.cwd);
159
+ runtime.ctx.ui.notify(applied.applied ? `Applied: ${applied.stat.split("\n").pop() ?? "changes staged in working tree"}` : "No changes to apply", "info");
160
+ } catch (error: any) {
161
+ runtime.ctx.ui.notify(`Apply failed: ${error?.message ?? error}`, "error");
162
+ }
163
+ },
164
+ async discardWorktree(id) {
165
+ const run = this.getRunById(id);
166
+ const changed = run?.results.filter((result) => result.worktree?.changed) ?? [];
167
+ if (!changed.length) return runtime.ctx.ui.notify("No changed worktree on this run", "warning");
168
+ const ok = await runtime.ctx.ui.confirm(
169
+ "Discard worktree(s)?",
170
+ `Permanently deletes ${changed.length} worktree(s) and branch(es): ${changed.map((result) => result.worktree!.branch).join(", ")}`,
171
+ );
172
+ if (!ok) return;
173
+ for (const result of changed) {
174
+ const tree = result.worktree!;
175
+ await runtime.worktrees.forceRemove({ cwd: tree.cwd, branch: tree.branch, baseCwd: runtime.ctx.cwd, baseCommit: tree.baseCommit, changed: true }).catch(() => {});
176
+ }
177
+ runtime.ctx.ui.notify(`Discarded ${changed.length} worktree(s)`, "info");
178
+ },
179
+ subscribe: (listener) => runtime.registry.subscribe((event) => {
180
+ if (event.sessionKey === runtime.key) listener();
181
+ }),
182
+ notify(message, level = "info") {
183
+ runtime.ctx.ui.notify(message, level === "warn" ? "warning" : level);
184
+ },
185
+ getSessionFilePath(id) {
186
+ const run = this.getRunById(id);
187
+ const result = run?.results.find((entry) => entry.sessionId);
188
+ const sessionId = result?.sessionId;
189
+ if (!sessionId) return undefined;
190
+ // Non-pi backends keep transcripts in their own vendor locations, so the
191
+ // live view resolves per backend instead of assuming pi's session dir.
192
+ const backend = result?.backend ?? "pi";
193
+ if (backend !== "pi") {
194
+ return resolveBackendSessionFilePath(backend, sessionId, { cwd: runtime.ctx.cwd });
195
+ }
196
+ return resolveSessionFilePath(runtime.config.sessionDir, sessionId);
197
+ },
198
+ };
199
+ }
200
+
201
+ function refreshFooter(runtime: SessionRuntime): void {
202
+ if (runtime.closed || !runtime.footer) return;
203
+ const active = runtime.registry.getLiveRuns(runtime.key).length;
204
+ runtime.footer.update(active);
205
+ // Terse and actionable only: Pi's native footer already reports session cost.
206
+ const text = runtime.footer.render(runtime.ctx.ui.theme);
207
+ runtime.ctx.ui.setStatus("subagent", text || undefined);
208
+ refreshWidget(runtime);
209
+ }
210
+
211
+ /**
212
+ * Ambient widget above the editor for BACKGROUND runs only — foreground runs
213
+ * already render inline as the tool result, so showing them here would
214
+ * double-render. Cleared when no background runs are live.
215
+ */
216
+ function refreshWidget(runtime: SessionRuntime): void {
217
+ if (runtime.closed || !runtime.ctx.hasUI) return;
218
+ if (runtime.config.widget === "off") {
219
+ runtime.ctx.ui.setWidget("subagent", undefined);
220
+ if (runtime.widgetTimer) {
221
+ clearInterval(runtime.widgetTimer);
222
+ runtime.widgetTimer = undefined;
223
+ }
224
+ return;
225
+ }
226
+ const theme = runtime.ctx.ui.theme;
227
+ const live = runtime.registry.getLiveRuns(runtime.key).filter((run) => runtime.asyncRuns.has(run.id));
228
+ if (!live.length) {
229
+ runtime.ctx.ui.setWidget("subagent", undefined);
230
+ if (runtime.widgetTimer) {
231
+ clearInterval(runtime.widgetTimer);
232
+ runtime.widgetTimer = undefined;
233
+ }
234
+ return;
235
+ }
236
+ // Animate spinner/elapsed even when the child is between events.
237
+ if (!runtime.widgetTimer) {
238
+ runtime.widgetTimer = setInterval(() => refreshWidget(runtime), 250);
239
+ runtime.widgetTimer.unref?.();
240
+ }
241
+ const now = Date.now();
242
+ const frame = Math.floor(now / 120) % SPINNERS.length;
243
+ const lines: string[] = [theme.fg("accent", "●") + " " + theme.bold("Subagents")];
244
+ const shown = live.slice(0, 4);
245
+ shown.forEach((run, index) => {
246
+ const last = index === shown.length - 1 && live.length <= 4;
247
+ const joint = last ? "└─" : "├─";
248
+ for (const result of run.results.slice(0, 2)) {
249
+ const active = isActiveState(result.state);
250
+ const glyph = active ? theme.fg("accent", SPINNERS[frame]!) : stateGlyph(result.state, theme);
251
+ const stats = [
252
+ result.usage.turns ? `↻${result.usage.turns}` : "",
253
+ result.usage.input + result.usage.output ? `${formatTokens(result.usage.input + result.usage.output)} tok` : "",
254
+ formatDuration(now - run.startedAt),
255
+ ].filter(Boolean).join(" · ");
256
+ const activity = result.liveText?.split("\n").reverse().find((line) => line.trim());
257
+ const modelText = result.model ?? "model unknown";
258
+ lines.push(`${theme.fg("dim", joint)} ${glyph} ${theme.fg("dim", modelText)} · ${theme.fg("text", result.label)} ${theme.fg("dim", stats)}`);
259
+ if (activity) lines.push(`${theme.fg("dim", last ? " " : "│ ")}${theme.fg("dim", "⎿ ")}${theme.fg("muted", oneLine(activity, 80))}`);
260
+ }
261
+ });
262
+ if (live.length > 4) lines.push(theme.fg("dim", `└─ +${live.length - 4} more · /subagents`));
263
+ runtime.ctx.ui.setWidget("subagent", lines);
264
+ }
265
+
266
+ function utf8Preview(value: unknown, maxBytes: number): string {
267
+ const buffer = Buffer.from(String(value ?? ""), "utf8");
268
+ if (buffer.length <= maxBytes) return buffer.toString("utf8");
269
+ let end = Math.max(0, maxBytes);
270
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
271
+ return buffer.subarray(0, end).toString("utf8");
272
+ }
273
+
274
+ interface RunMeta {
275
+ state?: RunSnapshot["state"];
276
+ startedAt?: number;
277
+ endedAt?: number;
278
+ }
279
+
280
+ function compactDetails(
281
+ mode: "single" | "parallel",
282
+ results: Array<TaskResult | RunSnapshot["results"][number]>,
283
+ maxDetailsTextBytes = defaultConfig.maxDetailsTextBytes,
284
+ run?: RunMeta,
285
+ ) {
286
+ const perResultText = Math.max(256, Math.floor(maxDetailsTextBytes / Math.max(1, results.length) / 2));
287
+ return {
288
+ mode,
289
+ state: run?.state,
290
+ startedAt: run?.startedAt,
291
+ endedAt: run?.endedAt,
292
+ results: results.map((result: any) => ({
293
+ label: result.label,
294
+ task: String(result.task ?? "").slice(0, 500),
295
+ state: result.state,
296
+ exitCode: result.exitCode,
297
+ stopReason: result.stopReason,
298
+ timeoutPhase: result.timeoutPhase,
299
+ errorMessage: result.errorMessage?.slice(0, 1_000),
300
+ usage: result.usage ?? emptyUsage(),
301
+ model: result.model,
302
+ thinking: result.thinking,
303
+ profile: result.profile,
304
+ canWrite: result.canWrite,
305
+ outputFile: result.outputFile,
306
+ outputMode: result.outputMode,
307
+ worktree: result.worktree,
308
+ sessionId: result.sessionId,
309
+ process: result.process,
310
+ finalOutput: utf8Preview(result.finalOutput ?? result.liveText, perResultText),
311
+ transcript: utf8Preview(result.transcript, perResultText),
312
+ wrappedUp: result.wrappedUp,
313
+ stalledSince: result.stalledSince,
314
+ attempts: result.attempts,
315
+ attemptedModels: result.attemptedModels,
316
+ structuredOutput: result.structuredOutput,
317
+ structuredError: result.structuredError,
318
+ })),
319
+ };
320
+ }
321
+
322
+ /** Minimal component for message renderers (fresh per render; no reuse contract). */
323
+ function lineComponentForMessage(render: (width: number) => string[]): Component {
324
+ return { render, invalidate() {} };
325
+ }
326
+
327
+ /** Reusable one-shot component: stable identity across renders, content swapped in place. */
328
+ class LineBlock implements Component {
329
+ private fn: (width: number) => string[] = () => [];
330
+ set(fn: (width: number) => string[]): void { this.fn = fn; }
331
+ render(width: number): string[] { return this.fn(width); }
332
+ invalidate(): void {}
333
+ }
334
+
335
+ /**
336
+ * Wall-clock spinner frame. While a foreground run streams, Pi's working
337
+ * indicator keeps the TUI repainting, so deriving the frame from time inside
338
+ * the render closure animates smoothly without owning any timer.
339
+ */
340
+ function liveSpinnerFrame(): number {
341
+ return Math.floor(Date.now() / 100) % SPINNERS.length;
342
+ }
343
+
344
+ // keyHint lives in the coding-agent runtime; load it lazily on first render so
345
+ // headless children never pay for Pi's provider/network stack at startup.
346
+ let keyHintFn: ((id: string, description: string) => string) | null | undefined;
347
+ function expandHint(): string {
348
+ if (keyHintFn === undefined) {
349
+ keyHintFn = null;
350
+ void import("@earendil-works/pi-coding-agent")
351
+ .then((m: any) => { keyHintFn = typeof m.keyHint === "function" ? m.keyHint : null; })
352
+ .catch(() => { keyHintFn = null; });
353
+ }
354
+ try {
355
+ return keyHintFn ? keyHintFn("app.tools.expand", "to expand") : "ctrl+o to expand";
356
+ } catch {
357
+ return "ctrl+o to expand";
358
+ }
359
+ }
360
+
361
+ function fail(message: string): never {
362
+ throw new Error(message);
363
+ }
364
+
365
+ /**
366
+ * Terminal delivery payload for the subagent tool. On Pi ≥ #6671 the extra
367
+ * `usage` field is persisted on the tool-result session entry and folded into
368
+ * the native footer, /session, and RPC session totals; older Pi copies only
369
+ * content/details and silently ignores it. Callers attach it exactly once per
370
+ * run by gating on the markDelivered result, mirroring the ledger's dedup.
371
+ */
372
+ function deliveredResult<TDetails>(
373
+ text: string,
374
+ details: TDetails,
375
+ results: ReadonlyArray<{ usage: UsageStats }>,
376
+ ): { content: Array<{ type: "text"; text: string }>; details: TDetails; usage?: Usage } {
377
+ const total = addUsage(...results.map((result) => result.usage));
378
+ return {
379
+ content: [{ type: "text", text }],
380
+ details,
381
+ ...(hasBilledUsage(total) ? { usage: toPiUsage(total) } : {}),
382
+ };
383
+ }
384
+
385
+ /** Status lines advertising resumable child session ids under a finished run. */
386
+ function resumableSessionLines(
387
+ snapshot: { state?: RunSnapshot["state"]; results: Array<{ sessionId?: string }> },
388
+ full: boolean,
389
+ ): string[] {
390
+ if (snapshot.state && isActiveState(snapshot.state)) return [];
391
+ const lines: string[] = [];
392
+ for (const result of snapshot.results) {
393
+ if (!result.sessionId) continue;
394
+ lines.push(full ? ` session ${result.sessionId}` : ` session ${result.sessionId.slice(0, 8)} (resumable)`);
395
+ }
396
+ return lines;
397
+ }
398
+
399
+ async function runPlanPreflights(
400
+ runtime: SessionRuntime,
401
+ tasks: ResolvedTask[],
402
+ parentCwd: string,
403
+ ): Promise<void> {
404
+ for (let index = 0; index < tasks.length; index++) {
405
+ const task = tasks[index]!;
406
+ const taskCwd = task.cwd ?? parentCwd;
407
+ if (task.isolation === "worktree") {
408
+ if (!(await runtime.worktrees.isGitRepo(taskCwd))) {
409
+ fail(`Task ${index + 1}: ${taskCwd} is not a git repository`);
410
+ }
411
+ }
412
+ if (task.contextFork) {
413
+ const sessionFile = task.parentSessionFile;
414
+ if (!sessionFile) fail(`Task ${index + 1}: context:'fork' requires a persisted parent session file`);
415
+ await fs.access(sessionFile).catch(() => {
416
+ fail(`context:'fork' failed: parent session file ${sessionFile} is not readable.`);
417
+ });
418
+ }
419
+ if (task.output) {
420
+ const parentDir = path.dirname(task.output);
421
+ const stat = await fs.stat(parentDir).catch(() => undefined);
422
+ if (!stat?.isDirectory()) fail(`Task ${index + 1}: output parent directory does not exist: ${parentDir}`);
423
+ await fs.access(parentDir, fsConstants.W_OK).catch(() => {
424
+ fail(`Task ${index + 1}: output parent directory is not writable: ${parentDir}`);
425
+ });
426
+ }
427
+ }
428
+ }
429
+
430
+ function formatPlanEntry(task: ResolvedTask, index: number) {
431
+ const agentNote = task.resolutionNotes.find((note) => note.startsWith("agent="));
432
+ const agent = agentNote?.slice("agent=".length);
433
+ return {
434
+ index,
435
+ label: task.label,
436
+ agent,
437
+ model: task.model,
438
+ fallbackModels: task.fallbackModels,
439
+ thinking: task.thinking,
440
+ profile: task.profile,
441
+ access: task.canWrite ? "RW" : "RO" as const,
442
+ tools: task.effectiveTools,
443
+ budgets: {
444
+ timeoutMs: task.timeoutMs,
445
+ maxTurns: task.maxTurns,
446
+ maxCost: task.maxCost,
447
+ graceTurns: task.graceTurns,
448
+ },
449
+ isolation: task.isolation ?? "shared",
450
+ resolutionNotes: task.resolutionNotes,
451
+ };
452
+ }
453
+
454
+ function formatPlanText(mode: "single" | "parallel", plan: ReturnType<typeof formatPlanEntry>[]): string {
455
+ const header = `Plan (dry-run, nothing spawned) — ${mode}, ${plan.length} task${plan.length === 1 ? "" : "s"}:`;
456
+ const body = plan.map((entry) => {
457
+ const budgets = [
458
+ `timeout_ms=${entry.budgets.timeoutMs}`,
459
+ entry.budgets.maxTurns !== undefined ? `max_turns=${entry.budgets.maxTurns}` : undefined,
460
+ entry.budgets.maxCost !== undefined ? `max_cost=${entry.budgets.maxCost}` : undefined,
461
+ entry.budgets.graceTurns !== undefined ? `grace_turns=${entry.budgets.graceTurns}` : undefined,
462
+ ].filter(Boolean).join(" ");
463
+ return [
464
+ `${entry.index + 1}. ${entry.label}${entry.agent ? ` [agent:${entry.agent}]` : ""} (${entry.profile}/${entry.access})`,
465
+ ` model=${entry.model ?? "(none)"} fallback_models=[${(entry.fallbackModels ?? []).join(", ")}] thinking=${entry.thinking ?? "(default)"} isolation=${entry.isolation}`,
466
+ ` tools=[${entry.tools.join(",")}]`,
467
+ ` ${budgets}`,
468
+ ` notes: ${entry.resolutionNotes.join(", ")}`,
469
+ ].join("\n");
470
+ });
471
+ return [header, ...body].join("\n");
472
+ }
473
+
474
+ /** Re-read agent files at most every few seconds; they can change mid-session. */
475
+ function agentCatalog(runtime: SessionRuntime): Map<string, AgentDefinition> {
476
+ const now = Date.now();
477
+ if (now - runtime.agentsLoadedAt > 5_000) {
478
+ runtime.agents = discoverAgents(runtime.ctx.cwd);
479
+ runtime.agentsLoadedAt = now;
480
+ }
481
+ return runtime.agents;
482
+ }
483
+
484
+ function guidelines(catalog?: Map<string, AgentDefinition>): string[] {
485
+ const agentLines = catalog?.size
486
+ ? [
487
+ "Named agents available via agent:'<name>' (persona prompt + defaults; explicit params still override):",
488
+ ...describeCatalog(catalog).map((line) => ` - ${line}`),
489
+ ]
490
+ : [];
491
+ return [
492
+ ...agentLines,
493
+ "**model is REQUIRED on every spawn call (task/tasks).** Pass the exact model mapped by the current model policy; agent-file/taskDefaults/parent-session model fields never apply. Omit model only for management actions.",
494
+ "Delegate independent, read-heavy exploration or clean-context review; keep tightly coupled work in the parent.",
495
+ "Prefer agent:'<name>' when a named agent matches the task — its persona prompt is usually better than an improvised one. Compose fields manually only when no agent fits.",
496
+ "Give every task a short description label (3-5 words) so runs are scannable in UIs and result indexes.",
497
+ "Profiles: explore/review are strictly read-only (safe for fanout); general inherits the parent's active tools and may write. Single tasks default to general, parallel tasks to explore.",
498
+ "Parallel writers need isolation:'worktree' (each gets an isolated checkout; changed work lands on a branch). After a worktree run finishes, use action:'diff' to inspect, then 'apply' to bring changes into the main checkout or 'discard' to drop them.",
499
+ "Set budgets: at max_turns/max_cost the child is steered to wrap up and given grace turns for a final answer (grace_turns tunes this); results end as 'partial' with wrappedUp:true when the child concluded. timeout_ms includes queue time; timeout results report the phase.",
500
+ "Transient failures (provider errors, stalls, queue timeouts) retry automatically; pass fallback_models:['…'] to escalate models across attempts. Task-quality failures never retry.",
501
+ "context:'fork' starts a single child from a branched copy of this conversation — use it when the task depends on discussion context instead of re-explaining. Single-task only.",
502
+ "Use async:true only when you have independent work meanwhile; then use action:'wait' with the run id (interruptible, does not cancel). action:'steer' injects mid-run guidance into a running child instead of cancel + retry.",
503
+ "For parallel research, add synthesis:'<instruction>' to have one read-only child fold all outputs into a single brief, delivered first.",
504
+ "Use output_schema (JSON Schema) when you need a machine-readable result: the child must end with a validated json:result block, invalid output gets one automatic repair round, and delivery is the clean JSON. Compose downstream steps from details.results[].structuredOutput.",
505
+ "Use output_mode:'file-only' for large reports; the parent gets a pointer instead of inline text.",
506
+ "Discover finished child session ids from action:'status' (listed as session <id8> (resumable)), then continue with resume: \"<session id>\"; fork_resume:true branches it instead.",
507
+ ];
508
+ }
509
+
510
+ /**
511
+ * Fan-in step for parallel runs: one read-only child folds the per-task
512
+ * outputs into a single brief. Best effort — returns undefined on any failure
513
+ * so the raw results still deliver.
514
+ */
515
+ async function runSynthesis(
516
+ runtime: SessionRuntime,
517
+ instruction: string,
518
+ results: TaskResult[],
519
+ options: { runId: string; modelPolicy: ModelPolicySnapshot; signal: AbortSignal },
520
+ ): Promise<TaskResult | undefined> {
521
+ const sections = results.map((result, index) => {
522
+ // Typed handoff: validated structured results feed the synthesis child
523
+ // clean JSON instead of prose tails.
524
+ if (result.structuredOutput !== undefined) {
525
+ const json = JSON.stringify(result.structuredOutput, null, 2).slice(0, 12_000);
526
+ return `## Task ${index + 1}: ${result.label} [${result.state}] (validated structured result)\n\n\`\`\`json\n${json}\n\`\`\``;
527
+ }
528
+ const raw = result.liveText ?? result.errorMessage ?? "(no output)";
529
+ const capped = raw.length > 12_000;
530
+ const body = capped
531
+ ? `${raw.slice(0, 12_000)}\n[… truncated ${raw.length - 12_000} chars; ${result.outputFile ? `full output: ${result.outputFile}` : "full output in the child session"}]`
532
+ : raw;
533
+ const invalid = result.structuredError ? `\n[structured output FAILED validation: ${result.structuredError}]` : "";
534
+ const pointer = result.outputFile ? `\nFull output file: ${result.outputFile}` : "";
535
+ return `## Task ${index + 1}: ${result.label} [${result.state}]${pointer}${invalid}\n\n${body}`;
536
+ });
537
+ const anyTruncated = results.some((result) => result.structuredOutput === undefined && (result.liveText ?? result.errorMessage ?? "").length > 12_000);
538
+ const task = [
539
+ "You are a synthesis agent. Fold the following subagent task outputs into one coherent brief.",
540
+ `Instruction: ${instruction}`,
541
+ "Report conflicts between tasks explicitly. Do not invent findings that no task produced.",
542
+ anyTruncated
543
+ ? "Some task outputs below are TRUNCATED samples — read the referenced full output files before drawing conclusions that depend on completeness, and flag any conclusion based on a truncated section."
544
+ : "",
545
+ "",
546
+ ...sections,
547
+ ].filter(Boolean).join("\n\n");
548
+ try {
549
+ const { runSubagent } = await import("./runner.js");
550
+ const route = resolveModelRoute(options.modelPolicy);
551
+ // runSubagent is a trusted low-level SDK primitive. Re-validate the
552
+ // internally constructed request here so synthesis remains inside the
553
+ // Extension's modelPolicy boundary rather than becoming a bypass path.
554
+ const approved = validateModelRequest(options.modelPolicy, {
555
+ model: route.model,
556
+ fallbackModels: [...route.fallbackModels],
557
+ fallbackModelsProvided: true,
558
+ });
559
+ if (approved.error || !approved.route) throw new Error(approved.error ?? "synthesis model policy validation failed");
560
+ const synth = await runSubagent(
561
+ {
562
+ task,
563
+ label: "synthesis",
564
+ profile: "review",
565
+ canWrite: false,
566
+ tools: ["read"],
567
+ model: approved.route.model,
568
+ fallbackModels: [...approved.route.fallbackModels],
569
+ thinking: "low",
570
+ timeoutMs: Math.min(runtime.config.defaultTimeoutMs, 5 * 60_000),
571
+ maxTurns: 8,
572
+ },
573
+ {
574
+ semaphore: runtime.semaphore,
575
+ getPiCommand: runtime.getPiCommand,
576
+ sessionDir: runtime.config.sessionDir,
577
+ killGraceMs: runtime.config.killGraceMs,
578
+ locks: runtime.locks,
579
+ runId: `${options.runId}:synthesis`,
580
+ parentSessionKey: runtime.key,
581
+ signal: options.signal,
582
+ },
583
+ );
584
+ if (synth.state !== "completed" && synth.state !== "partial") return undefined;
585
+ synth.label = "synthesis";
586
+ return synth;
587
+ } catch {
588
+ return undefined;
589
+ }
590
+ }
591
+
592
+ /** Compact completion payload for notification messages (LLM + renderer facing). */
593
+ function buildCompletionDetails(runtime: SessionRuntime, runIds: string[]): CompletionDetails {
594
+ const runs: CompletionDetailsRun[] = [];
595
+ for (const id of runIds) {
596
+ const found = runtime.registry.lookup(id, runtime.key);
597
+ if (found.status !== "found" || !found.run || "controller" in found.run) continue;
598
+ const snapshot = found.run;
599
+ let turns = 0, tokens = 0, cost = 0;
600
+ const pointers: string[] = [];
601
+ const tasks: CompletionDetailsTask[] = snapshot.results.map((result, index) => {
602
+ const taskPointers: string[] = [];
603
+ if (result.outputFile) {
604
+ taskPointers.push(result.outputFile);
605
+ pointers.push(result.outputFile);
606
+ }
607
+ if (result.worktree?.changed) {
608
+ taskPointers.push(`branch ${result.worktree.branch}`);
609
+ pointers.push(`branch ${result.worktree.branch}`);
610
+ }
611
+ const taskPreview = oneLine(
612
+ (result.finalOutput ?? result.errorMessage ?? "").split("\n").find((line) => line.trim()) ?? "",
613
+ 100,
614
+ );
615
+ turns += result.usage?.turns ?? 0;
616
+ tokens += (result.usage?.input ?? 0) + (result.usage?.output ?? 0);
617
+ cost += result.usage?.cost ?? 0;
618
+ return {
619
+ label: result.label ?? snapshot.taskPreviews[index] ?? `task-${index + 1}`,
620
+ state: result.state,
621
+ preview: taskPreview,
622
+ turns: result.usage?.turns ?? 0,
623
+ tokens: (result.usage?.input ?? 0) + (result.usage?.output ?? 0),
624
+ cost: result.usage?.cost ?? 0,
625
+ model: result.model,
626
+ attemptedModels: result.attemptedModels,
627
+ pointers: taskPointers,
628
+ };
629
+ });
630
+ const first = tasks[0];
631
+ const runPreview = oneLine(snapshot.summary ?? first?.preview ?? "", 100);
632
+ runs.push({
633
+ id: snapshot.id,
634
+ label: first?.label ?? snapshot.taskPreviews[0] ?? "task",
635
+ state: snapshot.state,
636
+ preview: runPreview,
637
+ turns,
638
+ tokens,
639
+ cost,
640
+ durationMs: (snapshot.endedAt ?? Date.now()) - snapshot.startedAt,
641
+ // Preserve the old top-level fields only for single-task consumers. The
642
+ // complete per-task model/attempt data lives in tasks[].
643
+ model: tasks.length === 1 ? first?.model : undefined,
644
+ attemptedModels: tasks.length === 1 ? first?.attemptedModels : undefined,
645
+ pointers,
646
+ tasks,
647
+ });
648
+ }
649
+ return { runs };
650
+ }
651
+
652
+ /**
653
+ * Fire-and-forget startup GC + orphan reclaim.
654
+ * Only top-level parents may run maintenance — nested children would race each
655
+ * other and could operate on worktrees still owned by a concurrent parent.
656
+ */
657
+ function scheduleMaintenance(runtime: SessionRuntime): void {
658
+ if (runtime.depth > 0) return;
659
+ void (async () => {
660
+ // Reconcile orphans first so "lost" is an honest fact before any resume.
661
+ const reaped = await runtime.locks.reconcileOrphans({
662
+ killGraceMs: runtime.config.killGraceMs,
663
+ parentSessionKey: runtime.key,
664
+ skipRunIds: new Set(runtime.registry.getLiveRuns(runtime.key).map((run) => run.id)),
665
+ });
666
+ for (const id of [...reaped.reaped, ...reaped.alreadyDead]) {
667
+ runtime.registry.clearResumeBlock(id, runtime.key);
668
+ }
669
+ runtime.locks.sweep((runtime.config.lockRetentionDays ?? 7) * 24 * 60 * 60_000);
670
+
671
+ // Lifecycle distillation: sessions whose runs are over are reduced to a
672
+ // digest (task, outcome, usage) and the transcript is deleted. "Over" is
673
+ // decided by references and machine-wide run records, not wall-clock age.
674
+ const keep = new Set(runtime.registry.planSessionRetention().keep);
675
+ const busy = new Set<string>();
676
+ for (const record of runtime.locks.listRunRecords()) {
677
+ if (record.state === "running" && record.childSessionId) busy.add(record.childSessionId);
678
+ }
679
+ for (const run of runtime.registry.getLiveRuns(runtime.key)) {
680
+ for (const id of run.childSessionIds) busy.add(id);
681
+ }
682
+ await sweepSessionsLifecycle(runtime.config.sessionDir, { keep, busy });
683
+ // Machine-wide worktree GC: this session's live worktrees plus every
684
+ // worktree recorded by a running run record (other concurrent Pi parents)
685
+ // are shielded; all containers under the global root are swept, not just
686
+ // this repo's, so repos the user stops visiting still get reclaimed.
687
+ const liveWorktrees = runtime.registry.getLiveWorktreeCwds(runtime.key);
688
+ for (const record of runtime.locks.listRunRecords()) {
689
+ if (record.state === "running" && record.worktreeCwd) liveWorktrees.add(record.worktreeCwd);
690
+ }
691
+ await runtime.worktrees.sweepAll(runtime.ctx.cwd, liveWorktrees);
692
+ })().catch(() => { /* maintenance is best effort */ });
693
+ }
694
+
695
+ export default function registerSubagent(pi: ExtensionAPI): void {
696
+ let current: SessionRuntime | undefined;
697
+
698
+ // Fail-closed depth parse (malformed env) walks past any plausible ceiling so we
699
+ // skip registering the tool entirely in scrubbed/forged-depth child processes.
700
+ // Normal nested children still register; execute-time validation + the runtime
701
+ // config maxDepth are the real limit (file-configured caps need session_start).
702
+ const bootDepth = parseDepth();
703
+ if (bootDepth >= 100) return;
704
+ // Parent set spawns:false (or a malformed PI_SUBAGENT_SPAWNS) — no tool surface
705
+ // for further nesting. Accidental-recursion guard only; not a security boundary.
706
+ if (parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]).kind === "disabled") return;
707
+
708
+ async function teardown(runtime: SessionRuntime): Promise<void> {
709
+ if (runtime.closed) return;
710
+ await runtime.registry.shutdown(runtime.key, 8_000);
711
+ runtime.closed = true;
712
+ runtime.unsubscribe?.();
713
+ runtime.unsubscribeLedger?.();
714
+ runtime.completions?.dispose();
715
+ runtime.footer?.dispose();
716
+ runtime.locks.dispose();
717
+ if (runtime.widgetTimer) clearInterval(runtime.widgetTimer);
718
+ runtime.ctx.ui.setStatus("subagent", undefined);
719
+ runtime.ctx.ui.setWidget("subagent", undefined);
720
+ }
721
+
722
+ pi.on("before_agent_start", async (event) => {
723
+ let policy: ModelPolicySnapshot | undefined;
724
+ let error: string | undefined;
725
+ try {
726
+ policy = await readModelPolicyFile();
727
+ } catch (caught) {
728
+ error = caught instanceof Error ? caught.message : String(caught);
729
+ }
730
+ return { systemPrompt: `${event.systemPrompt}\n\n${formatModelPolicyPrompt(policy, error)}` };
731
+ });
732
+
733
+ pi.on("session_start", async (_event, ctx) => {
734
+ if (current && !current.closed) await teardown(current);
735
+ const runtime = {} as SessionRuntime;
736
+ runtime.key = sessionKey(ctx);
737
+ runtime.ctx = ctx;
738
+ runtime.config = loadConfig(await readConfigFile());
739
+ runtime.depth = parseDepth();
740
+ runtime.output = new OutputManager(runtime.config);
741
+ runtime.semaphore = new Semaphore(runtime.config.maxActiveProcesses, runtime.config.maxQueuedTasks);
742
+ runtime.worktrees = new WorktreeManager(undefined, runtime.config.worktreeDir);
743
+ runtime.locks = new ProcessLockManager({
744
+ rootDir: runtime.config.lockDir,
745
+ maxGlobalActive: runtime.config.maxGlobalActive,
746
+ });
747
+ runtime.getPiCommand = createGetPiCommand(getLaunchResolution());
748
+ runtime.liveRunners = new Map();
749
+ runtime.asyncRuns = new Set();
750
+ runtime.agents = discoverAgents(ctx.cwd);
751
+ runtime.agentsLoadedAt = Date.now();
752
+ runtime.pendingRootMessages = [];
753
+ runtime.ledgerDirty = true;
754
+ runtime.closed = false;
755
+ runtime.registry = new SessionScopedRunRegistry(runtime.config, {
756
+ getEntries: () => ctx.sessionManager.getBranch() as any[],
757
+ appendEntry: (type, data) => {
758
+ // Captured runtime ownership prevents an old async callback from appending to a new session.
759
+ if (current !== runtime || runtime.closed || sessionKey(ctx) !== runtime.key) return;
760
+ pi.appendEntry(type, data);
761
+ },
762
+ }, runtime.locks);
763
+ runtime.unsubscribeLedger = runtime.registry.subscribe((event) => {
764
+ if (event.sessionKey === runtime.key) runtime.ledgerDirty = true;
765
+ });
766
+ // Background-run completion notifications: batched followUp messages so
767
+ // the parent LLM reacts without polling. Foreground runs deliver inline.
768
+ // `notifications: "off"` skips construction; subscribe still uses optional chain.
769
+ if (runtime.config.notifications !== "off") {
770
+ runtime.completions = new CompletionBatcher((runIds) => {
771
+ if (current !== runtime || runtime.closed) return;
772
+ // Delivered-state is re-checked at flush time: a wait that consumed the
773
+ // run during the batching window suppresses the redundant notification.
774
+ const undelivered = runIds.filter((id) => {
775
+ const found = runtime.registry.lookup(id, runtime.key);
776
+ return found.status === "found" && !!found.run && !found.run.delivered;
777
+ });
778
+ const details = buildCompletionDetails(runtime, undelivered);
779
+ if (!details.runs.length) return;
780
+ const lines = details.runs.flatMap((run) => {
781
+ const tasks = run.tasks?.length ? run.tasks : [{
782
+ label: run.label,
783
+ state: run.state,
784
+ preview: run.preview,
785
+ model: run.model,
786
+ attemptedModels: run.attemptedModels,
787
+ pointers: run.pointers,
788
+ turns: run.turns,
789
+ tokens: run.tokens,
790
+ cost: run.cost,
791
+ }];
792
+ return tasks.map((task) => {
793
+ const attemptText = task.attemptedModels && task.attemptedModels.length > 1
794
+ ? `; attempts: ${task.attemptedModels.join(" → ")}`
795
+ : "";
796
+ const label = tasks.length > 1 ? `${run.label}/${task.label}` : task.label;
797
+ return `- [${run.id.slice(0, 8)}] ${label}: ${task.state}${task.model ? ` on ${task.model}` : ""}${attemptText}${task.preview ? ` — ${task.preview}` : ""}${task.pointers.length ? ` (${task.pointers.join(", ")})` : ""}`;
798
+ });
799
+ });
800
+ pi.sendMessage({
801
+ customType: COMPLETION_MESSAGE_TYPE,
802
+ content: [
803
+ `${details.runs.length === 1 ? "A background subagent run" : `${details.runs.length} background subagent runs`} finished:`,
804
+ ...lines,
805
+ `Use { action: "wait", id } to collect full output, or dismiss with status if not needed.`,
806
+ ].join("\n"),
807
+ display: true,
808
+ details,
809
+ }, { deliverAs: "followUp", triggerTurn: true });
810
+ });
811
+ }
812
+
813
+ runtime.unsubscribe = runtime.registry.subscribe((event) => {
814
+ if (event.sessionKey !== runtime.key) return;
815
+ if (event.type === "terminal" && runtime.asyncRuns.has(event.runId)) {
816
+ runtime.asyncRuns.delete(event.runId);
817
+ // Delivered-state is checked again at flush time (wait may consume the
818
+ // run during the batching window).
819
+ runtime.completions?.add(event.runId, !["completed", "partial"].includes(event.state));
820
+ }
821
+ if (ctx.hasUI && event.type === "terminal") {
822
+ runtime.footer?.notifyTerminal(
823
+ event.runId,
824
+ `Subagent ${event.runId.slice(0, 8)} ${event.state}`,
825
+ event.state === "completed" ? "info" : "warn",
826
+ );
827
+ }
828
+ refreshFooter(runtime);
829
+ });
830
+ if (ctx.hasUI) {
831
+ runtime.footer = new FooterStatusModel(makeAdapter(runtime));
832
+ runtime.footer.setOnUpdate(() => refreshFooter(runtime));
833
+ }
834
+ current = runtime;
835
+ refreshFooter(runtime);
836
+ scheduleMaintenance(runtime);
837
+ });
838
+
839
+ pi.on("message_end", async (event) => {
840
+ const runtime = current;
841
+ if (!runtime || runtime.closed || event.message.role !== "assistant") return;
842
+ // Supplement immediately; ledger deduplicates when SessionManager exposes it.
843
+ runtime.pendingRootMessages.push(event.message);
844
+ if (runtime.pendingRootMessages.length > 20) runtime.pendingRootMessages.shift();
845
+ runtime.ledgerDirty = true;
846
+ refreshFooter(runtime);
847
+ });
848
+
849
+ pi.on("session_before_tree", async () => {
850
+ const runtime = current;
851
+ if (!runtime || runtime.closed) return;
852
+ // Finish persistence on the originating leaf before Pi moves the branch pointer.
853
+ await runtime.registry.shutdown(runtime.key, 8_000);
854
+ });
855
+
856
+ pi.on("session_tree", async () => {
857
+ const runtime = current;
858
+ if (!runtime || runtime.closed) return;
859
+ runtime.pendingRootMessages = [];
860
+ runtime.ledgerDirty = true;
861
+ runtime.registry.refreshSnapshots(runtime.key);
862
+ refreshFooter(runtime);
863
+ });
864
+
865
+ pi.on("session_shutdown", async () => {
866
+ const runtime = current;
867
+ if (!runtime) return;
868
+ // Keep ownership valid until cancellation, process close, and final persistence finish.
869
+ await teardown(runtime);
870
+ if (current === runtime) current = undefined;
871
+ });
872
+
873
+ // Themed completion box for background-run notifications; the LLM sees the
874
+ // plain content, the human sees this.
875
+ pi.registerMessageRenderer(COMPLETION_MESSAGE_TYPE, (message, { expanded }, theme) => {
876
+ const details = message.details as CompletionDetails | undefined;
877
+ if (!details?.runs.length) return undefined;
878
+ return lineComponentForMessage((width) => {
879
+ const lines: string[] = [];
880
+ for (const run of details.runs) {
881
+ const tasks = run.tasks?.length ? run.tasks : [{
882
+ label: run.label,
883
+ state: run.state,
884
+ preview: run.preview,
885
+ turns: run.turns,
886
+ tokens: run.tokens,
887
+ cost: run.cost,
888
+ model: run.model,
889
+ attemptedModels: run.attemptedModels,
890
+ pointers: run.pointers,
891
+ }];
892
+ tasks.forEach((task) => {
893
+ const glyph = stateGlyph(task.state as any, theme);
894
+ const stats = [
895
+ task.turns ? `↻${task.turns}` : "",
896
+ task.tokens ? `${formatTokens(task.tokens)} tok` : "",
897
+ task.cost > 0.00005 ? `$${task.cost.toFixed(3)}` : "",
898
+ tasks.length === 1 ? formatDuration(run.durationMs) : "",
899
+ task.model ?? "",
900
+ ].filter(Boolean).join(" · ");
901
+ const label = tasks.length > 1 ? `${run.label}/${task.label}` : task.label;
902
+ lines.push(truncateToWidth(`${glyph} ${theme.fg("dim", task.model ?? "model unknown")} · ${theme.bold(theme.fg("toolTitle", label))} ${theme.fg("dim", `[${run.id.slice(0, 8)}] ${stats}`)}`, width));
903
+ if (task.preview) lines.push(truncateToWidth(` ${theme.fg("dim", "⎿")} ${theme.fg("toolOutput", task.preview)}`, width));
904
+ if (task.attemptedModels && task.attemptedModels.length > 1) {
905
+ lines.push(truncateToWidth(` ${theme.fg("warning", `models: ${task.attemptedModels.join(" → ")}`)}`, width));
906
+ }
907
+ if ((expanded || tasks.length === 1) && task.pointers.length) {
908
+ lines.push(truncateToWidth(theme.fg("dim", ` ${task.pointers.join(" · ")}`), width));
909
+ }
910
+ });
911
+ }
912
+ lines.push(theme.fg("dim", truncateToWidth(`wait { id } collects full output`, width)));
913
+ return lines;
914
+ });
915
+ });
916
+
917
+ pi.registerCommand("subagent-cost", {
918
+ description: "Show parent / subagent / combined usage for this branch",
919
+ handler: async (_args, ctx) => {
920
+ const runtime = current;
921
+ if (!runtime || runtime.key !== sessionKey(ctx)) return ctx.ui.notify("Subagent runtime is not ready", "error");
922
+ ctx.ui.notify(formatLedger(ledger(runtime)), "info");
923
+ },
924
+ });
925
+
926
+ pi.registerCommand("subagents", {
927
+ description: "Inspect subagent runs, artifacts, sessions, and combined usage",
928
+ handler: async (_args, ctx) => {
929
+ const runtime = current;
930
+ if (!runtime || runtime.key !== sessionKey(ctx)) return ctx.ui.notify("Subagent runtime is not ready", "error");
931
+ await ctx.ui.custom(
932
+ (tui: TUI, theme: Theme, _keybindings, done) => createSubagentsOverlay(tui, theme, makeAdapter(runtime), () => done(undefined)),
933
+ { overlay: true, overlayOptions: { width: "80%", maxHeight: "80%" } },
934
+ );
935
+ },
936
+ });
937
+
938
+ const subagentTool = {
939
+ name: "subagent",
940
+ label: "Subagent",
941
+ description: "Run isolated Pi subagents in foreground, parallel, or cancellable background mode.",
942
+ // Guidelines are baked into the system prompt at registration (extension
943
+ // load runs per-session in the project cwd). Agents added mid-session are
944
+ // usable immediately via agent:'name' (execute-time refresh); only the
945
+ // system-prompt advertisement waits for the next session.
946
+ promptGuidelines: guidelines(discoverAgents(process.cwd())),
947
+ parameters: SubagentParamsSchema,
948
+ async execute(_id, params: SubagentParams, signal, onUpdate, ctx) {
949
+ // `subagent_wait` delegates here with a synthesized action:"wait" params
950
+ // object, carrying its timeout through this non-schema field so the two
951
+ // tools share exactly one collect/deliver path.
952
+ let waitTimeoutMs: number | undefined;
953
+ if ("__waitTimeoutMs" in (params as object)) {
954
+ const smuggled = params as SubagentParams & { __waitTimeoutMs?: number };
955
+ waitTimeoutMs = smuggled.__waitTimeoutMs;
956
+ // Strip before validation: the schema is additionalProperties:false.
957
+ delete smuggled.__waitTimeoutMs;
958
+ }
959
+ const runtime = current;
960
+ if (!runtime || runtime.closed || runtime.key !== sessionKey(ctx)) {
961
+ fail("Subagent runtime is not initialized for this session.");
962
+ }
963
+ if (!Value.Check(SubagentParamsSchema, params)) {
964
+ const errors = [...Value.Errors(SubagentParamsSchema, params)].slice(0, 5).map((error: any) => error.message).join("; ");
965
+ fail(`Invalid parameters: ${errors}`);
966
+ }
967
+
968
+ // Re-read the policy on every dispatch so changes take effect without
969
+ // restarting the parent session; no provider catalog or credentials are
970
+ // loaded by this path.
971
+ const dispatchConfig = loadConfig(await readConfigFile());
972
+ const model = ctx.model;
973
+ const validated = validateSubagentRequest(params, {
974
+ cwd: ctx.cwd,
975
+ model: model ? `${model.provider}/${model.id}` : undefined,
976
+ thinking: pi.getThinkingLevel() as TaskSpec["thinking"],
977
+ availableTools: pi.getAllTools().map((tool) => tool.name),
978
+ activeTools: pi.getActiveTools(),
979
+ depth: parseDepth(),
980
+ sessionFile: ctx.sessionManager.getSessionFile() ?? undefined,
981
+ }, {
982
+ maxDepth: runtime.config.maxDepth,
983
+ maxTasks: runtime.config.maxTasksPerRun,
984
+ defaultTimeoutMs: runtime.config.defaultTimeoutMs,
985
+ taskDefaults: dispatchConfig.taskDefaults,
986
+ agents: agentCatalog(runtime),
987
+ modelPolicy: dispatchConfig.modelPolicy,
988
+ modelPolicyError: dispatchConfig.modelPolicyError,
989
+ });
990
+ if (!validated.ok) fail(validated.error);
991
+
992
+ const details = (mode: "single" | "parallel", results: Array<TaskResult | RunSnapshot["results"][number]>, run?: RunMeta) =>
993
+ compactDetails(mode, results, runtime.config.maxDetailsTextBytes, run);
994
+
995
+ if (["status", "wait", "cancel", "steer", "diff", "apply", "discard"].includes(validated.mode)) {
996
+ if (validated.mode === "status" && !validated.id) {
997
+ const runs = [
998
+ ...runtime.registry.getLiveRuns(runtime.key).map(snapshotFromLiveRun),
999
+ ...runtime.registry.getSnapshots(runtime.key),
1000
+ ];
1001
+ const catalog = agentCatalog(runtime);
1002
+ const agentSection = catalog.size
1003
+ ? `Named agents (use agent:'<name>'):\n${describeCatalog(catalog).map((line) => `- ${line}`).join("\n")}`
1004
+ : "";
1005
+ const text = [
1006
+ runs.length
1007
+ ? runs.map((run) => [formatStatusPreview(run), ...resumableSessionLines(run, false)].join("\n")).join("\n")
1008
+ : "No subagent runs.",
1009
+ agentSection,
1010
+ formatLedger(ledger(runtime)),
1011
+ ].filter(Boolean).join("\n\n");
1012
+ return { content: [{ type: "text", text }], details: details("single", []) };
1013
+ }
1014
+ const found = runtime.registry.lookup(validated.id!, runtime.key);
1015
+ if (found.status === "ambiguous") fail(`Ambiguous id. Matches: ${found.matches!.join(", ")}`);
1016
+ if (found.status !== "found" || !found.run) fail(`Run ${validated.id} was not found in this session.`);
1017
+ const snapshot = "controller" in found.run ? snapshotFromLiveRun(found.run) : found.run;
1018
+ if (validated.mode === "status") {
1019
+ const text = [
1020
+ formatStatusPreview(snapshot),
1021
+ ...resumableSessionLines(snapshot, true),
1022
+ formatLedger(ledger(runtime)),
1023
+ ].filter(Boolean).join("\n");
1024
+ return { content: [{ type: "text", text }], details: details(snapshot.mode, snapshot.results, snapshot) };
1025
+ }
1026
+ if (validated.mode === "cancel") {
1027
+ if ("controller" in found.run) found.run.controller.abort();
1028
+ return { content: [{ type: "text", text: `Cancellation requested for ${snapshot.id}` }], details: details(snapshot.mode, snapshot.results, snapshot) };
1029
+ }
1030
+ if (validated.mode === "steer") {
1031
+ if (!("controller" in found.run)) fail(`Run ${snapshot.id} is not running; steer only applies to live runs. Use resume to continue a finished child.`);
1032
+ const runners = runtime.liveRunners.get(snapshot.id);
1033
+ if (!runners?.size) fail(`Run ${snapshot.id} has no steerable child yet (still queued or starting). Retry in a moment.`);
1034
+ const eligible = validated.index !== undefined
1035
+ ? runners.get(validated.index) ? [[validated.index, runners.get(validated.index)!] as const] : []
1036
+ : [...runners.entries()];
1037
+ if (!eligible.length) fail(`No live task at index ${validated.index} in run ${snapshot.id}. Live indexes: ${[...runners.keys()].join(", ")}`);
1038
+ if (validated.index === undefined && eligible.length > 1) {
1039
+ fail(`Run ${snapshot.id} has ${eligible.length} live tasks; pass index to pick one (live indexes: ${[...runners.keys()].join(", ")}).`);
1040
+ }
1041
+ const [index, runner] = eligible[0]!;
1042
+ if (!runner.steer(validated.message!)) fail(`Task ${index} in run ${snapshot.id} is no longer accepting input.`);
1043
+ return {
1044
+ content: [{ type: "text", text: `Steering message queued for run ${snapshot.id} task ${index}. It is delivered after the current assistant turn; watch status/wait for the response.` }],
1045
+ details: details(snapshot.mode, snapshot.results, snapshot),
1046
+ };
1047
+ }
1048
+ if (["diff", "apply", "discard"].includes(validated.mode)) {
1049
+ if ("controller" in found.run) fail(`Run ${snapshot.id} is still running; worktree actions apply to finished runs.`);
1050
+ const withTrees = snapshot.results
1051
+ .map((result, index) => ({ result, index }))
1052
+ .filter((entry) => entry.result.worktree?.changed);
1053
+ if (!withTrees.length) fail(`Run ${snapshot.id} has no changed worktrees.`);
1054
+ const chosen = validated.index !== undefined
1055
+ ? withTrees.find((entry) => entry.index === validated.index)
1056
+ : withTrees.length === 1 ? withTrees[0] : undefined;
1057
+ if (!chosen) {
1058
+ fail(`Run ${snapshot.id} has ${withTrees.length} changed worktrees; pass index to pick one (indexes: ${withTrees.map((entry) => entry.index).join(", ")}).`);
1059
+ }
1060
+ const tree = chosen.result.worktree!;
1061
+ // The worktree directory may already be reclaimed by lifecycle GC; its
1062
+ // unique work then lives in an archived patch. diff/apply degrade to
1063
+ // that patch; discard removes it.
1064
+ const treeGone = await fs.access(tree.cwd).then(() => false, () => true);
1065
+ const archivedPatch = runtime.worktrees.archivedPatchPathFor(tree.cwd);
1066
+ const archiveExists = treeGone && await fs.access(archivedPatch).then(() => true, () => false);
1067
+ if (treeGone && !archiveExists) {
1068
+ fail(`Worktree ${tree.cwd} is gone and no archived patch exists. Committed work may still be on branch ${tree.branch}.`);
1069
+ }
1070
+ if (validated.mode === "diff" && archiveExists) {
1071
+ const patch = await fs.readFile(archivedPatch, "utf8");
1072
+ const capped = runtime.output.capOutputForDelivery([{ ...chosen.result, finalOutput: `Worktree was reclaimed; archived patch for run ${snapshot.id} task ${chosen.index} (branch ${tree.branch}):\n\n${patch}`, outputMode: "inline" }] as any);
1073
+ return { content: [{ type: "text", text: capped.text }], details: details(snapshot.mode, snapshot.results, snapshot) };
1074
+ }
1075
+ if (validated.mode === "apply" && archiveExists) {
1076
+ const applied = await runtime.worktrees.applyArchivedPatch(archivedPatch, ctx.cwd);
1077
+ return { content: [{ type: "text", text: `Applied archived patch from run ${snapshot.id} task ${chosen.index} into ${ctx.cwd} as uncommitted working-tree changes:\n${applied.stat}\nReview and commit them. The archive ${archivedPatch} is preserved; use action:'discard' to clean up.` }], details: details(snapshot.mode, snapshot.results, snapshot) };
1078
+ }
1079
+ if (validated.mode === "discard" && archiveExists) {
1080
+ await fs.rm(archivedPatch, { force: true });
1081
+ await runtime.worktrees.forceRemove({ cwd: tree.cwd, branch: tree.branch, baseCwd: ctx.cwd, baseCommit: tree.baseCommit, changed: tree.changed });
1082
+ return { content: [{ type: "text", text: `Discarded archived patch and branch ${tree.branch} from run ${snapshot.id} task ${chosen.index}.` }], details: details(snapshot.mode, snapshot.results, snapshot) };
1083
+ }
1084
+ if (validated.mode === "diff") {
1085
+ const diff = await runtime.worktrees.diff({ cwd: tree.cwd, baseCommit: tree.baseCommit });
1086
+ const text = [
1087
+ `Worktree diff for run ${snapshot.id} task ${chosen.index} (branch ${tree.branch}):`,
1088
+ diff.stat || "(no stat)",
1089
+ "",
1090
+ diff.patch || "(no patch)",
1091
+ diff.truncated ? `\n[patch truncated; full diff: git -C ${tree.cwd} diff ${tree.baseCommit}]` : "",
1092
+ ].filter(Boolean).join("\n");
1093
+ const capped = runtime.output.capOutputForDelivery([{ ...chosen.result, finalOutput: text, outputMode: "inline" }] as any);
1094
+ return { content: [{ type: "text", text: capped.text }], details: details(snapshot.mode, snapshot.results, snapshot) };
1095
+ }
1096
+ if (validated.mode === "apply") {
1097
+ const applied = await runtime.worktrees.apply({ cwd: tree.cwd, baseCommit: tree.baseCommit }, ctx.cwd);
1098
+ const text = applied.applied
1099
+ ? `Applied worktree changes from run ${snapshot.id} task ${chosen.index} into ${ctx.cwd} as uncommitted working-tree changes:\n${applied.stat}\nReview and commit them. The worktree and branch ${tree.branch} are preserved; use action:'discard' to clean up.`
1100
+ : `Worktree for run ${snapshot.id} task ${chosen.index} had no changes to apply.`;
1101
+ return { content: [{ type: "text", text }], details: details(snapshot.mode, snapshot.results, snapshot) };
1102
+ }
1103
+ // discard
1104
+ await runtime.worktrees.forceRemove({ cwd: tree.cwd, branch: tree.branch, baseCwd: ctx.cwd, baseCommit: tree.baseCommit, changed: tree.changed });
1105
+ return { content: [{ type: "text", text: `Discarded worktree and branch ${tree.branch} from run ${snapshot.id} task ${chosen.index}.` }], details: details(snapshot.mode, snapshot.results, snapshot) };
1106
+ }
1107
+ if ("promise" in found.run) {
1108
+ // Wait must stay interruptible: aborting the wait returns promptly
1109
+ // WITHOUT cancelling the background run (that is cancel's job).
1110
+ const settled = found.run.promise.then(() => "done" as const, () => "done" as const);
1111
+ const races: Array<Promise<"done" | "aborted" | "timeout">> = [settled];
1112
+ const abortRace = abortAsPromise(signal);
1113
+ if (abortRace) races.push(abortRace as Promise<"aborted">);
1114
+ let timer: NodeJS.Timeout | undefined;
1115
+ if (waitTimeoutMs !== undefined) {
1116
+ races.push(new Promise<"timeout">((resolve) => {
1117
+ timer = setTimeout(() => resolve("timeout"), waitTimeoutMs);
1118
+ timer.unref?.();
1119
+ }));
1120
+ }
1121
+ let raced: "done" | "aborted" | "timeout";
1122
+ try {
1123
+ raced = await Promise.race(races);
1124
+ } finally {
1125
+ if (timer) clearTimeout(timer);
1126
+ }
1127
+ if (raced === "aborted") {
1128
+ return {
1129
+ content: [{ type: "text", text: `Wait aborted. Run ${snapshot.id} continues in the background; use status/wait/cancel later or open /subagents.` }],
1130
+ details: details(snapshot.mode, snapshot.results, snapshot),
1131
+ };
1132
+ }
1133
+ if (raced === "timeout") {
1134
+ // Timing out must not consume the result: the run keeps going and
1135
+ // stays collectable, so we deliberately skip markDelivered here.
1136
+ return {
1137
+ content: [{ type: "text", text: `Wait timed out after ${waitTimeoutMs}ms. Run ${snapshot.id} is still running and was NOT cancelled; collect it with subagent_wait again (or action:'status'), or stop it with action:'cancel'.` }],
1138
+ details: details(snapshot.mode, snapshot.results, snapshot),
1139
+ };
1140
+ }
1141
+ }
1142
+ const refreshed = runtime.registry.lookup(snapshot.id, runtime.key);
1143
+ const terminal = refreshed.status === "found" && refreshed.run
1144
+ ? "controller" in refreshed.run ? snapshotFromLiveRun(refreshed.run) : refreshed.run
1145
+ : snapshot;
1146
+ if (!runtime.registry.markDelivered(terminal.id, runtime.key)) {
1147
+ return { content: [{ type: "text", text: `Run ${terminal.id} was already delivered. Artifacts and sessions remain available in /subagents.` }], details: details(terminal.mode, terminal.results, terminal) };
1148
+ }
1149
+ const delivered = runtime.output.capOutputForDelivery(terminal.results);
1150
+ const text = delivered.text || terminal.summary || "(no output)";
1151
+ // Locate runs and partial/timeout deliveries still return content; hard
1152
+ // failures and “lost with resume blocked” raise so the agent notices.
1153
+ // (Thrown deliveries cannot carry native usage; the extension ledger
1154
+ // still counts them from persisted entries.)
1155
+ if (terminal.state === "failed" || terminal.state === "lost") fail(text);
1156
+ return deliveredResult(text, details(terminal.mode, delivered.cappedResults as any, terminal), terminal.results);
1157
+ }
1158
+
1159
+ if (validated.planOnly) {
1160
+ await runPlanPreflights(runtime, validated.tasks, ctx.cwd);
1161
+ const plan = validated.tasks.map((task, index) => formatPlanEntry(task, index));
1162
+ const mode = validated.mode as "single" | "parallel";
1163
+ return {
1164
+ content: [{ type: "text", text: formatPlanText(mode, plan) }],
1165
+ details: { mode, plan },
1166
+ };
1167
+ }
1168
+
1169
+ const specs: TaskSpec[] = validated.tasks.map((task: ResolvedTask) => ({
1170
+ task: task.task,
1171
+ label: task.label,
1172
+ systemPrompt: task.systemPrompt,
1173
+ model: task.model,
1174
+ thinking: task.thinking,
1175
+ tools: task.effectiveTools,
1176
+ profile: task.profile,
1177
+ canWrite: task.canWrite,
1178
+ cwd: task.cwd,
1179
+ timeoutMs: task.timeoutMs,
1180
+ maxTurns: task.maxTurns,
1181
+ maxCost: task.maxCost,
1182
+ output: task.output,
1183
+ outputMode: task.outputMode,
1184
+ outputSchema: task.outputSchema,
1185
+ resume: task.resume,
1186
+ forkResume: task.forkResume,
1187
+ isolation: task.isolation,
1188
+ allowSharedWrites: task.allowSharedWrites,
1189
+ keepBackground: task.keepBackground,
1190
+ graceTurns: task.graceTurns,
1191
+ fallbackModels: task.fallbackModels,
1192
+ maxRetries: task.maxRetries,
1193
+ contextFork: task.contextFork,
1194
+ parentSessionFile: task.parentSessionFile,
1195
+ spawns: task.spawns,
1196
+ }));
1197
+ const runId = runtime.registry.allocateRunId();
1198
+ const directResumes = validated.tasks.filter((task) => task.resume && !task.forkResume).map((task) => task.resume!);
1199
+ const lock = runtime.registry.acquireResumeLocks(directResumes, runId, runtime.key);
1200
+ if (!lock.ok) fail(`Child session ${lock.conflict!.sessionId} is already active in run ${lock.conflict!.runId}. Use fork_resume:true for an independent continuation.`);
1201
+
1202
+ const controller = new AbortController();
1203
+ const parentAbort = () => controller.abort();
1204
+ if (signal?.aborted) controller.abort();
1205
+ else signal?.addEventListener("abort", parentAbort, { once: true });
1206
+ let resolveDone!: () => void;
1207
+ const done = new Promise<void>((resolve) => { resolveDone = resolve; });
1208
+ try {
1209
+ runtime.registry.start(runtime.key, validated.mode as "single" | "parallel", specs, controller, done, validated.tasks.map((task) => task.label), runId);
1210
+ } catch (error) {
1211
+ for (const session of directResumes) runtime.registry.releaseResumeLock(session, runtime.key, runId);
1212
+ throw error;
1213
+ }
1214
+
1215
+ // Throttle streamed tool updates with a trailing-edge flush: structural
1216
+ // changes (state transition, new session id, billed turn) emit
1217
+ // immediately; live-text ticks coalesce into at most one deferred emit
1218
+ // per window, so the final state of a burst always renders. Runner
1219
+ // checkpoints spread the full result, so "structural" is detected by
1220
+ // diffing against the last seen values per task index.
1221
+ let lastStreamedUpdate = 0;
1222
+ let pendingFlush: NodeJS.Timeout | undefined;
1223
+ const lastSeen = new Map<number, { state?: string; sessionId?: string; turns: number }>();
1224
+ const emitUpdate = () => {
1225
+ if (pendingFlush) { clearTimeout(pendingFlush); pendingFlush = undefined; }
1226
+ lastStreamedUpdate = Date.now();
1227
+ const live = runtime.registry.lookup(runId, runtime.key);
1228
+ if (live.status !== "found" || !live.run) return;
1229
+ const snap = "controller" in live.run ? snapshotFromLiveRun(live.run) : live.run;
1230
+ // content stays compact and stable (LLM-facing); details carries the
1231
+ // frequently-updated render data (state, usage, live-text tail).
1232
+ onUpdate?.({ content: [{ type: "text", text: formatStatusPreview(snap) }], details: details(snap.mode, snap.results, snap) });
1233
+ };
1234
+ const streamUpdate = (index: number, partial: Partial<TaskResult>) => {
1235
+ const seen = lastSeen.get(index) ?? { turns: 0 };
1236
+ const structural =
1237
+ (partial.state !== undefined && partial.state !== seen.state) ||
1238
+ (partial.sessionId !== undefined && partial.sessionId !== seen.sessionId) ||
1239
+ (partial.usage !== undefined && partial.usage.turns > seen.turns);
1240
+ lastSeen.set(index, {
1241
+ state: partial.state ?? seen.state,
1242
+ sessionId: partial.sessionId ?? seen.sessionId,
1243
+ turns: Math.max(seen.turns, partial.usage?.turns ?? 0),
1244
+ });
1245
+ const now = Date.now();
1246
+ if (structural || now - lastStreamedUpdate >= 250) {
1247
+ emitUpdate();
1248
+ return;
1249
+ }
1250
+ if (!pendingFlush) {
1251
+ pendingFlush = setTimeout(emitUpdate, 250 - (now - lastStreamedUpdate));
1252
+ pendingFlush.unref?.();
1253
+ }
1254
+ };
1255
+
1256
+ const work = (async () => {
1257
+ try {
1258
+ const result = await runTasks(specs, {
1259
+ semaphore: runtime.semaphore,
1260
+ getPiCommand: runtime.getPiCommand,
1261
+ sessionDir: runtime.config.sessionDir,
1262
+ worktrees: runtime.worktrees,
1263
+ killGraceMs: runtime.config.killGraceMs,
1264
+ locks: runtime.locks,
1265
+ runId,
1266
+ parentSessionKey: runtime.key,
1267
+ signal: controller.signal,
1268
+ graceTurns: runtime.config.graceTurns,
1269
+ stallAfterMs: runtime.config.stallAfterMs,
1270
+ stallKillAfterMs: runtime.config.stallKillAfterMs,
1271
+ maxRetries: runtime.config.maxRetries,
1272
+ onRunnerCreated: (index, runner) => {
1273
+ let runners = runtime.liveRunners.get(runId);
1274
+ if (!runners) runtime.liveRunners.set(runId, (runners = new Map()));
1275
+ runners.set(index, runner);
1276
+ },
1277
+ onTaskProgress: (index, partial) => {
1278
+ if (runtime.closed || current !== runtime) return;
1279
+ // Keep the durable run record's childSessionId in sync the first
1280
+ // time we learn it (also used by orphan reclaim).
1281
+ if (partial.sessionId && partial.process) {
1282
+ const taskRunId = specs.length > 1 ? `${runId}:${index}` : runId;
1283
+ runtime.locks.writeRunRecord({
1284
+ runId: taskRunId,
1285
+ parentSessionKey: runtime.key,
1286
+ childSessionId: partial.sessionId,
1287
+ // Read-then-write is safe: all writes for one taskRunId come
1288
+ // from this parent's single-threaded event loop.
1289
+ worktreeCwd: partial.worktree?.cwd ?? runtime.locks.readRunRecord(taskRunId)?.worktreeCwd,
1290
+ process: {
1291
+ pid: partial.process.pid,
1292
+ startTime: partial.process.startTime,
1293
+ pgid: partial.process.pgid,
1294
+ hostname: partial.process.hostname ?? "unknown",
1295
+ },
1296
+ startedAt: Date.now(),
1297
+ state: "running",
1298
+ updatedAt: Date.now(),
1299
+ });
1300
+ }
1301
+ runtime.registry.checkpoint(runId, runtime.key, {
1302
+ resultIndex: index,
1303
+ resultUpdate: partial,
1304
+ childSessionId: partial.sessionId,
1305
+ progress: partial.liveText?.slice(0, 200),
1306
+ turn: partial.usage?.turns,
1307
+ state: partial.state,
1308
+ });
1309
+ streamUpdate(index, partial);
1310
+ },
1311
+ });
1312
+ // Optional fan-in: one read-only child folds parallel outputs into a
1313
+ // single brief, delivered first. Failures degrade to raw results.
1314
+ if (validated.synthesis && result.results.length > 1 && !controller.signal.aborted) {
1315
+ const synthesized = await runSynthesis(runtime, validated.synthesis, result.results, {
1316
+ runId,
1317
+ modelPolicy: dispatchConfig.modelPolicy!,
1318
+ signal: controller.signal,
1319
+ });
1320
+ if (synthesized) result.results = [synthesized, ...result.results];
1321
+ }
1322
+ runtime.registry.complete(runId, runtime.key, result.state, result.summary, result.results);
1323
+ return result;
1324
+ } catch (error: any) {
1325
+ const failed: TaskResult = {
1326
+ label: "task-1", task: specs[0]?.task ?? "", state: "failed", exitCode: 1,
1327
+ messages: [], stderr: "", usage: emptyUsage(), stopReason: "error", errorMessage: error?.message ?? String(error),
1328
+ protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
1329
+ };
1330
+ runtime.registry.complete(runId, runtime.key, "failed", failed.errorMessage, [failed]);
1331
+ return { mode: "single" as const, results: [failed], state: "failed" as const, summary: failed.errorMessage! };
1332
+ } finally {
1333
+ if (pendingFlush) { clearTimeout(pendingFlush); pendingFlush = undefined; }
1334
+ runtime.liveRunners.delete(runId);
1335
+ signal?.removeEventListener("abort", parentAbort);
1336
+ for (const session of directResumes) runtime.registry.releaseResumeLock(session, runtime.key, runId);
1337
+ resolveDone();
1338
+ }
1339
+ })();
1340
+
1341
+ if (validated.async) {
1342
+ runtime.asyncRuns.add(runId);
1343
+ return { content: [{ type: "text", text: `Started run ${runId}. You will be notified on completion; use status/wait/cancel with this full id, or open /subagents.` }], details: details(validated.mode as "single" | "parallel", []) };
1344
+ }
1345
+ const result = await work;
1346
+ // First delivery wins the native usage attachment: a rare concurrent
1347
+ // wait/dismiss that already consumed this run must not double-bill.
1348
+ const firstDelivery = runtime.registry.markDelivered(runId, runtime.key);
1349
+ const delivered = runtime.output.capOutputForDelivery(result.results);
1350
+ const text = delivered.text || result.summary;
1351
+ const finished = runtime.registry.lookup(runId, runtime.key);
1352
+ const meta: RunMeta | undefined = finished.status === "found" && finished.run && !("controller" in finished.run)
1353
+ ? finished.run
1354
+ : { state: result.state };
1355
+ // timeout is reportable content (with timeoutPhase for retry policy), not a hard throw.
1356
+ if (result.state === "failed") fail(text);
1357
+ const resultDetails = details(result.mode, delivered.cappedResults as any, meta);
1358
+ return firstDelivery
1359
+ ? deliveredResult(text, resultDetails, result.results)
1360
+ : { content: [{ type: "text", text }], details: resultDetails };
1361
+ },
1362
+ renderCall(args, theme, context) {
1363
+ // Stable component identity: reuse the previous block and swap content.
1364
+ const block = (context.lastComponent instanceof LineBlock ? context.lastComponent : new LineBlock()) as LineBlock;
1365
+ block.set((width) => [renderCallLine(args, theme, width)]);
1366
+ return block;
1367
+ },
1368
+ renderResult(result, options: ToolRenderResultOptions, theme, context) {
1369
+ const block = (context.lastComponent instanceof LineBlock ? context.lastComponent : new LineBlock()) as LineBlock;
1370
+ const detailsValue = result.details as ReturnType<typeof compactDetails> | undefined;
1371
+ if (!detailsValue?.results.length) {
1372
+ const text = result.content.find((item) => item.type === "text")?.text ?? "(no output)";
1373
+ block.set((width) => String(text).split("\n").map((line) => truncateToWidth(theme.fg("toolOutput", line), width)));
1374
+ return block;
1375
+ }
1376
+ const run: InlineRunView = {
1377
+ mode: detailsValue.mode,
1378
+ state: detailsValue.state,
1379
+ startedAt: detailsValue.startedAt,
1380
+ endedAt: detailsValue.endedAt,
1381
+ results: detailsValue.results.map((task: any) => ({
1382
+ label: task.label,
1383
+ state: task.state,
1384
+ usage: task.usage,
1385
+ model: task.model,
1386
+ stopReason: task.stopReason,
1387
+ timeoutPhase: task.timeoutPhase,
1388
+ errorMessage: task.errorMessage,
1389
+ finalOutput: task.finalOutput,
1390
+ outputFile: task.outputFile,
1391
+ sessionId: task.sessionId,
1392
+ worktree: task.worktree,
1393
+ wrappedUp: task.wrappedUp,
1394
+ stalledSince: task.stalledSince,
1395
+ attempts: task.attempts,
1396
+ structuredOutput: task.structuredOutput,
1397
+ structuredError: task.structuredError,
1398
+ })),
1399
+ };
1400
+ const active = options.isPartial && (isActiveState(detailsValue.state) || run.results.some((task) => isActiveState(task.state)) || detailsValue.state === undefined);
1401
+ block.set((width) => {
1402
+ const lines = renderRunLines(run, {
1403
+ theme,
1404
+ width,
1405
+ expanded: options.expanded,
1406
+ isPartial: active,
1407
+ spinnerFrame: liveSpinnerFrame(),
1408
+ });
1409
+ if (!options.expanded && !active && run.results.some((task) => task.finalOutput || task.errorMessage)) {
1410
+ // keyHint output is already themed; only add color to the raw fallback.
1411
+ const hint = expandHint();
1412
+ lines.push(truncateToWidth(hint.includes("\u001b[") ? hint : theme.fg("dim", hint), width));
1413
+ }
1414
+ return lines;
1415
+ });
1416
+ return block;
1417
+ },
1418
+ } satisfies Parameters<typeof pi.registerTool>[0];
1419
+
1420
+ pi.registerTool(subagentTool);
1421
+
1422
+ // `/btw` — user-originated side question. The run is a normal subagent run
1423
+ // (full policy/budget/lock machinery) but its result is delivered to the
1424
+ // TUI via appendEntry, which by design does NOT participate in LLM context.
1425
+ // So the parent agent keeps working, unaware, while the user gets an answer.
1426
+ // `/btw` results are custom entries: rendered for the human, invisible to the
1427
+ // model. Keep it compact; expand shows the full answer.
1428
+ pi.registerEntryRenderer(BTW_ENTRY_TYPE, (entry, { expanded }, theme) => {
1429
+ const data = entry.data as BtwEntry | undefined;
1430
+ if (!data) return undefined;
1431
+ return lineComponentForMessage((width) => {
1432
+ const glyph = data.state === "done" ? theme.fg("success", "✓")
1433
+ : data.state === "failed" ? theme.fg("error", "✗")
1434
+ : theme.fg("dim", "…");
1435
+ const lines = [truncateToWidth(`${glyph} ${theme.bold(theme.fg("toolTitle", "by the way"))} ${theme.fg("dim", data.label)}`, width)];
1436
+ const body = data.answer;
1437
+ if (body) {
1438
+ const rendered = expanded ? body.split("\n") : [body.split("\n").find((line) => line.trim()) ?? ""];
1439
+ for (const line of rendered) lines.push(truncateToWidth(` ${theme.fg("toolOutput", line)}`, width));
1440
+ if (!expanded && body.split("\n").length > 1) {
1441
+ lines.push(truncateToWidth(theme.fg("dim", " (expand for full answer)"), width));
1442
+ }
1443
+ }
1444
+ return lines;
1445
+ });
1446
+ });
1447
+
1448
+ pi.registerCommand("btw", {
1449
+ description: "Ask a one-off side question in a subagent, hidden from the main agent's context",
1450
+ handler: async (args, ctx) => {
1451
+ const runtime = current;
1452
+ if (!runtime || runtime.closed || runtime.key !== sessionKey(ctx)) {
1453
+ return ctx.ui.notify("Subagent runtime is not ready", "error");
1454
+ }
1455
+ let question = (args ?? "").trim();
1456
+ if (!question) {
1457
+ // The interactive prompt needs dialog-capable UI (TUI/RPC). In print
1458
+ // mode there is nothing to prompt with, so require an inline question
1459
+ // rather than silently doing nothing.
1460
+ if (!ctx.hasUI) {
1461
+ return ctx.ui.notify("/btw needs a question: /btw <your question>", "error");
1462
+ }
1463
+ question = (await ctx.ui.input("by the way", "Ask a one-off side question…"))?.trim() ?? "";
1464
+ if (!question) return;
1465
+ }
1466
+
1467
+ const label = btwLabel(question);
1468
+ pi.appendEntry(BTW_ENTRY_TYPE, { state: "running", question, label } satisfies BtwEntry);
1469
+ ctx.ui.notify(`by the way: ${label} — running in the background`, "info");
1470
+
1471
+ try {
1472
+ // Reuse the tool's own execute so /btw inherits validation, profiles,
1473
+ // budgets, semaphore + process locks, and output capping unchanged.
1474
+ const policy = await readModelPolicyFile().catch(() => undefined);
1475
+ const route = policy ? resolveModelRoute(policy) : undefined;
1476
+ const result = await subagentTool.execute(
1477
+ `btw-${Date.now()}`,
1478
+ {
1479
+ task: question,
1480
+ profile: "explore",
1481
+ description: label,
1482
+ model: route?.model,
1483
+ fallback_models: route?.fallbackModels,
1484
+ } as SubagentParams,
1485
+ undefined,
1486
+ undefined,
1487
+ ctx as never,
1488
+ );
1489
+ // The tool signals failure by throwing (fail()), so reaching here is success.
1490
+ const text = result.content.find((item) => item.type === "text")?.text ?? "(no output)";
1491
+ pi.appendEntry(BTW_ENTRY_TYPE, { state: "done", question, label, answer: String(text) } satisfies BtwEntry);
1492
+ ctx.ui.notify(`by the way: ${label} — answered`, "info");
1493
+ } catch (error) {
1494
+ const message = error instanceof Error ? error.message : String(error);
1495
+ pi.appendEntry(BTW_ENTRY_TYPE, { state: "failed", question, label, answer: message } satisfies BtwEntry);
1496
+ ctx.ui.notify(`by the way failed: ${message}`, "error");
1497
+ }
1498
+ },
1499
+ });
1500
+
1501
+
1502
+ // Dedicated blocking-collect tool. Thin front-end: it rewrites its args into
1503
+ // the equivalent `action:"wait"` request and reuses the main tool's execute,
1504
+ // so delivery/markDelivered/cap semantics cannot drift between the two.
1505
+ pi.registerTool({
1506
+ name: "subagent_wait",
1507
+ label: "Subagent wait",
1508
+ description:
1509
+ "Block until a background subagent run (async:true) settles, then deliver its output. Equivalent to subagent { action: 'wait', id }. Aborting or timing out leaves the run alive and collectable; use subagent { action: 'cancel' } to stop it.",
1510
+ parameters: SubagentWaitParamsSchema,
1511
+ async execute(id, params: SubagentWaitParams, signal, onUpdate, ctx) {
1512
+ return subagentTool.execute(
1513
+ id,
1514
+ {
1515
+ action: "wait",
1516
+ id: params.id,
1517
+ ...(params.timeout_ms !== undefined ? { __waitTimeoutMs: params.timeout_ms } : {}),
1518
+ } as SubagentParams,
1519
+ signal,
1520
+ onUpdate as never,
1521
+ ctx,
1522
+ );
1523
+ },
1524
+ renderCall: subagentTool.renderCall as never,
1525
+ renderResult: subagentTool.renderResult as never,
1526
+ });
1527
+ }