@pi-unipi/subagents 2.6.1 → 2.6.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 (107) hide show
  1. package/README.md +89 -78
  2. package/agents/delegate.md +14 -0
  3. package/agents/oracle.md +78 -0
  4. package/agents/researcher.md +52 -0
  5. package/agents/reviewer.md +79 -0
  6. package/agents/scout.md +50 -0
  7. package/agents/worker.md +59 -0
  8. package/package.json +19 -17
  9. package/prompts/council.md +48 -0
  10. package/prompts/gather-context-and-clarify.md +13 -0
  11. package/prompts/parallel-cleanup.md +59 -0
  12. package/prompts/parallel-research.md +50 -0
  13. package/prompts/parallel-review.md +54 -0
  14. package/prompts/review-loop.md +43 -0
  15. package/skills/council-mode/SKILL.md +230 -0
  16. package/skills/subagents/SKILL.md +49 -0
  17. package/skills/subagents/references/constraints-and-recipes.md +259 -0
  18. package/skills/subagents/references/execution-controls.md +454 -0
  19. package/skills/subagents/references/management-authoring-rpc.md +161 -0
  20. package/skills/subagents/references/multi-lane-orchestration.md +39 -0
  21. package/skills/subagents/references/prompting-and-roles.md +267 -0
  22. package/src/acceptance.ts +331 -0
  23. package/src/agent-manager.ts +405 -0
  24. package/src/agent-memory.ts +300 -0
  25. package/src/agent-overrides.ts +183 -0
  26. package/src/agent-runner.ts +265 -0
  27. package/src/async-runner.ts +417 -0
  28. package/src/authority-policy.ts +47 -0
  29. package/src/budgets.ts +285 -0
  30. package/src/child-safety.ts +140 -0
  31. package/src/config.ts +226 -0
  32. package/src/conversation-viewer.ts +281 -0
  33. package/src/core-compat.ts +94 -0
  34. package/src/custom-agents.ts +323 -0
  35. package/src/file-system-retry.ts +50 -0
  36. package/src/fleet-data.ts +61 -0
  37. package/src/fleet-view.ts +316 -0
  38. package/src/foreground-detach.ts +59 -0
  39. package/src/fork-context.ts +285 -0
  40. package/src/global.d.ts +7 -0
  41. package/src/guide.ts +129 -0
  42. package/src/index.ts +1149 -0
  43. package/src/mission-state.ts +133 -0
  44. package/src/mission-store.ts +434 -0
  45. package/src/model-resolver.ts +79 -0
  46. package/src/output-limits.ts +142 -0
  47. package/src/parity-types.ts +315 -0
  48. package/src/pi-args.ts +161 -0
  49. package/src/pi-spawn.ts +147 -0
  50. package/src/result-files.ts +232 -0
  51. package/src/result-watcher.ts +221 -0
  52. package/src/retained-children.ts +176 -0
  53. package/src/run-fanout-budget.ts +356 -0
  54. package/src/scheduled-runs.ts +351 -0
  55. package/src/schemas.ts +263 -0
  56. package/src/slash-commands.ts +77 -0
  57. package/src/supervisor-channel.ts +249 -0
  58. package/src/tool-handler.ts +1144 -0
  59. package/src/types.ts +238 -0
  60. package/src/widget.ts +447 -0
  61. package/src/workflow-script.ts +737 -0
  62. package/src/workflow-worker.ts +384 -0
  63. package/src/worktree.ts +614 -0
  64. package/dist/agent-manager.d.ts +0 -81
  65. package/dist/agent-manager.d.ts.map +0 -1
  66. package/dist/agent-manager.js +0 -295
  67. package/dist/agent-manager.js.map +0 -1
  68. package/dist/agent-runner.d.ts +0 -51
  69. package/dist/agent-runner.d.ts.map +0 -1
  70. package/dist/agent-runner.js +0 -262
  71. package/dist/agent-runner.js.map +0 -1
  72. package/dist/config.d.ts +0 -24
  73. package/dist/config.d.ts.map +0 -1
  74. package/dist/config.js +0 -132
  75. package/dist/config.js.map +0 -1
  76. package/dist/conversation-viewer.d.ts +0 -40
  77. package/dist/conversation-viewer.d.ts.map +0 -1
  78. package/dist/conversation-viewer.js +0 -276
  79. package/dist/conversation-viewer.js.map +0 -1
  80. package/dist/core-compat.d.ts +0 -21
  81. package/dist/core-compat.d.ts.map +0 -1
  82. package/dist/core-compat.js +0 -86
  83. package/dist/core-compat.js.map +0 -1
  84. package/dist/custom-agents.d.ts +0 -14
  85. package/dist/custom-agents.d.ts.map +0 -1
  86. package/dist/custom-agents.js +0 -113
  87. package/dist/custom-agents.js.map +0 -1
  88. package/dist/file-lock.d.ts +0 -42
  89. package/dist/file-lock.d.ts.map +0 -1
  90. package/dist/file-lock.js +0 -91
  91. package/dist/file-lock.js.map +0 -1
  92. package/dist/index.d.ts +0 -10
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js +0 -758
  95. package/dist/index.js.map +0 -1
  96. package/dist/model-resolver.d.ts +0 -19
  97. package/dist/model-resolver.d.ts.map +0 -1
  98. package/dist/model-resolver.js +0 -61
  99. package/dist/model-resolver.js.map +0 -1
  100. package/dist/types.d.ts +0 -98
  101. package/dist/types.d.ts.map +0 -1
  102. package/dist/types.js +0 -47
  103. package/dist/types.js.map +0 -1
  104. package/dist/widget.d.ts +0 -56
  105. package/dist/widget.d.ts.map +0 -1
  106. package/dist/widget.js +0 -396
  107. package/dist/widget.js.map +0 -1
