@diousk/pi-subagents-fast 0.20.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 (183) hide show
  1. package/CHANGELOG.md +808 -0
  2. package/CONTRIBUTING.md +72 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1034 -0
  5. package/SECURITY.md +95 -0
  6. package/dist/abortable.d.ts +12 -0
  7. package/dist/abortable.js +42 -0
  8. package/dist/agent-color.d.ts +35 -0
  9. package/dist/agent-color.js +123 -0
  10. package/dist/agent-file-toggle.d.ts +125 -0
  11. package/dist/agent-file-toggle.js +260 -0
  12. package/dist/agent-manager.d.ts +472 -0
  13. package/dist/agent-manager.js +1338 -0
  14. package/dist/agent-runner.d.ts +312 -0
  15. package/dist/agent-runner.js +1034 -0
  16. package/dist/agent-types.d.ts +119 -0
  17. package/dist/agent-types.js +286 -0
  18. package/dist/child-context.d.ts +2 -0
  19. package/dist/child-context.js +12 -0
  20. package/dist/context.d.ts +12 -0
  21. package/dist/context.js +56 -0
  22. package/dist/cross-extension-rpc.d.ts +66 -0
  23. package/dist/cross-extension-rpc.js +138 -0
  24. package/dist/custom-agents.d.ts +54 -0
  25. package/dist/custom-agents.js +316 -0
  26. package/dist/default-agents.d.ts +7 -0
  27. package/dist/default-agents.js +122 -0
  28. package/dist/enabled-models.d.ts +49 -0
  29. package/dist/enabled-models.js +145 -0
  30. package/dist/env.d.ts +6 -0
  31. package/dist/env.js +28 -0
  32. package/dist/group-join.d.ts +32 -0
  33. package/dist/group-join.js +116 -0
  34. package/dist/index.d.ts +50 -0
  35. package/dist/index.js +3682 -0
  36. package/dist/invocation-config.d.ts +107 -0
  37. package/dist/invocation-config.js +83 -0
  38. package/dist/memory.d.ts +53 -0
  39. package/dist/memory.js +165 -0
  40. package/dist/mention-clone.d.ts +87 -0
  41. package/dist/mention-clone.js +153 -0
  42. package/dist/mention.d.ts +81 -0
  43. package/dist/mention.js +131 -0
  44. package/dist/model-resolver.d.ts +36 -0
  45. package/dist/model-resolver.js +95 -0
  46. package/dist/model-scope.d.ts +49 -0
  47. package/dist/model-scope.js +48 -0
  48. package/dist/nested-tools.d.ts +55 -0
  49. package/dist/nested-tools.js +299 -0
  50. package/dist/output-file.d.ts +43 -0
  51. package/dist/output-file.js +142 -0
  52. package/dist/prompts.d.ts +55 -0
  53. package/dist/prompts.js +91 -0
  54. package/dist/schedule-store.d.ts +38 -0
  55. package/dist/schedule-store.js +155 -0
  56. package/dist/schedule.d.ts +109 -0
  57. package/dist/schedule.js +359 -0
  58. package/dist/settings.d.ts +360 -0
  59. package/dist/settings.js +251 -0
  60. package/dist/skill-loader.d.ts +24 -0
  61. package/dist/skill-loader.js +93 -0
  62. package/dist/status-note.d.ts +61 -0
  63. package/dist/status-note.js +85 -0
  64. package/dist/structured-output.d.ts +61 -0
  65. package/dist/structured-output.js +112 -0
  66. package/dist/types.d.ts +371 -0
  67. package/dist/types.js +5 -0
  68. package/dist/ui/agent-mention.d.ts +82 -0
  69. package/dist/ui/agent-mention.js +187 -0
  70. package/dist/ui/agent-widget.d.ts +219 -0
  71. package/dist/ui/agent-widget.js +592 -0
  72. package/dist/ui/conversation-viewer.d.ts +120 -0
  73. package/dist/ui/conversation-viewer.js +578 -0
  74. package/dist/ui/fleet-list.d.ts +195 -0
  75. package/dist/ui/fleet-list.js +471 -0
  76. package/dist/ui/schedule-menu.d.ts +16 -0
  77. package/dist/ui/schedule-menu.js +94 -0
  78. package/dist/ui/select-item.d.ts +27 -0
  79. package/dist/ui/select-item.js +34 -0
  80. package/dist/ui/viewer-keys.d.ts +20 -0
  81. package/dist/ui/viewer-keys.js +17 -0
  82. package/dist/ui/workflow-card.d.ts +175 -0
  83. package/dist/ui/workflow-card.js +332 -0
  84. package/dist/ui/workflow-dialog.d.ts +305 -0
  85. package/dist/ui/workflow-dialog.js +843 -0
  86. package/dist/ui/workflow-menu.d.ts +60 -0
  87. package/dist/ui/workflow-menu.js +147 -0
  88. package/dist/usage.d.ts +135 -0
  89. package/dist/usage.js +120 -0
  90. package/dist/workflow/collisions.d.ts +95 -0
  91. package/dist/workflow/collisions.js +88 -0
  92. package/dist/workflow/entry.d.ts +32 -0
  93. package/dist/workflow/entry.js +29 -0
  94. package/dist/workflow/host.d.ts +62 -0
  95. package/dist/workflow/host.js +362 -0
  96. package/dist/workflow/journal.d.ts +97 -0
  97. package/dist/workflow/journal.js +120 -0
  98. package/dist/workflow/json-schema.d.ts +51 -0
  99. package/dist/workflow/json-schema.js +111 -0
  100. package/dist/workflow/meta.d.ts +67 -0
  101. package/dist/workflow/meta.js +317 -0
  102. package/dist/workflow/progress.d.ts +224 -0
  103. package/dist/workflow/progress.js +361 -0
  104. package/dist/workflow/runtime.d.ts +334 -0
  105. package/dist/workflow/runtime.js +830 -0
  106. package/dist/workflow/saved.d.ts +90 -0
  107. package/dist/workflow/saved.js +203 -0
  108. package/dist/workflow/task.d.ts +136 -0
  109. package/dist/workflow/task.js +207 -0
  110. package/dist/workflow/tool-description.d.ts +38 -0
  111. package/dist/workflow/tool-description.js +199 -0
  112. package/dist/workflow/worker-source.d.ts +47 -0
  113. package/dist/workflow/worker-source.js +778 -0
  114. package/dist/worktree.d.ts +52 -0
  115. package/dist/worktree.js +164 -0
  116. package/dist/xml.d.ts +10 -0
  117. package/dist/xml.js +12 -0
  118. package/docs/rpc.md +183 -0
  119. package/docs/workflows.md +437 -0
  120. package/examples/agent-tool-description.md +42 -0
  121. package/examples/workflows/compose.js +51 -0
  122. package/examples/workflows/fan-out-audit.js +47 -0
  123. package/examples/workflows/gated-fix.js +60 -0
  124. package/examples/workflows/lib/count-child.js +27 -0
  125. package/examples/workflows/review-panel.js +63 -0
  126. package/examples/workflows/structured-findings.js +78 -0
  127. package/package.json +68 -0
  128. package/src/abortable.ts +43 -0
  129. package/src/agent-color.ts +161 -0
  130. package/src/agent-file-toggle.ts +270 -0
  131. package/src/agent-manager.ts +1581 -0
  132. package/src/agent-runner.ts +1286 -0
  133. package/src/agent-types.ts +346 -0
  134. package/src/child-context.ts +15 -0
  135. package/src/context.ts +58 -0
  136. package/src/cross-extension-rpc.ts +198 -0
  137. package/src/custom-agents.ts +333 -0
  138. package/src/default-agents.ts +126 -0
  139. package/src/enabled-models.ts +180 -0
  140. package/src/env.ts +33 -0
  141. package/src/group-join.ts +141 -0
  142. package/src/index.ts +3991 -0
  143. package/src/invocation-config.ts +155 -0
  144. package/src/memory.ts +179 -0
  145. package/src/mention-clone.ts +196 -0
  146. package/src/mention.ts +141 -0
  147. package/src/model-resolver.ts +118 -0
  148. package/src/model-scope.ts +70 -0
  149. package/src/nested-tools.ts +422 -0
  150. package/src/output-file.ts +155 -0
  151. package/src/prompts.ts +142 -0
  152. package/src/schedule-store.ts +153 -0
  153. package/src/schedule.ts +386 -0
  154. package/src/settings.ts +587 -0
  155. package/src/skill-loader.ts +102 -0
  156. package/src/status-note.ts +90 -0
  157. package/src/structured-output.ts +130 -0
  158. package/src/types.ts +384 -0
  159. package/src/ui/agent-mention.ts +216 -0
  160. package/src/ui/agent-widget.ts +664 -0
  161. package/src/ui/conversation-viewer.ts +589 -0
  162. package/src/ui/fleet-list.ts +543 -0
  163. package/src/ui/schedule-menu.ts +105 -0
  164. package/src/ui/select-item.ts +45 -0
  165. package/src/ui/viewer-keys.ts +39 -0
  166. package/src/ui/workflow-card.ts +470 -0
  167. package/src/ui/workflow-dialog.ts +1115 -0
  168. package/src/ui/workflow-menu.ts +193 -0
  169. package/src/usage.ts +167 -0
  170. package/src/workflow/collisions.ts +123 -0
  171. package/src/workflow/entry.ts +47 -0
  172. package/src/workflow/host.ts +403 -0
  173. package/src/workflow/journal.ts +164 -0
  174. package/src/workflow/json-schema.ts +128 -0
  175. package/src/workflow/meta.ts +325 -0
  176. package/src/workflow/progress.ts +550 -0
  177. package/src/workflow/runtime.ts +1219 -0
  178. package/src/workflow/saved.ts +217 -0
  179. package/src/workflow/task.ts +302 -0
  180. package/src/workflow/tool-description.ts +200 -0
  181. package/src/workflow/worker-source.ts +781 -0
  182. package/src/worktree.ts +205 -0
  183. package/src/xml.ts +13 -0
