@pi-unipi/subagents 0.1.12 → 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/dist/index.js CHANGED
@@ -1,49 +1,169 @@
1
1
  /**
2
2
  * @pi-unipi/subagents — Extension entry
3
3
  *
4
- * Tools: Agent, get_result
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
  import { defineTool } from "@mariozechner/pi-coding-agent";
9
+ import { Text } from "@mariozechner/pi-tui";
8
10
  import { Type } from "@sinclair/typebox";
11
+ import { existsSync, readdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { emitEvent, MODULES, UNIPI_EVENTS } from "@pi-unipi/core";
9
15
  import { AgentManager } from "./agent-manager.js";
10
16
  import { initConfig } from "./config.js";
11
17
  import { BUILTIN_TYPES } from "./types.js";
18
+ import { ConversationViewer } from "./conversation-viewer.js";
12
19
  import { AgentWidget } from "./widget.js";
13
- /** Format tokens safely. */
20
+ /** Get info registry from global */
21
+ function getInfoRegistry() {
22
+ const g = globalThis;
23
+ return g.__unipi_info_registry;
24
+ }
25
+ // ---- Formatting helpers (shared between renderers and inline text) ----
26
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
27
+ /** Tool name → human-readable action. */
28
+ const TOOL_DISPLAY = {
29
+ read: "reading",
30
+ bash: "running command",
31
+ edit: "editing",
32
+ write: "writing",
33
+ grep: "searching",
34
+ find: "finding files",
35
+ ls: "listing",
36
+ };
37
+ function formatTokens(count) {
38
+ if (count >= 1_000_000)
39
+ return `${(count / 1_000_000).toFixed(1)}M token`;
40
+ if (count >= 1_000)
41
+ return `${(count / 1_000).toFixed(1)}k token`;
42
+ return `${count} token`;
43
+ }
44
+ function formatTurns(turn, max) {
45
+ return max != null ? `⟳${turn}≤${max}` : `⟳${turn}`;
46
+ }
47
+ function formatMs(ms) {
48
+ if (ms >= 60_000)
49
+ return `${(ms / 60_000).toFixed(1)}m`;
50
+ if (ms >= 1_000)
51
+ return `${(ms / 1_000).toFixed(1)}s`;
52
+ return `${ms}ms`;
53
+ }
54
+ /** Build activity description from active tools. */
55
+ function describeActivity(activeTools, responseText) {
56
+ if (activeTools.size > 0) {
57
+ const groups = new Map();
58
+ for (const toolName of activeTools.values()) {
59
+ const action = TOOL_DISPLAY[toolName] ?? toolName;
60
+ groups.set(action, (groups.get(action) ?? 0) + 1);
61
+ }
62
+ const parts = [];
63
+ for (const [action, count] of groups) {
64
+ if (count > 1) {
65
+ parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
66
+ }
67
+ else {
68
+ parts.push(action);
69
+ }
70
+ }
71
+ return parts.join(", ") + "…";
72
+ }
73
+ if (responseText && responseText.trim().length > 0) {
74
+ const line = responseText.split("\n").find((l) => l.trim())?.trim() ?? "";
75
+ if (line.length > 60)
76
+ return line.slice(0, 60) + "…";
77
+ if (line.length > 0)
78
+ return line;
79
+ }
80
+ return "thinking…";
81
+ }
82
+ /** Format tokens safely from session. */
14
83
  function safeFormatTokens(session) {
15
84
  if (!session)
16
85
  return "";
17
86
  try {
18
87
  const stats = session.getSessionStats();
19
88
  const total = stats.tokens?.total ?? 0;
20
- if (total >= 1_000_000)
21
- return `${(total / 1_000_000).toFixed(1)}M`;
22
- if (total >= 1_000)
23
- return `${(total / 1_000).toFixed(1)}k`;
24
- return `${total}`;
89
+ return formatTokens(total);
25
90
  }
26
91
  catch {
27
92
  return "";
28
93
  }
29
94
  }
30
- /** Build result text. */
95
+ /** Get raw token count from session. */
96
+ function safeTokenCount(session) {
97
+ if (!session)
98
+ return 0;
99
+ try {
100
+ return session.getSessionStats().tokens?.total ?? 0;
101
+ }
102
+ catch {
103
+ return 0;
104
+ }
105
+ }
106
+ /** Build result text */
31
107
  function textResult(msg, details) {
32
108
  return { content: [{ type: "text", text: msg }], details };
33
109
  }
