@ferris1225/pi-subagents 0.1.0 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Focused sub-agent delegation for pi: explore / plan / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
17
17
  import { StringEnum } from "@earendil-works/pi-ai";
18
18
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
- import { Text } from "@earendil-works/pi-tui";
19
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
20
20
  import { Type } from "typebox";
21
21
  import { discoverAgents, type AgentConfig } from "./agents.ts";
22
22
  import { getConfigPath, loadConfig } from "./config.ts";
@@ -32,12 +32,13 @@ import {
32
32
  isFailedResult,
33
33
  mapWithConcurrencyLimit,
34
34
  runSingleAgent,
35
- truncateParallelOutput,
36
35
  type OnUpdateCallback,
37
36
  type SingleResult,
38
37
  type SubagentDetails,
38
+ type SubagentLiveEvent,
39
39
  type UsageStats,
40
40
  } from "./spawn.ts";
41
+ import { monitor, openSubagentOverlay, statusIcon } from "./monitor.ts";
41
42
 
42
43
  const TaskItem = Type.Object({
43
44
  agent: Type.String({ description: "Name of the agent to invoke" }),
@@ -121,6 +122,7 @@ export default function (pi: ExtensionAPI): void {
121
122
  parameters: SubagentParams,
122
123
 
123
124
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
125
+ monitor.beginTurn();
124
126
  const config = await loadConfig(configPath);
125
127
  const discovery = discoverAgents(ctx.cwd, {
126
128
  scope: config.agentScope,
@@ -186,6 +188,27 @@ export default function (pi: ExtensionAPI): void {
186
188
  };
187
189
 
188
190
  const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
191
+ const resolvedModel = agents.find((a) => a.name === t.agent)?.model;
192
+ const runId = monitor.addRun(t.agent, resolvedModel);
193
+ const onLive = (e: SubagentLiveEvent): void => {
194
+ switch (e.kind) {
195
+ case "status":
196
+ monitor.setStatus(runId, e.status);
197
+ break;
198
+ case "usage":
199
+ monitor.setUsage(runId, e.usage, e.model);
200
+ break;
201
+ case "tool_start":
202
+ monitor.appendTranscript(runId, { kind: "tool", text: `▸ ${e.toolName}(${typeof e.args === "object" ? JSON.stringify(e.args).slice(0, 120) : String(e.args).slice(0, 120)})` });
203
+ break;
204
+ case "tool_end":
205
+ monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
206
+ break;
207
+ case "text_delta":
208
+ monitor.appendTextDelta(runId, e.delta);
209
+ break;
210
+ }
211
+ };
189
212
  const perTaskUpdate: OnUpdateCallback | undefined = onUpdate
190
213
  ? (partial) => {
191
214
  const current = partial.details?.results[0];
@@ -203,6 +226,7 @@ export default function (pi: ExtensionAPI): void {
203
226
  cwd: t.cwd,
204
227
  signal,
205
228
  onUpdate: perTaskUpdate,
229
+ onLive,
206
230
  makeDetails: makeDetails("parallel"),
207
231
  });
208
232
  allResults[index] = result;
@@ -212,7 +236,7 @@ export default function (pi: ExtensionAPI): void {
212
236
 
213
237
  const successCount = results.filter((r) => !isFailedResult(r)).length;
214
238
  const summaries = results.map((r) => {
215
- const output = truncateParallelOutput(getResultOutput(r));
239
+ const output = getResultOutput(r);
216
240
  const status = isFailedResult(r) ? "failed" : "completed";
217
241
  const usage = formatUsage(r.usage);
218
242
  return `### [${r.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${output}`;
@@ -229,6 +253,27 @@ export default function (pi: ExtensionAPI): void {
229
253
  }
230
254
 
231
255
  // ---- Single mode ----
256
+ const resolvedModel = agents.find((a) => a.name === params.agent)?.model;
257
+ const runId = monitor.addRun(params.agent as string, resolvedModel);
258
+ const onLive = (e: SubagentLiveEvent): void => {
259
+ switch (e.kind) {
260
+ case "status":
261
+ monitor.setStatus(runId, e.status);
262
+ break;
263
+ case "usage":
264
+ monitor.setUsage(runId, e.usage, e.model);
265
+ break;
266
+ case "tool_start":
267
+ monitor.appendTranscript(runId, { kind: "tool", text: `▸ ${e.toolName}(${typeof e.args === "object" ? JSON.stringify(e.args).slice(0, 120) : String(e.args).slice(0, 120)})` });
268
+ break;
269
+ case "tool_end":
270
+ monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
271
+ break;
272
+ case "text_delta":
273
+ monitor.appendTextDelta(runId, e.delta);
274
+ break;
275
+ }
276
+ };
232
277
  const result = await runSingleAgent({
233
278
  defaultCwd: ctx.cwd,
234
279
  agent: agents.find((a) => a.name === params.agent),
@@ -237,6 +282,7 @@ export default function (pi: ExtensionAPI): void {
237
282
  cwd: params.cwd,
238
283
  signal,
239
284
  onUpdate,
285
+ onLive,
240
286
  makeDetails: makeDetails("single"),
241
287
  });
242
288
 
@@ -275,13 +321,27 @@ export default function (pi: ExtensionAPI): void {
275
321
  renderResult(result, _options, theme) {
276
322
  const details = result.details as SubagentDetails | undefined;
277
323
  if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
278
- const usage = formatUsage(aggregateUsage(details.results));
279
- const header =
280
- details.mode === "parallel"
281
- ? `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`
282
- : `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", details.results[0].agent)}`;
283
- const suffix = usage ? ` ${theme.fg("dim", usage)}` : "";
284
- return new Text(header + suffix, 0, 0);
324
+
325
+ if (details.mode === "single") {
326
+ const r = details.results[0];
327
+ const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
328
+ const usage = formatUsage(r.usage);
329
+ const model = r.model ?? "?";
330
+ const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`;
331
+ return new Text(line, 0, 0);
332
+ }
333
+
334
+ // Parallel mode: header + one compact line per agent
335
+ const lines: string[] = [
336
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
337
+ ];
338
+ for (const r of details.results) {
339
+ const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
340
+ const usage = formatUsage(r.usage);
341
+ const model = r.model ?? "?";
342
+ lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`);
343
+ }
344
+ return new Text(lines.join("\n"), 0, 0);
285
345
  },
286
346
  });
287
347
 
@@ -292,6 +352,55 @@ export default function (pi: ExtensionAPI): void {
292
352
  },
293
353
  });
294
354
 
355
+ // Persistent widget above the editor showing live sub-agent status.
356
+ pi.on("session_start", (_e, ctx) => {
357
+ if (ctx.mode !== "tui") return;
358
+ ctx.ui.setWidget(
359
+ "pi-subagents",
360
+ (tui, theme) => {
361
+ const unsub = monitor.subscribe(() => tui.requestRender());
362
+ return {
363
+ render(width: number): string[] {
364
+ const runs = monitor.getRuns();
365
+ if (runs.length === 0) return [];
366
+ const lines = runs.map((r) => {
367
+ const icon = statusIcon(r.status, theme);
368
+ return truncateToWidth(` ${icon} ${monitor.summarize(r)}`, width, "");
369
+ });
370
+ lines.push(truncateToWidth(theme.fg("dim", " ctrl+shift+a / /subagents to inspect"), width, ""));
371
+ return lines;
372
+ },
373
+ invalidate() {},
374
+ dispose() {
375
+ unsub();
376
+ },
377
+ };
378
+ },
379
+ { placement: "aboveEditor" },
380
+ );
381
+ });
382
+
383
+ // Drill-down overlay command.
384
+ pi.registerCommand("subagents", {
385
+ description: "Inspect running/recent sub-agents (model, tokens, live transcript)",
386
+ handler: async (_args, ctx) => {
387
+ if (ctx.mode !== "tui") {
388
+ ctx.ui.notify("The sub-agent monitor requires Pi's interactive TUI.", "warning");
389
+ return;
390
+ }
391
+ await openSubagentOverlay(ctx);
392
+ },
393
+ });
394
+
395
+ // Keyboard shortcut for the overlay.
396
+ pi.registerShortcut("ctrl+shift+a", {
397
+ description: "Open sub-agent monitor",
398
+ handler: async (ctx) => {
399
+ if (ctx.mode !== "tui") return;
400
+ await openSubagentOverlay(ctx);
401
+ },
402
+ });
403
+
295
404
  // Proactive dispatch: inject the delegation directive into the parent system prompt.
296
405
  pi.on("before_agent_start", async (event, ctx) => {
297
406
  const config = await loadConfig(configPath);
package/src/monitor.ts ADDED
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Sub-agent monitor: a module-level singleton store that tracks subagent runs
3
+ * for the current turn, plus a drill-down overlay component for live inspection.
4
+ *
5
+ * The store notifies subscribers on every mutation so the persistent widget and
6
+ * the overlay can re-render. The overlay shows a list of runs (↑/↓ + Enter) and
7
+ * a detail view with the live transcript (auto-tailing, scrollable).
8
+ */
9
+
10
+ import {
11
+ matchesKey,
12
+ truncateToWidth,
13
+ type Component,
14
+ type Focusable,
15
+ type TUI,
16
+ } from "@earendil-works/pi-tui";
17
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
18
+ import type { UsageStats } from "./spawn.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type RunStatus = "queued" | "running" | "done" | "failed";
25
+
26
+ export interface TranscriptLine {
27
+ kind: "tool" | "text" | "status" | "error";
28
+ text: string;
29
+ }
30
+
31
+ export interface RunView {
32
+ id: number;
33
+ agent: string;
34
+ model?: string;
35
+ status: RunStatus;
36
+ usage: UsageStats;
37
+ transcript: TranscriptLine[];
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Formatting helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function formatTokens(count: number): string {
45
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
46
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
47
+ return String(count);
48
+ }
49
+
50
+ export function formatUsageCompact(usage: UsageStats): string {
51
+ const parts: string[] = [];
52
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
53
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
54
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
55
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
56
+ return parts.join(" ");
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // MonitorStore
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export class MonitorStore {
64
+ private runs: RunView[] = [];
65
+ private nextId = 1;
66
+ private subscribers = new Set<() => void>();
67
+
68
+ beginTurn(): void {
69
+ // Clear finished runs from a previous turn, but keep any still-active
70
+ // (queued/running) ones so a concurrent sub-agent call is not wiped.
71
+ this.runs = this.runs.filter((r) => r.status === "queued" || r.status === "running");
72
+ this.notify();
73
+ }
74
+
75
+ addRun(agent: string, model?: string): number {
76
+ const id = this.nextId++;
77
+ this.runs.push({
78
+ id,
79
+ agent,
80
+ model,
81
+ status: "queued",
82
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
83
+ transcript: [],
84
+ });
85
+ this.notify();
86
+ return id;
87
+ }
88
+
89
+ setStatus(id: number, status: RunStatus): void {
90
+ const run = this.find(id);
91
+ if (!run) return;
92
+ run.status = status;
93
+ this.notify();
94
+ }
95
+
96
+ setUsage(id: number, usage: UsageStats, model?: string): void {
97
+ const run = this.find(id);
98
+ if (!run) return;
99
+ run.usage = { ...usage };
100
+ if (model) run.model = model;
101
+ this.notify();
102
+ }
103
+
104
+ appendTranscript(id: number, line: TranscriptLine): void {
105
+ const run = this.find(id);
106
+ if (!run) return;
107
+ run.transcript.push(line);
108
+ this.notify();
109
+ }
110
+
111
+ /** Append streamed assistant text, merging consecutive deltas into coherent
112
+ * lines (split only on real newlines) instead of one line per token fragment. */
113
+ appendTextDelta(id: number, delta: string): void {
114
+ const run = this.find(id);
115
+ if (!run || delta.length === 0) return;
116
+ const segments = delta.split("\n");
117
+ for (let i = 0; i < segments.length; i++) {
118
+ const seg = segments[i];
119
+ const last = run.transcript[run.transcript.length - 1];
120
+ if (i === 0 && last && last.kind === "text") {
121
+ last.text += seg;
122
+ } else {
123
+ run.transcript.push({ kind: "text", text: seg });
124
+ }
125
+ }
126
+ this.notify();
127
+ }
128
+
129
+ getRuns(): RunView[] {
130
+ return this.runs;
131
+ }
132
+
133
+ subscribe(cb: () => void): () => void {
134
+ this.subscribers.add(cb);
135
+ return () => {
136
+ this.subscribers.delete(cb);
137
+ };
138
+ }
139
+
140
+ summarize(run: RunView): string {
141
+ const usage = formatUsageCompact(run.usage);
142
+ const parts = [run.agent];
143
+ if (run.model) parts.push(run.model);
144
+ if (usage) parts.push(usage);
145
+ parts.push(run.status);
146
+ return parts.join(" · ");
147
+ }
148
+
149
+ private find(id: number): RunView | undefined {
150
+ return this.runs.find((r) => r.id === id);
151
+ }
152
+
153
+ private notify(): void {
154
+ for (const cb of this.subscribers) {
155
+ try {
156
+ cb();
157
+ } catch {
158
+ /* subscriber errors must not break the store */
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ export const monitor = new MonitorStore();
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Status icons
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export function statusIcon(status: RunStatus, theme: Theme): string {
171
+ switch (status) {
172
+ case "running":
173
+ return theme.fg("accent", "●");
174
+ case "done":
175
+ return theme.fg("success", "✓");
176
+ case "failed":
177
+ return theme.fg("error", "✗");
178
+ default:
179
+ return theme.fg("dim", "○");
180
+ }
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Overlay component
185
+ // ---------------------------------------------------------------------------
186
+
187
+ class SubagentOverlay implements Component, Focusable {
188
+ private _focused = false;
189
+ private mode: "list" | "detail" = "list";
190
+ private cursor = 0;
191
+ private scroll = 0;
192
+ private cachedWidth = -1;
193
+ private cachedLines: string[] = [];
194
+ private closed = false;
195
+
196
+ private readonly unsub: () => void;
197
+
198
+ constructor(
199
+ private readonly tui: TUI,
200
+ private readonly theme: Theme,
201
+ private readonly done: (result: void) => void,
202
+ ) {
203
+ this.unsub = monitor.subscribe(() => {
204
+ this.invalidate();
205
+ this.tui.requestRender();
206
+ });
207
+ }
208
+
209
+ get focused(): boolean {
210
+ return this._focused;
211
+ }
212
+
213
+ set focused(value: boolean) {
214
+ this._focused = value;
215
+ }
216
+
217
+ invalidate(): void {
218
+ this.cachedWidth = -1;
219
+ this.cachedLines = [];
220
+ }
221
+
222
+ handleInput(data: string): void {
223
+ if (this.mode === "list") {
224
+ const runs = monitor.getRuns();
225
+ if (matchesKey(data, "up")) {
226
+ if (runs.length > 0) this.cursor = this.cursor === 0 ? runs.length - 1 : this.cursor - 1;
227
+ } else if (matchesKey(data, "down")) {
228
+ if (runs.length > 0) this.cursor = this.cursor === runs.length - 1 ? 0 : this.cursor + 1;
229
+ } else if (matchesKey(data, "return")) {
230
+ if (runs.length > 0) {
231
+ this.mode = "detail";
232
+ this.scrollToBottom();
233
+ }
234
+ } else if (matchesKey(data, "escape")) {
235
+ this.close();
236
+ return;
237
+ }
238
+ } else {
239
+ if (matchesKey(data, "up")) {
240
+ this.scroll = Math.max(0, this.scroll - 1);
241
+ } else if (matchesKey(data, "down")) {
242
+ this.scroll++;
243
+ } else if (matchesKey(data, "escape")) {
244
+ this.mode = "list";
245
+ }
246
+ }
247
+ this.invalidate();
248
+ this.tui.requestRender();
249
+ }
250
+
251
+ render(width: number): string[] {
252
+ if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
253
+
254
+ const t = this.theme;
255
+ const fit = (line: string): string => truncateToWidth(line, width, "");
256
+ const border = fit(t.fg("border", "─".repeat(Math.max(1, width))));
257
+
258
+ const lines: string[] = [];
259
+
260
+ if (this.mode === "list") {
261
+ lines.push(border);
262
+ lines.push(fit(t.fg("accent", t.bold(" Sub-agents"))));
263
+ lines.push(border);
264
+
265
+ const runs = monitor.getRuns();
266
+ if (runs.length === 0) {
267
+ lines.push(fit(t.fg("dim", " (no sub-agent runs this turn)")));
268
+ } else {
269
+ this.cursor = Math.max(0, Math.min(this.cursor, runs.length - 1));
270
+ for (let i = 0; i < runs.length; i++) {
271
+ const run = runs[i];
272
+ const isCursor = i === this.cursor;
273
+ const mark = isCursor ? t.fg("accent", "❯ ") : " ";
274
+ const icon = statusIcon(run.status, t);
275
+ const usage = formatUsageCompact(run.usage);
276
+ const parts = [run.agent];
277
+ if (run.model) parts.push(run.model);
278
+ if (usage) parts.push(usage);
279
+ parts.push(run.status);
280
+ const label = isCursor ? t.fg("accent", t.bold(parts.join(" · "))) : parts.join(" · ");
281
+ lines.push(fit(`${mark}${icon} ${label}`));
282
+ }
283
+ }
284
+
285
+ lines.push(border);
286
+ lines.push(fit(t.fg("dim", " ↑↓ select · enter open · esc close")));
287
+ lines.push(border);
288
+ } else {
289
+ const runs = monitor.getRuns();
290
+ const run = runs[this.cursor];
291
+
292
+ lines.push(border);
293
+ if (run) {
294
+ const headerParts = [run.agent];
295
+ if (run.model) headerParts.push(run.model);
296
+ lines.push(fit(t.fg("accent", t.bold(` ${headerParts.join(" · ")}`))));
297
+ const usage = formatUsageCompact(run.usage);
298
+ const statusLine = ` ${statusIcon(run.status, t)} ${run.status}${usage ? ` · ${usage}` : ""}`;
299
+ lines.push(fit(statusLine));
300
+ } else {
301
+ lines.push(fit(t.fg("dim", " (no run selected)")));
302
+ }
303
+ lines.push(border);
304
+
305
+ if (run) {
306
+ // Available height for transcript: total minus header(4) + footer(2)
307
+ const transcriptLines = run.transcript;
308
+ const availHeight = Math.max(1, 40 - 6); // reasonable default; actual height varies
309
+ this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, transcriptLines.length - availHeight)));
310
+
311
+ // Auto-tail: if scroll is at the bottom, keep it there
312
+ const maxScroll = Math.max(0, transcriptLines.length - availHeight);
313
+ if (this.scroll >= maxScroll - 1) this.scroll = maxScroll;
314
+
315
+ const visible = transcriptLines.slice(this.scroll, this.scroll + availHeight);
316
+ for (const entry of visible) {
317
+ const color =
318
+ entry.kind === "tool"
319
+ ? "accent"
320
+ : entry.kind === "error"
321
+ ? "error"
322
+ : entry.kind === "status"
323
+ ? "dim"
324
+ : "text";
325
+ lines.push(fit(t.fg(color, ` ${entry.text}`)));
326
+ }
327
+ if (transcriptLines.length === 0) {
328
+ lines.push(fit(t.fg("dim", " (waiting for output…)")));
329
+ }
330
+ }
331
+
332
+ lines.push(border);
333
+ lines.push(fit(t.fg("dim", " ↑↓ scroll · esc back")));
334
+ lines.push(border);
335
+ }
336
+
337
+ this.cachedWidth = width;
338
+ this.cachedLines = lines;
339
+ return lines;
340
+ }
341
+
342
+ dispose(): void {
343
+ if (!this.closed) {
344
+ this.closed = true;
345
+ this.unsub();
346
+ }
347
+ }
348
+
349
+ private scrollToBottom(): void {
350
+ const runs = monitor.getRuns();
351
+ const run = runs[this.cursor];
352
+ if (run) this.scroll = Math.max(0, run.transcript.length);
353
+ }
354
+
355
+ private close(): void {
356
+ this.closed = true;
357
+ this.unsub();
358
+ this.done();
359
+ }
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Public entry point
364
+ // ---------------------------------------------------------------------------
365
+
366
+ export async function openSubagentOverlay(ctx: ExtensionContext): Promise<void> {
367
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => new SubagentOverlay(tui, theme, done), {
368
+ overlay: true,
369
+ });
370
+ }
package/src/spawn.ts CHANGED
@@ -20,7 +20,6 @@ import type { AgentConfig, AgentSource } from "./agents.ts";
20
20
 
21
21
  export const MAX_PARALLEL_TASKS = 8;
22
22
  export const MAX_CONCURRENCY = 4;
23
- export const PER_TASK_OUTPUT_CAP = 50 * 1024;
24
23
  /** Max nesting depth for sub-agent -> sub-agent spawning (recursion guard). */
25
24
  export const MAX_SUBAGENT_DEPTH = 2;
26
25
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
@@ -55,6 +54,13 @@ export interface SubagentDetails {
55
54
 
56
55
  export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
57
56
 
57
+ export type SubagentLiveEvent =
58
+ | { kind: "status"; status: "queued" | "running" | "done" | "failed" }
59
+ | { kind: "usage"; usage: UsageStats; model?: string }
60
+ | { kind: "tool_start"; toolName: string; args: unknown }
61
+ | { kind: "tool_end"; toolName: string; isError: boolean }
62
+ | { kind: "text_delta"; delta: string };
63
+
58
64
  function emptyUsage(): UsageStats {
59
65
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
60
66
  }
@@ -82,15 +88,6 @@ export function getResultOutput(result: SingleResult): string {
82
88
  return getFinalOutput(result.messages) || "(no output)";
83
89
  }
84
90
 
85
- export function truncateParallelOutput(output: string): string {
86
- const byteLength = Buffer.byteLength(output, "utf8");
87
- if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
88
- let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
89
- while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) truncated = truncated.slice(0, -1);
90
- const omitted = byteLength - Buffer.byteLength(truncated, "utf8");
91
- return `${truncated}\n\n[Output truncated: ${omitted} bytes omitted. Full output preserved in tool details.]`;
92
- }
93
-
94
91
  export async function mapWithConcurrencyLimit<TIn, TOut>(
95
92
  items: TIn[],
96
93
  concurrency: number,
@@ -148,13 +145,14 @@ export interface RunSingleOptions {
148
145
  cwd?: string;
149
146
  signal?: AbortSignal;
150
147
  onUpdate?: OnUpdateCallback;
148
+ onLive?: (e: SubagentLiveEvent) => void;
151
149
  makeDetails: (results: SingleResult[]) => SubagentDetails;
152
150
  env?: NodeJS.ProcessEnv;
153
151
  }
154
152
 
155
153
  /** Spawn one agent as an isolated pi child process and collect its output. */
156
154
  export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
157
- const { agent, agentName, task, cwd, signal, onUpdate, makeDetails } = options;
155
+ const { agent, agentName, task, cwd, signal, onUpdate, onLive, makeDetails } = options;
158
156
 
159
157
  if (!agent) {
160
158
  return {
@@ -230,6 +228,42 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
230
228
  return;
231
229
  }
232
230
 
231
+ // Live event: agent started
232
+ if (event.type === "agent_start" || event.type === "turn_start") {
233
+ if (onLive) {
234
+ try {
235
+ onLive({ kind: "status", status: "running" });
236
+ } catch { /* never throw from event handling */ }
237
+ }
238
+ }
239
+
240
+ // Live event: streamed assistant text
241
+ if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
242
+ if (onLive) {
243
+ try {
244
+ onLive({ kind: "text_delta", delta: event.assistantMessageEvent.delta ?? "" });
245
+ } catch { /* never throw from event handling */ }
246
+ }
247
+ }
248
+
249
+ // Live event: tool execution started
250
+ if (event.type === "tool_execution_start") {
251
+ if (onLive) {
252
+ try {
253
+ onLive({ kind: "tool_start", toolName: event.toolName ?? "unknown", args: event.args });
254
+ } catch { /* never throw from event handling */ }
255
+ }
256
+ }
257
+
258
+ // Live event: tool execution ended
259
+ if (event.type === "tool_execution_end") {
260
+ if (onLive) {
261
+ try {
262
+ onLive({ kind: "tool_end", toolName: event.toolName ?? "unknown", isError: Boolean(event.isError) });
263
+ } catch { /* never throw from event handling */ }
264
+ }
265
+ }
266
+
233
267
  if (event.type === "message_end" && event.message) {
234
268
  const msg = event.message as Message;
235
269
  currentResult.messages.push(msg);
@@ -248,6 +282,12 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
248
282
  if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason;
249
283
  if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage;
250
284
  }
285
+ // Live event: usage snapshot after accumulation
286
+ if (onLive) {
287
+ try {
288
+ onLive({ kind: "usage", usage: { ...currentResult.usage }, model: currentResult.model });
289
+ } catch { /* never throw from event handling */ }
290
+ }
251
291
  emitUpdate();
252
292
  }
253
293
 
@@ -256,7 +296,6 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
256
296
  emitUpdate();
257
297
  }
258
298
  };
259
-
260
299
  proc.stdout.on("data", (data) => {
261
300
  buffer += data.toString();
262
301
  const lines = buffer.split("\n");
@@ -270,6 +309,13 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
270
309
 
271
310
  proc.on("close", (code) => {
272
311
  if (buffer.trim()) processLine(buffer);
312
+ // Live event: final status derived from exit code
313
+ if (onLive) {
314
+ try {
315
+ const failed = (code ?? 0) !== 0 || currentResult.stopReason === "error" || currentResult.stopReason === "aborted";
316
+ onLive({ kind: "status", status: failed ? "failed" : "done" });
317
+ } catch { /* never throw from event handling */ }
318
+ }
273
319
  resolve(code ?? 0);
274
320
  });
275
321
 
@@ -289,7 +335,14 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
289
335
  });
290
336
 
291
337
  currentResult.exitCode = exitCode;
292
- if (wasAborted) throw new Error("Subagent was aborted");
338
+ if (wasAborted) {
339
+ if (onLive) {
340
+ try {
341
+ onLive({ kind: "status", status: "failed" });
342
+ } catch { /* never throw from event handling */ }
343
+ }
344
+ throw new Error("Subagent was aborted");
345
+ }
293
346
  return currentResult;
294
347
  } finally {
295
348
  if (tmpPromptPath)