package/src/index.ts ADDED
@@ -0,0 +1,1149 @@
1
+ /**
2
+ * @pi-unipi/subagents — Extension entry
3
+ *
4
+ * Tools: spawn_helper, get_helper_result
5
+ * Features: renderCall/renderResult, message renderer, conversation viewer
6
+ * ESC propagation: all children abort on parent ESC
7
+ */
8
+
9
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { Text } from "@earendil-works/pi-tui";
11
+ import { Type } from "typebox";
12
+ import { existsSync, readdirSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { MODULES, UNIPI_EVENTS, emitEvent, type UnipiBadgeGenerateRequestEvent } from "@pi-unipi/core";
16
+ import { boundHelperOutput, withHerdrBlocked } from "./core-compat.js";
17
+ import { AgentManager } from "./agent-manager.js";
18
+ import { initConfig, loadRawGlobalConfig, loadRawWorkspaceConfig } from "./config.js";
19
+ import { type AgentActivity, type NotificationDetails, BUILTIN_TYPES } from "./types.js";
20
+ import { loadBuiltinFileAgents } from "./custom-agents.js";
21
+ import { ConversationViewer } from "./conversation-viewer.js";
22
+ import { AgentWidget, SPINNER, TOOL_DISPLAY, formatMs, formatTurns, describeActivity } from "./widget.js";
23
+ import { handleSpawnHelper, type HandlerDeps } from "./tool-handler.js";
24
+ import { SpawnHelperParams, GetHelperResultParams } from "./schemas.js";
25
+ import { runAsyncSubagent, createAsyncRunDir, writeStatus, readStatus } from "./async-runner.js";
26
+ import { createResultWatcher, cleanupAsyncRetention } from "./result-watcher.js";
27
+ import { writeAsyncResultFile, readAsyncResultFile } from "./result-files.js";
28
+ import { writePendingSubscription } from "./result-watcher.js";
29
+ import { RESULTS_DIR, ASYNC_DIR, ensureDirs } from "./parity-types.js";
30
+ import { createForkContextResolver } from "./fork-context.js";
31
+ import { createWorktrees, cleanupWorktrees, diffWorktrees, type WorktreeSetup } from "./worktree.js";
32
+ import { FleetView } from "./fleet-view.js";
33
+ import { registerSlashCommands } from "./slash-commands.js";
34
+ import {
35
+ parseDetachShortcut,
36
+ formatDetachHint,
37
+ matchesDetachInput,
38
+ } from "./foreground-detach.js";
39
+ import { coerceThinkingLevel } from "./agent-runner.js";
40
+
41
+ /** Get info registry from global */
42
+ function getInfoRegistry() {
43
+ return globalThis.__unipi_info_registry;
44
+ }
45
+
46
+ // ---- Formatting helpers (shared between renderers and inline text) ----
47
+
48
+
49
+ /** Tool name → human-readable action. */
50
+
51
+ function formatTokens(count: number): string {
52
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
53
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
54
+ return `${count} token`;
55
+ }
56
+
57
+ /** Format tokens safely from session. */
58
+ function safeFormatTokens(session: any): string {
59
+ if (!session) return "";
60
+ try {
61
+ const stats = session.getSessionStats();
62
+ const total = stats.tokens?.total ?? 0;
63
+ return formatTokens(total);
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ /** Get raw token count from session. */
70
+ function safeTokenCount(session: any): number {
71
+ if (!session) return 0;
72
+ try {
73
+ return session.getSessionStats().tokens?.total ?? 0;
74
+ } catch {
75
+ return 0;
76
+ }
77
+ }
78
+
79
+ /** Build result text */
80
+ function textResult(msg: string, details?: any) {
81
+ return { content: [{ type: "text" as const, text: msg }], details };
82
+ }
83
+
84
+ /** Escape XML for structured notifications. */
85
+ function escapeXml(s: string): string {
86
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
87
+ }
88
+
89
+ /** Human-readable status label. */
90
+ function getStatusLabel(status: string, error?: string): string {
91
+ switch (status) {
92
+ case "error": return `Error: ${error ?? "unknown"}`;
93
+ case "aborted": return "Aborted (max turns exceeded)";
94
+ case "stopped": return "Stopped";
95
+ default: return "Done";
96
+ }
97
+ }
98
+
99
+ export default function (pi: ExtensionAPI) {
100
+ // Initialize config
101
+ const config = initConfig(process.cwd());
102
+ if (!config.enabled) return;
103
+
104
+ // Compute paths at factory time
105
+ const homeDir = homedir();
106
+ const cwd = process.cwd();
107
+ const globalAgentsDir = join(homeDir, ".unipi", "config", "agents");
108
+ const workspaceAgentsDir = join(cwd, ".unipi", "config", "agents");
109
+
110
+ // Activity tracking for widget
111
+ const agentActivity = new Map<string, AgentActivity>();
112
+
113
+ /**
114
+ * Set once `session_shutdown` fires — after which `pi` must not be touched.
115
+ *
116
+ * Pi disposes the session as soon as the shutdown handlers resolve, and
117
+ * `AgentSession.dispose()` invalidates the extension runtime. Every
118
+ * `assertActive`-gated method (`sendMessage`, `setSessionName`,
119
+ * `appendEntry`, `setModel`, …) then throws "This extension ctx is stale
120
+ * after session replacement or reload".
121
+ *
122
+ * Background agents outlive that moment: `abortAll()` only signals their
123
+ * AbortController, so the in-flight promise settles a microtask *later* and
124
+ * fires this completion callback against a dead runtime — an unhandled
125
+ * throw that crashed the process on exit.
126
+ *
127
+ * Scoped to the extension factory rather than module scope on purpose:
128
+ * `session_shutdown` also fires for `/new`, `/fork` and `/resume` (reasons
129
+ * "new" / "fork" / "resume"), and pi re-invokes the extension factory for
130
+ * the replacement session. A fresh closure therefore starts with
131
+ * `sessionEnded = false`, so the guard can never latch permanently.
132
+ * Verified: `/new` emits `shutdown reason=new` then re-runs the factory.
133
+ *
134
+ * `pi.events` is NOT gated, so cross-module events still fire.
135
+ */
136
+ let sessionEnded = false;
137
+
138
+ // Create manager with completion callback
139
+ const manager = new AgentManager(
140
+ (record) => {
141
+ agentActivity.delete(record.id);
142
+
143
+ // After shutdown the UI is gone and the runtime is stale — nothing here
144
+ // is deliverable, and touching `pi` would throw.
145
+ if (sessionEnded) return;
146
+
147
+ widget.markFinished(record.id);
148
+ widget.update();
149
+
150
+ // Build notification details
151
+ const details = buildNotificationDetails(record, agentActivity.get(record.id));
152
+
153
+ // Badge generation: extract name from agent result and set directly.
154
+ // Mark resultConsumed BEFORE the notification check so the main agent
155
+ // never sees this subagent.
156
+ if (record.description === "Generate session name" && record.result && record.status === "completed") {
157
+ const name = record.result.split("\n")[0]?.trim().slice(0, 50) ?? "";
158
+ if (name && !name.startsWith("Error") && !name.includes("error")) {
159
+ try {
160
+ pi.setSessionName(name);
161
+ } catch { /* best effort */ }
162
+ }
163
+ record.resultConsumed = true;
164
+ }
165
+
166
+
167
+ // Send styled notification via message renderer
168
+ const status = getStatusLabel(record.status, record.error);
169
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
170
+ const resultPreview = record.result
171
+ ? record.result.length > 500
172
+ ? record.result.slice(0, 500) + "…"
173
+ : record.result
174
+ : "No output.";
175
+
176
+ const notificationXml = [
177
+ `<task-notification>`,
178
+ `<task-id>${record.id}</task-id>`,
179
+ `<status>${escapeXml(status)}</status>`,
180
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
181
+ `<result>${escapeXml(resultPreview)}</result>`,
182
+ `<usage><total_tokens>${details.totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
183
+ `</task-notification>`,
184
+ ].join("\n");
185
+
186
+ if (!record.resultConsumed) {
187
+ // Defence in depth: `sessionEnded` covers the ordinary shutdown path,
188
+ // but a session can also be replaced mid-flight. Delivering a
189
+ // notification is best-effort — it must never take the process down.
190
+ try {
191
+ pi.sendMessage<NotificationDetails>(
192
+ {
193
+ customType: "subagent-notification",
194
+ content: notificationXml,
195
+ display: true,
196
+ details,
197
+ },
198
+ { deliverAs: "followUp", triggerTurn: true },
199
+ );
200
+ } catch {
201
+ // Runtime went stale between the guard and here — nothing to notify.
202
+ }
203
+ }
204
+
205
+ pi.events.emit("subagents:completed", {
206
+ id: record.id,
207
+ type: record.type,
208
+ description: record.description,
209
+ status: record.status,
210
+ result: record.result,
211
+ error: record.error,
212
+ });
213
+ },
214
+ config.maxConcurrent,
215
+ (record) => {
216
+ pi.events.emit("subagents:started", {
217
+ id: record.id,
218
+ type: record.type,
219
+ description: record.description,
220
+ });
221
+ },
222
+ config.types,
223
+ process.cwd(),
224
+ {
225
+ user: loadRawGlobalConfig()?.subagents,
226
+ project: loadRawWorkspaceConfig(process.cwd())?.subagents,
227
+ },
228
+ );
229
+
230
+ // ---- Async process runner (Phase 3) ----
231
+ ensureDirs();
232
+ const activeAsyncRuns = new Map<string, AbortController>();
233
+ const asyncSessionId = `unipi-${process.pid}`;
234
+
235
+ const runAsyncDep: NonNullable<HandlerDeps["runAsync"]> = async (launch) => {
236
+ const agent = manager.getAgentConfig(manager.resolveAlias(launch.agentName));
237
+ if (!agent) throw new Error(`Unknown agent "${launch.agentName}".`);
238
+ if (launch.resumeSessionFile && !existsSync(launch.resumeSessionFile)) {
239
+ throw new Error(`Resume session file is missing: ${launch.resumeSessionFile}`);
240
+ }
241
+
242
+ // Worktree isolation: managed worktree per child; cleaned up after the run
243
+ // (diffs preserved to the run dir as handoff artifacts).
244
+ let worktreeSetup: WorktreeSetup | undefined;
245
+ let childCwd = process.cwd();
246
+ if (launch.worktree === true) {
247
+ try {
248
+ worktreeSetup = createWorktrees(process.cwd(), `async-${Date.now().toString(36)}`, 1);
249
+ childCwd = worktreeSetup.worktrees[0]!.agentCwd;
250
+ } catch (worktreeError) {
251
+ throw new Error(
252
+ `Worktree isolation failed: ${worktreeError instanceof Error ? worktreeError.message : String(worktreeError)}`,
253
+ );
254
+ }
255
+ }
256
+ const runDir = createAsyncRunDir(launch.agentName);
257
+ const runId = runDir.split("/").pop()!;
258
+ const controller = new AbortController();
259
+ activeAsyncRuns.set(runId, controller);
260
+
261
+ // Fork context: branch a child session from the parent conversation
262
+ // (sanitized thinking blocks, thinking forced off when sanitized).
263
+ let forkSessionFile: string | undefined;
264
+ let forceThinkingOff: boolean | undefined;
265
+ if (launch.context === "fork") {
266
+ try {
267
+ const sessionManager = (sessionCtx as unknown as {
268
+ sessionManager?: {
269
+ getSessionFile(): string | undefined;
270
+ getLeafId(): string | null;
271
+ openSession?: Parameters<typeof createForkContextResolver>[0]["openSession"];
272
+ };
273
+ })?.sessionManager;
274
+ if (!sessionManager) {
275
+ throw new Error("Forked context requires a persisted parent session (session manager unavailable).");
276
+ }
277
+ const resolver = createForkContextResolver(sessionManager, "fork");
278
+ forkSessionFile = resolver.sessionFileForIndex(0);
279
+ forceThinkingOff = resolver.thinkingOverrideForIndex(0) === "off";
280
+ if (!forkSessionFile) {
281
+ throw new Error("Forked context failed to produce a branched session file.");
282
+ }
283
+ writeStatus(runDir, { context: "fork", forkSessionFile });
284
+ } catch (forkError) {
285
+ // Reference rule: explicit fork never silently downgrades.
286
+ activeAsyncRuns.delete(runId);
287
+ throw forkError;
288
+ }
289
+ }
290
+
291
+ // Fire-and-track: the promise writes the durable result file + notifies on
292
+ // completion; the tool call returns immediately with the run id.
293
+ void (async () => {
294
+ try {
295
+ const result = await runAsyncSubagent(
296
+ {
297
+ agent,
298
+ task: launch.task,
299
+ cwd: childCwd,
300
+ model: launch.model,
301
+ thinking: launch.thinking,
302
+ tools: agent.builtinToolNames,
303
+ timeoutMs: launch.timeoutMs,
304
+ parentSessionId: asyncSessionId,
305
+ config,
306
+ ...(launch.resumeSessionFile ? { sessionFile: launch.resumeSessionFile } : {}),
307
+ ...(forkSessionFile ? { forkSessionFile } : {}),
308
+ ...(forceThinkingOff ? { forceThinkingOff } : {}),
309
+ },
310
+ runDir,
311
+ controller.signal,
312
+ );
313
+ writeAsyncResultFile(RESULTS_DIR, {
314
+ runId,
315
+ sessionId: asyncSessionId,
316
+ ...(result.output !== undefined ? { output: result.output } : {}),
317
+ ...(result.error ? { error: result.error } : {}),
318
+ success: result.status === "completed",
319
+ state: result.status,
320
+ timedOut: result.status === "timedOut",
321
+ durationMs: result.durationMs,
322
+ }, { asyncDir: runDir });
323
+ } catch (error) {
324
+ writeStatus(runDir, {
325
+ status: "failed",
326
+ error: error instanceof Error ? error.message : String(error),
327
+ });
328
+ writeAsyncResultFile(RESULTS_DIR, {
329
+ runId,
330
+ sessionId: asyncSessionId,
331
+ error: error instanceof Error ? error.message : String(error),
332
+ success: false,
333
+ state: "failed",
334
+ }, { asyncDir: runDir });
335
+ } finally {
336
+ activeAsyncRuns.delete(runId);
337
+ if (worktreeSetup) {
338
+ try {
339
+ const diffs = diffWorktrees(worktreeSetup, [launch.agentName], runDir);
340
+ writeFileSync(
341
+ join(runDir, "handoff.json"),
342
+ JSON.stringify({ patches: diffs.map((d) => d.patchPath) }),
343
+ { mode: 0o600 },
344
+ );
345
+ cleanupWorktrees(worktreeSetup, {
346
+ kind: "preserve",
347
+ capturedDiffs: diffs,
348
+ handoffManifestPath: join(runDir, "handoff.json"),
349
+ });
350
+ } catch {
351
+ // Worktree cleanup is best-effort; preserved trees surface in the report.
352
+ }
353
+ }
354
+ }
355
+ })();
356
+
357
+ return { runId, status: "running" };
358
+ };
359
+
360
+ // Result watcher: deliver async completions as follow-up notifications.
361
+ const watcher = createResultWatcher({
362
+ resultsDir: RESULTS_DIR,
363
+ sessionId: asyncSessionId,
364
+ resultScanLogging: config.resultScanLogging ?? "activity",
365
+ notifier: (notification) => {
366
+ if (sessionEnded) return;
367
+ try {
368
+ pi.sendMessage(
369
+ {
370
+ customType: "unipi-response",
371
+ content:
372
+ `<task-notification>\n` +
373
+ `<task-id>${notification.runId}</task-id>\n` +
374
+ `<status>${notification.success ? "completed" : notification.state ?? "failed"}</status>\n` +
375
+ `<summary>Background agent "${notification.agent ?? "agent"}" ${notification.state ?? (notification.success ? "completed" : "failed")}</summary>\n` +
376
+ (notification.error
377
+ ? `<error>${notification.error}</error>\n`
378
+ : `<result>${(notification.output ?? "").slice(0, 2000)}</result>\n`) +
379
+ `</task-notification>`,
380
+ display: false,
381
+ },
382
+ { deliverAs: "followUp", triggerTurn: true },
383
+ );
384
+ } catch {
385
+ // Runtime went stale — nothing to notify.
386
+ }
387
+ },
388
+ });
389
+
390
+ // Periodic retention cleanup (hourly, unref'd).
391
+ const retentionTimer = setInterval(() => {
392
+ cleanupAsyncRetention(ASYNC_DIR, RESULTS_DIR);
393
+ }, 60 * 60 * 1000);
394
+ retentionTimer.unref?.();
395
+
396
+ // Scheduled runs: poll for due schedules every minute when enabled.
397
+ let schedulePoller: { stopPolling(): void } | undefined;
398
+ if (config.scheduledRuns?.enabled !== false && deps_runAsyncAvailable()) {
399
+ import("./scheduled-runs.js").then(({ ScheduledRunManager }) => {
400
+ const manager = new ScheduledRunManager(process.cwd(), {
401
+ storeRoot: config.scheduledRuns?.storeRoot,
402
+ maxPending: config.scheduledRuns?.maxPending,
403
+ launch: async (record) => {
404
+ const result = await runAsyncDep({
405
+ agentName: record.agent,
406
+ task: record.task,
407
+ description: `schedule: ${record.name}`,
408
+ context: "fresh",
409
+ timeoutMs: record.timeoutMs,
410
+ });
411
+ return result.runId;
412
+ },
413
+ });
414
+ manager.startPolling();
415
+ schedulePoller = manager;
416
+ }).catch(() => {});
417
+ }
418
+ function deps_runAsyncAvailable(): boolean {
419
+ return true; // runAsyncDep is defined below in the closure
420
+ }
421
+
422
+ // ---- Parity handler wiring (spawn_helper surface) ----
423
+ // Session-wide cumulative spawn accounting (maxSubagentSpawnsPerSession)
424
+ let sessionSpawnsUsed = 0;
425
+ const sessionSpawnCap = config.maxSubagentSpawnsPerSession && config.maxSubagentSpawnsPerSession > 0
426
+ ? config.maxSubagentSpawnsPerSession
427
+ : undefined;
428
+
429
+ const handlerDeps: HandlerDeps = {
430
+ pi,
431
+ manager,
432
+ config,
433
+ spawnAccounting: {
434
+ used: () => sessionSpawnsUsed,
435
+ cap: () => sessionSpawnCap,
436
+ consume: (count) => { sessionSpawnsUsed += count; },
437
+ },
438
+ runAsync: runAsyncDep,
439
+ spawnBackground: (spawnCtx, agentName, childPrompt, options) => {
440
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(options.maxTurns);
441
+ const origOnSession = bgCallbacks.onSessionCreated;
442
+ bgCallbacks.onSessionCreated = (session: any) => {
443
+ origOnSession(session);
444
+ bgState.tokens = safeFormatTokens(session);
445
+ widget.update();
446
+ };
447
+ const id = manager.spawn(pi, spawnCtx, agentName, childPrompt, {
448
+ description: options.description ?? `${agentName} task`,
449
+ maxTurns: options.maxTurns,
450
+ modelInput: options.modelInput,
451
+ modelRegistry: spawnCtx.modelRegistry,
452
+ thinkingLevel: coerceThinkingLevel(options.thinkingLevel as never),
453
+ isBackground: true,
454
+ ...bgCallbacks,
455
+ });
456
+ agentActivity.set(id, bgState);
457
+ widget.ensureTimer();
458
+ widget.update();
459
+ return id;
460
+ },
461
+ spawnForeground: async (spawnCtx, agentName, childPrompt, options) => {
462
+ // Stream progress via the widget — reuse the activity tracker.
463
+ let spinnerFrame = 0;
464
+ const startedAt = Date.now();
465
+ let fgId: string | undefined;
466
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(options.maxTurns);
467
+
468
+ const streamUpdate = () => {
469
+ onUpdateForForeground({
470
+ status: "running",
471
+ toolUses: fgState.toolUses,
472
+ tokens: fgState.tokens,
473
+ turnCount: fgState.turnCount,
474
+ maxTurns: fgState.maxTurns,
475
+ durationMs: Date.now() - startedAt,
476
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
477
+ spinnerFrame: spinnerFrame % SPINNER.length,
478
+ });
479
+ };
480
+ const origOnSession = fgCallbacks.onSessionCreated;
481
+ fgCallbacks.onSessionCreated = (session: any) => {
482
+ origOnSession(session);
483
+ fgState.tokens = safeFormatTokens(session);
484
+ for (const a of manager.listAgents()) {
485
+ if (a.session === session) {
486
+ fgId = a.id;
487
+ agentActivity.set(a.id, fgState);
488
+ widget.ensureTimer();
489
+ break;
490
+ }
491
+ }
492
+ };
493
+ const spinnerInterval = setInterval(() => { spinnerFrame++; streamUpdate(); }, 80);
494
+ // Detach: stop waiting without killing the child. The completion
495
+ // notification arrives later via the normal onComplete path.
496
+ activeForegroundDetach = () => {
497
+ if (!fgId) return false;
498
+ const record = manager.getRecord(fgId);
499
+ if (!record || record.status !== "running") return false;
500
+ record.resultConsumed = true; // suppress the duplicate notification
501
+ return true;
502
+ };
503
+ try {
504
+ const record = await manager.spawnAndWait(pi, spawnCtx, agentName, childPrompt, {
505
+ description: options.description ?? `${agentName} task`,
506
+ maxTurns: options.maxTurns,
507
+ modelInput: options.modelInput,
508
+ modelRegistry: spawnCtx.modelRegistry,
509
+ thinkingLevel: coerceThinkingLevel(options.thinkingLevel as never),
510
+ ...fgCallbacks,
511
+ });
512
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
513
+ const tokenText = safeFormatTokens(fgState.session);
514
+ if (record.status === "error") {
515
+ return { ok: false, output: record.error ?? "failed", error: record.error, toolUses: record.toolUses, durationMs };
516
+ }
517
+ const output = boundHelperOutput(record.result?.trim() || "No output.");
518
+ record.resultArtifactPath = output.artifactPath;
519
+ return { ok: true, output: output.text, toolUses: record.toolUses, durationMs };
520
+ } finally {
521
+ clearInterval(spinnerInterval);
522
+ activeForegroundDetach = undefined;
523
+ if (fgId) {
524
+ agentActivity.delete(fgId);
525
+ widget.markFinished(fgId);
526
+ widget.update();
527
+ }
528
+ }
529
+ },
530
+ };
531
+ // onUpdate stream for the current foreground execution (set per execute() call)
532
+ let onUpdateForForeground: (details: Record<string, unknown>) => void = () => {};
533
+
534
+ // ---- Foreground detach (foregroundDetachShortcut) ----
535
+ const detachParts = parseDetachShortcut(config.foregroundDetachShortcut);
536
+ let activeForegroundDetach: (() => boolean) | undefined;
537
+
538
+
539
+ // Build notification details for the message renderer
540
+ function buildNotificationDetails(record: any, activity?: AgentActivity): NotificationDetails {
541
+ return {
542
+ id: record.id,
543
+ description: record.description,
544
+ status: record.status,
545
+ toolUses: record.toolUses,
546
+ turnCount: activity?.turnCount ?? 0,
547
+ maxTurns: activity?.maxTurns,
548
+ totalTokens: safeTokenCount(record.session),
549
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
550
+ error: record.error,
551
+ resultPreview: record.result
552
+ ? record.result.length > 200
553
+ ? record.result.slice(0, 200) + "…"
554
+ : record.result
555
+ : "No output.",
556
+ };
557
+ }
558
+
559
+ // ---- Register custom notification renderer ----
560
+ pi.registerMessageRenderer<NotificationDetails>(
561
+ "subagent-notification",
562
+ (message, { expanded }, theme) => {
563
+ const d = message.details;
564
+ if (!d) return undefined;
565
+
566
+ function renderOne(d: NotificationDetails): string {
567
+ const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
568
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
569
+ const statusText = isError
570
+ ? d.status
571
+ : d.status === "steered"
572
+ ? "completed (steered)"
573
+ : "completed";
574
+
575
+ // Line 1: icon + agent description + status
576
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
577
+
578
+ // Line 2: stats
579
+ const parts: string[] = [];
580
+ if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
581
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
582
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
583
+ if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
584
+ if (parts.length) {
585
+ line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
586
+ }
587
+
588
+ // Line 3: result preview (collapsed) or full (expanded)
589
+ if (expanded) {
590
+ const lines = d.resultPreview.split("\n").slice(0, 30);
591
+ for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
592
+ } else {
593
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
594
+ line += "\n " + theme.fg("dim", `⎿ ${preview}`);
595
+ }
596
+
597
+ return line;
598
+ }
599
+
600
+ const all = [d, ...(d.others ?? [])];
601
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
602
+ },
603
+ );
604
+
605
+ // Create widget
606
+ const widget = new AgentWidget(manager, agentActivity);
607
+
608
+ // ---- FleetView (persistent fleet panel; our AgentWidget slot system) ----
609
+ const fleetView = new FleetView(manager, agentActivity, ASYNC_DIR, {
610
+ placement: config.fleetView === false ? undefined : (config.fleetViewPlacement ?? "belowEditor"),
611
+ openInspector: async (entry) => {
612
+ if (!sessionCtx?.ui) return;
613
+ if (entry.source === "inprocess") {
614
+ const record = manager.getRecord(entry.key.replace("inprocess:", ""));
615
+ if (record?.session) {
616
+ await withHerdrBlocked(
617
+ pi,
618
+ "fleet inspector",
619
+ () => sessionCtx!.ui.custom<undefined>(
620
+ (tui, theme, _keybindings, done) =>
621
+ new ConversationViewer(tui, record.session!, {
622
+ type: record.type,
623
+ description: record.description,
624
+ status: record.status,
625
+ toolUses: record.toolUses,
626
+ startedAt: record.startedAt,
627
+ completedAt: record.completedAt,
628
+ }, agentActivity.get(record.id), theme, done),
629
+ { overlay: true, overlayOptions: { anchor: "center", width: "90%" } },
630
+ ),
631
+ );
632
+ }
633
+ return;
634
+ }
635
+ // Async run: show the transcript tail from the result payload / output.txt.
636
+ const runId = entry.key.replace("async:", "");
637
+ const payload = readAsyncResultFile(RESULTS_DIR, runId);
638
+ const text = payload
639
+ ? `${payload.success ? "completed" : payload.state ?? "failed"}\n\n${payload.output ?? payload.error ?? "(no output)"}`
640
+ : "(run still active — transcript available after completion)";
641
+ await withHerdrBlocked(
642
+ pi,
643
+ "fleet inspector",
644
+ () => sessionCtx!.ui.custom<undefined>(
645
+ (_tui, theme, _keybindings, done) => ({
646
+ render: (width: number): string[] => [
647
+ ...text.split("\n").slice(-30).map((line) => ` ${line}`),
648
+ "",
649
+ ` ${theme.fg("dim", "esc/q close")}`,
650
+ ],
651
+ handleInput: (data: string): void => {
652
+ if (data === "\x1b" || data === "q") done(undefined);
653
+ },
654
+ invalidate: (): void => {},
655
+ }),
656
+ { overlay: true, overlayOptions: { anchor: "center", width: "90%" } },
657
+ ),
658
+ );
659
+ },
660
+ });
661
+
662
+
663
+ // ---- Slash commands (/unipi:subagents-*) ----
664
+ registerSlashCommands(pi, () => sessionCtx ?? undefined, {
665
+ manager,
666
+ config,
667
+ asyncDirRoot: ASYNC_DIR,
668
+ });
669
+
670
+ // Register info group at factory time (not session_start)
671
+ const registry = getInfoRegistry();
672
+ if (registry) {
673
+ registry.registerGroup({
674
+ id: "subagents",
675
+ name: "Subagents",
676
+ icon: "🤖",
677
+ priority: 80,
678
+ config: {
679
+ showByDefault: true,
680
+ stats: [
681
+ { id: "maxConcurrent", label: "Max Concurrent", show: true },
682
+ { id: "activeCount", label: "Active Agents", show: true },
683
+ { id: "enabled", label: "Enabled", show: true },
684
+ { id: "types", label: "Available Types", show: true },
685
+ ],
686
+ },
687
+ dataProvider: async () => {
688
+ const types = config.types || {};
689
+ const codeBuiltins: string[] = [...BUILTIN_TYPES];
690
+ const fileBuiltins = [...loadBuiltinFileAgents().keys()];
691
+
692
+ const customTypes: string[] = [];
693
+ for (const dir of [globalAgentsDir, workspaceAgentsDir]) {
694
+ try {
695
+ if (existsSync(dir)) {
696
+ for (const file of readdirSync(dir)) {
697
+ if (file.endsWith(".md") && !customTypes.includes(file.replace(".md", ""))) {
698
+ customTypes.push(file.replace(".md", ""));
699
+ }
700
+ }
701
+ }
702
+ } catch { /* ignore */ }
703
+ }
704
+
705
+ const allTypes = manager.getKnownTypes();
706
+ const typeList = allTypes.map((t) => {
707
+ const isEnabled = manager.isTypeEnabled(t);
708
+ const scope = codeBuiltins.includes(t) || fileBuiltins.includes(t)
709
+ ? "builtin"
710
+ : customTypes.includes(t)
711
+ ? "custom"
712
+ : "config";
713
+ return `${t}(${scope})${isEnabled ? "" : " [disabled]"}`;
714
+ }).join(", ");
715
+
716
+ const activeAgents = manager.listAgents().filter((a) => a.status === "running").length;
717
+
718
+ return {
719
+ maxConcurrent: { value: String(manager.getMaxConcurrent()) },
720
+ activeCount: { value: String(activeAgents) },
721
+ enabled: { value: config.enabled ? "yes" : "no" },
722
+ types: {
723
+ value: allTypes.length > 0 ? allTypes[0] : "none",
724
+ detail: allTypes.length > 1 ? typeList : undefined,
725
+ },
726
+ };
727
+ },
728
+ });
729
+ }
730
+
731
+ // Store session context for badge generation
732
+ let sessionCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = null;
733
+
734
+ // Session start: emit MODULE_READY + capture context
735
+ pi.on("session_start", async (_event, ctx) => {
736
+ sessionCtx = ctx;
737
+ // FleetView keyboard activation (↓/← to inspect active work).
738
+ try {
739
+ if (typeof (ctx.ui as unknown as { onTerminalInput?: unknown }).onTerminalInput === "function") {
740
+ (ctx.ui as unknown as {
741
+ onTerminalInput(handler: (data: string) => { consume?: boolean } | undefined): () => void;
742
+ }).onTerminalInput((data) => {
743
+ // Detach shortcut: detach the active foreground run.
744
+ if (detachParts && matchesDetachInput(data, detachParts) && activeForegroundDetach) {
745
+ if (activeForegroundDetach()) {
746
+ try {
747
+ ctx.ui.notify("Foreground run detached — it continues in the background.", "info");
748
+ } catch { /* best effort */ }
749
+ return { consume: true };
750
+ }
751
+ }
752
+ return fleetView.handleKey(data, () => {
753
+ try {
754
+ return ctx.ui.getEditorText() !== "";
755
+ } catch {
756
+ return false;
757
+ }
758
+ });
759
+ });
760
+ }
761
+ } catch {
762
+ // Terminal input hooking is optional.
763
+ }
764
+ fleetView.setUICtx(ctx.ui);
765
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
766
+ name: MODULES.SUBAGENTS || "subagents",
767
+ version: "0.2.0",
768
+ commands: [],
769
+ tools: ["spawn_helper", "get_helper_result"],
770
+ });
771
+ });
772
+
773
+ // Listen for badge generation requests — spawn background agent
774
+ pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST, async (data) => {
775
+ const event = data as UnipiBadgeGenerateRequestEvent;
776
+ if (!sessionCtx) return;
777
+
778
+ const summary = event?.conversationSummary ?? "";
779
+ const prompt = summary
780
+ ? `Based on this conversation, generate a concise session title (MAX 5 WORDS). Reply with ONLY the title. No quotes, no explanation, no punctuation.\n\nConversation:\n${summary}`
781
+ : `Generate a concise session title (MAX 5 WORDS) for this session. Reply with ONLY the title. No quotes, no explanation, no punctuation.`;
782
+
783
+ // Try with configured model, fallback to inherit
784
+ let modelInput: string | undefined = undefined;
785
+ try {
786
+ const fs = await import("node:fs");
787
+ const path = await import("node:path");
788
+ const configPath = path.resolve(process.cwd(), ".unipi/config/badge.json");
789
+ if (fs.existsSync(configPath)) {
790
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
791
+ if (typeof parsed.generationModel === "string" && parsed.generationModel !== "inherit") {
792
+ modelInput = parsed.generationModel;
793
+ }
794
+ }
795
+ } catch { /* ignore — inherit parent model */ }
796
+ let resolvedModel: any = undefined;
797
+
798
+ // Check if model is available
799
+ if (modelInput && sessionCtx.modelRegistry) {
800
+ const { resolveModel } = await import("./model-resolver.js");
801
+ const result = resolveModel(modelInput, sessionCtx.modelRegistry);
802
+ if (typeof result !== "string") {
803
+ resolvedModel = result;
804
+ }
805
+ // If result is a string (error), resolvedModel stays undefined → inherit parent
806
+ }
807
+
808
+ manager.spawn(pi, sessionCtx, "name-gen", prompt, {
809
+ description: "Generate session name",
810
+ model: resolvedModel,
811
+ isBackground: true,
812
+ isolated: true,
813
+ maxTurns: 1,
814
+ });
815
+ });
816
+
817
+ // ESC propagation: abort all agents on session shutdown.
818
+ // Set the guard FIRST: abortAll() settles in-flight promises, whose
819
+ // completion callbacks would otherwise reach a runtime that pi is about to
820
+ // invalidate.
821
+ pi.on("session_shutdown", async () => {
822
+ watcher.stop();
823
+ schedulePoller?.stopPolling();
824
+ sessionEnded = true;
825
+ manager.abortAll();
826
+ manager.dispose();
827
+ });
828
+
829
+ // Wire UI context for widget + age finished agents on new turn
830
+ pi.on("tool_execution_start", async (_event, ctx) => {
831
+ widget.setUICtx(ctx.ui);
832
+ widget.onTurnStart();
833
+ });
834
+
835
+ // Create activity tracker
836
+ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
837
+ const state: AgentActivity = {
838
+ activeTools: new Map(),
839
+ toolUses: 0,
840
+ turnCount: 1,
841
+ maxTurns,
842
+ tokens: "",
843
+ responseText: "",
844
+ };
845
+
846
+ const callbacks = {
847
+ onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
848
+ if (activity.type === "start") {
849
+ state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
850
+ } else {
851
+ for (const [key, name] of state.activeTools) {
852
+ if (name === activity.toolName) {
853
+ state.activeTools.delete(key);
854
+ break;
855
+ }
856
+ }
857
+ state.toolUses++;
858
+ }
859
+ state.tokens = safeFormatTokens(state.session);
860
+ onStreamUpdate?.();
861
+ },
862
+ onTextDelta: (_delta: string, fullText: string) => {
863
+ state.responseText = fullText;
864
+ onStreamUpdate?.();
865
+ },
866
+ onTurnEnd: (turnCount: number) => {
867
+ state.turnCount = turnCount;
868
+ onStreamUpdate?.();
869
+ },
870
+ onSessionCreated: (session: any) => {
871
+ state.session = session;
872
+ },
873
+ };
874
+
875
+ return { state, callbacks };
876
+ }
877
+
878
+ // ---- Agent tool ----
879
+
880
+ const builtinTypes = BUILTIN_TYPES.join(", ");
881
+ const enabledTypes = manager.getKnownTypes().filter((type) => manager.isTypeEnabled(type));
882
+ const availableTypes = enabledTypes.join(", ") || "none";
883
+
884
+ pi.registerTool(
885
+ defineTool({
886
+ name: "spawn_helper",
887
+ label: "Spawn Helper",
888
+ description: `Launch a sub-agent for parallel work.
889
+
890
+ Available agent types: ${availableTypes}
891
+ Custom types can be defined in:
892
+ - ~/.unipi/config/agents/<name>.md (global)
893
+ - <workspace>/.unipi/config/agents/<name>.md (project)
894
+
895
+ Guidelines:
896
+ - Use "explore" or "scout" for parallel file reads / fast codebase recon
897
+ - Use "work" or "worker" for parallel file writes (transparent locking)
898
+ - Use "reviewer" for code review of diffs, plans, or solutions
899
+ - Use "researcher" for web research (needs web-api tools)
900
+ - Use "oracle" for a second opinion on decisions before acting
901
+ - Use "delegate" for a lightweight general delegate
902
+ - Use run_in_background for work you don't need immediately
903
+ - ESC kills all running agents immediately
904
+ - Agents inherit the parent model by default`,
905
+ parameters: SpawnHelperParams,
906
+
907
+ // ---- Rich inline rendering ----
908
+
909
+ renderCall(args, theme) {
910
+ const displayName = args.type ? args.type : "Agent";
911
+ const desc = args.description ?? "";
912
+ return new Text(
913
+ "▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""),
914
+ 0,
915
+ 0,
916
+ );
917
+ },
918
+
919
+ renderResult(result, { expanded, isPartial }, theme) {
920
+ const details = result.details as any;
921
+ if (!details) {
922
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
923
+ return new Text(text, 0, 0);
924
+ }
925
+
926
+ // inlineToolDisplay: "summary" keeps one stable row per state.
927
+ if (config.inlineToolDisplay === "summary") {
928
+ const glyph = isPartial || details.status === "running"
929
+ ? theme.fg("accent", SPINNER[details.spinnerFrame ?? 0])
930
+ : details.status === "completed"
931
+ ? theme.fg("success", "✓")
932
+ : details.status === "background"
933
+ ? theme.fg("dim", "■")
934
+ : theme.fg("error", "✗");
935
+ const label = details.status === "completed"
936
+ ? "completed"
937
+ : details.status === "background"
938
+ ? "background"
939
+ : details.status === "running"
940
+ ? "running"
941
+ : details.status;
942
+ return new Text(theme.fg("muted", `${glyph} ${label}`), 0, 0);
943
+ }
944
+
945
+ // Stats helper
946
+ const stats = (d: any) => {
947
+ const parts: string[] = [];
948
+ if (d.turnCount != null && d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
949
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
950
+ if (d.tokens) parts.push(d.tokens);
951
+ return parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
952
+ };
953
+
954
+ // Running
955
+ if (isPartial || details.status === "running") {
956
+ const frame = SPINNER[details.spinnerFrame ?? 0];
957
+ const s = stats(details);
958
+ let line = theme.fg("accent", frame) + (s ? " " + s : "");
959
+ line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
960
+ return new Text(line, 0, 0);
961
+ }
962
+
963
+ // Background launched
964
+ if (details.status === "background") {
965
+ return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
966
+ }
967
+
968
+ // Completed
969
+ if (details.status === "completed") {
970
+ const duration = formatMs(details.durationMs);
971
+ const s = stats(details);
972
+ let line = theme.fg("success", "✓") + (s ? " " + s : "");
973
+ line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
974
+
975
+ if (expanded) {
976
+ const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
977
+ if (resultText) {
978
+ const rlines = resultText.split("\n").slice(0, 50);
979
+ for (const l of rlines) {
980
+ line += "\n" + theme.fg("dim", ` ${l}`);
981
+ }
982
+ }
983
+ } else {
984
+ line += "\n" + theme.fg("dim", " ⎿ Done");
985
+ }
986
+ return new Text(line, 0, 0);
987
+ }
988
+
989
+ // Error / Aborted / Stopped
990
+ const isError = details.status === "error";
991
+ const isStopped = details.status === "stopped";
992
+ const s = stats(details);
993
+ let line = (isStopped ? theme.fg("dim", "■") : theme.fg("error", "✗")) + (s ? " " + s : "");
994
+
995
+ if (isError) {
996
+ line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
997
+ } else if (isStopped) {
998
+ line += "\n" + theme.fg("dim", " ⎿ Stopped");
999
+ } else {
1000
+ line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
1001
+ }
1002
+ return new Text(line, 0, 0);
1003
+ },
1004
+
1005
+ // ---- Execute ----
1006
+
1007
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1008
+ widget.setUICtx(ctx.ui);
1009
+ fleetView.setUICtx(ctx.ui);
1010
+
1011
+ // Route through the parity handler (actions, workflowScript, legacy
1012
+ // single-child) — widget/notify plumbing lives in the deps adapters.
1013
+ onUpdateForForeground = (details) => {
1014
+ onUpdate?.({
1015
+ content: [{ type: "text", text: `${details.toolUses ?? 0} tool uses...` }],
1016
+ details,
1017
+ });
1018
+ };
1019
+ try {
1020
+ return (await handleSpawnHelper(handlerDeps, ctx, params as Record<string, unknown>, signal)) as never;
1021
+ } finally {
1022
+ onUpdateForForeground = () => {};
1023
+ }
1024
+ },
1025
+ }),
1026
+ );
1027
+
1028
+ // ---- get_helper_result tool ----
1029
+
1030
+ pi.registerTool(
1031
+ defineTool({
1032
+ name: "get_helper_result",
1033
+ label: "Get Helper Result",
1034
+ description: "Check status and retrieve results from a background agent or async run. Use view: true to open a live conversation overlay; nonBlocking: true to subscribe and be woken on completion.",
1035
+ parameters: GetHelperResultParams,
1036
+ execute: async (_toolCallId, rawParams, _signal, _onUpdate, ctx) => {
1037
+ const params = rawParams as Record<string, unknown>;
1038
+ const id = (params.id ?? params.agent_id) as string | undefined;
1039
+ const nonBlocking = params.nonBlocking === true;
1040
+ const waitAll = params.all === true;
1041
+ const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : undefined;
1042
+
1043
+ // ---- Async process runs ----
1044
+ const asyncPayload = id ? readAsyncResultFile(RESULTS_DIR, id) : undefined;
1045
+ if (asyncPayload && !nonBlocking) {
1046
+ const duration = asyncPayload.durationMs ? `${(asyncPayload.durationMs / 1000).toFixed(1)}s` : "";
1047
+ return textResult(
1048
+ `Run: ${asyncPayload.runId}\nAgent: ${asyncPayload.agent ?? "unknown"} | Status: ${asyncPayload.state ?? (asyncPayload.success ? "completed" : "failed")}\n\n${asyncPayload.output ?? asyncPayload.error ?? "(no output)"}`,
1049
+ { status: asyncPayload.success ? "completed" : "error", runId: asyncPayload.runId },
1050
+ );
1051
+ }
1052
+ if (nonBlocking && id) {
1053
+ // Persist a wake subscription: when the result file appears, the
1054
+ // watcher delivers a followUp notification that wakes this session.
1055
+ if (readAsyncResultFile(RESULTS_DIR, id)) {
1056
+ return textResult(`Run ${id} already completed.`, { status: "completed", runId: id });
1057
+ }
1058
+ writePendingSubscription(RESULTS_DIR, asyncSessionId, id);
1059
+ return textResult(
1060
+ `Subscribed to run ${id}. This session will be woken on completion or failure.`,
1061
+ { status: "subscribed", runId: id },
1062
+ );
1063
+ }
1064
+
1065
+ // ---- In-process records ----
1066
+ const record = manager.getRecord(id as string);
1067
+ if (!record) {
1068
+ if (waitAll) {
1069
+ // Wait for ALL active in-process agents.
1070
+ const active = manager.listAgents().filter((a) => a.status === "running" || a.status === "queued");
1071
+ if (active.length === 0) return textResult("No active agents.");
1072
+ await Promise.race([
1073
+ Promise.allSettled(active.map((a) => a.promise).filter(Boolean)),
1074
+ new Promise((resolve) => setTimeout(resolve, timeoutMs ?? 1_800_000)),
1075
+ ]);
1076
+ return textResult(`All ${active.length} agent(s) settled (or wait timed out).`);
1077
+ }
1078
+ return textResult(`Helper not found: "${id}". It may have been cleaned up.`);
1079
+ }
1080
+
1081
+ // Open conversation viewer overlay if requested
1082
+ if (params.view && record.session) {
1083
+ const activity = agentActivity.get(record.id);
1084
+ await withHerdrBlocked(
1085
+ pi,
1086
+ "helper viewer",
1087
+ () => ctx.ui.custom<undefined>(
1088
+ (tui, theme, _keybindings, done) => {
1089
+ return new ConversationViewer(
1090
+ tui,
1091
+ record.session!,
1092
+ {
1093
+ type: record.type,
1094
+ description: record.description,
1095
+ status: record.status,
1096
+ toolUses: record.toolUses,
1097
+ startedAt: record.startedAt,
1098
+ completedAt: record.completedAt,
1099
+ },
1100
+ activity,
1101
+ theme,
1102
+ done,
1103
+ );
1104
+ },
1105
+ {
1106
+ overlay: true,
1107
+ overlayOptions: { anchor: "center", width: "90%" },
1108
+ },
1109
+ ),
1110
+ );
1111
+ }
1112
+
1113
+ if (params.wait && record.status === "running" && record.promise) {
1114
+ record.resultConsumed = true;
1115
+ await record.promise;
1116
+ }
1117
+
1118
+ const duration = record.completedAt
1119
+ ? `${((record.completedAt - record.startedAt) / 1000).toFixed(1)}s`
1120
+ : "running";
1121
+
1122
+ let output =
1123
+ `Agent: ${record.id}\n` +
1124
+ `Type: ${record.type} | Status: ${record.status} | Tool uses: ${record.toolUses} | Duration: ${duration}\n` +
1125
+ `Description: ${record.description}\n\n`;
1126
+
1127
+ if (record.status === "running") {
1128
+ output += "Agent is still running. Use wait: true or check back later.";
1129
+ } else if (record.status === "error") {
1130
+ output += `Error: ${record.error}`;
1131
+ } else {
1132
+ const bounded = boundHelperOutput(
1133
+ record.result?.trim() || "No output.",
1134
+ 64 * 1024,
1135
+ record.resultArtifactPath,
1136
+ );
1137
+ record.resultArtifactPath = bounded.artifactPath;
1138
+ output += bounded.text;
1139
+ }
1140
+
1141
+ if (record.status !== "running" && record.status !== "queued") {
1142
+ record.resultConsumed = true;
1143
+ }
1144
+
1145
+ return textResult(output);
1146
+ },
1147
+ }),
1148
+ );
1149
+ }