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