@@ -0,0 +1,664 @@
1
+ /**
2
+ * agent-widget.ts — Persistent widget showing running/completed agents above the editor.
3
+ *
4
+ * Displays a tree of agents with animated spinners, live stats, and activity descriptions.
5
+ * Uses the callback form of setWidget for themed rendering.
6
+ */
7
+
8
+ import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { renderAgentName } from "../agent-color.js";
10
+ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
11
+ import { getConfig } from "../agent-types.js";
12
+ import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
13
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
14
+
15
+ // ---- Constants ----
16
+
17
+ /** Maximum number of rendered lines before overflow collapse kicks in. */
18
+ const MAX_WIDGET_LINES = 12;
19
+
20
+ /** Braille spinner frames for animated running indicator. */
21
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
+
23
+ /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
24
+ export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
25
+
26
+ /** Tool name → human-readable action for activity descriptions. */
27
+ const TOOL_DISPLAY: Record<string, string> = {
28
+ read: "reading",
29
+ bash: "running command",
30
+ edit: "editing",
31
+ write: "writing",
32
+ grep: "searching",
33
+ find: "finding files",
34
+ ls: "listing",
35
+ };
36
+
37
+ // ---- Types ----
38
+
39
+ export type Theme = {
40
+ fg(color: string, text: string): string;
41
+ bold(text: string): string;
42
+ };
43
+
44
+ export type UICtx = {
45
+ setStatus(key: string, text: string | undefined): void;
46
+ setWidget(
47
+ key: string,
48
+ content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
49
+ options?: { placement?: "aboveEditor" | "belowEditor" },
50
+ ): void;
51
+ };
52
+
53
+ /** Per-agent live activity state. */
54
+ export interface AgentActivity {
55
+ activeTools: Map<string, string>;
56
+ toolUses: number;
57
+ responseText: string;
58
+ session?: SessionLike;
59
+ /** Current turn count. */
60
+ turnCount: number;
61
+ /** Effective max turns for this agent (undefined = unlimited). */
62
+ maxTurns?: number;
63
+ }
64
+
65
+ /** Metadata attached to Agent tool results for custom rendering. */
66
+ export interface AgentDetails {
67
+ displayName: string;
68
+ description: string;
69
+ subagentType: string;
70
+ toolUses: number;
71
+ tokens: string;
72
+ durationMs: number;
73
+ status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
74
+ /** Human-readable description of what the agent is currently doing. */
75
+ activity?: string;
76
+ /** Current spinner frame index (for animated running indicator). */
77
+ spinnerFrame?: number;
78
+ /** Short label for the model the run used, e.g. "haiku 4.5". */
79
+ modelName?: string;
80
+ /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
81
+ tags?: string[];
82
+ /** Current turn count. */
83
+ turnCount?: number;
84
+ /** Effective max turns (undefined = unlimited). */
85
+ maxTurns?: number;
86
+ /** Estimated cost in USD; 0 when the model has no pricing data. */
87
+ cost?: number;
88
+ agentId?: string;
89
+ error?: string;
90
+ }
91
+
92
+ // ---- Formatting helpers ----
93
+
94
+ /** Apply foreground styling while restoring it after nested foreground/full ANSI resets. */
95
+ export function fgPreservingNestedStyles(theme: Theme, color: string, text: string): string {
96
+ const styledEmpty = theme.fg(color, "");
97
+ const styleStart = styledEmpty.replace(/\u001b\[(?:0|39)m/g, "");
98
+ return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, reset => `${reset}${styleStart}`));
99
+ }
100
+
101
+ /** Format a token count compactly: "33.8k token", "1.2M token". */
102
+ export function formatTokens(count: number): string {
103
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
104
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
105
+ return `${count} token`;
106
+ }
107
+
108
+ /**
109
+ * Format a cost as `~$0.0042`, or "" when there is nothing to show.
110
+ *
111
+ * The tilde is load-bearing: this is pi's own estimate from the model's listed
112
+ * rates, not a billed figure, and the surfaces that print it sit next to token
113
+ * counts that ARE exact.
114
+ *
115
+ * Nothing is printed for zero, which is also what a model with no pricing data
116
+ * reports: `$0.00` beside a local model's tokens would claim its cost was
117
+ * measured and found to be nothing, rather than never measured at all. For the
118
+ * same reason a real cost too small for four decimals reads `<$0.0001` — it was
119
+ * measured, and rounding it to `~$0.0000` would say the opposite.
120
+ */
121
+ export function formatCost(cost: number): string {
122
+ if (!(cost > 0)) return ""; // also catches NaN
123
+ if (cost < 0.0001) return "<$0.0001";
124
+ if (cost >= 1) return `~$${cost.toFixed(2)}`;
125
+ // Under a dollar: cents at minimum, four decimals at most, nothing trailing.
126
+ // Most single runs land between a tenth of a cent and a dime, where rounding
127
+ // to cents would collapse a 4x difference in spend into the same figure.
128
+ const rounded = Number(cost.toFixed(4));
129
+ const decimals = (String(rounded).split(".")[1] ?? "").length;
130
+ return `~$${rounded.toFixed(Math.max(2, decimals))}`;
131
+ }
132
+
133
+ /**
134
+ * Token count with optional context-fill % and compaction-count annotations.
135
+ * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
136
+ * Compaction count rendered as `⇊N` in dim.
137
+ *
138
+ * "12.3k token" — no annotations
139
+ * "12.3k token (45%)" — percent only
140
+ * "12.3k token (⇊2)" — compactions only (e.g. right after compact)
141
+ * "12.3k token (45% · ⇊2)" — both
142
+ */
143
+ export function formatSessionTokens(
144
+ tokens: number,
145
+ percent: number | null,
146
+ theme: Theme,
147
+ compactions = 0,
148
+ ): string {
149
+ const tokenStr = formatTokens(tokens);
150
+ const annot: string[] = [];
151
+ if (percent !== null) {
152
+ const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
153
+ annot.push(theme.fg(color, `${Math.round(percent)}%`));
154
+ }
155
+ if (compactions > 0) {
156
+ annot.push(theme.fg("dim", `⇊${compactions}`));
157
+ }
158
+ if (annot.length === 0) return tokenStr;
159
+ return `${tokenStr} (${annot.join(" · ")})`;
160
+ }
161
+
162
+ /** Format turn count with optional max limit: "↻5≤30" or "↻5". */
163
+ export function formatTurns(turnCount: number, maxTurns?: number | null): string {
164
+ return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
165
+ }
166
+
167
+ /** Format milliseconds as human-readable duration. */
168
+ export function formatMs(ms: number): string {
169
+ return `${(ms / 1000).toFixed(1)}s`;
170
+ }
171
+
172
+ /** Format duration from start/completed timestamps. */
173
+ export function formatDuration(startedAt: number, completedAt?: number): string {
174
+ if (completedAt) return formatMs(completedAt - startedAt);
175
+ return `${formatMs(Date.now() - startedAt)} (running)`;
176
+ }
177
+
178
+ /** Get display name for any agent type (built-in or custom). */
179
+ export function getDisplayName(type: SubagentType): string {
180
+ return getConfig(type).displayName;
181
+ }
182
+
183
+ /** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
184
+ export function getPromptModeLabel(type: SubagentType): string | undefined {
185
+ const config = getConfig(type);
186
+ return config.promptMode === "append" ? "twin" : undefined;
187
+ }
188
+
189
+ /**
190
+ * Mode label is not included — callers add it where they want it.
191
+ *
192
+ * Both model forms come back so each surface can pick by width; the
193
+ * "(asked X)" annotation is applied here rather than by callers, so a value the
194
+ * spawn did not honor cannot be rendered as though it had been (#182). A service
195
+ * tier is emitted only when the effective API supports it; callers leave it unset
196
+ * for unsupported providers so an ignored request is not shown as active.
197
+ */
198
+ export function buildInvocationTags(
199
+ invocation: AgentInvocation | undefined,
200
+ ): { modelName?: string; modelId?: string; tags: string[] } {
201
+ const tags: string[] = [];
202
+ if (!invocation) return { tags };
203
+ const asked = (value: string | undefined, requested: string | undefined): string | undefined =>
204
+ value && requested && requested !== value ? `${value} (asked ${requested})` : value;
205
+ const thinking = asked(invocation.thinking, invocation.requestedThinking);
206
+ if (thinking) tags.push(`thinking: ${thinking}`);
207
+ if (invocation.serviceTier) tags.push(`service tier: ${invocation.serviceTier}`);
208
+ if (invocation.isolated) tags.push("isolated");
209
+ if (invocation.isolation === "worktree") tags.push("worktree");
210
+ if (invocation.inheritContext) tags.push("inherit context");
211
+ if (invocation.runInBackground) tags.push("background");
212
+ if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
213
+ return {
214
+ modelName: asked(invocation.modelName, invocation.requestedModel),
215
+ modelId: asked(invocation.modelId, invocation.requestedModel),
216
+ tags,
217
+ };
218
+ }
219
+
220
+ /** Truncate text to a single line, max `len` chars. */
221
+ function truncateLine(text: string, len = 60): string {
222
+ const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
223
+ if (line.length <= len) return line;
224
+ return line.slice(0, len) + "…";
225
+ }
226
+
227
+ /** Build a human-readable activity string from currently-running tools or response text. */
228
+ export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
229
+ if (activeTools.size > 0) {
230
+ const groups = new Map<string, number>();
231
+ for (const toolName of activeTools.values()) {
232
+ const action = TOOL_DISPLAY[toolName] ?? toolName;
233
+ groups.set(action, (groups.get(action) ?? 0) + 1);
234
+ }
235
+
236
+ const parts: string[] = [];
237
+ for (const [action, count] of groups) {
238
+ if (count > 1) {
239
+ parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
240
+ } else {
241
+ parts.push(action);
242
+ }
243
+ }
244
+ return parts.join(", ") + "…";
245
+ }
246
+
247
+ // No tools active — show truncated response text if available
248
+ if (responseText && responseText.trim().length > 0) {
249
+ return truncateLine(responseText);
250
+ }
251
+
252
+ return "thinking…";
253
+ }
254
+
255
+ // ---- Widget manager ----
256
+
257
+ export class AgentWidget {
258
+ private uiCtx: UICtx | undefined;
259
+ private widgetFrame = 0;
260
+ private widgetInterval: ReturnType<typeof setInterval> | undefined;
261
+ /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
262
+ private finishedTurnAge = new Map<string, number>();
263
+ /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
264
+ private static readonly ERROR_LINGER_TURNS = 2;
265
+
266
+ /** Whether the widget callback is currently registered with the TUI. */
267
+ private widgetRegistered = false;
268
+ /** Cached TUI reference from widget factory callback, used for requestRender(). */
269
+ private tui: any | undefined;
270
+ /** Last status bar text, used to avoid redundant setStatus calls. */
271
+ private lastStatusText: string | undefined;
272
+
273
+ constructor(
274
+ private manager: AgentManager,
275
+ private agentActivity: Map<string, AgentActivity>,
276
+ /**
277
+ * Read live at render time. Selects which agents the widget shows — see
278
+ * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
279
+ * extension supplies one defaulting to `"background"`.
280
+ */
281
+ private mode: () => WidgetMode = () => "all",
282
+ /**
283
+ * Read live at render time, like `mode`. Whether running agents show an
284
+ * estimated cost beside their token count. Defaults to off — the extension
285
+ * supplies the user's `showCost` setting.
286
+ */
287
+ private showCost: () => boolean = () => false,
288
+ /**
289
+ * Read live at render time, like `mode`. Whether running agents name the
290
+ * model, thinking level, and configured service tier when the effective API
291
+ * supports it. Defaults to off — the extension supplies the user's `showModel`
292
+ * setting — because the row is already dense and the same details are on the
293
+ * tool result and in the conversation viewer when applicable.
294
+ */
295
+ private showModel: () => boolean = () => false,
296
+ ) {}
297
+
298
+ /**
299
+ * Agents eligible for the widget, per the current `WidgetMode`:
300
+ * - `off`: none (the widget's existing empty-state path hides it entirely).
301
+ * - `background`: drop only agents *known* to be foreground
302
+ * (`isBackground === false`); keep everything else — background, queued,
303
+ * scheduled, or RPC-spawned (`undefined`). Keying off the `isBackground`
304
+ * record flag rather than the UI-only `invocation` snapshot (which only the
305
+ * Agent-tool path sets), and excluding rather than allow-listing, means
306
+ * only proven-foreground runs drop out — nothing else silently vanishes.
307
+ * - `all`: every agent.
308
+ */
309
+ private widgetAgents() {
310
+ const all = this.manager.listAgents().filter(isTopLevelAgent);
311
+ switch (this.mode()) {
312
+ case "off": return [];
313
+ case "background": return all.filter(a => a.isBackground !== false);
314
+ default: return all;
315
+ }
316
+ }
317
+
318
+ /** Set the UI context (grabbed from first tool execution). */
319
+ setUICtx(ctx: UICtx) {
320
+ if (ctx !== this.uiCtx) {
321
+ // UICtx changed — the widget registered on the old context is gone.
322
+ // Force re-registration on next update().
323
+ this.uiCtx = ctx;
324
+ this.widgetRegistered = false;
325
+ this.tui = undefined;
326
+ this.lastStatusText = undefined;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Called on each new turn (tool_execution_start).
332
+ * Ages finished agents and clears those that have lingered long enough.
333
+ */
334
+ onTurnStart() {
335
+ // Age all finished agents
336
+ for (const [id, age] of this.finishedTurnAge) {
337
+ this.finishedTurnAge.set(id, age + 1);
338
+ }
339
+ // Trigger a widget refresh (will filter out expired agents)
340
+ this.update();
341
+ }
342
+
343
+ /** Ensure the widget update timer is running. */
344
+ ensureTimer() {
345
+ if (!this.widgetInterval) {
346
+ this.widgetInterval = setInterval(() => this.update(), 80);
347
+ }
348
+ }
349
+
350
+ /** Check if a finished agent should still be shown in the widget. */
351
+ private shouldShowFinished(agentId: string, status: string): boolean {
352
+ const age = this.finishedTurnAge.get(agentId) ?? 0;
353
+ const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
354
+ return age < maxAge;
355
+ }
356
+
357
+ /** Record an agent as finished (call when agent completes). */
358
+ markFinished(agentId: string) {
359
+ if (!this.finishedTurnAge.has(agentId)) {
360
+ this.finishedTurnAge.set(agentId, 0);
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Drop an agent's finished-age (call when a settled agent starts running
366
+ * again, i.e. a background resume). markFinished only seeds an age it has not
367
+ * seen before, so a resumed agent would otherwise keep the age from its
368
+ * previous run — already past the linger limit, hiding the new run's
369
+ * completion line entirely.
370
+ */
371
+ markRunning(agentId: string) {
372
+ this.finishedTurnAge.delete(agentId);
373
+ }
374
+
375
+ /** Render a finished agent line. */
376
+ private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
377
+ const modeLabel = getPromptModeLabel(a.type);
378
+ const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
379
+
380
+ let icon: string;
381
+ let statusText: string;
382
+ if (a.status === "completed") {
383
+ icon = theme.fg("success", "✓");
384
+ statusText = "";
385
+ } else if (a.status === "steered") {
386
+ icon = theme.fg("warning", "✓");
387
+ statusText = theme.fg("warning", " (turn limit)");
388
+ } else if (a.status === "stopped") {
389
+ icon = theme.fg("dim", "■");
390
+ statusText = theme.fg("dim", " stopped");
391
+ } else if (a.status === "error") {
392
+ icon = theme.fg("error", "✗");
393
+ const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
394
+ statusText = theme.fg("error", ` error${errMsg}`);
395
+ } else {
396
+ // aborted
397
+ icon = theme.fg("error", "✗");
398
+ statusText = theme.fg("warning", " aborted");
399
+ }
400
+
401
+ const parts: string[] = [];
402
+ const activity = this.agentActivity.get(a.id);
403
+ if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
404
+ if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
405
+ // From the record, not the activity tracker: that entry is deleted the
406
+ // moment an agent finishes, and "what did it cost" is a question asked
407
+ // about finished agents.
408
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
409
+ if (costText) parts.push(costText);
410
+ parts.push(duration);
411
+
412
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
413
+ return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
414
+ }
415
+
416
+ /**
417
+ * Render the widget content. Called from the registered widget's render() callback,
418
+ * reading live state each time instead of capturing it in a closure.
419
+ */
420
+ private renderWidget(tui: any, theme: Theme): string[] {
421
+ const allAgents = this.widgetAgents();
422
+ const running = allAgents.filter(a => a.status === "running");
423
+ const queued = allAgents.filter(a => a.status === "queued");
424
+ const finished = allAgents.filter(a =>
425
+ a.status !== "running" && a.status !== "queued" && a.completedAt
426
+ && this.shouldShowFinished(a.id, a.status),
427
+ );
428
+
429
+ const hasActive = running.length > 0 || queued.length > 0;
430
+ const hasFinished = finished.length > 0;
431
+
432
+ // Nothing to show — return empty (widget will be unregistered by update())
433
+ if (!hasActive && !hasFinished) return [];
434
+
435
+ const w = tui.terminal.columns;
436
+ const truncate = (line: string) => truncateToWidth(line, w);
437
+ const headingColor = hasActive ? "accent" : "dim";
438
+ const headingIcon = hasActive ? "●" : "○";
439
+ const frame = SPINNER[this.widgetFrame % SPINNER.length];
440
+
441
+ // Build sections separately for overflow-aware assembly.
442
+ // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
443
+
444
+ const finishedLines: string[] = [];
445
+ for (const a of finished) {
446
+ finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
447
+ }
448
+
449
+ const runningLines: string[][] = []; // each entry is [header, activity]
450
+ for (const a of running) {
451
+ const modeLabel = getPromptModeLabel(a.type);
452
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
453
+ const elapsed = formatMs(Date.now() - a.startedAt);
454
+
455
+ const bg = this.agentActivity.get(a.id);
456
+ const toolUses = bg?.toolUses ?? a.toolUses;
457
+ // Spend comes from the record, never from the activity tracker: the record
458
+ // is the one that survives the agent finishing, and the one nested-tools
459
+ // folds a hidden child's spend into. Reading the tracker while an agent
460
+ // runs and the record once it stops made the figure jump at completion.
461
+ const tokens = getLifetimeTotal(a.lifetimeUsage);
462
+ const contextPercent = getSessionContextPercent(bg?.session);
463
+ const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
464
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
465
+
466
+ const parts: string[] = [];
467
+ if (this.showModel()) {
468
+ // Leading, and paired: a thinking level means nothing without the model
469
+ // it applies to. The tag is taken from buildInvocationTags rather than
470
+ // rebuilt so the "(asked X)" annotation survives.
471
+ const { modelName, tags } = buildInvocationTags(a.invocation);
472
+ if (modelName) parts.push(modelName);
473
+ const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
474
+ if (thinkingTag) parts.push(thinkingTag);
475
+ const serviceTierTag = tags.find(tag => tag.startsWith("service tier: "));
476
+ if (serviceTierTag) parts.push(serviceTierTag);
477
+ }
478
+ if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
479
+ if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
480
+ if (tokenText) parts.push(tokenText);
481
+ if (costText) parts.push(costText);
482
+ parts.push(elapsed);
483
+ const statsText = parts.join(" · ");
484
+
485
+ const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
486
+
487
+ runningLines.push([
488
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
489
+ truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
490
+ ]);
491
+ }
492
+
493
+ const queuedLine = queued.length > 0
494
+ ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
495
+ : undefined;
496
+
497
+ // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
498
+ const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
499
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
500
+
501
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
502
+
503
+ if (totalBody <= maxBody) {
504
+ // Everything fits — add all lines and fix up connectors for the last item.
505
+ lines.push(...finishedLines);
506
+ for (const pair of runningLines) lines.push(...pair);
507
+ if (queuedLine) lines.push(queuedLine);
508
+
509
+ // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
510
+ if (lines.length > 1) {
511
+ const last = lines.length - 1;
512
+ lines[last] = lines[last].replace("├─", "└─");
513
+ // If last item is a running agent activity line, fix indent of that line
514
+ // and fix the header line above it.
515
+ if (runningLines.length > 0 && !queuedLine) {
516
+ // The last two lines are the last running agent's header + activity.
517
+ if (last >= 2) {
518
+ lines[last - 1] = lines[last - 1].replace("├─", "└─");
519
+ lines[last] = lines[last].replace("│ ", " ");
520
+ }
521
+ }
522
+ }
523
+ } else {
524
+ // Overflow — prioritize: running > queued > finished.
525
+ // Reserve 1 line for overflow indicator.
526
+ let budget = maxBody - 1;
527
+ let hiddenRunning = 0;
528
+ let hiddenFinished = 0;
529
+
530
+ // Reserve the queued line's row up front. It is a single summary of N
531
+ // waiting agents, so it cannot be folded into the "+N more" count (which
532
+ // is denominated in agents) without either under-reporting it as 1 or
533
+ // inflating the total with agents that were never getting their own rows.
534
+ // Reserving costs at most one running agent — which IS counted below —
535
+ // and makes the drop unreachable. It matters most exactly when it used to
536
+ // vanish: the pool is saturated and the queue is what the user needs to see.
537
+ const queuedReserve = queuedLine ? 1 : 0;
538
+ budget -= queuedReserve;
539
+
540
+ // 1. Running agents (2 lines each)
541
+ for (const pair of runningLines) {
542
+ if (budget >= 2) {
543
+ lines.push(...pair);
544
+ budget -= 2;
545
+ } else {
546
+ hiddenRunning++;
547
+ }
548
+ }
549
+
550
+ // 2. Queued line (always fits — its row was reserved above)
551
+ if (queuedLine) {
552
+ budget += queuedReserve;
553
+ lines.push(queuedLine);
554
+ budget--;
555
+ }
556
+
557
+ // 3. Finished agents
558
+ for (const fl of finishedLines) {
559
+ if (budget >= 1) {
560
+ lines.push(fl);
561
+ budget--;
562
+ } else {
563
+ hiddenFinished++;
564
+ }
565
+ }
566
+
567
+ // Overflow summary
568
+ const overflowParts: string[] = [];
569
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
570
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
571
+ const overflowText = overflowParts.join(", ");
572
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
573
+ );
574
+ }
575
+
576
+ return lines;
577
+ }
578
+
579
+ /** Force an immediate widget update. */
580
+ update() {
581
+ if (!this.uiCtx) return;
582
+ const allAgents = this.widgetAgents();
583
+
584
+ // Lightweight existence checks — full categorization happens in renderWidget()
585
+ let runningCount = 0;
586
+ let queuedCount = 0;
587
+ let hasFinished = false;
588
+ for (const a of allAgents) {
589
+ if (a.status === "running") { runningCount++; }
590
+ else if (a.status === "queued") { queuedCount++; }
591
+ else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
592
+ }
593
+ const hasActive = runningCount > 0 || queuedCount > 0;
594
+
595
+ // Nothing to show — clear widget
596
+ if (!hasActive && !hasFinished) {
597
+ if (this.widgetRegistered) {
598
+ this.uiCtx.setWidget("agents", undefined);
599
+ this.widgetRegistered = false;
600
+ this.tui = undefined;
601
+ }
602
+ if (this.lastStatusText !== undefined) {
603
+ this.uiCtx.setStatus("subagents", undefined);
604
+ this.lastStatusText = undefined;
605
+ }
606
+ if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
607
+ // Clean up stale entries
608
+ for (const [id] of this.finishedTurnAge) {
609
+ if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
610
+ }
611
+ return;
612
+ }
613
+
614
+ // Status bar — only call setStatus when the text actually changes
615
+ let newStatusText: string | undefined;
616
+ if (hasActive) {
617
+ const statusParts: string[] = [];
618
+ if (runningCount > 0) statusParts.push(`${runningCount} running`);
619
+ if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
620
+ const total = runningCount + queuedCount;
621
+ newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
622
+ }
623
+ if (newStatusText !== this.lastStatusText) {
624
+ this.uiCtx.setStatus("subagents", newStatusText);
625
+ this.lastStatusText = newStatusText;
626
+ }
627
+
628
+ this.widgetFrame++;
629
+
630
+ // Register widget callback once; subsequent updates use requestRender()
631
+ // which re-invokes render() without replacing the component (avoids layout thrashing).
632
+ if (!this.widgetRegistered) {
633
+ this.uiCtx.setWidget("agents", (tui, theme) => {
634
+ this.tui = tui;
635
+ return {
636
+ render: () => this.renderWidget(tui, theme),
637
+ invalidate: () => {
638
+ // Theme changed — force re-registration so factory captures fresh theme.
639
+ this.widgetRegistered = false;
640
+ this.tui = undefined;
641
+ },
642
+ };
643
+ }, { placement: "aboveEditor" });
644
+ this.widgetRegistered = true;
645
+ } else {
646
+ // Widget already registered — just request a re-render of existing components.
647
+ this.tui?.requestRender();
648
+ }
649
+ }
650
+
651
+ dispose() {
652
+ if (this.widgetInterval) {
653
+ clearInterval(this.widgetInterval);
654
+ this.widgetInterval = undefined;
655
+ }
656
+ if (this.uiCtx) {
657
+ this.uiCtx.setWidget("agents", undefined);
658
+ this.uiCtx.setStatus("subagents", undefined);
659
+ }
660
+ this.widgetRegistered = false;
661
+ this.tui = undefined;
662
+ this.lastStatusText = undefined;
663
+ }
664
+ }