@pi-spice/minimal-subagents 0.1.0 → 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 +13 -0
- package/README.md +4 -3
- package/index.ts +20 -3
- package/package.json +1 -1
- package/panel.ts +54 -17
- package/render.ts +201 -158
- package/spawn.ts +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @pi-spice/minimal-subagents
|
|
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
|
+
|
|
9
|
+
## 0.1.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 245d41f: Details panel: alt+a now toggles (also closes an open panel); panel data is seeded the moment spawn_agents starts, so alt+a works during the child-startup window and after an interrupt instead of reporting "no data"; alt+a with no run at all shows pi's notify; panel content gets inner padding and footer hints that fit narrow panels.
|
package/README.md
CHANGED
|
@@ -24,14 +24,15 @@ 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
|
-
- Terminal-style scrolling: `↑/↓`, `PgUp/PgDn`, `Home`/`g`, `End`/`G`, mouse wheel — pinned to the bottom while following new output, scrolling up pauses, `End` resumes. `Esc` closes.
|
|
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
|
+
- Pressing `alt+a` before any `spawn_agents` run shows pi's notify message above the input instead of opening an empty panel.
|
|
35
36
|
- Shows the latest call only. Two platform limits: it is an overlay (the transcript is covered, not reflowed), and mouse wheel works only under `--tui-mode fullscreen` — the only mode where pi enables terminal mouse reporting.
|
|
36
37
|
|
|
37
38
|
## No nesting
|
package/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type SubagentDetails,
|
|
29
29
|
} from "./spawn.ts";
|
|
30
30
|
import { renderSpawnCall, renderSpawnResult } from "./render.ts";
|
|
31
|
-
import { openAgentPanel, setPanelDetails } from "./panel.ts";
|
|
31
|
+
import { hasPanelDetails, openAgentPanel, setPanelDetails } from "./panel.ts";
|
|
32
32
|
|
|
33
33
|
const MAX_AGENTS = 8;
|
|
34
34
|
const MAX_CONCURRENCY = 4;
|
|
@@ -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 },
|
|
@@ -138,12 +139,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
138
139
|
}
|
|
139
140
|
};
|
|
140
141
|
|
|
142
|
+
// Seed the panel the moment the tool starts: alt+a can then open it and
|
|
143
|
+
// show "running" placeholders. Without this, the panel would stay
|
|
144
|
+
// "no data" until the first child event arrives — child boot plus the
|
|
145
|
+
// child's first model turn can take 5-20s, and interrupting (or just
|
|
146
|
+
// peeking) inside that window would claim there is nothing to show.
|
|
147
|
+
emitParallelUpdate();
|
|
148
|
+
|
|
141
149
|
const results = await mapWithConcurrencyLimit(params.agents, MAX_CONCURRENCY, async (spec, index) => {
|
|
142
150
|
// On abort, return what exists instead of throwing finished work away.
|
|
143
151
|
const abortPlaceholder = (): SingleResult => {
|
|
144
152
|
const partial = allResults[index];
|
|
145
153
|
partial.exitCode = 1;
|
|
146
154
|
partial.stopReason = "aborted";
|
|
155
|
+
partial.endedAt = Date.now();
|
|
147
156
|
return partial;
|
|
148
157
|
};
|
|
149
158
|
if (signal?.aborted) return abortPlaceholder();
|
|
@@ -202,7 +211,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
202
211
|
});
|
|
203
212
|
|
|
204
213
|
pi.registerShortcut("alt+a", {
|
|
205
|
-
description: "
|
|
206
|
-
handler: (ctx) =>
|
|
214
|
+
description: "Toggle the sub-agent details panel (tabs per agent, full timeline); alt+a or Esc closes it",
|
|
215
|
+
handler: (ctx) => {
|
|
216
|
+
// No run yet → pi's notify message, not an empty overlay stuck in the
|
|
217
|
+
// corner.
|
|
218
|
+
if (!hasPanelDetails()) {
|
|
219
|
+
ctx.ui.notify("No sub-agent data yet — run spawn_agents first", "info");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
openAgentPanel(ctx);
|
|
223
|
+
},
|
|
207
224
|
});
|
|
208
225
|
}
|
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
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* panel.ts — sub-agent details panel (overlay) for minimal-subagents
|
|
3
3
|
*
|
|
4
|
-
* Opened with alt+a (registered in index.ts)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Opened with alt+a (registered in index.ts), closed with alt+a or Esc —
|
|
5
|
+
* while the panel is focused the host routes all input here, so the open
|
|
6
|
+
* shortcut never fires; the panel must recognize alt+a itself to toggle.
|
|
7
|
+
* With no spawn_agents data yet, the shortcut shows a notify message
|
|
8
|
+
* instead of opening an empty overlay (guarded in index.ts via
|
|
9
|
+
* hasPanelDetails).
|
|
10
|
+
* Shows the latest spawn_agents call: one tab per sub-agent, a full scrollable
|
|
11
|
+
* timeline per tab (task, tool calls, tool-result previews, assistant output
|
|
12
|
+
* rendered as markdown, usage), live-updating while agents run.
|
|
8
13
|
*
|
|
9
14
|
* Rendering is line-based: the timeline is flattened into styled lines and
|
|
10
15
|
* windowed by a hand-rolled viewport (offset math) — the overlay contract is
|
|
@@ -13,7 +18,7 @@
|
|
|
13
18
|
* while the overlay is focused.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
|
-
import { Markdown, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
21
|
+
import { Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
17
22
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
18
23
|
import { type Message } from "@earendil-works/pi-ai";
|
|
19
24
|
import { formatToolCall, formatUsageStats, type RenderTheme } from "./render.ts";
|
|
@@ -32,6 +37,11 @@ export function setPanelDetails(details: SubagentDetails): void {
|
|
|
32
37
|
for (const listener of listeners) listener();
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
/** True once at least one spawn_agents result (even still running) exists. */
|
|
41
|
+
export function hasPanelDetails(): boolean {
|
|
42
|
+
return currentDetails !== null && currentDetails.results.length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
// ---------------------------------------------------------------------------
|
|
36
46
|
// Panel opening
|
|
37
47
|
// ---------------------------------------------------------------------------
|
|
@@ -39,7 +49,7 @@ export function setPanelDetails(details: SubagentDetails): void {
|
|
|
39
49
|
let opening: Promise<unknown> | null = null;
|
|
40
50
|
|
|
41
51
|
export function openAgentPanel(ctx: { ui: any; hasUI?: boolean }): void {
|
|
42
|
-
if (opening) return; // already open; Esc closes
|
|
52
|
+
if (opening) return; // already open; alt+a or Esc closes
|
|
43
53
|
if (ctx.hasUI === false) return;
|
|
44
54
|
opening = ctx.ui
|
|
45
55
|
.custom(
|
|
@@ -76,7 +86,8 @@ function isAgentRunning(result: SingleResult): boolean {
|
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
function statusIcon(result: SingleResult, theme: RenderTheme): string {
|
|
79
|
-
if (isAgentRunning(result))
|
|
89
|
+
if (isAgentRunning(result))
|
|
90
|
+
return result.messages.length === 0 ? theme.fg("muted", "·") : theme.fg("warning", "✻");
|
|
80
91
|
return isFailedResult(result) ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
81
92
|
}
|
|
82
93
|
|
|
@@ -187,6 +198,12 @@ class AgentPanel {
|
|
|
187
198
|
// A newer spawn_agents call may have fewer agents — clamp the tab.
|
|
188
199
|
this.activeTab = Math.min(this.activeTab, details.results.length - 1);
|
|
189
200
|
|
|
201
|
+
// Inner padding: 1 column each side and 1 blank line above/below the
|
|
202
|
+
// body, so content breathes away from the rules and panel edges. The
|
|
203
|
+
// rules span the full width; content wraps at width - 2.
|
|
204
|
+
const innerWidth = Math.max(10, width - 2);
|
|
205
|
+
const pad = (line: string) => (line.length === 0 ? "" : ` ${line}`);
|
|
206
|
+
|
|
190
207
|
// --- header: tab bar ------------------------------------------------
|
|
191
208
|
// Full labels (index + name + status) when they fit the panel width;
|
|
192
209
|
// otherwise degrade to compact slots (index + status) which always fit
|
|
@@ -200,35 +217,47 @@ class AgentPanel {
|
|
|
200
217
|
return `${i + 1} ${name} ${statusIcon(r, theme)}`;
|
|
201
218
|
});
|
|
202
219
|
const fullFits =
|
|
203
|
-
7 + fullLabels.reduce((sum, l) => sum + visibleWidth(l) + 2, 0) + (fullLabels.length - 1) <=
|
|
220
|
+
7 + fullLabels.reduce((sum, l) => sum + visibleWidth(l) + 2, 0) + (fullLabels.length - 1) <= innerWidth;
|
|
204
221
|
const tabs = fullFits
|
|
205
222
|
? renderSlots(fullLabels)
|
|
206
223
|
: renderSlots(details.results.map((r, i) => `${i + 1}${statusIcon(r, theme)}`));
|
|
207
|
-
const header = [theme.fg("toolTitle", theme.bold("agents ")) + tabs, theme.fg("muted", "─".repeat(width))];
|
|
224
|
+
const header = [pad(theme.fg("toolTitle", theme.bold("agents ")) + tabs), theme.fg("muted", "─".repeat(width))];
|
|
208
225
|
|
|
209
226
|
// --- body: windowed timeline ----------------------------------------
|
|
210
227
|
const rows = this.tui?.terminal?.rows ?? process.stdout.rows ?? 24;
|
|
211
|
-
const headerH = header.length; // tab bar + rule
|
|
212
|
-
const footerH = 2; // rule + status line
|
|
228
|
+
const headerH = header.length + 1; // blank pad + tab bar + rule
|
|
229
|
+
const footerH = 2 + 1; // rule + status line + blank pad above the rule
|
|
213
230
|
this.bodyHeight = Math.max(4, rows - headerH - footerH);
|
|
214
231
|
|
|
215
|
-
const lines = buildTimeline(details.results[this.activeTab], theme,
|
|
232
|
+
const lines = buildTimeline(details.results[this.activeTab], theme, innerWidth);
|
|
216
233
|
this.lineCount = lines.length;
|
|
217
234
|
|
|
218
235
|
const maxOffset = Math.max(0, this.lineCount - this.bodyHeight);
|
|
219
236
|
if (this.follow) this.offset = maxOffset;
|
|
220
237
|
this.offset = Math.min(Math.max(0, this.offset), maxOffset);
|
|
221
|
-
const body = lines.slice(this.offset, this.offset + this.bodyHeight);
|
|
238
|
+
const body = lines.slice(this.offset, this.offset + this.bodyHeight).map(pad);
|
|
222
239
|
while (body.length < this.bodyHeight) body.push(""); // stable panel height
|
|
223
240
|
|
|
224
241
|
// --- footer: scroll position + hints ---------------------------------
|
|
225
242
|
const pos = this.lineCount > 0 ? `${this.offset + 1}-${Math.min(this.offset + this.bodyHeight, this.lineCount)}/${this.lineCount}` : "0";
|
|
226
243
|
const mode = this.follow ? "following" : "paused";
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
244
|
+
const statusText = theme.fg("dim", `${pos} ${mode}`);
|
|
245
|
+
// Hint set degrades as the panel narrows; truncate is only a backstop.
|
|
246
|
+
const hintVariants = [
|
|
247
|
+
" · ←/→ tab · ↑/↓ scroll · End follow · alt+a/Esc close",
|
|
248
|
+
" · ←/→ tab · End follow · alt+a/Esc close",
|
|
249
|
+
" · alt+a/Esc close",
|
|
250
|
+
];
|
|
251
|
+
let hints = "";
|
|
252
|
+
for (const hint of hintVariants) {
|
|
253
|
+
if (visibleWidth(statusText) + hint.length <= innerWidth) {
|
|
254
|
+
hints = theme.fg("muted", hint);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const footer = truncateToWidth(pad(statusText + hints), width);
|
|
230
259
|
|
|
231
|
-
return [...header, ...body, theme.fg("muted", "─".repeat(width)), footer];
|
|
260
|
+
return ["", ...header, ...body, "", theme.fg("muted", "─".repeat(width)), footer];
|
|
232
261
|
}
|
|
233
262
|
|
|
234
263
|
handleInput(data: string): void {
|
|
@@ -241,6 +270,14 @@ class AgentPanel {
|
|
|
241
270
|
}
|
|
242
271
|
|
|
243
272
|
private handleInputInner(data: string): void {
|
|
273
|
+
// alt+a toggles: while we hold focus the host shortcut cannot fire, so
|
|
274
|
+
// the panel closes on the same key that opened it (matchesKey covers
|
|
275
|
+
// legacy ESC+a and kitty/CSI-u encodings alike).
|
|
276
|
+
if (matchesKey(data, "alt+a")) {
|
|
277
|
+
this.close();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
244
281
|
const details = currentDetails;
|
|
245
282
|
if (!details || details.results.length === 0) {
|
|
246
283
|
if (data === "\x1b") this.close();
|
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
|
}
|