110
+ /** Escape XML for structured notifications. */
111
+ function escapeXml(s) {
112
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
113
+ }
114
+ /** Human-readable status label. */
115
+ function getStatusLabel(status, error) {
116
+ switch (status) {
117
+ case "error": return `Error: ${error ?? "unknown"}`;
118
+ case "aborted": return "Aborted (max turns exceeded)";
119
+ case "stopped": return "Stopped";
120
+ default: return "Done";
121
+ }
122
+ }
34
123
  export default function (pi) {
35
124
  // Initialize config
36
125
  const config = initConfig(process.cwd());
37
126
  if (!config.enabled)
38
127
  return;
128
+ // Compute paths at factory time
129
+ const homeDir = homedir();
130
+ const cwd = process.cwd();
131
+ const globalAgentsDir = join(homeDir, ".unipi", "config", "agents");
132
+ const workspaceAgentsDir = join(cwd, ".unipi", "config", "agents");
39
133
  // Activity tracking for widget
40
134
  const agentActivity = new Map();
41
135
  // Create manager with completion callback
42
136
  const manager = new AgentManager((record) => {
43
- // On complete: clean up activity, emit event
44
137
  agentActivity.delete(record.id);
45
138
  widget.markFinished(record.id);
46
139
  widget.update();
140
+ // Build notification details
141
+ const details = buildNotificationDetails(record, agentActivity.get(record.id));
142
+ // Send styled notification via message renderer
143
+ const status = getStatusLabel(record.status, record.error);
144
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
145
+ const resultPreview = record.result
146
+ ? record.result.length > 500
147
+ ? record.result.slice(0, 500) + "…"
148
+ : record.result
149
+ : "No output.";
150
+ const notificationXml = [
151
+ `<task-notification>`,
152
+ `<task-id>${record.id}</task-id>`,
153
+ `<status>${escapeXml(status)}</status>`,
154
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
155
+ `<result>${escapeXml(resultPreview)}</result>`,
156
+ `<usage><total_tokens>${details.totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
157
+ `</task-notification>`,
158
+ ].join("\n");
159
+ if (!record.resultConsumed) {
160
+ pi.sendMessage({
161
+ customType: "subagent-notification",
162
+ content: notificationXml,
163
+ display: true,
164
+ details,
165
+ }, { deliverAs: "followUp", triggerTurn: true });
166
+ }
47
167
  pi.events.emit("subagents:completed", {
48
168
  id: record.id,
49
169
  type: record.type,
@@ -53,40 +173,150 @@ export default function (pi) {
53
173
  error: record.error,
54
174
  });
55
175
  }, config.maxConcurrent, (record) => {
56
- // On start: emit event
57
176
  pi.events.emit("subagents:started", {
58
177
  id: record.id,
59
178
  type: record.type,
60
179
  description: record.description,
61
180
  });
62
181
  });
182
+ // Build notification details for the message renderer
183
+ function buildNotificationDetails(record, activity) {
184
+ return {
185
+ id: record.id,
186
+ description: record.description,
187
+ status: record.status,
188
+ toolUses: record.toolUses,
189
+ turnCount: activity?.turnCount ?? 0,
190
+ maxTurns: activity?.maxTurns,
191
+ totalTokens: safeTokenCount(record.session),
192
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
193
+ error: record.error,
194
+ resultPreview: record.result
195
+ ? record.result.length > 200
196
+ ? record.result.slice(0, 200) + "…"
197
+ : record.result
198
+ : "No output.",
199
+ };
200
+ }
201
+ // ---- Register custom notification renderer ----
202
+ pi.registerMessageRenderer("subagent-notification", (message, { expanded }, theme) => {
203
+ const d = message.details;
204
+ if (!d)
205
+ return undefined;
206
+ function renderOne(d) {
207
+ const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
208
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
209
+ const statusText = isError
210
+ ? d.status
211
+ : d.status === "steered"
212
+ ? "completed (steered)"
213
+ : "completed";
214
+ // Line 1: icon + agent description + status
215
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
216
+ // Line 2: stats
217
+ const parts = [];
218
+ if (d.turnCount > 0)
219
+ parts.push(formatTurns(d.turnCount, d.maxTurns));
220
+ if (d.toolUses > 0)
221
+ parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
222
+ if (d.totalTokens > 0)
223
+ parts.push(formatTokens(d.totalTokens));
224
+ if (d.durationMs > 0)
225
+ parts.push(formatMs(d.durationMs));
226
+ if (parts.length) {
227
+ line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
228
+ }
229
+ // Line 3: result preview (collapsed) or full (expanded)
230
+ if (expanded) {
231
+ const lines = d.resultPreview.split("\n").slice(0, 30);
232
+ for (const l of lines)
233
+ line += "\n" + theme.fg("dim", ` ${l}`);
234
+ }
235
+ else {
236
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
237
+ line += "\n " + theme.fg("dim", `⎿ ${preview}`);
238
+ }
239
+ return line;
240
+ }
241
+ const all = [d, ...(d.others ?? [])];
242
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
243
+ });
63
244
  // Create widget
64
245
  const widget = new AgentWidget(manager, agentActivity);
65
- // Session start: notify agent about config paths
246
+ // Register info group at factory time (not session_start)
247
+ const registry = getInfoRegistry();
248
+ if (registry) {
249
+ registry.registerGroup({
250
+ id: "subagents",
251
+ name: "Subagents",
252
+ icon: "🤖",
253
+ priority: 80,
254
+ config: {
255
+ showByDefault: true,
256
+ stats: [
257
+ { id: "maxConcurrent", label: "Max Concurrent", show: true },
258
+ { id: "activeCount", label: "Active Agents", show: true },
259
+ { id: "enabled", label: "Enabled", show: true },
260
+ { id: "types", label: "Available Types", show: true },
261
+ ],
262
+ },
263
+ dataProvider: async () => {
264
+ const types = config.types || {};
265
+ const builtinTypes = ["explore", "work"];
266
+ const customTypes = [];
267
+ for (const dir of [globalAgentsDir, workspaceAgentsDir]) {
268
+ try {
269
+ if (existsSync(dir)) {
270
+ for (const file of readdirSync(dir)) {
271
+ if (file.endsWith(".md") && !customTypes.includes(file.replace(".md", ""))) {
272
+ customTypes.push(file.replace(".md", ""));
273
+ }
274
+ }
275
+ }
276
+ }
277
+ catch { /* ignore */ }
278
+ }
279
+ const allTypes = [...new Set([...builtinTypes, ...Object.keys(types), ...customTypes])];
280
+ const typeList = allTypes.map((t) => {
281
+ const isEnabled = types[t]?.enabled !== false;
282
+ const isBuiltin = builtinTypes.includes(t);
283
+ const scope = customTypes.includes(t) ? "project" : "global";
284
+ return `${t}(${scope})${isEnabled ? "" : " [disabled]"}`;
285
+ }).join(", ");
286
+ const activeAgents = manager.listAgents().filter((a) => a.status === "running").length;
287
+ return {
288
+ maxConcurrent: { value: String(manager.getMaxConcurrent()) },
289
+ activeCount: { value: String(activeAgents) },
290
+ enabled: { value: config.enabled ? "yes" : "no" },
291
+ types: {
292
+ value: allTypes.length > 0 ? allTypes[0] : "none",
293
+ detail: allTypes.length > 1 ? typeList : undefined,
294
+ },
295
+ };
296
+ },
297
+ });
298
+ }
299
+ // Session start: emit MODULE_READY
66
300
  pi.on("session_start", async (_event, ctx) => {
67
- const homedir = require("os").homedir();
68
- const globalConfig = `${homedir}/.unipi/config/subagents.json`;
69
- const globalAgents = `${homedir}/.unipi/config/agents/`;
70
- const workspaceConfig = `${ctx.cwd}/.unipi/config/subagents.json`;
71
- const workspaceAgents = `${ctx.cwd}/.unipi/config/agents/`;
72
- ctx.ui.notify(`UniPi Subagents config:\n` +
73
- `• Global: ${globalConfig}\n` +
74
- `• Global agents: ${globalAgents}\n` +
75
- `• Workspace: ${workspaceConfig}\n` +
76
- `• Workspace agents: ${workspaceAgents}`, "info");
301
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
302
+ name: MODULES.SUBAGENTS || "subagents",
303
+ version: "0.2.0",
304
+ commands: [],
305
+ tools: ["spawn_helper", "get_helper_result"],
306
+ });
77
307
  });
78
308
  // ESC propagation: abort all agents on session shutdown
79
309
  pi.on("session_shutdown", async () => {
80
310
  manager.abortAll();
81
311
  manager.dispose();
82
312
  });
83
- // Wire UI context for widget
313
+ // Wire UI context for widget + age finished agents on new turn
84
314
  pi.on("tool_execution_start", async (_event, ctx) => {
85
315
  widget.setUICtx(ctx.ui);
86
- widget.update();
316
+ widget.onTurnStart();
87
317
  });
88
318
  // Create activity tracker
89
- function createActivityTracker(maxTurns) {
319
+ function createActivityTracker(maxTurns, onStreamUpdate) {
90
320
  const state = {
91
321
  activeTools: new Map(),
92
322
  toolUses: 0,
@@ -109,20 +339,19 @@ export default function (pi) {
109
339
  }
110
340
  state.toolUses++;
111
341
  }
112
- widget.update();
342
+ state.tokens = safeFormatTokens(state.session);
343
+ onStreamUpdate?.();
113
344
  },
114
345
  onTextDelta: (_delta, fullText) => {
115
346
  state.responseText = fullText;
116
- widget.update();
347
+ onStreamUpdate?.();
117
348
  },
118
349
  onTurnEnd: (turnCount) => {
119
350
  state.turnCount = turnCount;
120
- widget.update();
351
+ onStreamUpdate?.();
121
352
  },
122
353
  onSessionCreated: (session) => {
123
354
  state.session = session;
124
- state.tokens = safeFormatTokens(session);
125
- widget.update();
126
355
  },
127
356
  };
128
357
  return { state, callbacks };
@@ -130,8 +359,8 @@ export default function (pi) {
130
359
  // ---- Agent tool ----
131
360
  const builtinTypes = BUILTIN_TYPES.join(", ");
132
361
  pi.registerTool(defineTool({
133
- name: "Agent",
134
- label: "Agent",
362
+ name: "spawn_helper",
363
+ label: "Spawn Helper",
135
364
  description: `Launch a sub-agent for parallel work.
136
365
 
137
366
  Available agent types: ${builtinTypes}
@@ -156,7 +385,7 @@ Guidelines:
156
385
  description: "A short (3-5 word) description of the task.",
157
386
  }),
158
387
  run_in_background: Type.Optional(Type.Boolean({
159
- description: "Run in background. Returns agent ID immediately.",
388
+ description: "Run in background. Returns helper ID immediately.",
160
389
  })),
161
390
  max_turns: Type.Optional(Type.Number({
162
391
  description: "Max agentic turns before stopping.",
@@ -169,6 +398,78 @@ Guidelines:
169
398
  description: "Thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit parent.",
170
399
  })),
171
400
  }),
