@pi-unipi/subagents 0.1.13 → 0.2.2

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 (47) hide show
  1. package/dist/__tests__/config.test.d.ts +11 -0
  2. package/dist/__tests__/config.test.d.ts.map +1 -0
  3. package/dist/__tests__/config.test.js +196 -0
  4. package/dist/__tests__/config.test.js.map +1 -0
  5. package/dist/__tests__/esc-propagation.test.d.ts +10 -0
  6. package/dist/__tests__/esc-propagation.test.d.ts.map +1 -0
  7. package/dist/__tests__/esc-propagation.test.js +140 -0
  8. package/dist/__tests__/esc-propagation.test.js.map +1 -0
  9. package/dist/__tests__/file-lock.test.d.ts +12 -0
  10. package/dist/__tests__/file-lock.test.d.ts.map +1 -0
  11. package/dist/__tests__/file-lock.test.js +187 -0
  12. package/dist/__tests__/file-lock.test.js.map +1 -0
  13. package/dist/__tests__/workflow-integration.test.d.ts +12 -0
  14. package/dist/__tests__/workflow-integration.test.d.ts.map +1 -0
  15. package/dist/__tests__/workflow-integration.test.js +261 -0
  16. package/dist/__tests__/workflow-integration.test.js.map +1 -0
  17. package/dist/agent-manager.d.ts +4 -1
  18. package/dist/agent-manager.d.ts.map +1 -1
  19. package/dist/agent-manager.js +10 -0
  20. package/dist/agent-manager.js.map +1 -1
  21. package/dist/agent-runner.d.ts +2 -1
  22. package/dist/agent-runner.d.ts.map +1 -1
  23. package/dist/agent-runner.js +23 -7
  24. package/dist/agent-runner.js.map +1 -1
  25. package/dist/conversation-viewer.d.ts +40 -0
  26. package/dist/conversation-viewer.d.ts.map +1 -0
  27. package/dist/conversation-viewer.js +276 -0
  28. package/dist/conversation-viewer.js.map +1 -0
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +410 -58
  32. package/dist/index.js.map +1 -1
  33. package/dist/types.d.ts +17 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js +30 -0
  36. package/dist/types.js.map +1 -1
  37. package/dist/widget.d.ts +32 -3
  38. package/dist/widget.d.ts.map +1 -1
  39. package/dist/widget.js +298 -56
  40. package/dist/widget.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/agent-manager.ts +12 -1
  43. package/src/agent-runner.ts +23 -8
  44. package/src/conversation-viewer.ts +299 -0
  45. package/src/index.ts +411 -49
  46. package/src/types.ts +49 -0
  47. package/src/widget.ts +332 -72
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,41 @@ 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
+
64
182
  pi.events.emit("subagents:completed", {
65
183
  id: record.id,
66
184
  type: record.type,
@@ -80,6 +198,72 @@ export default function (pi: ExtensionAPI) {
80
198
  },
81
199
  );
82
200
 
201
+ // Build notification details for the message renderer
202
+ function buildNotificationDetails(record: any, activity?: AgentActivity): NotificationDetails {
203
+ return {
204
+ id: record.id,
205
+ description: record.description,
206
+ status: record.status,
207
+ toolUses: record.toolUses,
208
+ turnCount: activity?.turnCount ?? 0,
209
+ maxTurns: activity?.maxTurns,
210
+ totalTokens: safeTokenCount(record.session),
211
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
212
+ error: record.error,
213
+ resultPreview: record.result
214
+ ? record.result.length > 200
215
+ ? record.result.slice(0, 200) + "…"
216
+ : record.result
217
+ : "No output.",
218
+ };
219
+ }
220
+
221
+ // ---- Register custom notification renderer ----
222
+ pi.registerMessageRenderer<NotificationDetails>(
223
+ "subagent-notification",
224
+ (message, { expanded }, theme) => {
225
+ const d = message.details;
226
+ if (!d) return undefined;
227
+
228
+ function renderOne(d: NotificationDetails): string {
229
+ const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
230
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
231
+ const statusText = isError
232
+ ? d.status
233
+ : d.status === "steered"
234
+ ? "completed (steered)"
235
+ : "completed";
236
+
237
+ // Line 1: icon + agent description + status
238
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
239
+
240
+ // Line 2: stats
241
+ const parts: string[] = [];
242
+ if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
243
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
244
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
245
+ if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
246
+ if (parts.length) {
247
+ line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
248
+ }
249
+
250
+ // Line 3: result preview (collapsed) or full (expanded)
251
+ if (expanded) {
252
+ const lines = d.resultPreview.split("\n").slice(0, 30);
253
+ for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
254
+ } else {
255
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
256
+ line += "\n " + theme.fg("dim", `⎿ ${preview}`);
257
+ }
258
+
259
+ return line;
260
+ }
261
+
262
+ const all = [d, ...(d.others ?? [])];
263
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
264
+ },
265
+ );
266
+
83
267
  // Create widget
84
268
  const widget = new AgentWidget(manager, agentActivity);
85
269
 
@@ -104,7 +288,6 @@ export default function (pi: ExtensionAPI) {
104
288
  const types = config.types || {};
105
289
  const builtinTypes = ["explore", "work"];
106
290
 
107
- // Scan for custom agent types
108
291
  const customTypes: string[] = [];
109
292
  for (const dir of [globalAgentsDir, workspaceAgentsDir]) {
110
293
  try {
@@ -119,14 +302,14 @@ export default function (pi: ExtensionAPI) {
119
302
  }
120
303
 
121
304
  const allTypes = [...new Set([...builtinTypes, ...Object.keys(types), ...customTypes])];
122
- const typeList = allTypes.map(t => {
305
+ const typeList = allTypes.map((t) => {
123
306
  const isEnabled = types[t]?.enabled !== false;
124
307
  const isBuiltin = builtinTypes.includes(t);
125
308
  const scope = customTypes.includes(t) ? "project" : "global";
126
309
  return `${t}(${scope})${isEnabled ? "" : " [disabled]"}`;
127
310
  }).join(", ");
128
311
 
129
- const activeAgents = manager.listAgents().filter(a => a.status === "running").length;
312
+ const activeAgents = manager.listAgents().filter((a) => a.status === "running").length;
130
313
 
131
314
  return {
132
315
  maxConcurrent: { value: String(manager.getMaxConcurrent()) },
@@ -141,42 +324,65 @@ export default function (pi: ExtensionAPI) {
141
324
  });
142
325
  }
143
326
 
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
- );
327
+ // Store session context for badge generation
328
+ let sessionCtx: any = null;
157
329
 
330
+ // Session start: emit MODULE_READY + capture context
331
+ pi.on("session_start", async (_event, ctx) => {
332
+ sessionCtx = ctx;
158
333
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
159
334
  name: MODULES.SUBAGENTS || "subagents",
160
- version: "0.1.8",
335
+ version: "0.2.0",
161
336
  commands: [],
162
337
  tools: ["spawn_helper", "get_helper_result"],
163
338
  });
164
339
  });
165
340
 
341
+ // Listen for badge generation requests — spawn background agent
342
+ pi.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST as any, async (event: any) => {
343
+ if (!sessionCtx) return;
344
+
345
+ const summary = event?.conversationSummary ?? "";
346
+ const prompt = summary
347
+ ? `Generate a concise session title (MAX 5 WORDS) for this conversation:\n\n"${summary}"\n\nCall the set_session_name tool with the name. Do not explain.`
348
+ : `Generate a concise session title (MAX 5 WORDS) for the current session. Call the set_session_name tool. Do not explain.`;
349
+
350
+ // Try with openai/gpt-oss-20b, fallback to inherit
351
+ const modelInput = "openai/gpt-oss-20b";
352
+ let resolvedModel: any = undefined;
353
+
354
+ // Check if model is available
355
+ if (sessionCtx.modelRegistry) {
356
+ const { resolveModel } = await import("./model-resolver.js");
357
+ const result = resolveModel(modelInput, sessionCtx.modelRegistry);
358
+ if (typeof result !== "string") {
359
+ resolvedModel = result;
360
+ }
361
+ // If result is a string (error), resolvedModel stays undefined → inherit parent
362
+ }
363
+
364
+ manager.spawn(pi, sessionCtx, "explore", prompt, {
365
+ description: "Generate session name",
366
+ model: resolvedModel,
367
+ isBackground: true,
368
+ maxTurns: 3,
369
+ });
370
+ });
371
+
166
372
  // ESC propagation: abort all agents on session shutdown
167
373
  pi.on("session_shutdown", async () => {
168
374
  manager.abortAll();
169
375
  manager.dispose();
170
376
  });
171
377
 
172
- // Wire UI context for widget
378
+ // Wire UI context for widget + age finished agents on new turn
173
379
  pi.on("tool_execution_start", async (_event, ctx) => {
174
380
  widget.setUICtx(ctx.ui);
175
- widget.update();
381
+ widget.onTurnStart();
176
382
  });
177
383
 
178
384
  // Create activity tracker
179
- function createActivityTracker(maxTurns?: number) {
385
+ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
180
386
  const state: AgentActivity = {
181
387
  activeTools: new Map(),
182
388
  toolUses: 0,
@@ -199,20 +405,19 @@ export default function (pi: ExtensionAPI) {
199
405
  }
200
406
  state.toolUses++;
201
407
  }
202
- widget.update();
408
+ state.tokens = safeFormatTokens(state.session);
409
+ onStreamUpdate?.();
203
410
  },
204
411
  onTextDelta: (_delta: string, fullText: string) => {
205
412
  state.responseText = fullText;
206
- widget.update();
413
+ onStreamUpdate?.();
207
414
  },
208
415
  onTurnEnd: (turnCount: number) => {
209
416
  state.turnCount = turnCount;
210
- widget.update();
417
+ onStreamUpdate?.();
211
418
  },
212
419
  onSessionCreated: (session: any) => {
213
420
  state.session = session;
214
- state.tokens = safeFormatTokens(session);
215
- widget.update();
216
421
  },
217
422
  };
