@ferris1225/pi-subagents 0.1.0 → 0.3.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.3.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, statusColor, statusIcon, statusLabel } 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];
@@ -195,16 +218,23 @@ export default function (pi: ExtensionAPI): void {
195
218
  }
196
219
  }
197
220
  : undefined;
198
- const result = await runSingleAgent({
199
- defaultCwd: ctx.cwd,
200
- agent: agents.find((a) => a.name === t.agent),
201
- agentName: t.agent,
202
- task: t.task,
203
- cwd: t.cwd,
204
- signal,
205
- onUpdate: perTaskUpdate,
206
- makeDetails: makeDetails("parallel"),
207
- });
221
+ let result: SingleResult;
222
+ try {
223
+ result = await runSingleAgent({
224
+ defaultCwd: ctx.cwd,
225
+ agent: agents.find((a) => a.name === t.agent),
226
+ agentName: t.agent,
227
+ task: t.task,
228
+ cwd: t.cwd,
229
+ signal,
230
+ onUpdate: perTaskUpdate,
231
+ onLive,
232
+ makeDetails: makeDetails("parallel"),
233
+ });
234
+ } catch (err) {
235
+ monitor.setStatus(runId, "failed");
236
+ throw err;
237
+ }
208
238
  allResults[index] = result;
209
239
  emitParallelUpdate();
210
240
  return result;
@@ -212,7 +242,7 @@ export default function (pi: ExtensionAPI): void {
212
242
 
213
243
  const successCount = results.filter((r) => !isFailedResult(r)).length;
214
244
  const summaries = results.map((r) => {
215
- const output = truncateParallelOutput(getResultOutput(r));
245
+ const output = getResultOutput(r);
216
246
  const status = isFailedResult(r) ? "failed" : "completed";
217
247
  const usage = formatUsage(r.usage);
218
248
  return `### [${r.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${output}`;
@@ -229,16 +259,44 @@ export default function (pi: ExtensionAPI): void {
229
259
  }
230
260
 
231
261
  // ---- Single mode ----
232
- const result = await runSingleAgent({
233
- defaultCwd: ctx.cwd,
234
- agent: agents.find((a) => a.name === params.agent),
235
- agentName: params.agent as string,
236
- task: params.task as string,
237
- cwd: params.cwd,
238
- signal,
239
- onUpdate,
240
- makeDetails: makeDetails("single"),
241
- });
262
+ const resolvedModel = agents.find((a) => a.name === params.agent)?.model;
263
+ const runId = monitor.addRun(params.agent as string, resolvedModel);
264
+ const onLive = (e: SubagentLiveEvent): void => {
265
+ switch (e.kind) {
266
+ case "status":
267
+ monitor.setStatus(runId, e.status);
268
+ break;
269
+ case "usage":
270
+ monitor.setUsage(runId, e.usage, e.model);
271
+ break;
272
+ case "tool_start":
273
+ 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)})` });
274
+ break;
275
+ case "tool_end":
276
+ monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
277
+ break;
278
+ case "text_delta":
279
+ monitor.appendTextDelta(runId, e.delta);
280
+ break;
281
+ }
282
+ };
283
+ let result: SingleResult;
284
+ try {
285
+ result = await runSingleAgent({
286
+ defaultCwd: ctx.cwd,
287
+ agent: agents.find((a) => a.name === params.agent),
288
+ agentName: params.agent as string,
289
+ task: params.task as string,
290
+ cwd: params.cwd,
291
+ signal,
292
+ onUpdate,
293
+ onLive,
294
+ makeDetails: makeDetails("single"),
295
+ });
296
+ } catch (err) {
297
+ monitor.setStatus(runId, "failed");
298
+ throw err;
299
+ }
242
300
 
243
301
  if (isFailedResult(result)) {
244
302
  return {
@@ -275,13 +333,27 @@ export default function (pi: ExtensionAPI): void {
275
333
  renderResult(result, _options, theme) {
276
334
  const details = result.details as SubagentDetails | undefined;
277
335
  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);
336
+
337
+ if (details.mode === "single") {
338
+ const r = details.results[0];
339
+ const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
340
+ const usage = formatUsage(r.usage);
341
+ const model = r.model ?? "?";
342
+ const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`;
343
+ return new Text(line, 0, 0);
344
+ }
345
+
346
+ // Parallel mode: header + one compact line per agent
347
+ const lines: string[] = [
348
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
349
+ ];
350
+ for (const r of details.results) {
351
+ const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
352
+ const usage = formatUsage(r.usage);
353
+ const model = r.model ?? "?";
354
+ lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`);
355
+ }
356
+ return new Text(lines.join("\n"), 0, 0);
285
357
  },
286
358
  });
287
359
 
@@ -292,6 +364,44 @@ export default function (pi: ExtensionAPI): void {
292
364
  },
293
365
  });
294
366
 
367
+ // Persistent widget above the editor showing live sub-agent status.
368
+ pi.on("session_start", (_e, ctx) => {
369
+ if (ctx.mode !== "tui") return;
370
+ ctx.ui.setWidget(
371
+ "pi-subagents",
372
+ (tui, theme) => {
373
+ const unsub = monitor.subscribe(() => tui.requestRender());
374
+ // Tick once a second so elapsed time stays live while runs are active.
375
+ const timer = setInterval(() => {
376
+ if (monitor.getRuns().some((r) => r.status === "queued" || r.status === "running")) {
377
+ tui.requestRender();
378
+ }
379
+ }, 1000);
380
+ return {
381
+ render(width: number): string[] {
382
+ const runs = monitor.getRuns();
383
+ if (runs.length === 0) return [];
384
+ const lines: string[] = [];
385
+ for (const r of runs) {
386
+ const icon = statusIcon(r.status, theme);
387
+ const label = theme.fg(statusColor(r.status), statusLabel(r.status));
388
+ lines.push(truncateToWidth(` ${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
389
+ const activity = monitor.lastActivity(r);
390
+ if (activity) lines.push(truncateToWidth(theme.fg("dim", ` ${activity}`), width, ""));
391
+ }
392
+ return lines;
393
+ },
394
+ invalidate() {},
395
+ dispose() {
396
+ unsub();
397
+ clearInterval(timer);
398
+ },
399
+ };
400
+ },
401
+ { placement: "aboveEditor" },
402
+ );
403
+ });
404
+
295
405
  // Proactive dispatch: inject the delegation directive into the parent system prompt.