401
+ // ---- Rich inline rendering ----
402
+ renderCall(args, theme) {
403
+ const displayName = args.type ? args.type : "Agent";
404
+ const desc = args.description ?? "";
405
+ return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
406
+ },
407
+ renderResult(result, { expanded, isPartial }, theme) {
408
+ const details = result.details;
409
+ if (!details) {
410
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
411
+ return new Text(text, 0, 0);
412
+ }
413
+ // Stats helper
414
+ const stats = (d) => {
415
+ const parts = [];
416
+ if (d.turnCount != null && d.turnCount > 0)
417
+ parts.push(formatTurns(d.turnCount, d.maxTurns));
418
+ if (d.toolUses > 0)
419
+ parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
420
+ if (d.tokens)
421
+ parts.push(d.tokens);
422
+ return parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
423
+ };
424
+ // Running
425
+ if (isPartial || details.status === "running") {
426
+ const frame = SPINNER[details.spinnerFrame ?? 0];
427
+ const s = stats(details);
428
+ let line = theme.fg("accent", frame) + (s ? " " + s : "");
429
+ line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
430
+ return new Text(line, 0, 0);
431
+ }
432
+ // Background launched
433
+ if (details.status === "background") {
434
+ return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
435
+ }
436
+ // Completed
437
+ if (details.status === "completed") {
438
+ const duration = formatMs(details.durationMs);
439
+ const s = stats(details);
440
+ let line = theme.fg("success", "✓") + (s ? " " + s : "");
441
+ line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
442
+ if (expanded) {
443
+ const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
444
+ if (resultText) {
445
+ const rlines = resultText.split("\n").slice(0, 50);
446
+ for (const l of rlines) {
447
+ line += "\n" + theme.fg("dim", ` ${l}`);
448
+ }
449
+ }
450
+ }
451
+ else {
452
+ line += "\n" + theme.fg("dim", " ⎿ Done");
453
+ }
454
+ return new Text(line, 0, 0);
455
+ }
456
+ // Error / Aborted / Stopped
457
+ const isError = details.status === "error";
458
+ const isStopped = details.status === "stopped";
459
+ const s = stats(details);
460
+ let line = (isStopped ? theme.fg("dim", "■") : theme.fg("error", "✗")) + (s ? " " + s : "");
461
+ if (isError) {
462
+ line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
463
+ }
464
+ else if (isStopped) {
465
+ line += "\n" + theme.fg("dim", " ⎿ Stopped");
466
+ }
467
+ else {
468
+ line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
469
+ }
470
+ return new Text(line, 0, 0);
471
+ },
472
+ // ---- Execute ----
172
473
  execute: async (toolCallId, params, signal, onUpdate, ctx) => {
173
474
  widget.setUICtx(ctx.ui);
174
475
  const type = params.type;
@@ -178,10 +479,15 @@ Guidelines:
178
479
  const maxTurns = params.max_turns;
179
480
  const modelInput = params.model;
180
481
  const thinkingLevel = params.thinking;
181
- // Create activity tracker
182
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(maxTurns);
183
482
  if (runInBackground) {
184
- // Background execution
483
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(maxTurns);
484
+ // Wrap onSessionCreated to sync tokens
485
+ const origOnSession = bgCallbacks.onSessionCreated;
486
+ bgCallbacks.onSessionCreated = (session) => {
487
+ origOnSession(session);
488
+ bgState.tokens = safeFormatTokens(session);
489
+ widget.update();
490
+ };
185
491
  const id = manager.spawn(pi, ctx, type, prompt, {
186
492
  description,
187
493
  maxTurns,
@@ -204,33 +510,44 @@ Guidelines:
204
510
  `\nYou will be notified when this agent completes.\n` +
205
511
  `Use get_result to retrieve full results.`, { status: "background", agentId: id });
206
512
  }
207
- // Foreground execution
513
+ // Foreground execution — stream progress via onUpdate
208
514
  let spinnerFrame = 0;
209
515
  const startedAt = Date.now();
210
516
  let fgId;
517
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(maxTurns);
211
518
  const streamUpdate = () => {
212
519
  onUpdate?.({
213
- content: [{ type: "text", text: `${bgState.toolUses} tool uses...` }],
520
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
214
521
  details: {
215
522
  status: "running",
216
- toolUses: bgState.toolUses,
217
- tokens: bgState.tokens,
218
- turnCount: bgState.turnCount,
219
- maxTurns: bgState.maxTurns,
523
+ toolUses: fgState.toolUses,
524
+ tokens: fgState.tokens,
525
+ turnCount: fgState.turnCount,
526
+ maxTurns: fgState.maxTurns,
220
527
  durationMs: Date.now() - startedAt,
221
- activity: bgState.responseText
222
- ? bgState.responseText.split("\n").pop()?.trim().slice(0, 60)
223
- : "thinking…",
224
- spinnerFrame: spinnerFrame % 10,
528
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
529
+ spinnerFrame: spinnerFrame % SPINNER.length,
225
530
  },
226
531
  });
227
- widget.update();
532
+ };
533
+ // Wire session to register in widget
534
+ const origOnSession = fgCallbacks.onSessionCreated;
535
+ fgCallbacks.onSessionCreated = (session) => {
536
+ origOnSession(session);
537
+ fgState.tokens = safeFormatTokens(session);
538
+ for (const a of manager.listAgents()) {
539
+ if (a.session === session) {
540
+ fgId = a.id;
541
+ agentActivity.set(a.id, fgState);
542
+ widget.ensureTimer();
543
+ break;
544
+ }
545
+ }
228
546
  };
229
547
  const spinnerInterval = setInterval(() => {
230
548
  spinnerFrame++;
231
549
  streamUpdate();
232
550
  }, 80);
233
- widget.ensureTimer();
234
551
  streamUpdate();
235
552
  const record = await manager.spawnAndWait(pi, ctx, type, prompt, {
236
553
  description,
@@ -238,39 +555,74 @@ Guidelines:
238
555
  modelInput,
239
556
  modelRegistry: ctx.modelRegistry,
240
557
  thinkingLevel,
241
- ...bgCallbacks,
558
+ ...fgCallbacks,
242
559
  });
243
560
  clearInterval(spinnerInterval);
561
+ // Clean up foreground agent from widget
244
562
  if (fgId) {
245
563
  agentActivity.delete(fgId);
246
564
  widget.markFinished(fgId);
565
+ widget.update();
247
566
  }
248
- const tokenText = safeFormatTokens(bgState.session);
567
+ const tokenText = safeFormatTokens(fgState.session);
249
568
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
250
569
  if (record.status === "error") {
251
- return textResult(`Agent failed: ${record.error}`);
570
+ return textResult(`Agent failed: ${record.error}`, {
571
+ status: "error",
572
+ toolUses: record.toolUses,
573
+ tokens: tokenText,
574
+ durationMs,
575
+ error: record.error,
576
+ });
252
577
  }
253
578
  return textResult(`Agent completed in ${(durationMs / 1000).toFixed(1)}s (${record.toolUses} tool uses${tokenText ? `, ${tokenText} tokens` : ""}).\n\n` +
254
- (record.result?.trim() || "No output."));
579
+ (record.result?.trim() || "No output."), {
580
+ status: "completed",
581
+ toolUses: record.toolUses,
582
+ tokens: tokenText,
583
+ durationMs,
584
+ turnCount: fgState.turnCount,
585
+ maxTurns: fgState.maxTurns,
586
+ });
255
587
  },