218
423
 
@@ -273,6 +478,87 @@ Guidelines:
273
478
  ),
274
479
  }),
275
480
 
481
+ // ---- Rich inline rendering ----
482
+
483
+ renderCall(args, theme) {
484
+ const displayName = args.type ? args.type : "Agent";
485
+ const desc = args.description ?? "";
486
+ return new Text(
487
+ "▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""),
488
+ 0,
489
+ 0,
490
+ );
491
+ },
492
+
493
+ renderResult(result, { expanded, isPartial }, theme) {
494
+ const details = result.details as any;
495
+ if (!details) {
496
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
497
+ return new Text(text, 0, 0);
498
+ }
499
+
500
+ // Stats helper
501
+ const stats = (d: any) => {
502
+ const parts: string[] = [];
503
+ if (d.turnCount != null && d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
504
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
505
+ if (d.tokens) parts.push(d.tokens);
506
+ return parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
507
+ };
508
+
509
+ // Running
510
+ if (isPartial || details.status === "running") {
511
+ const frame = SPINNER[details.spinnerFrame ?? 0];
512
+ const s = stats(details);
513
+ let line = theme.fg("accent", frame) + (s ? " " + s : "");
514
+ line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
515
+ return new Text(line, 0, 0);
516
+ }
517
+
518
+ // Background launched
519
+ if (details.status === "background") {
520
+ return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
521
+ }
522
+
523
+ // Completed
524
+ if (details.status === "completed") {
525
+ const duration = formatMs(details.durationMs);
526
+ const s = stats(details);
527
+ let line = theme.fg("success", "✓") + (s ? " " + s : "");
528
+ line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
529
+
530
+ if (expanded) {
531
+ const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
532
+ if (resultText) {
533
+ const rlines = resultText.split("\n").slice(0, 50);
534
+ for (const l of rlines) {
535
+ line += "\n" + theme.fg("dim", ` ${l}`);
536
+ }
537
+ }
538
+ } else {
539
+ line += "\n" + theme.fg("dim", " ⎿ Done");
540
+ }
541
+ return new Text(line, 0, 0);
542
+ }
543
+
544
+ // Error / Aborted / Stopped
545
+ const isError = details.status === "error";
546
+ const isStopped = details.status === "stopped";
547
+ const s = stats(details);
548
+ let line = (isStopped ? theme.fg("dim", "■") : theme.fg("error", "✗")) + (s ? " " + s : "");
549
+
550
+ if (isError) {
551
+ line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
552
+ } else if (isStopped) {
553
+ line += "\n" + theme.fg("dim", " ⎿ Stopped");
554
+ } else {
555
+ line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
556
+ }
557
+ return new Text(line, 0, 0);
558
+ },
559
+
560
+ // ---- Execute ----
561
+
276
562
  execute: async (toolCallId, params, signal, onUpdate, ctx) => {
277
563
  widget.setUICtx(ctx.ui);
278
564
 
@@ -284,9 +570,17 @@ Guidelines:
284
570
  const modelInput = params.model as string | undefined;
285
571
  const thinkingLevel = params.thinking as any | undefined;
286
572
 
287
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(maxTurns);
288
-
289
573
  if (runInBackground) {
574
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(maxTurns);
575
+
576
+ // Wrap onSessionCreated to sync tokens
577
+ const origOnSession = bgCallbacks.onSessionCreated;
578
+ bgCallbacks.onSessionCreated = (session: any) => {
579
+ origOnSession(session);
580
+ bgState.tokens = safeFormatTokens(session);
581
+ widget.update();
582
+ };
583
+
290
584
  const id = manager.spawn(pi, ctx, type, prompt, {
291
585
  description,
292
586
  maxTurns,
@@ -316,27 +610,42 @@ Guidelines:
316
610
  );
317
611
  }
318
612
 
319
- // Foreground execution
613
+ // Foreground execution — stream progress via onUpdate
320
614
  let spinnerFrame = 0;
321
615
  const startedAt = Date.now();
616
+ let fgId: string | undefined;
617
+
618
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(maxTurns);
322
619
 
323
620
  const streamUpdate = () => {
324
621
  onUpdate?.({
325
- content: [{ type: "text", text: `${bgState.toolUses} tool uses...` }],
622
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
326
623
  details: {
327
624
  status: "running",
328
- toolUses: bgState.toolUses,
329
- tokens: bgState.tokens,
330
- turnCount: bgState.turnCount,
331
- maxTurns: bgState.maxTurns,
625
+ toolUses: fgState.toolUses,
626
+ tokens: fgState.tokens,
627
+ turnCount: fgState.turnCount,
628
+ maxTurns: fgState.maxTurns,
332
629
  durationMs: Date.now() - startedAt,
333
- activity: bgState.responseText
334
- ? bgState.responseText.split("\n").pop()?.trim().slice(0, 60)
335
- : "thinking…",
336
- spinnerFrame: spinnerFrame % 10,
630
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
631
+ spinnerFrame: spinnerFrame % SPINNER.length,
337
632
  },
338
633
  });
339
- widget.update();
634
+ };
635
+
636
+ // Wire session to register in widget
637
+ const origOnSession = fgCallbacks.onSessionCreated;
638
+ fgCallbacks.onSessionCreated = (session: any) => {
639
+ origOnSession(session);
640
+ fgState.tokens = safeFormatTokens(session);
641
+ for (const a of manager.listAgents()) {
642
+ if (a.session === session) {
643
+ fgId = a.id;
644
+ agentActivity.set(a.id, fgState);
645
+ widget.ensureTimer();
646
+ break;
647
+ }
648
+ }
340
649
  };
341
650
 
342
651
  const spinnerInterval = setInterval(() => {
@@ -344,7 +653,6 @@ Guidelines:
344
653
  streamUpdate();
345
654
  }, 80);
346
655
 
347
- widget.ensureTimer();
348
656
  streamUpdate();
349
657
 
350
658
  const record = await manager.spawnAndWait(pi, ctx, type, prompt, {
@@ -353,21 +661,42 @@ Guidelines:
353
661
  modelInput,
354
662
  modelRegistry: ctx.modelRegistry,
355
663
  thinkingLevel,
356
- ...bgCallbacks,
664
+ ...fgCallbacks,
357
665
  });
358
666
 
359
667
  clearInterval(spinnerInterval);
360
668
 
361
- const tokenText = safeFormatTokens(bgState.session);
669
+ // Clean up foreground agent from widget
670
+ if (fgId) {
671
+ agentActivity.delete(fgId);
672
+ widget.markFinished(fgId);
673
+ widget.update();
674
+ }
675
+
676
+ const tokenText = safeFormatTokens(fgState.session);
362
677
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
363
678
 
364
679
  if (record.status === "error") {
365
- return textResult(`Agent failed: ${record.error}`);
680
+ return textResult(`Agent failed: ${record.error}`, {
681
+ status: "error",
682
+ toolUses: record.toolUses,
683
+ tokens: tokenText,
684
+ durationMs,
685
+ error: record.error,
686
+ });
366
687
  }
367
688
 
368
689
  return textResult(
369
690
  `Agent completed in ${(durationMs / 1000).toFixed(1)}s (${record.toolUses} tool uses${tokenText ? `, ${tokenText} tokens` : ""}).\n\n` +
370
691
  (record.result?.trim() || "No output."),
692
+ {
693
+ status: "completed",
694
+ toolUses: record.toolUses,
695
+ tokens: tokenText,
696
+ durationMs,
697
+ turnCount: fgState.turnCount,
698
+ maxTurns: fgState.maxTurns,
699
+ },
371
700
  );
372
701
  },
373
702
  }),
