@pi-unipi/subagents 0.1.13 → 0.2.3

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/badge-generation.test.ts +244 -0
  3. package/src/agent-manager.ts +12 -1
  4. package/src/agent-runner.ts +23 -8
  5. package/src/conversation-viewer.ts +299 -0
  6. package/src/index.ts +432 -49
  7. package/src/types.ts +49 -0
  8. package/src/widget.ts +332 -72
  9. package/dist/agent-manager.d.ts +0 -72
  10. package/dist/agent-manager.d.ts.map +0 -1
  11. package/dist/agent-manager.js +0 -258
  12. package/dist/agent-manager.js.map +0 -1
  13. package/dist/agent-runner.d.ts +0 -50
  14. package/dist/agent-runner.d.ts.map +0 -1
  15. package/dist/agent-runner.js +0 -238
  16. package/dist/agent-runner.js.map +0 -1
  17. package/dist/config.d.ts +0 -24
  18. package/dist/config.d.ts.map +0 -1
  19. package/dist/config.js +0 -132
  20. package/dist/config.js.map +0 -1
  21. package/dist/custom-agents.d.ts +0 -14
  22. package/dist/custom-agents.d.ts.map +0 -1
  23. package/dist/custom-agents.js +0 -106
  24. package/dist/custom-agents.js.map +0 -1
  25. package/dist/file-lock.d.ts +0 -42
  26. package/dist/file-lock.d.ts.map +0 -1
  27. package/dist/file-lock.js +0 -91
  28. package/dist/file-lock.js.map +0 -1
  29. package/dist/index.d.ts +0 -9
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/index.js +0 -301
  32. package/dist/index.js.map +0 -1
  33. package/dist/model-resolver.d.ts +0 -19
  34. package/dist/model-resolver.d.ts.map +0 -1
  35. package/dist/model-resolver.js +0 -61
  36. package/dist/model-resolver.js.map +0 -1
  37. package/dist/prompts.d.ts +0 -13
  38. package/dist/prompts.d.ts.map +0 -1
  39. package/dist/prompts.js +0 -31
  40. package/dist/prompts.js.map +0 -1
  41. package/dist/types.d.ts +0 -79
  42. package/dist/types.d.ts.map +0 -1
  43. package/dist/types.js +0 -6
  44. package/dist/types.js.map +0 -1
  45. package/dist/widget.d.ts +0 -26
  46. package/dist/widget.d.ts.map +0 -1
  47. package/dist/widget.js +0 -162
  48. package/dist/widget.js.map +0 -1
package/src/index.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  * @pi-unipi/subagents — Extension entry
3
3
  *
4
4
  * Tools: spawn_helper, get_helper_result
5
+ * Features: renderCall/renderResult, message renderer, conversation viewer
5
6
  * ESC propagation: all children abort on parent ESC
6
7
  */
7
8
 
8
9
  import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { Text } from "@mariozechner/pi-tui";
9
11
  import { Type } from "@sinclair/typebox";
10
12
  import { existsSync, readdirSync } from "node:fs";
11
13
  import { join } from "node:path";
@@ -13,7 +15,8 @@ import { homedir } from "node:os";
13
15
  import { emitEvent, MODULES, UNIPI_EVENTS } from "@pi-unipi/core";
14
16
  import { AgentManager } from "./agent-manager.js";
15
17
  import { initConfig } from "./config.js";
16
- import { type AgentActivity, BUILTIN_TYPES } from "./types.js";
18
+ import { type AgentActivity, type NotificationDetails, BUILTIN_TYPES } from "./types.js";
19
+ import { ConversationViewer } from "./conversation-viewer.js";
17
20
  import { AgentWidget } from "./widget.js";
18
21
 
19
22
  /** Get info registry from global */
@@ -22,25 +25,105 @@ function getInfoRegistry() {
22
25
  return g.__unipi_info_registry;
23
26
  }
24
27
 