256
588
  }));
257
- // ---- get_result tool ----
589
+ // ---- get_helper_result tool ----
258
590
  pi.registerTool(defineTool({
259
- name: "get_result",
260
- label: "Get Agent Result",
261
- description: "Check status and retrieve results from a background agent.",
591
+ name: "get_helper_result",
592
+ label: "Get Helper Result",
593
+ description: "Check status and retrieve results from a background agent. Use view: true to open a live conversation overlay.",
262
594
  parameters: Type.Object({
263
595
  agent_id: Type.String({
264
- description: "The agent ID to check.",
596
+ description: "The helper ID to check.",
265
597
  }),
266
598
  wait: Type.Optional(Type.Boolean({
267
599
  description: "Wait for completion. Default: false.",
268
600
  })),
601
+ view: Type.Optional(Type.Boolean({
602
+ description: "Open a live conversation viewer overlay. Default: false.",
603
+ })),
269
604
  }),
270
- execute: async (_toolCallId, params) => {
605
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
271
606
  const record = manager.getRecord(params.agent_id);
272
607
  if (!record) {
273
- return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
608
+ return textResult(`Helper not found: "${params.agent_id}". It may have been cleaned up.`);
609
+ }
610
+ // Open conversation viewer overlay if requested
611
+ if (params.view && record.session) {
612
+ const activity = agentActivity.get(record.id);
613
+ await ctx.ui.custom((tui, theme, _keybindings, done) => {
614
+ return new ConversationViewer(tui, record.session, {
615
+ type: record.type,
616
+ description: record.description,
617
+ status: record.status,
618
+ toolUses: record.toolUses,
619
+ startedAt: record.startedAt,
620
+ completedAt: record.completedAt,
621
+ }, activity, theme, done);
622
+ }, {
623
+ overlay: true,
624
+ overlayOptions: { anchor: "center", width: "90%" },
625
+ });
274
626
  }
275
627
  if (params.wait && record.status === "running" && record.promise) {
276
628
  record.resultConsumed = true;