@clanker-code/pi-subagents 0.10.8 → 0.11.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.
@@ -0,0 +1,405 @@
1
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth } from "@earendil-works/pi-tui";
4
+ import { Type } from "@sinclair/typebox";
5
+ import type { AgentManager } from "./agent-manager.js";
6
+ import { SUBAGENT_TOOL_NAMES } from "./agent-runner.js";
7
+ import type { AgentRecord } from "./types.js";
8
+
9
+ const DEFAULT_RECENT_SUCCESS_LIMIT = 2;
10
+ const DEFAULT_CLEAR_AGE_MS = 5 * 60_000;
11
+ const INDENT = " ";
12
+ const HEADER_GLYPH = "⏣";
13
+ const TREE_MID = "├─";
14
+ const TREE_END = "└─";
15
+ const TREE_GAP = " ";
16
+ const TREE_PREFIX_LEN = INDENT.length + TREE_MID.length + TREE_GAP.length;
17
+
18
+ const SUCCESS_STATUSES = new Set(["completed", "steered"]);
19
+ const ACTIVE_STATUSES = new Set(["running", "queued"]);
20
+ const PROBLEM_STATUSES = new Set(["error", "aborted", "stopped"]);
21
+
22
+ export interface ListSubagentsOptions {
23
+ all?: boolean;
24
+ now?: number;
25
+ recentSuccessLimit?: number;
26
+ }
27
+
28
+ export interface ListSubagentsAgentDetails {
29
+ id: string;
30
+ type: AgentRecord["type"];
31
+ description: string;
32
+ status: AgentRecord["status"];
33
+ startedAt: number;
34
+ completedAt?: number;
35
+ }
36
+
37
+ export interface ListSubagentsDetails {
38
+ total: number;
39
+ all: boolean;
40
+ visible: ListSubagentsAgentDetails[];
41
+ hiddenDoneCount: number;
42
+ activeCount: number;
43
+ problemCount: number;
44
+ recentDoneCount: number;
45
+ now: number;
46
+ }
47
+
48
+ export interface ClearSubagentsOptions {
49
+ agentIds?: string[];
50
+ now?: number;
51
+ olderThanMs?: number;
52
+ includeErrors?: boolean;
53
+ }
54
+
55
+ export interface ClearSelectionResult {
56
+ clearIds: string[];
57
+ errors: string[];
58
+ requestedCount: number;
59
+ keptActiveCount: number;
60
+ keptFailedCount: number;
61
+ keptYoungSuccessCount: number;
62
+ }
63
+
64
+ export interface ClearSubagentsDetails extends ClearSelectionResult {
65
+ clearedCount: number;
66
+ }
67
+
68
+ export type RenderTheme = {
69
+ fg(color: string, text: string): string;
70
+ bold(text: string): string;
71
+ };
72
+
73
+ class LineListComponent implements Component {
74
+ constructor(private readonly getLines: (width: number) => string[]) {}
75
+ render(width: number): string[] { return this.getLines(width); }
76
+ invalidate(): void {}
77
+ }
78
+
79
+ function isActive(record: AgentRecord): boolean {
80
+ return ACTIVE_STATUSES.has(record.status);
81
+ }
82
+
83
+ function isSuccess(record: AgentRecord): boolean {
84
+ return SUCCESS_STATUSES.has(record.status);
85
+ }
86
+
87
+ function isProblem(record: AgentRecord): boolean {
88
+ return PROBLEM_STATUSES.has(record.status);
89
+ }
90
+
91
+ function recency(record: AgentRecord): number {
92
+ return record.completedAt ?? record.startedAt;
93
+ }
94
+
95
+ function newestFirst(a: AgentRecord, b: AgentRecord): number {
96
+ return recency(b) - recency(a);
97
+ }
98
+
99
+ function plural(n: number, one: string, many = `${one}s`): string {
100
+ return `${n} ${n === 1 ? one : many}`;
101
+ }
102
+
103
+ function toListSubagentsAgentDetails(record: AgentRecord): ListSubagentsAgentDetails {
104
+ const details: ListSubagentsAgentDetails = {
105
+ id: record.id,
106
+ type: record.type,
107
+ description: record.description,
108
+ status: record.status,
109
+ startedAt: record.startedAt,
110
+ };
111
+ if (record.completedAt !== undefined) details.completedAt = record.completedAt;
112
+ return details;
113
+ }
114
+
115
+ function formatAge(record: ListSubagentsAgentDetails, now: number): string {
116
+ const start = record.completedAt ?? record.startedAt;
117
+ const elapsed = Math.max(0, now - start);
118
+ if (elapsed < 1_000) return "0s";
119
+ const seconds = Math.floor(elapsed / 1_000);
120
+ if (seconds < 60) return `${seconds}s`;
121
+ const minutes = Math.floor(seconds / 60);
122
+ if (minutes < 60) return `${minutes}m`;
123
+ const hours = Math.floor(minutes / 60);
124
+ if (hours < 48) return `${hours}h`;
125
+ return `${Math.floor(hours / 24)}d`;
126
+ }
127
+
128
+ function pad(text: string, width: number): string {
129
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
130
+ }
131
+
132
+ function shortId(id: string): string {
133
+ return id.slice(0, 8);
134
+ }
135
+
136
+ function displayType(type: AgentRecord["type"]): string {
137
+ return type === "general-purpose" ? "general" : type;
138
+ }
139
+
140
+ function statusLabel(status: AgentRecord["status"]): string {
141
+ if (status === "completed") return "done";
142
+ if (status === "steered") return "done";
143
+ return status;
144
+ }
145
+
146
+ function statusIcon(status: AgentRecord["status"]): string {
147
+ switch (status) {
148
+ case "running": return "⠋";
149
+ case "queued": return "◼";
150
+ case "completed":
151
+ case "steered": return "✓";
152
+ case "error":
153
+ case "aborted": return "✗";
154
+ case "stopped": return "■";
155
+ default: return "•";
156
+ }
157
+ }
158
+
159
+ function statusColor(status: AgentRecord["status"]): string {
160
+ switch (status) {
161
+ case "running":
162
+ case "queued": return "accent";
163
+ case "completed":
164
+ case "steered": return "success";
165
+ case "stopped": return "dim";
166
+ default: return "error";
167
+ }
168
+ }
169
+
170
+ function labeledPrefix(label: string, theme: RenderTheme): string {
171
+ return `${INDENT}${theme.fg("accent", HEADER_GLYPH)} ${theme.fg("accent", label)}${theme.fg("dim", " · ")}`;
172
+ }
173
+
174
+ function normalizeIds(agentIds: string[] | undefined): string[] {
175
+ return [...new Set((agentIds ?? []).map((id) => id.trim()).filter(Boolean))];
176
+ }
177
+
178
+ function resolveId(records: AgentRecord[], query: string): { id?: string; error?: string } {
179
+ const exact = records.find((record) => record.id === query);
180
+ if (exact) return { id: exact.id };
181
+ const matches = records.filter((record) => record.id.startsWith(query));
182
+ if (matches.length === 0) return { error: `${query} not found` };
183
+ if (matches.length > 1) return { error: `${query} matched multiple agents: ${matches.map((r) => shortId(r.id)).join(", ")}` };
184
+ return { id: matches[0].id };
185
+ }
186
+
187
+ export function buildListSubagentsDetails(records: AgentRecord[], options: ListSubagentsOptions = {}): ListSubagentsDetails {
188
+ const now = options.now ?? Date.now();
189
+ const all = options.all === true;
190
+ const sorted = [...records].sort(newestFirst);
191
+ const active = sorted.filter(isActive);
192
+ const problems = sorted.filter(isProblem);
193
+ const successes = sorted.filter(isSuccess);
194
+ const recentSuccessLimit = options.recentSuccessLimit ?? DEFAULT_RECENT_SUCCESS_LIMIT;
195
+ const recentSuccesses = successes.slice(0, recentSuccessLimit);
196
+ const visible = all ? sorted : [...active, ...problems, ...recentSuccesses];
197
+
198
+ return {
199
+ total: records.length,
200
+ all,
201
+ visible: visible.map(toListSubagentsAgentDetails),
202
+ hiddenDoneCount: all ? 0 : Math.max(0, successes.length - recentSuccesses.length),
203
+ activeCount: active.length,
204
+ problemCount: problems.length,
205
+ recentDoneCount: all ? successes.length : recentSuccesses.length,
206
+ now,
207
+ };
208
+ }
209
+
210
+ export function clearSubagentRecords(records: AgentRecord[], options: ClearSubagentsOptions = {}): ClearSelectionResult {
211
+ const now = options.now ?? Date.now();
212
+ const olderThanMs = options.olderThanMs ?? DEFAULT_CLEAR_AGE_MS;
213
+ const requestedIds = normalizeIds(options.agentIds);
214
+ const errors: string[] = [];
215
+ const clearIds: string[] = [];
216
+
217
+ if (requestedIds.length > 0) {
218
+ for (const query of requestedIds) {
219
+ const resolved = resolveId(records, query);
220
+ if (resolved.error || !resolved.id) {
221
+ errors.push(resolved.error ?? `${query} not found`);
222
+ continue;
223
+ }
224
+ const record = records.find((r) => r.id === resolved.id)!;
225
+ if (isActive(record)) {
226
+ errors.push(`${query} matched ${record.status} agent ${record.id}`);
227
+ continue;
228
+ }
229
+ clearIds.push(record.id);
230
+ }
231
+ } else {
232
+ for (const record of records) {
233
+ const age = now - (record.completedAt ?? record.startedAt);
234
+ if (age < olderThanMs) continue;
235
+ if (isSuccess(record) || (options.includeErrors && isProblem(record))) {
236
+ clearIds.push(record.id);
237
+ }
238
+ }
239
+ }
240
+
241
+ const clearIdSet = new Set(clearIds);
242
+ const remaining = records.filter((record) => !clearIdSet.has(record.id));
243
+ const keptActiveCount = remaining.filter(isActive).length;
244
+ const keptFailedCount = remaining.filter(isProblem).length;
245
+ const keptYoungSuccessCount = remaining.filter((record) => isSuccess(record) && now - (record.completedAt ?? record.startedAt) < olderThanMs).length;
246
+
247
+ return { clearIds, errors, requestedCount: requestedIds.length, keptActiveCount, keptFailedCount, keptYoungSuccessCount };
248
+ }
249
+
250
+ export function buildClearSubagentsDetails(result: ClearSelectionResult): ClearSubagentsDetails {
251
+ return { ...result, clearedCount: result.clearIds.length };
252
+ }
253
+
254
+ function renderAgentLine(record: ListSubagentsAgentDetails, theme: RenderTheme, now: number): string {
255
+ const icon = theme.fg(statusColor(record.status), statusIcon(record.status));
256
+ const id = theme.fg("muted", shortId(record.id));
257
+ const type = theme.fg("text", pad(displayType(record.type), 8));
258
+ const status = theme.fg(statusColor(record.status), pad(statusLabel(record.status), 8));
259
+ const age = theme.fg("dim", pad(formatAge(record, now), 4));
260
+ return `${icon} ${id} ${type} ${status} ${age} ${theme.fg("muted", record.description)}`;
261
+ }
262
+
263
+ function listSummary(details: ListSubagentsDetails, theme: RenderTheme): string {
264
+ if (details.total === 0) return `${theme.fg("text", "0 visible")} ${theme.fg("dim", "(empty)")}`;
265
+ if (details.all) return `${theme.fg("text", plural(details.visible.length, "agent"))} ${theme.fg("dim", "(full list)")}`;
266
+ const parts = [
267
+ plural(details.activeCount, "active"),
268
+ plural(details.problemCount, "problem"),
269
+ `${plural(details.recentDoneCount, "recent done", "recent done")}`,
270
+ ];
271
+ const hidden = details.hiddenDoneCount > 0 ? `; ${plural(details.hiddenDoneCount, "hidden done", "hidden done")}` : "";
272
+ return `${theme.fg("text", `${details.visible.length} visible`)} ${theme.fg("dim", `(${parts.join(", ")}${hidden})`)}`;
273
+ }
274
+
275
+ export function renderListSubagentsDetails(details: ListSubagentsDetails, theme: RenderTheme): Component {
276
+ return new LineListComponent((width) => {
277
+ const header = `${labeledPrefix("List Agents", theme)}${listSummary(details, theme)}`;
278
+ if (details.visible.length === 0) return [truncateToWidth(header, width)];
279
+ const lines = [truncateToWidth(header, width)];
280
+ const avail = Math.max(1, width - TREE_PREFIX_LEN);
281
+ details.visible.forEach((record, i) => {
282
+ const connector = i === details.visible.length - 1 ? TREE_END : TREE_MID;
283
+ const line = truncateToWidth(renderAgentLine(record, theme, details.now), avail);
284
+ lines.push(`${INDENT}${connector}${TREE_GAP}${line}`);
285
+ });
286
+ return lines;
287
+ });
288
+ }
289
+
290
+ function clearSummary(details: ClearSubagentsDetails, theme: RenderTheme): string {
291
+ const primary = theme.fg("text", `cleared ${plural(details.clearedCount, "record")}`);
292
+ const extra: string[] = [];
293
+ if (details.keptYoungSuccessCount) extra.push(`${plural(details.keptYoungSuccessCount, "new done", "new done")} kept`);
294
+ if (details.keptFailedCount) extra.push(`${plural(details.keptFailedCount, "failed", "failed")} kept`);
295
+ if (details.keptActiveCount) extra.push(`${plural(details.keptActiveCount, "active", "active")} kept`);
296
+ if (details.errors.length) extra.push(theme.fg("error", `${plural(details.errors.length, "error")}`));
297
+ if (extra.length === 0) return primary;
298
+ return `${primary}${theme.fg("dim", " (")}${extra.join(theme.fg("dim", ", "))}${theme.fg("dim", ")")}`;
299
+ }
300
+
301
+ export function renderClearSubagentsDetails(details: ClearSubagentsDetails, theme: RenderTheme): Component {
302
+ return new LineListComponent((width) => [truncateToWidth(`${labeledPrefix("Clear Agents", theme)}${clearSummary(details, theme)}`, width)]);
303
+ }
304
+
305
+ export function renderEmptyCall(): Component {
306
+ return new LineListComponent(() => []);
307
+ }
308
+
309
+ export function formatListSubagentsText(details: ListSubagentsDetails): string {
310
+ const lines = [`${details.visible.length} visible of ${details.total} retained subagents.`];
311
+ for (const record of details.visible) {
312
+ lines.push(`${record.id} | ${displayType(record.type)} | ${record.status} | ${record.description}`);
313
+ }
314
+ if (details.hiddenDoneCount > 0) {
315
+ lines.push(`${details.hiddenDoneCount} successful completed subagent(s) hidden. Pass all: true for the full retained list.`);
316
+ }
317
+ return lines.join("\n");
318
+ }
319
+
320
+ export function formatClearSubagentsText(details: ClearSubagentsDetails): string {
321
+ const lines = [`Cleared ${details.clearedCount} subagent record(s).`];
322
+ if (details.clearIds.length) lines.push(`Cleared IDs: ${details.clearIds.join(", ")}`);
323
+ if (details.keptYoungSuccessCount) lines.push(`Kept ${details.keptYoungSuccessCount} successful subagent(s) newer than the age threshold.`);
324
+ if (details.keptFailedCount) lines.push(`Kept ${details.keptFailedCount} failed/stopped/aborted subagent(s).`);
325
+ if (details.keptActiveCount) lines.push(`Kept ${details.keptActiveCount} active subagent(s).`);
326
+ if (details.errors.length) lines.push(`Errors: ${details.errors.join("; ")}`);
327
+ return lines.join("\n");
328
+ }
329
+
330
+ function textResult(msg: string, details?: unknown) {
331
+ return { content: [{ type: "text" as const, text: msg }], details: details as any };
332
+ }
333
+
334
+ export function registerSubagentListClearTools(pi: ExtensionAPI, manager: AgentManager): void {
335
+ pi.registerTool(defineTool({
336
+ name: SUBAGENT_TOOL_NAMES.LIST_SUBAGENTS,
337
+ label: "List Agents",
338
+ description:
339
+ "List retained subagent records. By default shows queued/running agents, failed/stopped/aborted agents, " +
340
+ "and the most recent 2 successful agents that have not been cleaned up. Also reports how many successful " +
341
+ "completed agents are hidden. Pass all: true to show the full retained list.",
342
+ promptSnippet: "List retained subagents and their current status",
343
+ parameters: Type.Object({
344
+ all: Type.Optional(
345
+ Type.Boolean({
346
+ description: "If true, show every retained subagent record instead of the default compact view.",
347
+ }),
348
+ ),
349
+ }),
350
+ renderShell: "self",
351
+ renderCall: () => renderEmptyCall(),
352
+ renderResult: (result: any, _options: any, theme: any) => {
353
+ const details = result?.details;
354
+ return details ? renderListSubagentsDetails(details, theme) : renderEmptyCall();
355
+ },
356
+ execute: async (_toolCallId, params) => {
357
+ const details = buildListSubagentsDetails(manager.listAgents(), { all: params.all === true });
358
+ return textResult(formatListSubagentsText(details), details);
359
+ },
360
+ }));
361
+
362
+ pi.registerTool(defineTool({
363
+ name: SUBAGENT_TOOL_NAMES.CLEAR_SUBAGENTS,
364
+ label: "Clear Agents",
365
+ description:
366
+ "Clear retained terminal subagent records. By default clears successful completed/steered subagents older than 5 minutes. " +
367
+ "Provide agent_ids to clear specific terminal subagents by exact ID or unique prefix. Running and queued subagents are never cleared; " +
368
+ "specific attempts to clear them are reported as errors.",
369
+ promptSnippet: "Clear completed subagent records that are still retained",
370
+ parameters: Type.Object({
371
+ agent_ids: Type.Optional(
372
+ Type.Array(Type.String(), {
373
+ description: "Optional exact IDs or unique prefixes to clear. When provided, the age threshold is ignored for those IDs.",
374
+ }),
375
+ ),
376
+ older_than_minutes: Type.Optional(
377
+ Type.Number({
378
+ description: "Default-mode age threshold in minutes. Defaults to 5. Ignored when agent_ids is provided.",
379
+ minimum: 0,
380
+ }),
381
+ ),
382
+ include_errors: Type.Optional(
383
+ Type.Boolean({
384
+ description: "In default mode, also clear failed/stopped/aborted terminal records older than the age threshold. Default false.",
385
+ }),
386
+ ),
387
+ }),
388
+ renderShell: "self",
389
+ renderCall: () => renderEmptyCall(),
390
+ renderResult: (result: any, _options: any, theme: any) => {
391
+ const details = result?.details;
392
+ return details ? renderClearSubagentsDetails(details, theme) : renderEmptyCall();
393
+ },
394
+ execute: async (_toolCallId, params) => {
395
+ const selection = clearSubagentRecords(manager.listAgents(), {
396
+ agentIds: params.agent_ids,
397
+ olderThanMs: (params.older_than_minutes ?? 5) * 60_000,
398
+ includeErrors: params.include_errors === true,
399
+ });
400
+ const removed = manager.clearRecords(selection.clearIds);
401
+ const details = buildClearSubagentsDetails({ ...selection, clearIds: removed });
402
+ return textResult(formatClearSubagentsText(details), details);
403
+ },
404
+ }));
405
+ }
@@ -23,7 +23,7 @@ export function snipMiddleLines(text: string, edgeLines = 20): string[] {
23
23
  const omitted = lines.length - maxLines;
24
24
  return [
25
25
  ...lines.slice(0, edgeLines),
26
- `... ${omitted} lines omitted; expand for full output ...`,
26
+ `─────── ${omitted} lines hidden from preview ───────`,
27
27
  ...lines.slice(-edgeLines),
28
28
  ];
29
29
  }