@@ -379,7 +708,7 @@ Guidelines:
379
708
  defineTool({
380
709
  name: "get_helper_result",
381
710
  label: "Get Helper Result",
382
- description: "Check status and retrieve results from a background agent.",
711
+ description: "Check status and retrieve results from a background agent. Use view: true to open a live conversation overlay.",
383
712
  parameters: Type.Object({
384
713
  agent_id: Type.String({
385
714
  description: "The helper ID to check.",
@@ -389,13 +718,46 @@ Guidelines:
389
718
  description: "Wait for completion. Default: false.",
390
719
  }),
391
720
  ),
721
+ view: Type.Optional(
722
+ Type.Boolean({
723
+ description: "Open a live conversation viewer overlay. Default: false.",
724
+ }),
725
+ ),
392
726
  }),
393
- execute: async (_toolCallId, params) => {
727
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
394
728
  const record = manager.getRecord(params.agent_id as string);
395
729
  if (!record) {
396
730
  return textResult(`Helper not found: "${params.agent_id}". It may have been cleaned up.`);
397
731
  }
398
732
 
733
+ // Open conversation viewer overlay if requested
734
+ if (params.view && record.session) {
735
+ const activity = agentActivity.get(record.id);
736
+ await ctx.ui.custom<undefined>(
737
+ (tui, theme, _keybindings, done) => {
738
+ return new ConversationViewer(
739
+ tui,
740
+ record.session!,
741
+ {
742
+ type: record.type,
743
+ description: record.description,
744
+ status: record.status,
745
+ toolUses: record.toolUses,
746
+ startedAt: record.startedAt,
747
+ completedAt: record.completedAt,
748
+ },
749
+ activity,
750
+ theme,
751
+ done,
752
+ );
753
+ },
754
+ {
755
+ overlay: true,
756
+ overlayOptions: { anchor: "center", width: "90%" },
757
+ },
758
+ );
759
+ }
760
+
399
761
  if (params.wait && record.status === "running" && record.promise) {
400
762
  record.resultConsumed = true;
401
763
  await record.promise;