@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.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +17 -0
- package/README.md +22 -2
- package/bugs.txt +57 -0
- package/dist/agent-manager.d.ts +8 -0
- package/dist/agent-manager.js +54 -22
- package/dist/agent-runner.d.ts +3 -0
- package/dist/agent-runner.js +12 -3
- package/dist/dashboard-ui.d.ts +15 -0
- package/dist/dashboard-ui.js +206 -0
- package/dist/default-agents.js +0 -1
- package/dist/index.js +96 -11
- package/dist/peek.js +8 -2
- package/dist/subagent-list-clear.d.ts +57 -0
- package/dist/subagent-list-clear.js +331 -0
- package/dist/ui/agent-tool-rendering.js +1 -1
- package/dist/ui/agent-widget-tree.js +19 -2
- package/dist/ui/agent-widget.d.ts +7 -1
- package/dist/ui/agent-widget.js +52 -10
- package/package.json +1 -1
- package/src/agent-manager.ts +44 -13
- package/src/agent-runner.ts +14 -3
- package/src/dashboard-ui.ts +270 -0
- package/src/default-agents.ts +0 -1
- package/src/index.ts +113 -15
- package/src/peek.ts +7 -2
- package/src/subagent-list-clear.ts +405 -0
- package/src/ui/agent-tool-rendering.ts +1 -1
- package/src/ui/agent-widget-tree.ts +16 -2
- package/src/ui/agent-widget.ts +50 -10
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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", "
|
|
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
|
|
303
|
-
this.
|
|
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
|
-
|
|
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"
|
|
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" &&
|
|
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 (
|
|
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)
|
|
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
|
}
|