25
- /** Format tokens safely */
28
+ // ---- Formatting helpers (shared between renderers and inline text) ----
29
+
30
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
31
+
32
+ /** Tool name → human-readable action. */
33
+ const TOOL_DISPLAY: Record<string, string> = {
34
+ read: "reading",
35
+ bash: "running command",
36
+ edit: "editing",
37
+ write: "writing",
38
+ grep: "searching",
39
+ find: "finding files",
40
+ ls: "listing",
41
+ };
42
+
43
+ function formatTokens(count: number): string {
44
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
45
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
46
+ return `${count} token`;
47
+ }
48
+
49
+ function formatTurns(turn: number, max?: number | null): string {
50
+ return max != null ? `⟳${turn}≤${max}` : `⟳${turn}`;
51
+ }
52
+
53
+ function formatMs(ms: number): string {
54
+ if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`;
55
+ if (ms >= 1_000) return `${(ms / 1_000).toFixed(1)}s`;
56
+ return `${ms}ms`;
57
+ }
58
+
59
+ /** Build activity description from active tools. */
60
+ function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
61
+ if (activeTools.size > 0) {
62
+ const groups = new Map<string, number>();
63
+ for (const toolName of activeTools.values()) {
64
+ const action = TOOL_DISPLAY[toolName] ?? toolName;
65
+ groups.set(action, (groups.get(action) ?? 0) + 1);
66
+ }
67
+ const parts: string[] = [];
68
+ for (const [action, count] of groups) {
69
+ if (count > 1) {
70
+ parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
71
+ } else {
72
+ parts.push(action);
73
+ }
74
+ }
75
+ return parts.join(", ") + "…";
76
+ }
77
+ if (responseText && responseText.trim().length > 0) {
78
+ const line = responseText.split("\n").find((l) => l.trim())?.trim() ?? "";
79
+ if (line.length > 60) return line.slice(0, 60) + "…";
80
+ if (line.length > 0) return line;
81
+ }
82
+ return "thinking…";
83
+ }
84
+
85
+ /** Format tokens safely from session. */
26
86
  function safeFormatTokens(session: any): string {
27
87
  if (!session) return "";
28
88
  try {
29
89
  const stats = session.getSessionStats();
30
90
  const total = stats.tokens?.total ?? 0;
31
- if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`;
32
- if (total >= 1_000) return `${(total / 1_000).toFixed(1)}k`;
33
- return `${total}`;
91
+ return formatTokens(total);
34
92
  } catch {
35
93
  return "";
36
94
  }
37
95
  }
38
96
 
97
+ /** Get raw token count from session. */
98
+ function safeTokenCount(session: any): number {
99
+ if (!session) return 0;
100
+ try {
101
+ return session.getSessionStats().tokens?.total ?? 0;
102
+ } catch {
103
+ return 0;
104
+ }
105
+ }
106
+
39
107
  /** Build result text */
40
108
  function textResult(msg: string, details?: any) {
41
109
  return { content: [{ type: "text" as const, text: msg }], details };
42
110
  }
43
111
 