296
406
  pi.on("before_agent_start", async (event, ctx) => {
297
407
  const config = await loadConfig(configPath);
package/src/monitor.ts ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Sub-agent monitor: a module-level singleton store that tracks subagent runs
3
+ * for the current turn.
4
+ *
5
+ * The store notifies subscribers on every mutation so the persistent widget
6
+ * above the editor can re-render. Each run carries timing information
7
+ * (started/ended) and a transcript whose most recent entry is surfaced as the
8
+ * run's current activity.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import type { UsageStats } from "./spawn.ts";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type RunStatus = "queued" | "running" | "done" | "failed";
19
+
20
+ export interface TranscriptLine {
21
+ kind: "tool" | "text" | "status" | "error";
22
+ text: string;
23
+ }
24
+
25
+ export interface RunView {
26
+ id: number;
27
+ agent: string;
28
+ model?: string;
29
+ status: RunStatus;
30
+ usage: UsageStats;
31
+ transcript: TranscriptLine[];
32
+ /** Epoch ms when the run started executing (set on first "running" status). */
33
+ startedAt?: number;
34
+ /** Epoch ms when the run finished (set on "done"/"failed"). */
35
+ endedAt?: number;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Formatting helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function formatTokens(count: number): string {
43
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
44
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
45
+ return String(count);
46
+ }
47
+
48
+ export function formatUsageCompact(usage: UsageStats): string {
49
+ const parts: string[] = [];
50
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
51
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
52
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
53
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
54
+ return parts.join(" ");
55
+ }
56
+
57
+ export function formatDuration(ms: number): string {
58
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
59
+ if (totalSeconds < 60) return `${totalSeconds}s`;
60
+ const minutes = Math.floor(totalSeconds / 60);
61
+ const seconds = totalSeconds % 60;
62
+ if (minutes < 60) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
63
+ const hours = Math.floor(minutes / 60);
64
+ return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
65
+ }
66
+
67
+ /** Elapsed wall time of a run: live while running, final once finished. */
68
+ export function formatElapsed(run: RunView, now: number = Date.now()): string {
69
+ if (run.startedAt === undefined) return "";
70
+ const end = run.endedAt ?? now;
71
+ return formatDuration(end - run.startedAt);
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // MonitorStore
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export class MonitorStore {
79
+ private runs: RunView[] = [];
80
+ private nextId = 1;
81
+ private subscribers = new Set<() => void>();
82
+
83
+ beginTurn(): void {
84
+ // Clear finished runs from a previous turn, but keep any still-active
85
+ // (queued/running) ones so a concurrent sub-agent call is not wiped.
86
+ this.runs = this.runs.filter((r) => r.status === "queued" || r.status === "running");
87
+ this.notify();
88
+ }
89
+
90
+ addRun(agent: string, model?: string): number {
91
+ const id = this.nextId++;
92
+ this.runs.push({
93
+ id,
94
+ agent,
95
+ model,
96
+ status: "queued",
97
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
98
+ transcript: [],
99
+ });
100
+ this.notify();
101
+ return id;
102
+ }
103
+
104
+ setStatus(id: number, status: RunStatus): void {
105
+ const run = this.find(id);
106
+ if (!run) return;
107
+ run.status = status;
108
+ if (status === "running" && run.startedAt === undefined) {
109
+ run.startedAt = Date.now();
110
+ } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
111
+ run.endedAt = Date.now();
112
+ }
113
+ this.notify();
114
+ }
115
+ setUsage(id: number, usage: UsageStats, model?: string): void {
116
+ const run = this.find(id);
117
+ if (!run) return;
118
+ run.usage = { ...usage };
119
+ if (model) run.model = model;
120
+ this.notify();
121
+ }
122
+
123
+ appendTranscript(id: number, line: TranscriptLine): void {
124
+ const run = this.find(id);
125
+ if (!run) return;
126
+ run.transcript.push(line);
127
+ this.notify();
128
+ }
129
+
130
+ /** Append streamed assistant text, merging consecutive deltas into coherent
131
+ * lines (split only on real newlines) instead of one line per token fragment. */
132
+ appendTextDelta(id: number, delta: string): void {
133
+ const run = this.find(id);
134
+ if (!run || delta.length === 0) return;
135
+ const segments = delta.split("\n");
136
+ for (let i = 0; i < segments.length; i++) {
137
+ const seg = segments[i];
138
+ const last = run.transcript[run.transcript.length - 1];
139
+ if (i === 0 && last && last.kind === "text") {
140
+ last.text += seg;
141
+ } else {
142
+ run.transcript.push({ kind: "text", text: seg });
143
+ }
144
+ }
145
+ this.notify();
146
+ }
147
+
148
+ getRuns(): RunView[] {
149
+ return this.runs;
150
+ }
151
+
152
+ subscribe(cb: () => void): () => void {
153
+ this.subscribers.add(cb);
154
+ return () => {
155
+ this.subscribers.delete(cb);
156
+ };
157
+ }
158
+
159
+ summarize(run: RunView): string {
160
+ const usage = formatUsageCompact(run.usage);
161
+ const parts = [run.agent];
162
+ if (run.model) parts.push(run.model);
163
+ if (usage) parts.push(usage);
164
+ const elapsed = formatElapsed(run);
165
+ if (elapsed) parts.push(elapsed);
166
+ return parts.join(" · ");
167
+ }
168
+
169
+ /** Text of the most recent transcript entry (what the run is doing now). */
170
+ lastActivity(run: RunView): string | undefined {
171
+ for (let i = run.transcript.length - 1; i >= 0; i--) {
172
+ const text = run.transcript[i].text.trim();
173
+ if (text.length > 0) return text;
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ private find(id: number): RunView | undefined {
179
+ return this.runs.find((r) => r.id === id);
180
+ }
181
+
182
+ private notify(): void {
183
+ for (const cb of this.subscribers) {
184
+ try {
185
+ cb();
186
+ } catch {
187
+ /* subscriber errors must not break the store */
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ export const monitor = new MonitorStore();
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Status icons
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export function statusIcon(status: RunStatus, theme: Theme): string {
200
+ switch (status) {
201
+ case "running":
202
+ return theme.fg("accent", "●");
203
+ case "done":
204
+ return theme.fg("success", "✓");
205
+ case "failed":
206
+ return theme.fg("error", "✗");
207
+ default:
208
+ return theme.fg("dim", "○");
209
+ }
210
+ }
211
+
212
+ /** User-facing status label shown in the widget. */
213
+ export function statusLabel(status: RunStatus): string {
214
+ switch (status) {
215
+ case "queued":
216
+ return "ready";
217
+ case "running":
218
+ return "running";
219
+ case "done":
220
+ return "done";
221
+ case "failed":
222
+ return "stopped";
223
+ }
224
+ }
225
+
226
+ /** Theme color matching the status label. */
227
+ export function statusColor(status: RunStatus): "accent" | "success" | "error" | "dim" {
228
+ switch (status) {
229
+ case "running":
230
+ return "accent";
231
+ case "done":
232
+ return "success";
233
+ case "failed":
234
+ return "error";
235
+ default:
236
+ return "dim";
237
+ }
238
+ }
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)