@@ -1,6 +1,7 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import { getConfig } from "../agent-types.js";
3
3
  import type { AgentInvocation, SubagentType } from "../types.js";
4
+ import { getSessionTokens } from "../usage.js";
4
5
  import type { AgentActivity, Theme } from "./agent-widget.js";
5
6
 
6
7
  export type WidgetDisplayMode = "auto" | "rich" | "compact";
@@ -92,10 +93,18 @@ function formatElapsed(startedAt: number, now: number): string {
92
93
  return rest > 0 ? `${minutes}m ${rest}s` : `${minutes}m`;
93
94
  }
94
95
 
96
+ /** Compact token count for widget rows: "12.3k tok", "1.2M tok". */
97
+ function formatCompactTokens(count: number): string {
98
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tok`;
99
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tok`;
100
+ return `${count} tok`;
101
+ }
102
+
95
103
  function statusIcon(snapshot: WidgetAgentSnapshot, frame: string, theme: Theme): string {
96
104
  if (snapshot.status === "running") return theme.fg("accent", frame);
97
105
  if (snapshot.status === "queued") return theme.fg("muted", "◦");
98
106
  if (snapshot.status === "completed") return theme.fg("success", "✓");
107
+ if (snapshot.status === "steered") return theme.fg("warning", "✓");
99
108
  if (snapshot.status === "stopped") return theme.fg("dim", "■");
100
109
  return theme.fg("error", "✗");
101
110
  }
