@pi-spice/minimal-subagents 0.1.1 → 0.2.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/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/index.ts +2 -0
- package/package.json +1 -1
- package/panel.ts +2 -1
- package/render.ts +201 -158
- package/spawn.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @pi-spice/minimal-subagents
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 349aa7f: Restyle the `spawn_agents` transcript display: a one-line call header, then one block per agent — status, duration, tool count, and the first line of the task as a stable identifier, with a third line for live activity while running. Call totals (tokens/cost) sit on a summary line after completion; the hint line is running-only. Expanded view shows final outputs only. Transcript lines are truncated by display width — long commands and CJK text no longer break the layout.
|
|
8
|
+
|
|
3
9
|
## 0.1.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -24,12 +24,12 @@ Quick test from this repo: `pi -e ./extensions/minimal-subagents`
|
|
|
24
24
|
| `name` | — | `agent-<index>` |
|
|
25
25
|
|
|
26
26
|
- **Failures don't cancel siblings** — every agent runs to completion; each result is a `### [name] completed/failed` section with the agent's final output (50 KB cap; full transcripts stay in the tool details). `isError` only when all fail.
|
|
27
|
-
- **Live progress** — a one-line
|
|
27
|
+
- **Live progress** — a one-line call header (`spawn_agents (3 agents)`), then one block per agent: glyph + name + duration/tools, then the first line of the task (always — so agents stay distinguishable even when names are opaque). Running agents grow a third line with the latest tool call; failed agents put the error on the header. A dim summary line (multi-agent, finished) carries the call totals (wall-clock, tools, tokens, cost); `alt+a live details` is shown only while something is still running. `alt+a` opens the live detail panel; `Ctrl+O` after completion expands to each agent's final output.
|
|
28
28
|
- **Abort** returns partial results — finished agents keep their output, the rest are marked `aborted`; the whole child process group is killed (`SIGTERM`, then `SIGKILL` after 5 s).
|
|
29
29
|
|
|
30
30
|
## Details panel (`alt+a`)
|
|
31
31
|
|
|
32
|
-
- One tab per sub-agent (`←`/`→` or `1`-`8`; the tab bar compacts automatically on narrow panels), labeled with name and live status (
|
|
32
|
+
- One tab per sub-agent (`←`/`→` or `1`-`8`; the tab bar compacts automatically on narrow panels), labeled with name and live status (`✻`/`·`/`✓`/`✗`).
|
|
33
33
|
- Each tab is the agent's full timeline: task, tool calls, tool-result previews (first 10 lines), assistant output rendered as markdown, usage. Thinking is not shown.
|
|
34
34
|
- Terminal-style scrolling: `↑/↓`, `PgUp/PgDn`, `Home`/`g`, `End`/`G`, mouse wheel — pinned to the bottom while following new output, scrolling up pauses, `End` resumes. `alt+a` toggles (same key opens and closes); `Esc` also closes.
|
|
35
35
|
- Pressing `alt+a` before any `spawn_agents` run shows pi's notify message above the input instead of opening an empty panel.
|
package/index.ts
CHANGED
|
@@ -118,6 +118,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
118
118
|
name: params.agents[i].name ?? `agent-${i + 1}`,
|
|
119
119
|
task: params.agents[i].task,
|
|
120
120
|
exitCode: -1, // -1 = still running
|
|
121
|
+
startedAt: Date.now(),
|
|
121
122
|
messages: [],
|
|
122
123
|
stderr: "",
|
|
123
124
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
@@ -151,6 +152,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
151
152
|
const partial = allResults[index];
|
|
152
153
|
partial.exitCode = 1;
|
|
153
154
|
partial.stopReason = "aborted";
|
|
155
|
+
partial.endedAt = Date.now();
|
|
154
156
|
return partial;
|
|
155
157
|
};
|
|
156
158
|
if (signal?.aborted) return abortPlaceholder();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-spice/minimal-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Create sub-agents dynamically and run them in parallel; single blocking tool, no orchestration, nesting prevented",
|
|
5
5
|
"keywords": ["pi-package"],
|
|
6
6
|
"license": "MIT",
|
package/panel.ts
CHANGED
|
@@ -86,7 +86,8 @@ function isAgentRunning(result: SingleResult): boolean {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function statusIcon(result: SingleResult, theme: RenderTheme): string {
|
|
89
|
-
if (isAgentRunning(result))
|
|
89
|
+
if (isAgentRunning(result))
|
|
90
|
+
return result.messages.length === 0 ? theme.fg("muted", "·") : theme.fg("warning", "✻");
|
|
90
91
|
return isFailedResult(result) ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
91
92
|
}
|
|
92
93
|
|
package/render.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as os from "node:os";
|
|
|
11
11
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
12
12
|
import type { Message } from "@earendil-works/pi-ai";
|
|
13
13
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
14
|
+
import { Container, Markdown, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
15
15
|
import {
|
|
16
16
|
getFinalOutput,
|
|
17
17
|
isFailedResult,
|
|
@@ -62,6 +62,22 @@ export function formatUsageStats(
|
|
|
62
62
|
return parts.join(" ");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Terminal width for transcript lines — the render hooks get no width from the host. */
|
|
66
|
+
function terminalColumns(): number {
|
|
67
|
+
return process.stdout.columns ?? 80;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Width-aware truncation for possibly ANSI-styled text: `truncateToWidth`
|
|
72
|
+
* measures display columns (CJK = 2, escapes = 0) and never slices an escape
|
|
73
|
+
* sequence or grapheme cluster in half. Hand-assembled transcript lines must
|
|
74
|
+
* go through this — never `String.slice`, which counts UTF-16 units and
|
|
75
|
+
* overflows on CJK text or styled strings.
|
|
76
|
+
*/
|
|
77
|
+
function truncateVisual(text: string, maxCols: number): string {
|
|
78
|
+
return truncateToWidth(text, maxCols, "…");
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
export function formatToolCall(
|
|
66
82
|
toolName: string,
|
|
67
83
|
args: Record<string, unknown>,
|
|
@@ -75,7 +91,7 @@ export function formatToolCall(
|
|
|
75
91
|
switch (toolName) {
|
|
76
92
|
case "bash": {
|
|
77
93
|
const command = (args.command as string) || "...";
|
|
78
|
-
const preview = command
|
|
94
|
+
const preview = truncateVisual(command, 60);
|
|
79
95
|
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
|
|
80
96
|
}
|
|
81
97
|
case "read": {
|
|
@@ -123,9 +139,11 @@ export function formatToolCall(
|
|
|
123
139
|
);
|
|
124
140
|
}
|
|
125
141
|
default: {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
// Unknown tools: preview the first string argument (a path, pattern,
|
|
143
|
+
// command — whatever it is) instead of dumping raw JSON.
|
|
144
|
+
const firstString = Object.values(args).find((v): v is string => typeof v === "string" && v.length > 0);
|
|
145
|
+
const preview = firstString ? ` ${truncateVisual(firstString, 50)}` : "";
|
|
146
|
+
return themeFg("accent", toolName) + themeFg("dim", preview);
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
}
|
|
@@ -146,56 +164,168 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
|
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
export function renderSpawnCall(args: SpawnAgentsArgs, theme: RenderTheme): Text {
|
|
149
|
-
|
|
150
|
-
|
|
167
|
+
// One line only: agent names and task previews live in the result blocks
|
|
168
|
+
// (seeded the moment the tool starts), so repeating them here would just
|
|
169
|
+
// duplicate the list below.
|
|
170
|
+
const count = args.agents?.length;
|
|
171
|
+
if (count) {
|
|
172
|
+
const text =
|
|
151
173
|
theme.fg("toolTitle", theme.bold("spawn_agents ")) +
|
|
152
|
-
theme.fg("accent", `(${
|
|
153
|
-
for (const a of args.agents.slice(0, 3)) {
|
|
154
|
-
const preview = a.task.length > 40 ? `${a.task.slice(0, 40)}...` : a.task;
|
|
155
|
-
const model = a.model ? theme.fg("dim", ` [${a.model}]`) : "";
|
|
156
|
-
text += `\n ${theme.fg("accent", a.name || "agent")}${model}${theme.fg("dim", ` ${preview}`)}`;
|
|
157
|
-
}
|
|
158
|
-
if (args.agents.length > 3) text += `\n ${theme.fg("muted", `... +${args.agents.length - 3} more`)}`;
|
|
174
|
+
theme.fg("accent", `(${count} agent${count > 1 ? "s" : ""})`);
|
|
159
175
|
return new Text(text, 0, 0);
|
|
160
176
|
}
|
|
161
177
|
return new Text(theme.fg("toolTitle", theme.bold("spawn_agents")), 0, 0);
|
|
162
178
|
}
|
|
163
179
|
|
|
164
|
-
function truncatePlain(text: string, max: number): string {
|
|
165
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
180
|
function isAgentRunning(details: SubagentDetails): boolean {
|
|
169
181
|
return details.results.some((r) => r.exitCode === -1);
|
|
170
182
|
}
|
|
171
183
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
184
|
+
// --- glyph system ------------------------------------------------------------
|
|
185
|
+
// Single-width glyphs only (⏳ renders emoji-wide on many terminals and breaks
|
|
186
|
+
// column alignment): ✻ running · queued ✓ done ✗ failed.
|
|
187
|
+
|
|
188
|
+
function statusGlyph(r: SingleResult, theme: RenderTheme): string {
|
|
189
|
+
if (r.exitCode === -1) return r.messages.length === 0 ? theme.fg("muted", "·") : theme.fg("warning", "✻");
|
|
190
|
+
return isFailedResult(r) ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toolUseCount(messages: Message[]): number {
|
|
194
|
+
let n = 0;
|
|
195
|
+
for (const msg of messages) {
|
|
196
|
+
if (msg.role !== "assistant") continue;
|
|
197
|
+
for (const part of msg.content as any[]) if (part.type === "toolCall") n++;
|
|
198
|
+
}
|
|
199
|
+
return n;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function formatDuration(ms: number): string {
|
|
203
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
204
|
+
if (s < 60) return `${s}s`;
|
|
205
|
+
const m = Math.floor(s / 60);
|
|
206
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
207
|
+
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Agent wall time; while running, elapsed since start (recomputed each render). */
|
|
211
|
+
function agentDuration(r: SingleResult): number {
|
|
212
|
+
return Math.max(0, (r.endedAt ?? Date.now()) - r.startedAt);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whole-call wall time: earliest start to latest end. */
|
|
216
|
+
function callDuration(details: SubagentDetails): number {
|
|
217
|
+
const start = Math.min(...details.results.map((r) => r.startedAt));
|
|
218
|
+
const end = Math.max(...details.results.map((r) => r.endedAt ?? Date.now()));
|
|
219
|
+
return Math.max(0, end - start);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function aggregateUsage(results: SingleResult[]) {
|
|
223
|
+
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
224
|
+
for (const r of results) {
|
|
225
|
+
total.input += r.usage.input;
|
|
226
|
+
total.output += r.usage.output;
|
|
227
|
+
total.cacheRead += r.usage.cacheRead;
|
|
228
|
+
total.cacheWrite += r.usage.cacheWrite;
|
|
229
|
+
total.cost += r.usage.cost;
|
|
230
|
+
total.turns += r.usage.turns;
|
|
231
|
+
}
|
|
232
|
+
return total;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Duration + tool count + (once finished) tokens and cost — expanded headers. */
|
|
236
|
+
function headerStats(r: SingleResult): string {
|
|
237
|
+
const parts = [formatDuration(agentDuration(r))];
|
|
238
|
+
const tools = toolUseCount(r.messages);
|
|
239
|
+
if (tools) parts.push(`${tools} tool${tools > 1 ? "s" : ""}`);
|
|
240
|
+
if (r.exitCode !== -1) {
|
|
241
|
+
if (r.usage.output) parts.push(`↓${formatTokens(r.usage.output)}`);
|
|
242
|
+
if (r.usage.cost) parts.push(`$${r.usage.cost.toFixed(4)}`);
|
|
243
|
+
}
|
|
244
|
+
return parts.join(" · ");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Duration + tool count only — collapsed headers; tokens/cost live on the summary line. */
|
|
248
|
+
function collapsedStats(r: SingleResult): string {
|
|
249
|
+
const parts = [formatDuration(agentDuration(r))];
|
|
250
|
+
const tools = toolUseCount(r.messages);
|
|
251
|
+
if (tools) parts.push(`${tools} tool${tools > 1 ? "s" : ""}`);
|
|
252
|
+
return parts.join(" · ");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function failReason(r: SingleResult): string {
|
|
256
|
+
return (r.errorMessage || r.stderr || r.stopReason || "error").split("\n")[0];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function taskFirstLine(task: string): string {
|
|
260
|
+
return task.split("\n").find((l) => l.trim().length > 0) ?? "";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Latest tool call or assistant text; only meaningful while the agent is in flight. */
|
|
264
|
+
function runningActivity(r: SingleResult, theme: RenderTheme): string | null {
|
|
265
|
+
if (r.exitCode !== -1 || isQueued(r)) return null;
|
|
266
|
+
const items = getDisplayItems(r.messages);
|
|
267
|
+
const last = items[items.length - 1];
|
|
268
|
+
if (!last) return theme.fg("muted", "starting…");
|
|
269
|
+
if (last.type === "toolCall") return formatToolCall(last.name, last.args, theme.fg.bind(theme));
|
|
270
|
+
return theme.fg("toolOutput", last.text.split("\n")[0]);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isQueued(r: SingleResult): boolean {
|
|
274
|
+
return r.exitCode === -1 && r.messages.length === 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The collapsed transcript block under the one-line call header
|
|
279
|
+
* (`spawn_agents (N agents)`). Every agent is a 2-line block — glyph + name
|
|
280
|
+
* + stats, then the first line of its task (the stable identifier, even
|
|
281
|
+
* when names are opaque). Running agents grow a third line with the latest
|
|
282
|
+
* tool call. Failed agents put the error on the header; tokens/cost live
|
|
283
|
+
* only on the call-total summary (multi-agent, finished). The hint line is
|
|
284
|
+
* running-only. The panel (alt+a) is the live timeline; this stays a summary.
|
|
285
|
+
*/
|
|
286
|
+
function scoreboardView(details: SubagentDetails, theme: RenderTheme): Text {
|
|
287
|
+
const results = details.results;
|
|
288
|
+
const running = results.filter((r) => r.exitCode === -1).length;
|
|
289
|
+
const successCount = results.filter((r) => r.exitCode !== -1 && !isFailedResult(r)).length;
|
|
290
|
+
const failCount = results.length - running - successCount;
|
|
291
|
+
const isRunning = running > 0;
|
|
292
|
+
|
|
293
|
+
let summary = "";
|
|
294
|
+
if (!isRunning && results.length > 1) {
|
|
295
|
+
const parts = [`${successCount}/${results.length}`];
|
|
296
|
+
if (failCount > 0) parts.push(`${failCount} failed`);
|
|
297
|
+
parts.push(formatDuration(callDuration(details)));
|
|
298
|
+
const total = aggregateUsage(results);
|
|
299
|
+
const totalTools = results.reduce((n, r) => n + toolUseCount(r.messages), 0);
|
|
300
|
+
if (totalTools > 0) parts.push(`${totalTools} tools`);
|
|
301
|
+
if (total.output) parts.push(`↓${formatTokens(total.output)}`);
|
|
302
|
+
if (total.cost) parts.push(`$${total.cost.toFixed(4)}`);
|
|
303
|
+
summary = parts.join(" · ");
|
|
195
304
|
}
|
|
196
305
|
|
|
197
|
-
|
|
198
|
-
|
|
306
|
+
// Label models only when the call mixes them — a uniform call would
|
|
307
|
+
// just repeat the parent's model on every block.
|
|
308
|
+
const models = new Set(results.map((r) => r.model).filter(Boolean));
|
|
309
|
+
const showModels = models.size > 1;
|
|
310
|
+
const cols = terminalColumns();
|
|
311
|
+
|
|
312
|
+
const lines: string[] = [];
|
|
313
|
+
for (const r of results) {
|
|
314
|
+
let header = `${statusGlyph(r, theme)} ${theme.fg("toolTitle", theme.bold(r.name))}`;
|
|
315
|
+
if (!isQueued(r)) header += ` ${theme.fg("dim", collapsedStats(r))}`;
|
|
316
|
+
if (showModels && r.model) header += ` ${theme.fg("dim", r.model.split("/").pop() ?? r.model)}`;
|
|
317
|
+
if (isFailedResult(r)) header += ` ${theme.fg("error", failReason(r))}`;
|
|
318
|
+
lines.push(truncateVisual(header, cols));
|
|
319
|
+
|
|
320
|
+
const task = taskFirstLine(r.task);
|
|
321
|
+
if (task) lines.push(` ${truncateVisual(theme.fg("dim", task), cols - 2)}`);
|
|
322
|
+
|
|
323
|
+
const activity = runningActivity(r, theme);
|
|
324
|
+
if (activity) lines.push(` ${truncateVisual(activity, cols - 2)}`);
|
|
325
|
+
}
|
|
326
|
+
if (summary) lines.push(theme.fg("dim", summary));
|
|
327
|
+
if (isRunning) lines.push(theme.fg("muted", "alt+a live details"));
|
|
328
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
199
329
|
}
|
|
200
330
|
|
|
201
331
|
export function renderSpawnResult(
|
|
@@ -211,129 +341,42 @@ export function renderSpawnResult(
|
|
|
211
341
|
|
|
212
342
|
const mdTheme = getMarkdownTheme();
|
|
213
343
|
|
|
214
|
-
if (expanded && !isAgentRunning(details)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const finalOutput = getFinalOutput(r.messages);
|
|
220
|
-
|
|
344
|
+
if (expanded && !isAgentRunning(details)) {
|
|
345
|
+
// Expanded (ctrl+o, after completion): final outputs only — one block
|
|
346
|
+
// per agent with a quantified header and the answer rendered as
|
|
347
|
+
// markdown. The per-tool timeline is the panel's job (alt+a); the
|
|
348
|
+
// transcript archive does not duplicate it.
|
|
221
349
|
const container = new Container();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
container.addChild(new Spacer(1));
|
|
231
|
-
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
|
232
|
-
if (displayItems.length === 0 && !finalOutput) {
|
|
233
|
-
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
234
|
-
} else {
|
|
235
|
-
for (const item of displayItems) {
|
|
236
|
-
if (item.type === "toolCall")
|
|
237
|
-
container.addChild(
|
|
238
|
-
new Text(
|
|
239
|
-
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
|
240
|
-
0, 0,
|
|
241
|
-
),
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
if (finalOutput) {
|
|
245
|
-
container.addChild(new Spacer(1));
|
|
246
|
-
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
const usageStr = formatUsageStats(r.usage, r.model);
|
|
250
|
-
if (usageStr) {
|
|
251
|
-
container.addChild(new Spacer(1));
|
|
252
|
-
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
|
253
|
-
}
|
|
254
|
-
return container;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const aggregateUsage = (results: SingleResult[]) => {
|
|
258
|
-
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
259
|
-
for (const r of results) {
|
|
260
|
-
total.input += r.usage.input;
|
|
261
|
-
total.output += r.usage.output;
|
|
262
|
-
total.cacheRead += r.usage.cacheRead;
|
|
263
|
-
total.cacheWrite += r.usage.cacheWrite;
|
|
264
|
-
total.cost += r.usage.cost;
|
|
265
|
-
total.turns += r.usage.turns;
|
|
266
|
-
}
|
|
267
|
-
return total;
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
const running = details.results.filter((r) => r.exitCode === -1).length;
|
|
271
|
-
const successCount = details.results.filter((r) => r.exitCode !== -1 && !isFailedResult(r)).length;
|
|
272
|
-
const failCount = details.results.filter((r) => r.exitCode !== -1 && isFailedResult(r)).length;
|
|
273
|
-
const isRunning = running > 0;
|
|
274
|
-
const icon = isRunning
|
|
275
|
-
? theme.fg("warning", "⏳")
|
|
276
|
-
: failCount > 0
|
|
277
|
-
? theme.fg("warning", "◐")
|
|
278
|
-
: theme.fg("success", "✓");
|
|
279
|
-
const status = isRunning
|
|
280
|
-
? `${successCount + failCount}/${details.results.length} done, ${running} running`
|
|
281
|
-
: `${successCount}/${details.results.length} succeeded${failCount > 0 ? `, ${failCount} failed` : ""}`;
|
|
282
|
-
|
|
283
|
-
if (expanded && !isRunning) {
|
|
284
|
-
const container = new Container();
|
|
285
|
-
container.addChild(
|
|
286
|
-
new Text(`${icon} ${theme.fg("toolTitle", theme.bold("spawn_agents "))}${theme.fg("accent", status)}`, 0, 0),
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
for (const r of details.results) {
|
|
290
|
-
const rIcon = isFailedResult(r) ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
291
|
-
const displayItems = getDisplayItems(r.messages);
|
|
292
|
-
const finalOutput = getFinalOutput(r.messages);
|
|
293
|
-
|
|
294
|
-
container.addChild(new Spacer(1));
|
|
295
|
-
container.addChild(new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.name)} ${rIcon}`, 0, 0));
|
|
350
|
+
details.results.forEach((r, i) => {
|
|
351
|
+
if (i > 0) container.addChild(new Spacer(1));
|
|
352
|
+
const isError = isFailedResult(r);
|
|
353
|
+
let header = `${statusGlyph(r, theme)} ${theme.fg("toolTitle", theme.bold(r.name))} ${theme.fg("dim", headerStats(r))}`;
|
|
354
|
+
if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
355
|
+
container.addChild(new Text(header, 0, 0));
|
|
356
|
+
if (isError && r.errorMessage)
|
|
357
|
+
container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
|
|
296
358
|
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
for (const item of displayItems) {
|
|
300
|
-
if (item.type === "toolCall") {
|
|
301
|
-
container.addChild(
|
|
302
|
-
new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0),
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Show final output as markdown
|
|
308
|
-
if (finalOutput) {
|
|
359
|
+
const finalOutput = getFinalOutput(r.messages);
|
|
360
|
+
if (finalOutput.trim()) {
|
|
309
361
|
container.addChild(new Spacer(1));
|
|
310
362
|
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
363
|
+
} else {
|
|
364
|
+
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
311
365
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
366
|
+
});
|
|
367
|
+
if (details.results.length > 1) {
|
|
368
|
+
// Same shape as the collapsed header, prefixed with Total.
|
|
369
|
+
const total = aggregateUsage(details.results);
|
|
370
|
+
const totalTools = toolUseCount(details.results.flatMap((r) => r.messages));
|
|
371
|
+
const headParts = [formatDuration(callDuration(details))];
|
|
372
|
+
if (totalTools > 0) headParts.push(`${totalTools} tools`);
|
|
373
|
+
if (total.output) headParts.push(`↓${formatTokens(total.output)}`);
|
|
374
|
+
if (total.cost) headParts.push(`$${total.cost.toFixed(4)}`);
|
|
319
375
|
container.addChild(new Spacer(1));
|
|
320
|
-
container.addChild(new Text(theme.fg("dim", `Total: ${
|
|
376
|
+
container.addChild(new Text(theme.fg("dim", `Total: ${headParts.join(" · ")}`), 0, 0));
|
|
321
377
|
}
|
|
322
378
|
return container;
|
|
323
379
|
}
|
|
324
380
|
|
|
325
|
-
|
|
326
|
-
// The panel (alt+a) is the live view; the collapsed transcript block is a
|
|
327
|
-
// summary, not a competing log. Expanded (Ctrl+O, after completion) is the
|
|
328
|
-
// archive.
|
|
329
|
-
let text = `${icon} ${theme.fg("toolTitle", theme.bold("spawn_agents "))}${theme.fg("accent", status)}`;
|
|
330
|
-
for (const r of details.results) text += `\n${scoreboardLine(r, theme)}`;
|
|
331
|
-
if (!isRunning) {
|
|
332
|
-
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
|
333
|
-
if (usageStr) text += `\n${theme.fg("dim", `Total: ${usageStr}`)}`;
|
|
334
|
-
text += `\n${theme.fg("muted", "(alt+a · Ctrl+O)")}`;
|
|
335
|
-
} else {
|
|
336
|
-
text += `\n${theme.fg("muted", "(alt+a · live details)")}`;
|
|
337
|
-
}
|
|
338
|
-
return new Text(text, 0, 0);
|
|
381
|
+
return scoreboardView(details, theme);
|
|
339
382
|
}
|
package/spawn.ts
CHANGED
|
@@ -30,6 +30,9 @@ export interface SingleResult {
|
|
|
30
30
|
task: string;
|
|
31
31
|
/** -1 while the agent is still running */
|
|
32
32
|
exitCode: number;
|
|
33
|
+
/** Wall-clock timestamps: call time to agent end (unset while running). */
|
|
34
|
+
startedAt: number;
|
|
35
|
+
endedAt?: number;
|
|
33
36
|
messages: Message[];
|
|
34
37
|
stderr: string;
|
|
35
38
|
usage: UsageStats;
|
|
@@ -162,6 +165,7 @@ export async function runSpec(
|
|
|
162
165
|
name: displayName,
|
|
163
166
|
task: spec.task,
|
|
164
167
|
exitCode: -1, // -1 = still running; real exit code set on close
|
|
168
|
+
startedAt: Date.now(),
|
|
165
169
|
messages: [],
|
|
166
170
|
stderr: "",
|
|
167
171
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
@@ -281,6 +285,7 @@ export async function runSpec(
|
|
|
281
285
|
});
|
|
282
286
|
|
|
283
287
|
currentResult.exitCode = exitCode;
|
|
288
|
+
currentResult.endedAt = Date.now();
|
|
284
289
|
if (wasAborted) throw new Error("Subagent was aborted");
|
|
285
290
|
return currentResult;
|
|
286
291
|
}
|