@tt-a1i/openpi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,1225 @@
1
+ /**
2
+ * Subagents — spawn background subagents as in-process pi sessions, behind a
3
+ * single Effect service interface.
4
+ *
5
+ * Tools (for the parent LLM):
6
+ * - subagent_spawn: fire-and-forget spawn (prompt, name, optional harness,
7
+ * working_dir, model, reasoning_effort, and agent_type when this environment
8
+ * defines any). Model-spawned subagents and user /btw asides run in separate
9
+ * pools (MAX_RUNNING and MAX_RUNNING_BTW).
10
+ * - subagent_wait: block until the listed subagents settle, return results.
11
+ * - subagent_cancel: stop one or more running subagents.
12
+ * - subagent_check: peek at a subagent's status and recent activity.
13
+ * - subagent_list: list all subagents.
14
+ *
15
+ * Unawaited subagents queue their result as a follow-up message when they
16
+ * settle. `/subagents` opens a picker + full interactive takeover view.
17
+ *
18
+ * Agent types (`src/agent-types.ts`) are optional named presets that fix a
19
+ * child's system prompt, model, and tool allowlist; see `docs/agent-types.md`.
20
+ *
21
+ * Architecture: Effect v4 generators throughout (backend -> manager ->
22
+ * runtime); this file is the async boundary where tool handlers run effects
23
+ * against one shared ManagedRuntime.
24
+ */
25
+
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import { StringEnum } from "@earendil-works/pi-ai";
29
+ import type {
30
+ ExtensionAPI,
31
+ ExtensionCommandContext,
32
+ ExtensionContext,
33
+ ExtensionUIContext,
34
+ } from "@earendil-works/pi-coding-agent";
35
+ import {
36
+ CustomEditor,
37
+ DEFAULT_MAX_BYTES,
38
+ DEFAULT_MAX_LINES,
39
+ defineTool,
40
+ formatSize,
41
+ getAgentDir,
42
+ getMarkdownTheme,
43
+ keyHint,
44
+ truncateHead,
45
+ } from "@earendil-works/pi-coding-agent";
46
+ import { Markdown, Text } from "@earendil-works/pi-tui";
47
+ import { Type } from "typebox";
48
+ import {
49
+ formatAgentTypeDiagnostics,
50
+ loadAgentTypes,
51
+ roleModelForAgentType,
52
+ selectSubagentModel,
53
+ type AgentType,
54
+ } from "./src/agent-types.ts";
55
+ import { deriveBtwTitle, isModelVisible } from "./src/by-the-way.ts";
56
+ import {
57
+ BACKEND_NAMES,
58
+ formatElapsed,
59
+ latestText,
60
+ REASONING_EFFORTS,
61
+ type SubagentSnapshot,
62
+ } from "./src/domain.ts";
63
+ import {
64
+ formatActivityStatus,
65
+ hasActivity,
66
+ unreadActivityCounts,
67
+ } from "../shared/activity-status.ts";
68
+ import { formatContextUtilization } from "./src/format.ts";
69
+ import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
70
+ import {
71
+ buildSubagentResultMessage,
72
+ createAgentTypeParameterSchema,
73
+ buildSubagentSendResult,
74
+ buildSubagentSpawnResult,
75
+ buildSubagentSpawnToolDescription,
76
+ SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS,
77
+ SUBAGENT_CANCEL_TOOL_DESCRIPTION,
78
+ SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS,
79
+ SUBAGENT_CHECK_TOOL_DESCRIPTION,
80
+ SUBAGENT_LIST_TOOL_DESCRIPTION,
81
+ SUBAGENT_SEND_PARAMETER_DESCRIPTIONS,
82
+ SUBAGENT_SEND_TOOL_DESCRIPTION,
83
+ SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS,
84
+ SUBAGENT_SPAWN_PROMPT_GUIDELINES,
85
+ SUBAGENT_SPAWN_PROMPT_SNIPPET,
86
+ SUBAGENT_SPAWN_TOOL_DESCRIPTION,
87
+ SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS,
88
+ SUBAGENT_WAIT_TOOL_DESCRIPTION,
89
+ } from "./src/prompt.ts";
90
+ import { createDeferredResultDelivery } from "./src/result-delivery.ts";
91
+ import { resultDeliveryOptions } from "../background-terminals/src/result-delivery.ts";
92
+ import {
93
+ effectiveChildToolAllowlist,
94
+ resolveStandaloneChildProjectTrust,
95
+ } from "../shared/child-session.ts";
96
+ import {
97
+ BelowEditorNavigationEditor,
98
+ BelowEditorStripState,
99
+ } from "../shared/below-editor-navigation.ts";
100
+ import { loadSetupConfig } from "../shared/setup-config.ts";
101
+ import {
102
+ PLAN_MODE_CHANNEL,
103
+ planModeAllowsDeclaredTools,
104
+ planModeChildTools,
105
+ type PlanModeState,
106
+ } from "../shared/plan-mode-state.ts";
107
+ import {
108
+ createWorktree,
109
+ reclaimWorktree,
110
+ type Worktree,
111
+ } from "../shared/worktree.ts";
112
+ import {
113
+ createSubagentRuntime,
114
+ runTool,
115
+ type SubagentRuntime,
116
+ } from "./src/runtime.ts";
117
+ import {
118
+ normalizeSubagentTitle,
119
+ selectSubagentStripEntry,
120
+ SubagentStripWidget,
121
+ } from "./navigation.ts";
122
+ import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts";
123
+ import {
124
+ renderWaitResult,
125
+ type WaitResultDetails,
126
+ } from "./src/ui/wait-result.ts";
127
+
128
+ const SUBAGENT_OUTPUT_MAX_BYTES = 24 * 1024;
129
+ const WAIT_OUTPUT_MAX_BYTES = 48 * 1024;
130
+ const WAIT_PER_AGENT_MAX_BYTES = 16 * 1024;
131
+
132
+ interface SpawnResultDetails {
133
+ readonly id?: string;
134
+ readonly title?: string;
135
+ readonly harness?: string;
136
+ readonly model?: string;
137
+ readonly agentType?: string;
138
+ }
139
+
140
+ interface SubagentFinishedData {
141
+ readonly id: string;
142
+ readonly title: string;
143
+ readonly status: SubagentSnapshot["status"];
144
+ readonly elapsed: string;
145
+ }
146
+
147
+ interface BtwResultData {
148
+ readonly id: string;
149
+ readonly title: string;
150
+ readonly status: SubagentSnapshot["status"];
151
+ readonly errorText?: string;
152
+ readonly prompt: string;
153
+ readonly answer: string;
154
+ readonly sessionFilePath?: string;
155
+ }
156
+
157
+ function describeSubagent(snap: SubagentSnapshot) {
158
+ const details = [
159
+ `${snap.backend}: ${snap.meta.modelLabel ?? "?"}`,
160
+ formatContextUtilization(snap.usage),
161
+ formatElapsed(snap),
162
+ snap.cwd,
163
+ ].filter(Boolean);
164
+ return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`;
165
+ }
166
+
167
+ function truncatedOutput(
168
+ snap: SubagentSnapshot,
169
+ maxBytes = SUBAGENT_OUTPUT_MAX_BYTES,
170
+ ): string {
171
+ const output = snap.finalText || "(no output)";
172
+ const truncation = truncateHead(output, {
173
+ maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
174
+ maxLines: Math.min(600, DEFAULT_MAX_LINES),
175
+ });
176
+ let text = truncation.content;
177
+ if (truncation.truncated) {
178
+ text += `\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)} shown. Full transcript in session file: ${snap.meta.sessionFilePath ?? "?"}]`;
179
+ }
180
+ return text;
181
+ }
182
+
183
+ export default function (pi: ExtensionAPI) {
184
+ let runtime: SubagentRuntime | undefined;
185
+ let managerPromise: Promise<SubagentManagerShape> | undefined;
186
+ let sessionContext: ExtensionContext | undefined;
187
+ let ui: ExtensionUIContext | undefined;
188
+ let unsubStatus: (() => void) | undefined;
189
+ /**
190
+ * Settled subagents are an unread notice: the user's next explicit request
191
+ * acknowledges everything that had already finished.
192
+ */
193
+ let settledAcknowledgedAt = 0;
194
+ const stripState = new BelowEditorStripState();
195
+ const widgetKey = "subagent-navigation";
196
+ let navigationManager: SubagentManagerShape | undefined;
197
+ let widgetVisible = false;
198
+ let requestWidgetRender: (() => void) | undefined;
199
+ let dashboardOpen = false;
200
+ const resultDelivery = createDeferredResultDelivery<SubagentSnapshot>();
201
+
202
+ const getRuntime = () => (runtime ??= createSubagentRuntime());
203
+
204
+ /** Resolve the manager service once per runtime and wire the extension hooks. */
205
+ const getManager = () => {
206
+ managerPromise ??= getRuntime()
207
+ .runPromise(SubagentManager)
208
+ .then((manager) => {
209
+ navigationManager = manager;
210
+ manager.view.setOnSettled(onSettled);
211
+ unsubStatus?.();
212
+ unsubStatus = manager.view.subscribe(() => updateStatus(manager));
213
+ updateStatus(manager);
214
+ return manager;
215
+ });
216
+ return managerPromise;
217
+ };
218
+
219
+ const stripEntry = () =>
220
+ navigationManager
221
+ ? selectSubagentStripEntry(
222
+ navigationManager.view.list(),
223
+ settledAcknowledgedAt,
224
+ )
225
+ : undefined;
226
+
227
+ const updateSubagentWidget = () => {
228
+ const ctx = sessionContext;
229
+ if (!ctx || ctx.mode !== "tui") return;
230
+ const visible = Boolean(stripEntry());
231
+ if (visible === widgetVisible) return;
232
+ if (!visible) {
233
+ stripState.focused = false;
234
+ requestWidgetRender = undefined;
235
+ ctx.ui.setWidget(widgetKey, undefined);
236
+ widgetVisible = false;
237
+ return;
238
+ }
239
+ ctx.ui.setWidget(
240
+ widgetKey,
241
+ (tui, theme) => {
242
+ requestWidgetRender = () => tui.requestRender();
243
+ return new SubagentStripWidget(tui, theme, stripState, stripEntry);
244
+ },
245
+ { placement: "belowEditor" },
246
+ );
247
+ widgetVisible = true;
248
+ };
249
+
250
+ const updateStatus = (manager: SubagentManagerShape) => {
251
+ if (!ui) return;
252
+ const counts = unreadActivityCounts(
253
+ manager.view.list(),
254
+ settledAcknowledgedAt,
255
+ );
256
+ ui.setStatus(
257
+ "subagents",
258
+ hasActivity(counts)
259
+ ? formatActivityStatus(ui.theme, "subagents", counts)
260
+ : undefined,
261
+ );
262
+ updateSubagentWidget();
263
+ };
264
+
265
+ const openDashboard = async (ctx: ExtensionContext, initialId?: string) => {
266
+ if (dashboardOpen || ctx.mode !== "tui") return;
267
+ dashboardOpen = true;
268
+ stripState.focused = false;
269
+ let manager: SubagentManagerShape | undefined;
270
+ try {
271
+ manager = await getManager();
272
+ if (manager.view.size() === 0) return;
273
+ await openSubagentPicker(ctx, manager.view, initialId);
274
+ settledAcknowledgedAt = Date.now();
275
+ } finally {
276
+ dashboardOpen = false;
277
+ if (manager) updateStatus(manager);
278
+ }
279
+ };
280
+
281
+ const installSubagentNavigation = (ctx: ExtensionContext) => {
282
+ if (ctx.mode !== "tui") return;
283
+ const previous = ctx.ui.getEditorComponent();
284
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
285
+ const base =
286
+ previous?.(tui, theme, keybindings) ??
287
+ new CustomEditor(tui, theme, keybindings);
288
+ return new BelowEditorNavigationEditor(
289
+ base,
290
+ keybindings,
291
+ stripState,
292
+ () => Boolean(stripEntry()),
293
+ () => {
294
+ const entry = stripEntry();
295
+ if (entry) void openDashboard(ctx, entry.snapshot.id);
296
+ },
297
+ () => {
298
+ requestWidgetRender?.();
299
+ tui.requestRender();
300
+ },
301
+ );
302
+ });
303
+ };
304
+
305
+ /**
306
+ * `wake` decides whether this costs the model a turn. A subagent that
307
+ * settled while the model sits idle is the result it is waiting on. A
308
+ * backlog that piled up while it worked is not: waking once per stale
309
+ * subagent forces a turn each, and the model can only answer "that one
310
+ * already finished". `nextTurn` still enters context with the user's next
311
+ * message, without demanding a reply.
312
+ */
313
+ const deliverResults = (
314
+ snaps: readonly SubagentSnapshot[],
315
+ wake: boolean,
316
+ ) => {
317
+ if (snaps.length === 0) return;
318
+ pi.sendMessage(
319
+ {
320
+ customType: "subagent-result",
321
+ // One message per flush, not per subagent.
322
+ content: snaps
323
+ .map((snap) =>
324
+ buildSubagentResultMessage({
325
+ id: snap.id,
326
+ title: snap.title,
327
+ status: snap.status,
328
+ errorText: snap.errorText,
329
+ output: truncatedOutput(snap),
330
+ }),
331
+ )
332
+ .join("\n\n"),
333
+ display: true,
334
+ details:
335
+ snaps.length === 1
336
+ ? {
337
+ id: snaps[0]!.id,
338
+ title: snaps[0]!.title,
339
+ status: snaps[0]!.status,
340
+ }
341
+ : {
342
+ count: snaps.length,
343
+ results: snaps.map((snap) => ({
344
+ id: snap.id,
345
+ title: snap.title,
346
+ status: snap.status,
347
+ })),
348
+ },
349
+ },
350
+ resultDeliveryOptions(wake),
351
+ );
352
+ };
353
+
354
+ const flushResults = (wake: boolean) => {
355
+ deliverResults(resultDelivery.drain(), wake);
356
+ };
357
+
358
+ const deliverBtwResult = (snap: SubagentSnapshot) => {
359
+ // appendEntry is a synchronous SessionManager operation and emits an
360
+ // entry_appended event, so it is safe while the parent is streaming and
361
+ // never enters the model's context or follow-up queue.
362
+ pi.appendEntry<BtwResultData>("btw-result", {
363
+ id: snap.id,
364
+ title: snap.title,
365
+ status: snap.status,
366
+ errorText: snap.errorText,
367
+ prompt: snap.prompt,
368
+ answer: truncatedOutput(snap),
369
+ sessionFilePath: snap.meta.sessionFilePath,
370
+ });
371
+ ui?.notify(
372
+ snap.status === "error"
373
+ ? `by the way “${snap.title}” failed — reopen it with /subagents`
374
+ : `by the way “${snap.title}” answered — reopen it with /subagents`,
375
+ snap.status === "error" ? "error" : "info",
376
+ );
377
+ };
378
+
379
+ const onSettled = (snap: SubagentSnapshot, consumed: boolean) => {
380
+ // A shutdown can settle children while disposing their scopes. Never
381
+ // append into a session whose extension runtime is already closing.
382
+ if (!sessionContext) return;
383
+ if (snap.origin === "btw") {
384
+ deliverBtwResult({ ...snap, meta: { ...snap.meta } });
385
+ return;
386
+ }
387
+ // Mark the finish in the transcript. The result itself reaches the model
388
+ // separately; this line is for the reader watching the run.
389
+ pi.appendEntry<SubagentFinishedData>("subagent-finished", {
390
+ id: snap.id,
391
+ title: snap.title,
392
+ status: snap.status,
393
+ elapsed: formatElapsed(snap),
394
+ });
395
+ if (consumed) {
396
+ resultDelivery.consume([snap.id]);
397
+ return;
398
+ }
399
+ // Keep the result retractable while the parent is working. A later
400
+ // subagent_wait can consume it before agent_settled flushes follow-ups.
401
+ // Defer a copy: the live snapshot keeps mutating if the subagent is
402
+ // restarted before the deferred result flushes.
403
+ resultDelivery.defer({ ...snap, meta: { ...snap.meta } });
404
+ // Settled while the model sits idle: it has nothing else in flight, so
405
+ // this is the result it is waiting on — wake it.
406
+ if (sessionContext?.isIdle()) flushResults(true);
407
+ };
408
+
409
+ pi.on("session_start", (_event, ctx) => {
410
+ refreshAgentTypes(ctx.cwd, ctx.isProjectTrusted());
411
+ sessionContext = ctx;
412
+ settledAcknowledgedAt = 0;
413
+ if (ctx.hasUI) ui = ctx.ui;
414
+ installSubagentNavigation(ctx);
415
+ updateSubagentWidget();
416
+ // A malformed agent type is silently missing from the roster otherwise, so
417
+ // report it once. Never fatal: the rest still loaded. Non-UI modes receive
418
+ // stderr rather than a model-context message.
419
+ const notice = formatAgentTypeDiagnostics(agentTypeDiagnostics);
420
+ if (notice && ctx.hasUI) ctx.ui.notify(notice, "warning");
421
+ else if (notice) process.stderr.write(`${notice}\n`);
422
+ });
423
+
424
+ // A new explicit request starts a fresh unread window: previously finished
425
+ // subagents stop being reported, running ones keep reporting.
426
+ pi.on("input", (event) => {
427
+ if (event.source === "extension") return;
428
+ settledAcknowledgedAt = Date.now();
429
+ managerPromise?.then(updateStatus).catch(() => undefined);
430
+ });
431
+
432
+ // These settled while the model was working on something else, so they go
433
+ // into context without forcing a turn per stale subagent.
434
+ pi.on("agent_settled", () => flushResults(false));
435
+
436
+ pi.on("session_shutdown", async () => {
437
+ resultDelivery.clear();
438
+ unsubStatus?.();
439
+ unsubStatus = undefined;
440
+ try {
441
+ ui?.setStatus("subagents", undefined);
442
+ sessionContext?.ui.setWidget(widgetKey, undefined);
443
+ } catch {
444
+ // UI may already be disposed.
445
+ }
446
+ sessionContext = undefined;
447
+ ui = undefined;
448
+ navigationManager = undefined;
449
+ widgetVisible = false;
450
+ requestWidgetRender = undefined;
451
+ stripState.focused = false;
452
+ dashboardOpen = false;
453
+ const closing = runtime;
454
+ runtime = undefined;
455
+ managerPromise = undefined;
456
+ // Disposing the runtime runs the manager finalizer, which tears down all
457
+ // subagent scopes (and, later, their real child processes).
458
+ await closing?.dispose();
459
+ });
460
+
461
+ // --- Agent types ---------------------------------------------------------
462
+
463
+ /**
464
+ * Register a safe initial roster before Pi gives us a session context. It
465
+ * includes only built-ins and global types; session_start immediately
466
+ * refreshes it with ctx.cwd and ctx.isProjectTrusted(), including temporary
467
+ * trust decisions and cross-cwd session replacements.
468
+ */
469
+ let { agentTypes, diagnostics: agentTypeDiagnostics } = loadAgentTypes({
470
+ agentDir: getAgentDir(),
471
+ cwd: process.cwd(),
472
+ projectTrusted: false,
473
+ });
474
+ let agentTypeList = [...agentTypes.values()];
475
+ /**
476
+ * Disambiguates worktree directory/branch names. The subagent id is only
477
+ * assigned inside the manager, after the worktree already has to exist, and
478
+ * two children with the same title would otherwise collide on the branch.
479
+ */
480
+ let worktreeCounter = 0;
481
+
482
+ /**
483
+ * Mirror of the session's `/plan` stance, published by plan-mode. Kept here
484
+ * rather than queried because spawning must not depend on that extension
485
+ * being loaded: absent it, this stays false and behaviour is unchanged.
486
+ */
487
+ let planning = false;
488
+ pi.events.on(PLAN_MODE_CHANNEL, (state: unknown) => {
489
+ planning =
490
+ typeof state === "object" &&
491
+ state !== null &&
492
+ (state as PlanModeState).planning === true;
493
+ });
494
+ /**
495
+ * Session trust is not just persisted trust: Pi may grant it for this
496
+ * session only. Re-registering refreshes both the agent_type enum and its
497
+ * model-facing roster before the parent can call subagent_spawn.
498
+ */
499
+ const refreshAgentTypes = (cwd: string, projectTrusted: boolean) => {
500
+ const loaded = loadAgentTypes({
501
+ agentDir: getAgentDir(),
502
+ cwd,
503
+ projectTrusted,
504
+ });
505
+ agentTypes = loaded.agentTypes;
506
+ agentTypeDiagnostics = loaded.diagnostics;
507
+ agentTypeList = [...agentTypes.values()];
508
+ subagentSpawnTool.description =
509
+ buildSubagentSpawnToolDescription(agentTypeList);
510
+ subagentSpawnTool.parameters = createSubagentSpawnParameters();
511
+ registerSubagentSpawnTool();
512
+ };
513
+
514
+ // --- Tools -------------------------------------------------------------
515
+
516
+ const createSubagentSpawnParameters = () =>
517
+ Type.Object({
518
+ agent_type: createAgentTypeParameterSchema(agentTypeList),
519
+ prompt: Type.String({
520
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.prompt,
521
+ }),
522
+ name: Type.String({
523
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.name,
524
+ }),
525
+ harness: Type.Optional(
526
+ StringEnum(BACKEND_NAMES, {
527
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.harness,
528
+ }),
529
+ ),
530
+ working_dir: Type.Optional(
531
+ Type.String({
532
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.workingDir,
533
+ }),
534
+ ),
535
+ isolation: Type.Optional(
536
+ StringEnum(["worktree"] as const, {
537
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.isolation,
538
+ }),
539
+ ),
540
+ model: Type.Optional(
541
+ Type.String({
542
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.model,
543
+ }),
544
+ ),
545
+ reasoning_effort: Type.Optional(
546
+ StringEnum(REASONING_EFFORTS, {
547
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort,
548
+ }),
549
+ ),
550
+ });
551
+
552
+ const subagentSpawnTool = defineTool({
553
+ name: "subagent_spawn",
554
+ label: "Spawn Subagent",
555
+ description: buildSubagentSpawnToolDescription(agentTypeList),
556
+ promptSnippet: SUBAGENT_SPAWN_PROMPT_SNIPPET,
557
+ promptGuidelines: SUBAGENT_SPAWN_PROMPT_GUIDELINES,
558
+ parameters: createSubagentSpawnParameters(),
559
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
560
+ // Only one backend exists; harness is optional and defaults to it.
561
+ const harness = params.harness ?? BACKEND_NAMES[0];
562
+
563
+ const cwd = path.resolve(ctx.cwd, params.working_dir ?? ".");
564
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
565
+ throw new Error(`working_dir is not a directory: ${cwd}`);
566
+ }
567
+
568
+ // The enum always includes built-in roles and any loaded overrides.
569
+ const requestedType = (params as { agent_type?: string }).agent_type;
570
+ const agentType = requestedType
571
+ ? agentTypes.get(requestedType)
572
+ : undefined;
573
+
574
+ const title = normalizeSubagentTitle(params.name);
575
+ const declaredChildTools = effectiveChildToolAllowlist(agentType?.tools);
576
+
577
+ // A worktree creation mutates git metadata, so reject before attempting
578
+ // it. Plan-mode children are investigation-only and never need one.
579
+ if (planning && params.isolation === "worktree") {
580
+ throw new Error(
581
+ 'Plan mode is active: isolation: "worktree" creates a checkout and is unavailable while planning. Omit isolation and use an explorer, reviewer, advisor, or no incompatible agent type for read-only investigation.',
582
+ );
583
+ }
584
+ if (
585
+ planning &&
586
+ agentType &&
587
+ !planModeAllowsDeclaredTools(declaredChildTools)
588
+ ) {
589
+ throw new Error(
590
+ `Plan mode is active: agent type "${agentType.name}" would be narrowed to capabilities that contradict its unchanged prompt. Use explorer, reviewer, advisor, or omit agent_type for read-only investigation.`,
591
+ );
592
+ }
593
+
594
+ /**
595
+ * Isolation is requested, not best-effort: a caller asks for a worktree
596
+ * precisely because a shared checkout would let this child collide with
597
+ * its siblings, so quietly falling back to `cwd` would deliver the
598
+ * hazard they were avoiding. Fail loudly with git's own reason instead.
599
+ */
600
+ let worktree: Worktree | undefined;
601
+ if (params.isolation === "worktree") {
602
+ const created = await createWorktree({
603
+ cwd,
604
+ label: title,
605
+ id: String(++worktreeCounter),
606
+ });
607
+ if (!created.ok) {
608
+ throw new Error(
609
+ `isolation: "worktree" requested but could not be created (${created.reason}). Omit isolation to run in ${cwd}.`,
610
+ );
611
+ }
612
+ worktree = created.worktree;
613
+ }
614
+ const childCwd = worktree?.path ?? cwd;
615
+
616
+ /**
617
+ * Trust follows the directory the worktree was branched from, not the
618
+ * worktree path. It is the same project at the same commit, so a live
619
+ * "trusted" decision that was never persisted must not be downgraded
620
+ * just because the checkout moved into `.git/`.
621
+ */
622
+ const projectTrusted = resolveStandaloneChildProjectTrust({
623
+ parentCwd: ctx.cwd,
624
+ childCwd: cwd,
625
+ parentTrusted: ctx.isProjectTrusted(),
626
+ });
627
+
628
+ // Planning narrows the child to investigation tools. This is what makes
629
+ // delegation safe during `/plan`: the allowlist is enforced by the
630
+ // harness, so the child has no write/edit/bash to call, where a
631
+ // tool_call handler in this session could never have reached it.
632
+ const requestedChildTools = planning
633
+ ? planModeChildTools(declaredChildTools)
634
+ : declaredChildTools;
635
+ const childTools = effectiveChildToolAllowlist(requestedChildTools);
636
+ // Read at spawn time so `/my-pi-setup` changes affect the next child
637
+ // without reloading this extension. Undefined preserves parent-model
638
+ // inheritance in the backend.
639
+ const model = selectSubagentModel(
640
+ params.model,
641
+ agentType,
642
+ roleModelForAgentType(
643
+ agentType,
644
+ loadSetupConfig().subagents.roleModels,
645
+ ),
646
+ );
647
+
648
+ const manager = await getManager();
649
+ const spawn = manager.spawn(harness, {
650
+ prompt: params.prompt,
651
+ title,
652
+ cwd: childCwd,
653
+ // Explicit spawn > role file > package role assignment > parent.
654
+ model,
655
+ reasoningEffort: params.reasoning_effort ?? agentType?.reasoningEffort,
656
+ ...(agentType?.body ? { appendSystemPrompt: [agentType.body] } : {}),
657
+ ...(childTools ? { tools: childTools } : {}),
658
+ ...(agentType ? { agentTypeName: agentType.name } : {}),
659
+ ...(worktree ? { worktree: { ...worktree, repoCwd: cwd } } : {}),
660
+ parent: {
661
+ parentCwd: ctx.cwd,
662
+ projectTrusted,
663
+ inheritedModel: ctx.model
664
+ ? { provider: ctx.model.provider, id: ctx.model.id }
665
+ : undefined,
666
+ inheritedThinkingLevel: pi.getThinkingLevel(),
667
+ modelRegistry: ctx.modelRegistry,
668
+ },
669
+ });
670
+
671
+ let snap;
672
+ try {
673
+ snap = await runTool(getRuntime(), spawn, {
674
+ signal,
675
+ interruptMessage: "Subagent spawn aborted.",
676
+ });
677
+ } catch (error) {
678
+ // The session scope owns reclamation, but it never opened, so this
679
+ // worktree would otherwise be orphaned on disk.
680
+ if (worktree) await reclaimWorktree(cwd, worktree).catch(() => {});
681
+ throw error;
682
+ }
683
+
684
+ return {
685
+ content: [
686
+ {
687
+ type: "text",
688
+ text: buildSubagentSpawnResult({
689
+ id: snap.id,
690
+ title: snap.title,
691
+ harness,
692
+ modelLabel: snap.meta.modelLabel ?? "?",
693
+ cwd: childCwd,
694
+ ...(worktree ? { worktreeBranch: worktree.branch } : {}),
695
+ ...(agentType ? { agentTypeName: agentType.name } : {}),
696
+ ...(childTools ? { tools: childTools } : {}),
697
+ }),
698
+ },
699
+ ],
700
+ details: {
701
+ id: snap.id,
702
+ title: snap.title,
703
+ cwd: childCwd,
704
+ harness,
705
+ model: snap.meta.modelLabel,
706
+ ...(agentType ? { agentType: agentType.name } : {}),
707
+ },
708
+ };
709
+ },
710
+ renderCall() {
711
+ // The result line already names the agent; a bare tool header adds nothing.
712
+ return new Text("");
713
+ },
714
+ renderResult(result, _options, theme) {
715
+ const details = result.details as SpawnResultDetails | undefined;
716
+ if (!details?.id) {
717
+ const first = result.content[0];
718
+ return new Text(
719
+ first?.type === "text" ? first.text : "(no output)",
720
+ 0,
721
+ 0,
722
+ );
723
+ }
724
+ const meta = [details.harness, details.model]
725
+ .filter(Boolean)
726
+ .join(" \u00b7 ");
727
+ return new Text(
728
+ `${theme.fg("success", "\u25cf")} ${theme.bold(details.title ?? details.id)} ${theme.fg("dim", meta)}`,
729
+ 0,
730
+ 0,
731
+ );
732
+ },
733
+ });
734
+
735
+ const registerSubagentSpawnTool = () => pi.registerTool(subagentSpawnTool);
736
+ registerSubagentSpawnTool();
737
+
738
+ pi.registerTool({
739
+ name: "subagent_wait",
740
+ label: "Wait for Subagents",
741
+ description: SUBAGENT_WAIT_TOOL_DESCRIPTION,
742
+ parameters: Type.Object({
743
+ ids: Type.Array(Type.String(), {
744
+ maxItems: 64,
745
+ description: SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS.ids,
746
+ }),
747
+ }),
748
+ async execute(_toolCallId, params, signal, onUpdate) {
749
+ const manager = await getManager();
750
+ const ids = [...new Set(params.ids)];
751
+ if (ids.length === 0)
752
+ throw new Error("Provide at least one subagent id.");
753
+ const known = manager.view
754
+ .list()
755
+ .filter(isModelVisible)
756
+ .map((snap) => snap.id);
757
+ const unknown = ids.filter((id) => {
758
+ const snap = manager.view.get(id);
759
+ return !snap || !isModelVisible(snap);
760
+ });
761
+ if (unknown.length > 0) {
762
+ throw new Error(
763
+ `Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`,
764
+ );
765
+ }
766
+
767
+ await runTool(
768
+ getRuntime(),
769
+ manager.waitFor(ids, (pending) => {
770
+ onUpdate?.({
771
+ content: [
772
+ { type: "text", text: `Waiting for ${pending.join(", ")}...` },
773
+ ],
774
+ details: { pending },
775
+ });
776
+ }),
777
+ { signal, interruptMessage: "Wait aborted. Subagents keep running." },
778
+ );
779
+
780
+ // Settlement may have happened before this wait began. Remove any
781
+ // deferred automatic delivery now that the tool is returning the result.
782
+ resultDelivery.consume(ids);
783
+
784
+ const sections: string[] = [];
785
+ let remainingBytes = WAIT_OUTPUT_MAX_BYTES;
786
+ for (const id of ids) {
787
+ const snap = manager.view.get(id);
788
+ if (!snap) {
789
+ sections.push(`## ${id}\n\n(no longer tracked)`);
790
+ continue;
791
+ }
792
+ const verb = snap.status === "error" ? "failed" : "finished";
793
+ let section = `## ${snap.id} "${snap.title}" ${verb}`;
794
+ if (snap.errorText) section += `\nError: ${snap.errorText}`;
795
+ const headerBytes = Buffer.byteLength(section, "utf8") + 2;
796
+ const outputBudget = Math.max(
797
+ 512,
798
+ Math.min(WAIT_PER_AGENT_MAX_BYTES, remainingBytes - headerBytes),
799
+ );
800
+ section += `\n\n${truncatedOutput(snap, outputBudget)}`;
801
+ const sectionBytes = Buffer.byteLength(section, "utf8");
802
+ if (sectionBytes > remainingBytes) {
803
+ sections.push(
804
+ `## ${snap.id} "${snap.title}"\n\n[omitted: total wait output limit reached]`,
805
+ );
806
+ break;
807
+ }
808
+ sections.push(section);
809
+ remainingBytes -= sectionBytes;
810
+ }
811
+
812
+ const combined = sections.join("\n\n---\n\n");
813
+ const bounded = truncateHead(combined, {
814
+ maxBytes: WAIT_OUTPUT_MAX_BYTES - 128,
815
+ maxLines: DEFAULT_MAX_LINES,
816
+ });
817
+ const text = bounded.truncated
818
+ ? `${bounded.content}\n\n[wait output truncated at the total output limit]`
819
+ : bounded.content;
820
+ return {
821
+ content: [{ type: "text", text }],
822
+ details: {
823
+ results: ids.map((id) => {
824
+ const snap = manager.view.get(id);
825
+ return { id, title: snap?.title, status: snap?.status };
826
+ }),
827
+ },
828
+ };
829
+ },
830
+ renderResult(result, { expanded, isPartial }, theme) {
831
+ const first = result.content[0];
832
+ const content = first?.type === "text" ? first.text : "(no output)";
833
+ if (isPartial) {
834
+ const pending = (result.details as { pending?: string[] } | undefined)
835
+ ?.pending?.length;
836
+ return new Text(
837
+ theme.fg(
838
+ "warning",
839
+ pending
840
+ ? `\u273b Waiting for ${pending} subagent${pending === 1 ? "" : "s"} to finish`
841
+ : content,
842
+ ),
843
+ 0,
844
+ 0,
845
+ );
846
+ }
847
+ return renderWaitResult(
848
+ content,
849
+ result.details as WaitResultDetails | undefined,
850
+ expanded || loadSetupConfig().ui.subagentResultDisplay === "full",
851
+ theme,
852
+ );
853
+ },
854
+ });
855
+
856
+ pi.registerTool({
857
+ name: "subagent_cancel",
858
+ label: "Cancel Subagents",
859
+ description: SUBAGENT_CANCEL_TOOL_DESCRIPTION,
860
+ parameters: Type.Object({
861
+ ids: Type.Array(Type.String(), {
862
+ description: SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS.ids,
863
+ }),
864
+ }),
865
+ async execute(_toolCallId, params, signal) {
866
+ const manager = await getManager();
867
+ const ids = [...new Set(params.ids)];
868
+ if (ids.length === 0)
869
+ throw new Error("Provide at least one subagent id.");
870
+
871
+ const known = manager.view
872
+ .list()
873
+ .filter(isModelVisible)
874
+ .map((snap) => snap.id);
875
+ const unknown = ids.filter((id) => {
876
+ const snap = manager.view.get(id);
877
+ return !snap || !isModelVisible(snap);
878
+ });
879
+ if (unknown.length > 0) {
880
+ throw new Error(
881
+ `Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`,
882
+ );
883
+ }
884
+
885
+ const report = await runTool(getRuntime(), manager.cancel(ids), {
886
+ signal,
887
+ interruptMessage: "Subagent cancellation aborted.",
888
+ });
889
+
890
+ const lines = report.map((entry) =>
891
+ entry.cancelled
892
+ ? `Cancelled ${entry.id} "${entry.title}".`
893
+ : `${entry.id} "${entry.title}" was already ${entry.status}.`,
894
+ );
895
+
896
+ return {
897
+ content: [{ type: "text", text: lines.join("\n") }],
898
+ details: {
899
+ results: report.map((entry) => ({
900
+ id: entry.id,
901
+ title: entry.title,
902
+ status: entry.status,
903
+ })),
904
+ },
905
+ };
906
+ },
907
+ });
908
+
909
+ pi.registerTool({
910
+ name: "subagent_send",
911
+ label: "Send to Subagent",
912
+ description: SUBAGENT_SEND_TOOL_DESCRIPTION,
913
+ parameters: Type.Object({
914
+ id: Type.String({
915
+ description: SUBAGENT_SEND_PARAMETER_DESCRIPTIONS.id,
916
+ }),
917
+ text: Type.String({
918
+ description: SUBAGENT_SEND_PARAMETER_DESCRIPTIONS.text,
919
+ }),
920
+ }),
921
+ async execute(_toolCallId, params, signal) {
922
+ const manager = await getManager();
923
+ const snap = manager.view.get(params.id);
924
+ if (!snap || !isModelVisible(snap)) {
925
+ const known = manager.view
926
+ .list()
927
+ .filter(isModelVisible)
928
+ .map((s) => s.id);
929
+ throw new Error(
930
+ `Unknown subagent id "${params.id}". Known: ${known.join(", ") || "none"}.`,
931
+ );
932
+ }
933
+ const text = params.text.trim();
934
+ if (!text) throw new Error("Provide a non-empty message.");
935
+
936
+ // Captured before the send: a settled subagent restarts, a running one
937
+ // is steered, and the result message must say which happened.
938
+ const wasRunning = snap.status === "running";
939
+ await runTool(getRuntime(), manager.send(params.id, text), {
940
+ signal,
941
+ interruptMessage: "Subagent send aborted.",
942
+ });
943
+ // A settled subagent may already have an undelivered result buffered;
944
+ // the restart supersedes it, so drop it and let the new run deliver.
945
+ resultDelivery.consume([params.id]);
946
+
947
+ return {
948
+ content: [
949
+ {
950
+ type: "text",
951
+ text: buildSubagentSendResult({
952
+ id: snap.id,
953
+ title: snap.title,
954
+ wasRunning,
955
+ }),
956
+ },
957
+ ],
958
+ details: {
959
+ id: snap.id,
960
+ title: snap.title,
961
+ status: manager.view.get(params.id)?.status ?? snap.status,
962
+ },
963
+ };
964
+ },
965
+ });
966
+
967
+ pi.registerTool({
968
+ name: "subagent_check",
969
+ label: "Check Subagent",
970
+ description: SUBAGENT_CHECK_TOOL_DESCRIPTION,
971
+ parameters: Type.Object({
972
+ id: Type.String({
973
+ description: SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS.id,
974
+ }),
975
+ }),
976
+ async execute(_toolCallId, params) {
977
+ const manager = await getManager();
978
+ const snap = manager.view.get(params.id);
979
+ if (!snap || !isModelVisible(snap)) {
980
+ const known = manager.view
981
+ .list()
982
+ .filter(isModelVisible)
983
+ .map((s) => s.id);
984
+ throw new Error(
985
+ `Unknown subagent id "${params.id}". Known: ${known.join(", ") || "none"}.`,
986
+ );
987
+ }
988
+
989
+ let text = `${describeSubagent(snap)}\nTurns: ${snap.turns}`;
990
+ if (snap.errorText) text += `\nError: ${snap.errorText}`;
991
+
992
+ const output = latestText(snap);
993
+ if (output) {
994
+ const preview = truncateHead(output, { maxBytes: 2048, maxLines: 20 });
995
+ text += `\n\nLatest output:\n${preview.content}`;
996
+ if (preview.truncated) text += "\n[...]";
997
+ } else if (snap.status === "running") {
998
+ text += "\n\n(no text output yet)";
999
+ }
1000
+
1001
+ return {
1002
+ content: [{ type: "text", text }],
1003
+ details: { id: snap.id, status: snap.status, turns: snap.turns },
1004
+ };
1005
+ },
1006
+ });
1007
+
1008
+ pi.registerTool({
1009
+ name: "subagent_list",
1010
+ label: "List Subagents",
1011
+ description: SUBAGENT_LIST_TOOL_DESCRIPTION,
1012
+ parameters: Type.Object({}),
1013
+ async execute() {
1014
+ const manager = await getManager();
1015
+ const subs = manager.view.list().filter(isModelVisible);
1016
+ const text =
1017
+ subs.length === 0
1018
+ ? "No subagents."
1019
+ : subs.map((snap) => describeSubagent(snap)).join("\n");
1020
+ return {
1021
+ content: [{ type: "text", text }],
1022
+ details: {
1023
+ subagents: subs.map((snap) => ({
1024
+ id: snap.id,
1025
+ title: snap.title,
1026
+ harness: snap.backend,
1027
+ status: snap.status,
1028
+ })),
1029
+ },
1030
+ };
1031
+ },
1032
+ });
1033
+
1034
+ // --- Result message rendering ------------------------------------------
1035
+
1036
+ pi.registerMessageRenderer(
1037
+ "subagent-result",
1038
+ (message, { expanded }, theme) => {
1039
+ const details = (message.details ?? {}) as {
1040
+ id?: string;
1041
+ title?: string;
1042
+ status?: string;
1043
+ };
1044
+ const failed = details.status === "error";
1045
+ const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
1046
+ const header =
1047
+ `${icon} ` +
1048
+ theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
1049
+ theme.fg(
1050
+ "muted",
1051
+ ` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`,
1052
+ );
1053
+
1054
+ const content =
1055
+ typeof message.content === "string" ? message.content : "";
1056
+ // Remove only the summary line. The following Error line (when present)
1057
+ // is part of the actual result and must remain visible.
1058
+ const body = content.split("\n").slice(1).join("\n").trim();
1059
+
1060
+ if (expanded || loadSetupConfig().ui.subagentResultDisplay === "full") {
1061
+ const md = new Markdown(`${body}`, 0, 0, getMarkdownTheme());
1062
+ const container = new Text(header, 0, 0);
1063
+ return {
1064
+ render: (width: number) => [
1065
+ ...container.render(width),
1066
+ ...md.render(width),
1067
+ ],
1068
+ invalidate: () => {
1069
+ container.invalidate();
1070
+ md.invalidate();
1071
+ },
1072
+ };
1073
+ }
1074
+
1075
+ const previewLines = body.split("\n").slice(0, 8);
1076
+ let text = header;
1077
+ for (const line of previewLines)
1078
+ text += `\n${theme.fg("toolOutput", line)}`;
1079
+ if (body.split("\n").length > 8)
1080
+ text += `\n${theme.fg("dim", `... (${keyHint("app.tools.expand", "to expand")})`)}`;
1081
+ return new Text(text, 0, 0);
1082
+ },
1083
+ );
1084
+
1085
+ pi.registerEntryRenderer<SubagentFinishedData>(
1086
+ "subagent-finished",
1087
+ (entry, _options, theme) => {
1088
+ const data = entry.data;
1089
+ const failed = data?.status === "error";
1090
+ return new Text(
1091
+ `${theme.fg(failed ? "error" : "success", "\u25cf")} ` +
1092
+ theme.fg(
1093
+ "muted",
1094
+ `Agent "${data?.title ?? "?"}" ${failed ? "failed" : "finished"} \u00b7 ${data?.elapsed ?? "?"}`,
1095
+ ),
1096
+ 1,
1097
+ 0,
1098
+ );
1099
+ },
1100
+ );
1101
+
1102
+ pi.registerEntryRenderer<BtwResultData>(
1103
+ "btw-result",
1104
+ (entry, { expanded }, theme) => {
1105
+ const data = entry.data;
1106
+ const failed = data?.status === "error";
1107
+ const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
1108
+ const header =
1109
+ `${icon} ` +
1110
+ theme.fg("accent", theme.bold(`by the way · ${data?.title ?? "?"}`)) +
1111
+ theme.fg(
1112
+ "muted",
1113
+ ` · ${failed ? "failed" : "answered"} · ${data?.id ?? "?"}`,
1114
+ );
1115
+ const body = [
1116
+ data?.errorText ? `Error: ${data.errorText}` : "",
1117
+ data?.answer ?? "(no answer)",
1118
+ ]
1119
+ .filter(Boolean)
1120
+ .join("\n\n");
1121
+
1122
+ if (expanded || loadSetupConfig().ui.subagentResultDisplay === "full") {
1123
+ const md = new Markdown(body, 0, 0, getMarkdownTheme());
1124
+ const container = new Text(header, 0, 0);
1125
+ return {
1126
+ render: (width: number) => [
1127
+ ...container.render(width),
1128
+ ...md.render(width),
1129
+ ],
1130
+ invalidate: () => {
1131
+ container.invalidate();
1132
+ md.invalidate();
1133
+ },
1134
+ };
1135
+ }
1136
+
1137
+ const lines = body.split("\n");
1138
+ let text = header;
1139
+ for (const line of lines.slice(0, 8))
1140
+ text += `\n${theme.fg("toolOutput", line)}`;
1141
+ if (lines.length > 8)
1142
+ text += `\n${theme.fg("dim", `... (${keyHint("app.tools.expand", "to expand")})`)}`;
1143
+ return new Text(text, 0, 0);
1144
+ },
1145
+ );
1146
+
1147
+ // --- Commands -----------------------------------------------------------
1148
+
1149
+ const runByTheWay = async (rawArgs: string, ctx: ExtensionCommandContext) => {
1150
+ if (ctx.mode !== "tui") {
1151
+ if (ctx.hasUI)
1152
+ ctx.ui.notify("by the way is only available in the TUI", "error");
1153
+ return;
1154
+ }
1155
+
1156
+ let prompt = rawArgs.trim();
1157
+ if (!prompt) {
1158
+ const input = await ctx.ui.input("by the way", "Ask a one-off question…");
1159
+ prompt = input?.trim() ?? "";
1160
+ if (!prompt) return;
1161
+ }
1162
+
1163
+ const manager = await getManager();
1164
+ let snap: SubagentSnapshot;
1165
+ try {
1166
+ snap = await runTool(
1167
+ getRuntime(),
1168
+ manager.spawn("pi", {
1169
+ origin: "btw",
1170
+ prompt,
1171
+ title: normalizeSubagentTitle(deriveBtwTitle(prompt), "by the way"),
1172
+ cwd: ctx.cwd,
1173
+ parent: {
1174
+ parentCwd: ctx.cwd,
1175
+ projectTrusted: ctx.isProjectTrusted(),
1176
+ inheritedModel: ctx.model
1177
+ ? { provider: ctx.model.provider, id: ctx.model.id }
1178
+ : undefined,
1179
+ inheritedThinkingLevel: pi.getThinkingLevel(),
1180
+ modelRegistry: ctx.modelRegistry,
1181
+ },
1182
+ }),
1183
+ );
1184
+ } catch (error) {
1185
+ ctx.ui.notify(
1186
+ error instanceof Error ? error.message : String(error),
1187
+ "error",
1188
+ );
1189
+ return;
1190
+ }
1191
+
1192
+ await openSubagentTakeover(ctx, manager.view, snap.id, {
1193
+ badge: "by the way",
1194
+ });
1195
+ };
1196
+
1197
+ pi.registerCommand("btw", {
1198
+ description:
1199
+ "Ask a one-off side question while the main agent keeps working",
1200
+ handler: runByTheWay,
1201
+ });
1202
+
1203
+ pi.registerCommand("subagents", {
1204
+ description: "List, inspect, and take over subagents",
1205
+ handler: async (_args, ctx) => {
1206
+ if (ctx.mode !== "tui") {
1207
+ if (ctx.hasUI)
1208
+ ctx.ui.notify(
1209
+ "Subagent takeover is only available in the TUI",
1210
+ "error",
1211
+ );
1212
+ return;
1213
+ }
1214
+ const manager = await getManager();
1215
+ if (manager.view.size() === 0) {
1216
+ ctx.ui.notify(
1217
+ "No subagents yet. The agent spawns them with subagent_spawn.",
1218
+ "info",
1219
+ );
1220
+ return;
1221
+ }
1222
+ await openDashboard(ctx);
1223
+ },
1224
+ });
1225
+ }