@@ -117,11 +126,16 @@ function collectRows(
117
126
  const childPrefix = prefix + (isLast ? " " : "│ ");
118
127
  const s = node.snapshot;
119
128
  const name = displayName(s.type);
120
- const elapsed = formatElapsed(s.startedAt, options.now ?? Date.now());
129
+ const elapsedUntil = s.status === "running" || s.status === "queued" ? (options.now ?? Date.now()) : (s.completedAt ?? options.now ?? Date.now());
130
+ const elapsed = formatElapsed(s.startedAt, elapsedUntil);
121
131
  const stats: string[] = [];
122
132
  if (s.activity?.turnCount) stats.push(`↻${s.activity.turnCount}`);
123
133
  if (s.toolUses > 0) stats.push(`${s.toolUses} tool${s.toolUses === 1 ? "" : "s"}`);
124
134
  stats.push(elapsed);
135
+ if (s.activity?.session) {
136
+ const tokens = getSessionTokens(s.activity.session);
137
+ if (tokens > 0) stats.push(formatCompactTokens(tokens));
138
+ }
125
139
  const orphan = node.orphaned ? " ⚠ orphan" : "";
126
140
  const error = s.error ? ` error: ${s.error}` : "";
127
141
  rows.push(`${prefix}${connector} ${statusIcon(s, options.frame, options.theme)} ${options.theme.bold(name)} ${options.theme.fg("muted", s.description)} ${options.theme.fg("dim", `· ${stats.join(" · ")}${orphan}${error}`)}`);
@@ -165,5 +179,5 @@ export function renderAgentTree(records: WidgetAgentSnapshot[], options: RenderT
165
179
  ? options.theme.fg("dim", ` ${active} running · ${queued} queued · depth ${maxDepth}/4`)
166
180
  : "";
167
181
  const heading = `${active > 0 ? options.theme.fg("accent", "●") : options.theme.fg("dim", "○")} ${options.theme.fg(active > 0 ? "accent" : "dim", "Agents")}${suffix}`;
168
- return applyOverflow([heading, ...rows], options.maxLines, options.width);
182
+ return applyOverflow([heading, ...rows], options.maxLines, options.width, mode === "rich" ? "lines" : "agents");
169
183
  }
@@ -26,7 +26,7 @@ const DEFAULT_STATUS_TEXT_WIDTH = 20;
26
26
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
27
27
 
28
28
  /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
29
- export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
29
+ export const ERROR_STATUSES = new Set(["error", "aborted", "stopped"]);
30
30
 
31
31
  /** Tool name → human-readable action for activity descriptions. */
32
32
  const TOOL_DISPLAY: Record<string, string> = {
@@ -273,8 +273,14 @@ export class AgentWidget {
273
273
  private widgetInterval: ReturnType<typeof setInterval> | undefined;
274
274
  /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
275
275
  private finishedTurnAge = new Map<string, number>();
276
+ /** Tracks wall-clock finish time so long-running turns cannot keep completed rows forever. */
277
+ private finishedAt = new Map<string, number>();
276
278
  /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
277
279
  private static readonly ERROR_LINGER_TURNS = 2;
280
+ /** Max wall-clock linger for successful completions when no new parent turn starts. */
281
+ private static readonly COMPLETED_LINGER_MS = 30_000;
282
+ /** Max wall-clock linger for non-success outcomes when no new parent turn starts. */
283
+ private static readonly ERROR_LINGER_MS = 120_000;
278
284
 
279
285
  /** Whether the widget callback is currently registered with the TUI. */
280
286
  private widgetRegistered = false;
@@ -299,19 +305,26 @@ export class AgentWidget {
299
305
 
300
306
  upsertSnapshot(snapshot: WidgetAgentSnapshot) {
301
307
  this.descendantSnapshots.set(snapshot.id, snapshot);
302
- if (snapshot.status !== "running" && snapshot.status !== "queued") {
303
- this.markFinished(snapshot.id);
308
+ if (snapshot.status === "running" || snapshot.status === "queued") {
309
+ this.finishedTurnAge.delete(snapshot.id);
310
+ this.finishedAt.delete(snapshot.id);
311
+ } else {
312
+ this.markFinished(snapshot.id, snapshot.completedAt);
304
313
  }
305
314
  this.update();
306
315
  }
307
316
 
308
317
  removeSnapshot(id: string) {
309
318
  this.descendantSnapshots.delete(id);
319
+ this.finishedTurnAge.delete(id);
320
+ this.finishedAt.delete(id);
310
321
  this.update();
311
322
  }
312
323
 
313
324
  clearSnapshots() {
314
325
  this.descendantSnapshots.clear();
326
+ this.finishedTurnAge.clear();
327
+ this.finishedAt.clear();
315
328
  this.update();
316
329
  }
317
330
 
@@ -348,17 +361,25 @@ export class AgentWidget {
348
361
  }
349
362
 
350
363
  /** Check if a finished agent should still be shown in the widget. */
351
- private shouldShowFinished(agentId: string, status: string): boolean {
364
+ private shouldShowFinished(agentId: string, status: string, completedAt?: number): boolean {
352
365
  const age = this.finishedTurnAge.get(agentId) ?? 0;
353
366
  const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
354
- return age < maxAge;
367
+ if (age >= maxAge) return false;
368
+
369
+ const finishedAt = this.finishedAt.get(agentId) ?? completedAt;
370
+ if (finishedAt == null) return true;
371
+ const maxMs = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_MS : AgentWidget.COMPLETED_LINGER_MS;
372
+ return Date.now() - finishedAt < maxMs;
355
373
  }
356
374
 
357
375
  /** Record an agent as finished (call when agent completes). */
358
- markFinished(agentId: string) {
376
+ markFinished(agentId: string, completedAt = Date.now()) {
359
377
  if (!this.finishedTurnAge.has(agentId)) {
360
378
  this.finishedTurnAge.set(agentId, 0);
361
379
  }
380
+ if (!this.finishedAt.has(agentId)) {
381
+ this.finishedAt.set(agentId, completedAt);
382
+ }
362
383
  }
363
384
 
364
385
  private recordToSnapshot(a: any): WidgetAgentSnapshot {
@@ -383,13 +404,23 @@ export class AgentWidget {
383
404
  const merged = new Map(this.descendantSnapshots);
384
405
  const allAgents = this.manager.listAgents();
385
406
  for (const a of allAgents) {
386
- if (a.status === "running" || a.status === "queued" || (a.completedAt && this.shouldShowFinished(a.id, a.status))) {
407
+ if (a.status === "running" || a.status === "queued") {
408
+ this.finishedTurnAge.delete(a.id);
409
+ this.finishedAt.delete(a.id);
410
+ merged.set(a.id, this.recordToSnapshot(a));
411
+ } else if (a.completedAt && this.shouldShowFinished(a.id, a.status, a.completedAt)) {
387
412
  merged.set(a.id, this.recordToSnapshot(a));
388
413
  }
389
414
  }
415
+ const liveRecordIds = new Set(allAgents.map(a => a.id));
390
416
  for (const [id, snapshot] of merged) {
391
- if (snapshot.status !== "running" && snapshot.status !== "queued" && snapshot.completedAt && !this.shouldShowFinished(id, snapshot.status)) {
417
+ if (snapshot.status !== "running" && snapshot.status !== "queued" && !this.shouldShowFinished(id, snapshot.status, snapshot.completedAt)) {
392
418
  merged.delete(id);
419
+ if (this.descendantSnapshots.has(id)) this.descendantSnapshots.delete(id);
420
+ if (!liveRecordIds.has(id)) {
421
+ this.finishedTurnAge.delete(id);
422
+ this.finishedAt.delete(id);
423
+ }
393
424
  }
394
425
  }
395
426
  return [...merged.values()];
@@ -425,7 +456,7 @@ export class AgentWidget {
425
456
  for (const a of snapshots) {
426
457
  if (a.status === "running") { runningCount++; }
427
458
  else if (a.status === "queued") { queuedCount++; }
428
- else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
459
+ else if (this.shouldShowFinished(a.id, a.status, a.completedAt)) { hasFinished = true; }
429
460
  }
430
461
  const hasActive = runningCount > 0 || queuedCount > 0;
431
462
 
@@ -443,11 +474,16 @@ export class AgentWidget {
443
474
  if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
444
475
  // Clean up stale entries
445
476
  for (const [id] of this.finishedTurnAge) {
446
- if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
477
+ if (!allAgents.some(a => a.id === id) && !this.descendantSnapshots.has(id)) {
478
+ this.finishedTurnAge.delete(id);
479
+ this.finishedAt.delete(id);
480
+ }
447
481
  }
448
482
  return;
449
483
  }
450
484
 
485
+ this.ensureTimer();
486
+
451
487
  // Status bar — only call setStatus when the text actually changes
452
488
  const statusWidth = this.tui?.terminal.columns ?? DEFAULT_STATUS_TEXT_WIDTH;
453
489
  const newStatusText = hasActive
@@ -492,6 +528,10 @@ export class AgentWidget {
492
528
  }
493
529
  this.widgetRegistered = false;
494
530
  this.tui = undefined;
531
+ this.uiCtx = undefined;
495
532
  this.lastStatusText = undefined;
533
+ this.descendantSnapshots.clear();
534
+ this.finishedTurnAge.clear();
535
+ this.finishedAt.clear();
496
536
  }
497
537
  }