@jopqior/pi-subagents 1.0.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 (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * transcript-content.ts — the rows the `/subagents:sessions` overlay paints.
3
+ *
4
+ * Owns the transcript's content model: the `SessionMessage` → Pi-component
5
+ * mapping (mirroring Pi's own interactive-mode `renderSessionContext`), and the
6
+ * rendered rows those components produce at a given width. The overlay
7
+ * (`session-navigator.ts`) owns scroll state, chrome, and key handling, and
8
+ * asks this collaborator only for rows — it never reaches into the components.
9
+ *
10
+ * Lives in the SDK/TUI layer rather than the pure `session-navigation.ts` core
11
+ * because Pi's per-entry components require a `TUI`, `cwd`, and markdown theme.
12
+ */
13
+
14
+ import {
15
+ AssistantMessageComponent,
16
+ BashExecutionComponent,
17
+ BranchSummaryMessageComponent,
18
+ CompactionSummaryMessageComponent,
19
+ parseSkillBlock,
20
+ SkillInvocationMessageComponent,
21
+ ToolExecutionComponent,
22
+ UserMessageComponent,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import { Container, type MarkdownTheme, Spacer, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
25
+ import type { AgentSessionEvent, SessionMessage } from "#src/types";
26
+ import { describeActivity } from "#src/ui/display";
27
+ import { GLYPHS } from "#src/ui/glyphs";
28
+ import type { TranscriptSource } from "#src/ui/session-navigation";
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ /** The SDK/TUI environment Pi's per-entry components need, plus the transcript's source. */
33
+ export interface TranscriptContentOptions {
34
+ tui: TUI;
35
+ cwd: string;
36
+ markdownTheme: MarkdownTheme;
37
+ source: TranscriptSource;
38
+ }
39
+
40
+ /**
41
+ * The transcript's renderable rows: settled history rendered through Pi's
42
+ * per-entry components, followed by the running agent's activity row.
43
+ *
44
+ * Settled rows are rendered once per width and cached, so a paint or a scroll
45
+ * costs a slice rather than a walk of the whole component tree. The activity
46
+ * row is recomputed per call — it is two rows, and it tracks live state the
47
+ * source updates without a rebuild.
48
+ *
49
+ * Settled history is held as one block of components per message, so a message
50
+ * that settles appends a block and a tool result touches only its own — neither
51
+ * discards the rest of the transcript's rendering. This is sound because the
52
+ * agent core pushes a finished message into its message array before notifying
53
+ * listeners, so at event time the settled prefix is already visible.
54
+ *
55
+ * The message currently being streamed is held as its own component, updated
56
+ * per delta exactly as Pi's interactive mode does, so a token never touches
57
+ * settled history. The two provably cannot overlap: the agent core keeps the
58
+ * in-flight message outside its message array until it settles.
59
+ */
60
+ export class TranscriptContent {
61
+ private readonly options: TranscriptContentOptions;
62
+ /** Settled history, one block per message that produced components. */
63
+ private blocks: SettledBlock[] = [];
64
+ /** How many source messages have been consumed into `blocks`. */
65
+ private consumedCount = 0;
66
+ /** The last consumed message; a mismatch means history was rewritten. */
67
+ private lastConsumed: SessionMessage | undefined;
68
+ /** Whether any consumed block holds components, driving user-message spacing. */
69
+ private hasVisibleContent = false;
70
+ /** In-flight tool components by tool-call id, pairing a later result to its block. */
71
+ private readonly pendingTools = new Map<string, PendingTool>();
72
+ /** Width `blocks` and `settledRows` were rendered at. */
73
+ private settledWidth: number | undefined;
74
+ /** Concatenation of every block's rows, or undefined when the cache is cold. */
75
+ private settledRows: readonly string[] | undefined;
76
+ /** The message being streamed right now, rendered below settled history. */
77
+ private inFlight: AssistantMessageComponent | undefined;
78
+ /** Width `inFlightRows` was rendered at. */
79
+ private inFlightWidth: number | undefined;
80
+ private inFlightRows: readonly string[] | undefined;
81
+
82
+ constructor(options: TranscriptContentOptions) {
83
+ this.options = options;
84
+ this.consumeSettled();
85
+ }
86
+
87
+ /** Total content rows at `width`: settled history plus the live tail. */
88
+ lineCount(width: number): number {
89
+ if (width <= 0) return 0;
90
+ return this.settled(width).length + this.tail(width).length;
91
+ }
92
+
93
+ /** Rows `[start, start + count)` at `width`, clamped to what exists. */
94
+ slice(width: number, start: number, count: number): string[] {
95
+ if (width <= 0) return [];
96
+ const settled = this.settled(width);
97
+ const end = start + count;
98
+ const rows = settled.slice(start, Math.min(end, settled.length));
99
+ if (end > settled.length) {
100
+ const tail = this.tail(width);
101
+ rows.push(...tail.slice(Math.max(0, start - settled.length), end - settled.length));
102
+ }
103
+ return rows;
104
+ }
105
+
106
+ /**
107
+ * Route one session event to the narrowest update it allows. A delta on the
108
+ * in-flight message touches only that component. A run or compaction boundary
109
+ * may have rewritten or mutated history in place, so it rebuilds wholesale.
110
+ * Everything else consumes whatever settled since the last check.
111
+ */
112
+ apply(event?: AgentSessionEvent): void {
113
+ const partial = inFlightAssistantMessage(event);
114
+ if (partial) {
115
+ this.updateInFlight(partial);
116
+ return;
117
+ }
118
+ if (event?.type === "agent_end" || event?.type === "compaction_end") {
119
+ this.reset();
120
+ } else if (event?.type === "message_end") {
121
+ this.clearInFlight();
122
+ }
123
+ this.consumeSettled();
124
+ }
125
+
126
+ /** Drop cached rendering held by the mounted components and by this object. */
127
+ invalidate(): void {
128
+ for (const block of this.blocks) {
129
+ block.container.invalidate();
130
+ block.rows = undefined;
131
+ }
132
+ this.settledRows = undefined;
133
+ this.inFlight?.invalidate();
134
+ this.inFlightRows = undefined;
135
+ }
136
+
137
+ // ---- Private ----
138
+
139
+ /** Settled rows at `width`; each block renders once per width and content change. */
140
+ private settled(width: number): readonly string[] {
141
+ if (this.settledWidth !== width) {
142
+ this.settledWidth = width;
143
+ for (const block of this.blocks) block.rows = undefined;
144
+ this.settledRows = undefined;
145
+ }
146
+ if (this.settledRows) return this.settledRows;
147
+ const rows: string[] = [];
148
+ for (const block of this.blocks) {
149
+ block.rows ??= block.container.render(width).map((row) => truncateToWidth(row, width));
150
+ rows.push(...block.rows);
151
+ }
152
+ this.settledRows = rows;
153
+ return rows;
154
+ }
155
+
156
+ /** Rows below settled history: the in-flight message, then the activity row. */
157
+ private tail(width: number): readonly string[] {
158
+ const rows = [...this.inFlightRendered(width)];
159
+ const streaming = this.options.source.streaming();
160
+ if (streaming) {
161
+ const activity = `${GLYPHS.streaming} ${describeActivity(streaming.activeTools, streaming.responseText)}`;
162
+ rows.push("", truncateToWidth(activity, width));
163
+ }
164
+ return rows;
165
+ }
166
+
167
+ /** The in-flight message's rows, re-rendered independently of settled history. */
168
+ private inFlightRendered(width: number): readonly string[] {
169
+ if (!this.inFlight) return [];
170
+ if (this.inFlightWidth !== width) {
171
+ this.inFlightWidth = width;
172
+ this.inFlightRows = undefined;
173
+ }
174
+ this.inFlightRows ??= this.inFlight.render(width).map((row) => truncateToWidth(row, width));
175
+ return this.inFlightRows;
176
+ }
177
+
178
+ private updateInFlight(message: AssistantSessionMessage): void {
179
+ if (this.inFlight) this.inFlight.updateContent(message);
180
+ else this.inFlight = new AssistantMessageComponent(message, false, this.options.markdownTheme);
181
+ this.inFlightRows = undefined;
182
+ }
183
+
184
+ private clearInFlight(): void {
185
+ this.inFlight = undefined;
186
+ this.inFlightRows = undefined;
187
+ }
188
+
189
+ /** Discard all settled state, so the next consume rebuilds from scratch. */
190
+ private reset(): void {
191
+ this.blocks = [];
192
+ this.consumedCount = 0;
193
+ this.lastConsumed = undefined;
194
+ this.hasVisibleContent = false;
195
+ this.pendingTools.clear();
196
+ this.settledRows = undefined;
197
+ this.clearInFlight();
198
+ }
199
+
200
+ /**
201
+ * Append blocks for every message that settled since the last check. When the
202
+ * consumed prefix no longer mirrors the source — history replaced wholesale by
203
+ * compaction or branching — start over rather than appending onto stale blocks.
204
+ */
205
+ private consumeSettled(): void {
206
+ const messages = this.options.source.getMessages();
207
+ if (this.hasRewrittenHistory(messages)) this.reset();
208
+ if (messages.length === this.consumedCount) return;
209
+ for (let i = this.consumedCount; i < messages.length; i++) this.consumeMessage(messages[i]);
210
+ this.consumedCount = messages.length;
211
+ this.lastConsumed = messages.at(-1);
212
+ this.settledRows = undefined;
213
+ }
214
+
215
+ private hasRewrittenHistory(messages: readonly SessionMessage[]): boolean {
216
+ if (messages.length < this.consumedCount) return true;
217
+ return this.consumedCount > 0 && messages[this.consumedCount - 1] !== this.lastConsumed;
218
+ }
219
+
220
+ /**
221
+ * Map one settled message onto its own block of Pi's per-entry components,
222
+ * mirroring Pi's own interactive-mode `renderSessionContext` mapping.
223
+ * `custom`-role messages produce no block — rendering them needs the child
224
+ * session's message-renderer registry, which the navigator does not hold.
225
+ */
226
+ private consumeMessage(message: SessionMessage): void {
227
+ switch (message.role) {
228
+ case "assistant":
229
+ this.consumeAssistant(message);
230
+ break;
231
+ case "toolResult":
232
+ this.consumeToolResult(message);
233
+ break;
234
+ case "user":
235
+ this.consumeUser(message);
236
+ break;
237
+ case "bashExecution":
238
+ this.consumeBashExecution(message);
239
+ break;
240
+ case "compactionSummary":
241
+ this.consumeSummary(new CompactionSummaryMessageComponent(message, this.options.markdownTheme));
242
+ break;
243
+ case "branchSummary":
244
+ this.consumeSummary(new BranchSummaryMessageComponent(message, this.options.markdownTheme));
245
+ break;
246
+ }
247
+ }
248
+
249
+ private consumeAssistant(message: AssistantSessionMessage): void {
250
+ const block = this.appendBlock();
251
+ block.container.addChild(new AssistantMessageComponent(message, false, this.options.markdownTheme));
252
+ for (const content of message.content) {
253
+ if (content.type !== "toolCall") continue;
254
+ const tool = new ToolExecutionComponent(
255
+ content.name,
256
+ content.id,
257
+ content.arguments,
258
+ { showImages: false },
259
+ this.options.source.getToolDefinition(content.name),
260
+ this.options.tui,
261
+ this.options.cwd,
262
+ );
263
+ tool.setExpanded(true);
264
+ block.container.addChild(tool);
265
+ this.pendingTools.set(content.id, { component: tool, block });
266
+ }
267
+ this.hasVisibleContent = true;
268
+ }
269
+
270
+ /** A tool result mutates the block holding its call; no other block changes. */
271
+ private consumeToolResult(message: Extract<SessionMessage, { role: "toolResult" }>): void {
272
+ const pending = this.pendingTools.get(message.toolCallId);
273
+ if (!pending) return;
274
+ pending.component.updateResult(message);
275
+ pending.block.rows = undefined;
276
+ this.settledRows = undefined;
277
+ this.pendingTools.delete(message.toolCallId);
278
+ }
279
+
280
+ private consumeUser(message: Extract<SessionMessage, { role: "user" }>): void {
281
+ const block = this.appendBlock();
282
+ addUserComponents(block.container, message.content, this.options.markdownTheme, this.hasVisibleContent);
283
+ if (block.container.children.length > 0) this.hasVisibleContent = true;
284
+ }
285
+
286
+ private consumeBashExecution(message: Extract<SessionMessage, { role: "bashExecution" }>): void {
287
+ const block = this.appendBlock();
288
+ const bash = new BashExecutionComponent(message.command, this.options.tui, message.excludeFromContext);
289
+ if (message.output) bash.appendOutput(message.output);
290
+ bash.setComplete(message.exitCode, message.cancelled, undefined, message.fullOutputPath);
291
+ block.container.addChild(bash);
292
+ this.hasVisibleContent = true;
293
+ }
294
+
295
+ /** Compaction and branch summaries share the same spacer-plus-expanded shape. */
296
+ private consumeSummary(
297
+ summary: CompactionSummaryMessageComponent | BranchSummaryMessageComponent,
298
+ ): void {
299
+ const block = this.appendBlock();
300
+ block.container.addChild(new Spacer(1));
301
+ summary.setExpanded(true);
302
+ block.container.addChild(summary);
303
+ this.hasVisibleContent = true;
304
+ }
305
+
306
+ private appendBlock(): SettledBlock {
307
+ const block: SettledBlock = { container: new Container(), rows: undefined };
308
+ this.blocks.push(block);
309
+ return block;
310
+ }
311
+ }
312
+
313
+ /** One settled message's components plus its rows at the current width. */
314
+ interface SettledBlock {
315
+ readonly container: Container;
316
+ rows: readonly string[] | undefined;
317
+ }
318
+
319
+ /** A tool call awaiting its result, and the block whose rows it will invalidate. */
320
+ interface PendingTool {
321
+ readonly component: ToolExecutionComponent;
322
+ readonly block: SettledBlock;
323
+ }
324
+
325
+ /** The assistant variant of a session message, as Pi's components consume it. */
326
+ type AssistantSessionMessage = Extract<SessionMessage, { role: "assistant" }>;
327
+
328
+ /**
329
+ * The in-flight assistant message a partial event carries, or undefined when the
330
+ * event is anything else. A partial for another role means a message settled
331
+ * elsewhere in the history, which only a rebuild can pick up.
332
+ */
333
+ function inFlightAssistantMessage(event?: AgentSessionEvent): AssistantSessionMessage | undefined {
334
+ if (event?.type !== "message_start" && event?.type !== "message_update") return undefined;
335
+ return event.message.role === "assistant" ? event.message : undefined;
336
+ }
337
+
338
+ /**
339
+ * Render a user message (skill block + text) into the block, mirroring Pi.
340
+ * Whether a leading spacer is needed is a whole-transcript property, so the
341
+ * caller — which alone knows what precedes this block — supplies it.
342
+ */
343
+ function addUserComponents(
344
+ container: Container,
345
+ content: string | readonly { type: string; text?: string }[],
346
+ markdownTheme: MarkdownTheme,
347
+ hasPrecedingContent: boolean,
348
+ ): void {
349
+ const text = userMessageText(content);
350
+ if (!text) return;
351
+ if (hasPrecedingContent) container.addChild(new Spacer(1));
352
+
353
+ const skillBlock = parseSkillBlock(text);
354
+ if (!skillBlock) {
355
+ container.addChild(new UserMessageComponent(text, markdownTheme));
356
+ return;
357
+ }
358
+ const skill = new SkillInvocationMessageComponent(skillBlock, markdownTheme);
359
+ skill.setExpanded(true);
360
+ container.addChild(skill);
361
+ if (skillBlock.userMessage) {
362
+ container.addChild(new Spacer(1));
363
+ container.addChild(new UserMessageComponent(skillBlock.userMessage, markdownTheme));
364
+ }
365
+ }
366
+
367
+ /** Concatenate the text blocks of a user message's content (mirrors Pi). */
368
+ function userMessageText(content: string | readonly { type: string; text?: string }[]): string {
369
+ if (typeof content === "string") return content;
370
+ return content
371
+ .filter((block) => block.type === "text")
372
+ .map((block) => block.text ?? "")
373
+ .join("");
374
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * widget-renderer.ts — Pure rendering functions for the agent widget.
3
+ *
4
+ * All functions are stateless: they receive data and return formatted strings.
5
+ * No timers, no SDK types, no side effects. Consumed by AgentWidget.
6
+ */
7
+
8
+ import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import type { AgentConfigLookup } from "#src/config/agent-types";
10
+ import {
11
+ isActiveStatus,
12
+ type SubagentStatus,
13
+ } from "#src/lifecycle/subagent-state";
14
+ import type { LifetimeUsage } from "#src/lifecycle/usage";
15
+ import { getLifetimeTotal } from "#src/lifecycle/usage";
16
+ import type { SubagentType } from "#src/types";
17
+ import {
18
+ describeActivity,
19
+ formatMs,
20
+ formatSessionTokens,
21
+ formatTurns,
22
+ getDisplayName,
23
+ getPromptModeLabel,
24
+ PENDING_SELECTION_ACTIVITY,
25
+ type Theme,
26
+ } from "#src/ui/display";
27
+ import { GLYPHS, SPINNER } from "#src/ui/glyphs";
28
+
29
+ // ── Data interfaces ──────────────────────────────────────────────────────────
30
+
31
+ /** Minimal agent snapshot for rendering — no class methods, no mutation surface. */
32
+ export interface WidgetAgent {
33
+ readonly id: string;
34
+ readonly type: SubagentType;
35
+ readonly status: SubagentStatus;
36
+ readonly description: string;
37
+ readonly toolUses: number;
38
+ readonly startedAt: number;
39
+ readonly completedAt?: number;
40
+ readonly error?: string;
41
+ readonly lifetimeUsage?: Readonly<LifetimeUsage>;
42
+ readonly compactionCount: number;
43
+ // Live activity (folded from the former WidgetActivity — precomputed by AgentWidget)
44
+ readonly turnCount: number;
45
+ readonly maxTurns?: number;
46
+ readonly activeTools: ReadonlyMap<string, string>;
47
+ readonly responseText: string;
48
+ /** True while this run is waiting for a human model/thinking selection. */
49
+ readonly awaitingSelection?: boolean;
50
+ /** Context-window utilisation (0–100), or null when unavailable. */
51
+ readonly contextPercent: number | null;
52
+ }
53
+
54
+ // ── Per-agent rendering ──────────────────────────────────────────────────────
55
+
56
+ /** Render a single finished agent line (no tree connector prefix). */
57
+ export function renderFinishedLine(
58
+ agent: WidgetAgent,
59
+ registry: AgentConfigLookup,
60
+ theme: Theme,
61
+ ): string {
62
+ const name = getDisplayName(agent.type, registry);
63
+ const modeLabel = getPromptModeLabel(agent.type, registry);
64
+ const duration = formatMs((agent.completedAt ?? Date.now()) - agent.startedAt);
65
+
66
+ let icon: string;
67
+ let statusText: string;
68
+ if (agent.status === "completed") {
69
+ icon = theme.fg("success", GLYPHS.success);
70
+ statusText = "";
71
+ } else if (agent.status === "steered") {
72
+ icon = theme.fg("warning", GLYPHS.success);
73
+ statusText = theme.fg("warning", " (turn limit)");
74
+ } else if (agent.status === "stopped") {
75
+ icon = theme.fg("dim", GLYPHS.stopped);
76
+ statusText = theme.fg("dim", " stopped");
77
+ } else if (agent.status === "error") {
78
+ icon = theme.fg("error", GLYPHS.failure);
79
+ const errMsg = agent.error ? `: ${agent.error.slice(0, 60)}` : "";
80
+ statusText = theme.fg("error", ` error${errMsg}`);
81
+ } else {
82
+ // aborted
83
+ icon = theme.fg("error", GLYPHS.failure);
84
+ statusText = theme.fg("warning", " aborted");
85
+ }
86
+
87
+ const parts: string[] = [];
88
+ parts.push(formatTurns(agent.turnCount, agent.maxTurns));
89
+ if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
90
+ parts.push(duration);
91
+
92
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
93
+ return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", agent.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
94
+ }
95
+
96
+ /** Render a single running agent as header + activity line pair (no tree connector prefix). */
97
+ export function renderRunningLines(
98
+ agent: WidgetAgent,
99
+ registry: AgentConfigLookup,
100
+ spinnerFrame: number,
101
+ theme: Theme,
102
+ ): [header: string, activity: string] {
103
+ const name = getDisplayName(agent.type, registry);
104
+ const modeLabel = getPromptModeLabel(agent.type, registry);
105
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
106
+ const elapsed = formatMs(Date.now() - agent.startedAt);
107
+
108
+ const tokens = getLifetimeTotal(agent.lifetimeUsage);
109
+ const tokenText = tokens > 0 ? formatSessionTokens(tokens, agent.contextPercent, theme, agent.compactionCount) : "";
110
+
111
+ const parts: string[] = [];
112
+ parts.push(formatTurns(agent.turnCount, agent.maxTurns));
113
+ if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
114
+ if (tokenText) parts.push(tokenText);
115
+ parts.push(elapsed);
116
+ const statsText = parts.join(" · ");
117
+
118
+ const frame = SPINNER[spinnerFrame % SPINNER.length];
119
+ const activityText = agent.awaitingSelection
120
+ ? PENDING_SELECTION_ACTIVITY
121
+ : describeActivity(agent.activeTools, agent.responseText);
122
+
123
+ const header = `${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", agent.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`;
124
+ const activityLine = theme.fg("dim", ` ${GLYPHS.subLine} ${activityText}`);
125
+
126
+ return [header, activityLine];
127
+ }
128
+
129
+ // ── Full widget rendering ────────────────────────────────────────────────────
130
+
131
+ /** Maximum number of rendered lines before overflow collapse kicks in. */
132
+ const MAX_WIDGET_LINES = 12;
133
+
134
+ interface AgentCategories {
135
+ running: WidgetAgent[];
136
+ queued: WidgetAgent[];
137
+ finished: WidgetAgent[];
138
+ }
139
+
140
+ /** Partition agents into rendering buckets. */
141
+ function categorizeAgents(
142
+ agents: readonly WidgetAgent[],
143
+ shouldShowFinished: (agentId: string, status: string) => boolean,
144
+ ): AgentCategories {
145
+ return {
146
+ running: agents.filter(a => a.status === "running"),
147
+ queued: agents.filter(a => a.status === "queued"),
148
+ finished: agents.filter(
149
+ a => !isActiveStatus(a.status) && a.completedAt != null
150
+ && shouldShowFinished(a.id, a.status),
151
+ ),
152
+ };
153
+ }
154
+
155
+ interface WidgetSections {
156
+ finishedLines: string[];
157
+ runningLines: [string, string][];
158
+ queuedLine: string | undefined;
159
+ }
160
+
161
+ /** Render each agent bucket into pre-formatted lines with ├─ tree connectors. */
162
+ function buildSections(
163
+ categories: AgentCategories,
164
+ registry: AgentConfigLookup,
165
+ spinnerFrame: number,
166
+ theme: Theme,
167
+ truncate: (line: string) => string,
168
+ ): WidgetSections {
169
+ const finishedLines: string[] = [];
170
+ for (const a of categories.finished) {
171
+ finishedLines.push(truncate(theme.fg("dim", "\u251C\u2500") + " " + renderFinishedLine(a, registry, theme)));
172
+ }
173
+
174
+ const runningLines: [string, string][] = [];
175
+ for (const a of categories.running) {
176
+ const [header, act] = renderRunningLines(a, registry, spinnerFrame, theme);
177
+ runningLines.push([
178
+ truncate(theme.fg("dim", "\u251C\u2500") + ` ${header}`),
179
+ truncate(theme.fg("dim", "\u2502 ") + act),
180
+ ]);
181
+ }
182
+
183
+ const queuedLine = categories.queued.length > 0
184
+ ? truncate(theme.fg("dim", "\u251C\u2500") + ` ${theme.fg("muted", GLYPHS.queued)} ${theme.fg("dim", `${categories.queued.length} queued`)}`)
185
+ : undefined;
186
+
187
+ return { finishedLines, runningLines, queuedLine };
188
+ }
189
+
190
+ /**
191
+ * Assemble widget lines when total body fits within MAX_WIDGET_LINES.
192
+ * Fixes the last tree connector: ├─ → └─, and │ → space for the running-agent activity line.
193
+ */
194
+ function assembleWithinBudget(heading: string, sections: WidgetSections): string[] {
195
+ const { finishedLines, runningLines, queuedLine } = sections;
196
+ const lines: string[] = [heading, ...finishedLines];
197
+ for (const pair of runningLines) lines.push(...pair);
198
+ if (queuedLine) lines.push(queuedLine);
199
+
200
+ // Fix last connector: swap \u251C\u2500 \u2192 \u2514\u2500.
201
+ if (lines.length > 1) {
202
+ const last = lines.length - 1;
203
+ lines[last] = lines[last].replace("\u251C\u2500", "\u2514\u2500");
204
+ if (runningLines.length > 0 && !queuedLine) {
205
+ if (last >= 2) {
206
+ lines[last - 1] = lines[last - 1].replace("\u251C\u2500", "\u2514\u2500");
207
+ lines[last] = lines[last].replace("\u2502 ", " ");
208
+ }
209
+ }
210
+ }
211
+ return lines;
212
+ }
213
+
214
+ /**
215
+ * Assemble widget lines when total body exceeds MAX_WIDGET_LINES.
216
+ * Prioritizes running > queued > finished and appends an overflow indicator.
217
+ */
218
+ function assembleOverflow(
219
+ heading: string,
220
+ sections: WidgetSections,
221
+ maxBody: number,
222
+ truncate: (line: string) => string,
223
+ theme: Theme,
224
+ ): string[] {
225
+ const { finishedLines, runningLines, queuedLine } = sections;
226
+ const lines: string[] = [heading];
227
+ let budget = maxBody - 1;
228
+ let hiddenRunning = 0;
229
+ let hiddenFinished = 0;
230
+
231
+ for (const pair of runningLines) {
232
+ if (budget >= 2) {
233
+ lines.push(...pair);
234
+ budget -= 2;
235
+ } else {
236
+ hiddenRunning++;
237
+ }
238
+ }
239
+
240
+ if (queuedLine && budget >= 1) {
241
+ lines.push(queuedLine);
242
+ budget--;
243
+ }
244
+
245
+ for (const fl of finishedLines) {
246
+ if (budget >= 1) {
247
+ lines.push(fl);
248
+ budget--;
249
+ } else {
250
+ hiddenFinished++;
251
+ }
252
+ }
253
+
254
+ const overflowParts: string[] = [];
255
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
256
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
257
+ const overflowText = overflowParts.join(", ");
258
+ lines.push(truncate(theme.fg("dim", "\u2514\u2500") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
259
+ return lines;
260
+ }
261
+
262
+ /** Pure rendering of the widget body. Returns lines to display. */
263
+ export function renderWidgetLines(params: {
264
+ agents: readonly WidgetAgent[];
265
+ registry: AgentConfigLookup;
266
+ spinnerFrame: number;
267
+ terminalWidth: number;
268
+ theme: Theme;
269
+ shouldShowFinished: (agentId: string, status: string) => boolean;
270
+ }): string[] {
271
+ const { agents, registry, spinnerFrame, terminalWidth, theme, shouldShowFinished } = params;
272
+
273
+ const { running, queued, finished } = categorizeAgents(agents, shouldShowFinished);
274
+
275
+ const hasActive = running.length > 0 || queued.length > 0;
276
+ const hasFinished = finished.length > 0;
277
+
278
+ if (!hasActive && !hasFinished) return [];
279
+
280
+ const truncate = (line: string) => truncateToWidth(line, terminalWidth);
281
+ const headingColor = hasActive ? "accent" : "dim";
282
+ const headingIcon = hasActive ? GLYPHS.agentsActive : GLYPHS.agentsIdle;
283
+
284
+ const { finishedLines, runningLines, queuedLine } = buildSections(
285
+ { running, queued, finished },
286
+ registry,
287
+ spinnerFrame,
288
+ theme,
289
+ truncate,
290
+ );
291
+
292
+ // Assemble with overflow cap (heading takes 1 line).
293
+ const maxBody = MAX_WIDGET_LINES - 1;
294
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
295
+ const heading = truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"));
296
+
297
+ if (totalBody <= maxBody) {
298
+ return assembleWithinBudget(heading, { finishedLines, runningLines, queuedLine });
299
+ }
300
+ return assembleOverflow(heading, { finishedLines, runningLines, queuedLine }, maxBody, truncate, theme);
301
+ }