112
+ /** Escape XML for structured notifications. */
113
+ function escapeXml(s: string): string {
114
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
115
+ }
116
+
117
+ /** Human-readable status label. */
118
+ function getStatusLabel(status: string, error?: string): string {
119
+ switch (status) {
120
+ case "error": return `Error: ${error ?? "unknown"}`;
121
+ case "aborted": return "Aborted (max turns exceeded)";
122
+ case "stopped": return "Stopped";
123
+ default: return "Done";
124
+ }
125
+ }
126
+
44
127
  export default function (pi: ExtensionAPI) {
45
128
  // Initialize config
46
129
  const config = initConfig(process.cwd());
@@ -61,6 +144,51 @@ export default function (pi: ExtensionAPI) {
61
144
  agentActivity.delete(record.id);
62
145
  widget.markFinished(record.id);
63
146
  widget.update();
147
+
148
+ // Build notification details
149
+ const details = buildNotificationDetails(record, agentActivity.get(record.id));
150
+
151
+ // Send styled notification via message renderer
152
+ const status = getStatusLabel(record.status, record.error);
153
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
154
+ const resultPreview = record.result
155
+ ? record.result.length > 500
156
+ ? record.result.slice(0, 500) + "…"
157
+ : record.result
158
+ : "No output.";
159
+
160
+ const notificationXml = [
161
+ `<task-notification>`,
162
+ `<task-id>${record.id}</task-id>`,
163
+ `<status>${escapeXml(status)}</status>`,
164
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
165
+ `<result>${escapeXml(resultPreview)}</result>`,
166
+ `<usage><total_tokens>${details.totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
167
+ `</task-notification>`,
168
+ ].join("\n");
169
+
170
+ if (!record.resultConsumed) {
171
+ pi.sendMessage<NotificationDetails>(
172
+ {
173
+ customType: "subagent-notification",
174
+ content: notificationXml,
175
+ display: true,
176
+ details,
177
+ },
178
+ { deliverAs: "followUp", triggerTurn: true },
179
+ );
180
+ }
181
+
182
+ // Badge generation: extract name from agent result and set directly
183
+ if (record.description === "Generate session name" && record.result && record.status === "completed") {
184
+ const name = record.result.split("\n")[0]?.trim().slice(0, 50) ?? "";
185
+ if (name && !name.startsWith("Error") && !name.includes("error")) {
186
+ try {
187
+ pi.setSessionName(name);
188
+ } catch { /* best effort */ }
189
+ }
190
+ }
191
+
64
192
  pi.events.emit("subagents:completed", {
65
193
  id: record.id,
66
194
  type: record.type,
@@ -80,6 +208,72 @@ export default function (pi: ExtensionAPI) {
80
208
  },
81
209
  );
82
210
 
211
+ // Build notification details for the message renderer
212
+ function buildNotificationDetails(record: any, activity?: AgentActivity): NotificationDetails {
213
+ return {
214
+ id: record.id,
215
+ description: record.description,
216
+ status: record.status,
217
+ toolUses: record.toolUses,
218
+ turnCount: activity?.turnCount ?? 0,
219
+ maxTurns: activity?.maxTurns,
220
+ totalTokens: safeTokenCount(record.session),
221
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
222
+ error: record.error,
223
+ resultPreview: record.result
224
+ ? record.result.length > 200
225
+ ? record.result.slice(0, 200) + "…"
226
+ : record.result
227
+ : "No output.",
228
+ };
229
+ }
230
+
231
+ // ---- Register custom notification renderer ----
232
+ pi.registerMessageRenderer<NotificationDetails>(
233
+ "subagent-notification",
234
+ (message, { expanded }, theme) => {
235
+ const d = message.details;
236
+ if (!d) return undefined;
237
+
238
+ function renderOne(d: NotificationDetails): string {
239
+ const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
240
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
241
+ const statusText = isError
242
+ ? d.status
243
+ : d.status === "steered"
244
+ ? "completed (steered)"
245
+ : "completed";
246
+
247
+ // Line 1: icon + agent description + status
248
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
249
+
250
+ // Line 2: stats
251
+ const parts: string[] = [];
252
+ if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
253
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
254
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
255
+ if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
256
+ if (parts.length) {
257
+ line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
258
+ }
259
+
260
+ // Line 3: result preview (collapsed) or full (expanded)
261
+ if (expanded) {
262
+ const lines = d.resultPreview.split("\n").slice(0, 30);
263
+ for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
264
+ } else {
265
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
266
+ line += "\n " + theme.fg("dim", `⎿ ${preview}`);
267
+ }
268
+
269
+ return line;
270
+ }
271
+
272
+ const all = [d, ...(d.others ?? [])];
273
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
274
+ },
275
+ );
276
+
83
277
  // Create widget
84
278
  const widget = new AgentWidget(manager, agentActivity);
85
279
 
@@ -104,7 +298,6 @@ export default function (pi: ExtensionAPI) {
104
298
  const types = config.types || {};
105
299
  const builtinTypes = ["explore", "work"];
106
300
 
107
- // Scan for custom agent types
108
301
  const customTypes: string[] = [];
109
302
  for (const dir of [globalAgentsDir, workspaceAgentsDir]) {
110
303
  try {
@@ -119,14 +312,14 @@ export default function (pi: ExtensionAPI) {
119
312
  }
120
313
 
121
314
  const allTypes = [...new Set([...builtinTypes, ...Object.keys(types), ...customTypes])];
122
- const typeList = allTypes.map(t => {
315
+ const typeList = allTypes.map((t) => {
123
316
  const isEnabled = types[t]?.enabled !== false;
124
317
  const isBuiltin = builtinTypes.includes(t);
125
318
  const scope = customTypes.includes(t) ? "project" : "global";
126
319
  return `${t}(${scope})${isEnabled ? "" : " [disabled]"}`;
127
320
  }).join(", ");
128
321
 
129
- const activeAgents = manager.listAgents().filter(a => a.status === "running").length;
322
+ const activeAgents = manager.listAgents().filter((a) => a.status === "running").length;
130
323
 
131
324
  return {
132
325
  maxConcurrent: { value: String(manager.getMaxConcurrent()) },
@@ -141,42 +334,76 @@ export default function (pi: ExtensionAPI) {
141
334
  });
142
335
  }
143
336
 
144
- // Session start: emit MODULE_READY
145
- pi.on("session_start", async (_event, ctx) => {
146
- const globalConfig = `${homeDir}/.unipi/config/subagents.json`;
147
- const workspaceConfig = `${cwd}/.unipi/config/subagents.json`;
148
-
149
- ctx.ui.notify(
150
- `UniPi Subagents config:\n` +
151
- `• Global: ${globalConfig}\n` +
152
- `• Global agents: ${globalAgentsDir}\n` +
153
- `• Workspace: ${workspaceConfig}\n` +
154
- `• Workspace agents: ${workspaceAgentsDir}`,
155
- "info",
156
- );
337
+ // Store session context for badge generation
338
+ let sessionCtx: any = null;
157
339
 
340
+ // Session start: emit MODULE_READY + capture context
341
+ pi.on("session_start", async (_event, ctx) => {
342
+ sessionCtx = ctx;
158
343
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
159
344
  name: MODULES.SUBAGENTS || "subagents",
160
- version: "0.1.8",
345
+ version: "0.2.0",
161
346
  commands: [],
162
347
  tools: ["spawn_helper", "get_helper_result"],
163
348
  });
164
349
  });
165
350
 
351
+ // Listen for badge generation requests — spawn background agent
352
+ pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST, async (event: any) => {
353
+ if (!sessionCtx) return;
354
+
355
+ const summary = event?.conversationSummary ?? "";
356
+ const prompt = summary
357
+ ? `Generate a concise session title (MAX 5 WORDS) for this conversation:\n\n"${summary}"\n\nReply with ONLY the title. No quotes, no explanation, no punctuation.`
358
+ : `Generate a concise session title (MAX 5 WORDS) for the current session. Reply with ONLY the title. No quotes, no explanation, no punctuation.`;
359
+
360
+ // Try with configured model, fallback to inherit
361
+ let modelInput: string | undefined = undefined;
362
+ try {
363
+ const fs = await import("node:fs");
364
+ const path = await import("node:path");
365
+ const configPath = path.resolve(process.cwd(), ".unipi/config/badge.json");
366
+ if (fs.existsSync(configPath)) {
367
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
368
+ if (typeof parsed.generationModel === "string" && parsed.generationModel !== "inherit") {
369
+ modelInput = parsed.generationModel;
370
+ }
371
+ }
372
+ } catch { /* ignore — inherit parent model */ }
373
+ let resolvedModel: any = undefined;
374
+
375
+ // Check if model is available
376
+ if (modelInput && sessionCtx.modelRegistry) {
377
+ const { resolveModel } = await import("./model-resolver.js");
378
+ const result = resolveModel(modelInput, sessionCtx.modelRegistry);
379
+ if (typeof result !== "string") {
380
+ resolvedModel = result;
381
+ }
382
+ // If result is a string (error), resolvedModel stays undefined → inherit parent
383
+ }
384
+
385
+ manager.spawn(pi, sessionCtx, "explore", prompt, {
386
+ description: "Generate session name",
387
+ model: resolvedModel,
388
+ isBackground: true,
389
+ maxTurns: 3,
390
+ });
391
+ });
392
+
166
393
  // ESC propagation: abort all agents on session shutdown
167
394
  pi.on("session_shutdown", async () => {
168
395
  manager.abortAll();
169
396
  manager.dispose();
170
397
  });
171
398
 
172
- // Wire UI context for widget
399
+ // Wire UI context for widget + age finished agents on new turn
173
400
  pi.on("tool_execution_start", async (_event, ctx) => {
174
401
  widget.setUICtx(ctx.ui);
175
- widget.update();
402
+ widget.onTurnStart();
176
403
  });
177
404
 
178
405
  // Create activity tracker
179
- function createActivityTracker(maxTurns?: number) {
406
+ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
180
407
  const state: AgentActivity = {
181
408
  activeTools: new Map(),
182
409
  toolUses: 0,
@@ -199,20 +426,19 @@ export default function (pi: ExtensionAPI) {
199
426
  }
200
427
  state.toolUses++;
201
428
  }
202
- widget.update();
429
+ state.tokens = safeFormatTokens(state.session);
430
+ onStreamUpdate?.();
203
431
  },
204
432
  onTextDelta: (_delta: string, fullText: string) => {
205
433
  state.responseText = fullText;
206
- widget.update();
434
+ onStreamUpdate?.();
207
435
  },
208
436
  onTurnEnd: (turnCount: number) => {
209
437
  state.turnCount = turnCount;
210
- widget.update();
438
+ onStreamUpdate?.();
211
439
  },
212
440
  onSessionCreated: (session: any) => {
213
441
  state.session = session;
214
- state.tokens = safeFormatTokens(session);
215
- widget.update();
216
442
  },
217
443
  };
218
444
 
@@ -273,6 +499,87 @@ Guidelines:
273
499
  ),
274
500
  }),
275
501
 
502
+ // ---- Rich inline rendering ----
503
+
504
+ renderCall(args, theme) {
505
+ const displayName = args.type ? args.type : "Agent";
506
+ const desc = args.description ?? "";
507
+ return new Text(
508
+ "▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""),
509
+ 0,
510
+ 0,
511
+ );
512
+ },
513
+
514
+ renderResult(result, { expanded, isPartial }, theme) {
515
+ const details = result.details as any;
516
+ if (!details) {
517
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
518
+ return new Text(text, 0, 0);
519
+ }
520
+
521
+ // Stats helper
522
+ const stats = (d: any) => {
523
+ const parts: string[] = [];
524
+ if (d.turnCount != null && d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
525
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
526
+ if (d.tokens) parts.push(d.tokens);
527
+ return parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
528
+ };
529
+
530
+ // Running
531
+ if (isPartial || details.status === "running") {
532
+ const frame = SPINNER[details.spinnerFrame ?? 0];
533
+ const s = stats(details);
534
+ let line = theme.fg("accent", frame) + (s ? " " + s : "");
535
+ line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
536
+ return new Text(line, 0, 0);
537
+ }
538
+
539
+ // Background launched
540
+ if (details.status === "background") {
541
+ return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
542
+ }
543
+
544
+ // Completed
545
+ if (details.status === "completed") {
546
+ const duration = formatMs(details.durationMs);
547
+ const s = stats(details);
548
+ let line = theme.fg("success", "✓") + (s ? " " + s : "");
549
+ line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
550
+
551
+ if (expanded) {
552
+ const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
553
+ if (resultText) {
554
+ const rlines = resultText.split("\n").slice(0, 50);
555
+ for (const l of rlines) {
556
+ line += "\n" + theme.fg("dim", ` ${l}`);
557
+ }
558
+ }
559
+ } else {
560
+ line += "\n" + theme.fg("dim", " ⎿ Done");
561
+ }
562
+ return new Text(line, 0, 0);
563
+ }
564
+
565
+ // Error / Aborted / Stopped
566
+ const isError = details.status === "error";
567
+ const isStopped = details.status === "stopped";
568
+ const s = stats(details);
569
+ let line = (isStopped ? theme.fg("dim", "■") : theme.fg("error", "✗")) + (s ? " " + s : "");
570
+
571
+ if (isError) {
572
+ line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
573
+ } else if (isStopped) {
574
+ line += "\n" + theme.fg("dim", " ⎿ Stopped");
575
+ } else {
576
+ line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
577
+ }
578
+ return new Text(line, 0, 0);
579
+ },
580
+
581
+ // ---- Execute ----
582
+
276
583
  execute: async (toolCallId, params, signal, onUpdate, ctx) => {
277
584
  widget.setUICtx(ctx.ui);
278
585
 
@@ -284,9 +591,17 @@ Guidelines:
284
591
  const modelInput = params.model as string | undefined;
285
592
  const thinkingLevel = params.thinking as any | undefined;
286
593
 
287
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(maxTurns);
288
-
289
594
  if (runInBackground) {
595
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(maxTurns);
596
+
597
+ // Wrap onSessionCreated to sync tokens
598
+ const origOnSession = bgCallbacks.onSessionCreated;
599
+ bgCallbacks.onSessionCreated = (session: any) => {
600
+ origOnSession(session);
601
+ bgState.tokens = safeFormatTokens(session);
602
+ widget.update();
603
+ };
604
+
290
605
  const id = manager.spawn(pi, ctx, type, prompt, {
291
606
  description,
292
607
  maxTurns,
@@ -316,27 +631,42 @@ Guidelines:
316
631
  );
317
632
  }
318
633
 
319
- // Foreground execution
634
+ // Foreground execution — stream progress via onUpdate
320
635
  let spinnerFrame = 0;
321
636
  const startedAt = Date.now();
637
+ let fgId: string | undefined;
638
+
639
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(maxTurns);
322
640
 
323
641
  const streamUpdate = () => {
324
642
  onUpdate?.({
325
- content: [{ type: "text", text: `${bgState.toolUses} tool uses...` }],
643
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
326
644
  details: {
327
645
  status: "running",
328
- toolUses: bgState.toolUses,
329
- tokens: bgState.tokens,
330
- turnCount: bgState.turnCount,
331
- maxTurns: bgState.maxTurns,
646
+ toolUses: fgState.toolUses,
647
+ tokens: fgState.tokens,
648
+ turnCount: fgState.turnCount,
649
+ maxTurns: fgState.maxTurns,
332
650
  durationMs: Date.now() - startedAt,
333
- activity: bgState.responseText
334
- ? bgState.responseText.split("\n").pop()?.trim().slice(0, 60)
335
- : "thinking…",
336
- spinnerFrame: spinnerFrame % 10,
651
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
652
+ spinnerFrame: spinnerFrame % SPINNER.length,
337
653
  },
338
654
  });
339
- widget.update();
655
+ };
656
+
657
+ // Wire session to register in widget
658
+ const origOnSession = fgCallbacks.onSessionCreated;
659
+ fgCallbacks.onSessionCreated = (session: any) => {
660
+ origOnSession(session);
661
+ fgState.tokens = safeFormatTokens(session);
662
+ for (const a of manager.listAgents()) {
663
+ if (a.session === session) {
664
+ fgId = a.id;
665
+ agentActivity.set(a.id, fgState);
666
+ widget.ensureTimer();
667
+ break;
668
+ }
669
+ }
340
670
  };
341
671
 
342
672
  const spinnerInterval = setInterval(() => {
@@ -344,7 +674,6 @@ Guidelines:
344
674
  streamUpdate();
345
675
  }, 80);
346
676
 
347
- widget.ensureTimer();
348
677
  streamUpdate();
349
678
 
350
679
  const record = await manager.spawnAndWait(pi, ctx, type, prompt, {
@@ -353,21 +682,42 @@ Guidelines:
353
682
  modelInput,
354
683
  modelRegistry: ctx.modelRegistry,
355
684
  thinkingLevel,
356
- ...bgCallbacks,
685
+ ...fgCallbacks,
357
686
  });
358
687
 
359
688
  clearInterval(spinnerInterval);
360
689
 
361
- const tokenText = safeFormatTokens(bgState.session);
690
+ // Clean up foreground agent from widget
691
+ if (fgId) {
692
+ agentActivity.delete(fgId);
693
+ widget.markFinished(fgId);
694
+ widget.update();
695
+ }
696
+
697
+ const tokenText = safeFormatTokens(fgState.session);
362
698
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
363
699
 
364
700
  if (record.status === "error") {
365
- return textResult(`Agent failed: ${record.error}`);
701
+ return textResult(`Agent failed: ${record.error}`, {
702
+ status: "error",
703
+ toolUses: record.toolUses,
704
+ tokens: tokenText,
705
+ durationMs,
706
+ error: record.error,
707
+ });
366
708
  }
367
709
 
368
710
  return textResult(
369
711
  `Agent completed in ${(durationMs / 1000).toFixed(1)}s (${record.toolUses} tool uses${tokenText ? `, ${tokenText} tokens` : ""}).\n\n` +
370
712
  (record.result?.trim() || "No output."),
713
+ {
714
+ status: "completed",
715
+ toolUses: record.toolUses,
716
+ tokens: tokenText,
717
+ durationMs,
718
+ turnCount: fgState.turnCount,
719
+ maxTurns: fgState.maxTurns,
720
+ },
371
721
  );
372
722
  },
373
723
  }),
@@ -379,7 +729,7 @@ Guidelines:
379
729
  defineTool({
380
730
  name: "get_helper_result",
381
731
  label: "Get Helper Result",
382
- description: "Check status and retrieve results from a background agent.",
732
+ description: "Check status and retrieve results from a background agent. Use view: true to open a live conversation overlay.",
383
733
  parameters: Type.Object({
384
734
  agent_id: Type.String({
385
735
  description: "The helper ID to check.",
@@ -389,13 +739,46 @@ Guidelines:
389
739
  description: "Wait for completion. Default: false.",
390
740
  }),
391
741
  ),
742
+ view: Type.Optional(
743
+ Type.Boolean({
744
+ description: "Open a live conversation viewer overlay. Default: false.",
745
+ }),
746
+ ),
392
747
  }),
393
- execute: async (_toolCallId, params) => {
748
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
394
749
  const record = manager.getRecord(params.agent_id as string);
395
750
  if (!record) {
396
751
  return textResult(`Helper not found: "${params.agent_id}". It may have been cleaned up.`);
397
752
  }
398
753
 
754
+ // Open conversation viewer overlay if requested
755
+ if (params.view && record.session) {
756
+ const activity = agentActivity.get(record.id);
757
+ await ctx.ui.custom<undefined>(
758
+ (tui, theme, _keybindings, done) => {
759
+ return new ConversationViewer(
760
+ tui,
761
+ record.session!,
762
+ {
763
+ type: record.type,
764
+ description: record.description,
765
+ status: record.status,
766
+ toolUses: record.toolUses,
767
+ startedAt: record.startedAt,
768
+ completedAt: record.completedAt,
769
+ },
770
+ activity,
771
+ theme,
772
+ done,
773
+ );
774
+ },
775
+ {
776
+ overlay: true,
777
+ overlayOptions: { anchor: "center", width: "90%" },
778
+ },
779
+ );
780
+ }
781
+
399
782
  if (params.wait && record.status === "running" && record.promise) {
400
783
  record.resultConsumed = true;
401
784
  await record.promise;