@pi-spice/minimal-subagents 0.1.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/README.md +39 -0
- package/index.ts +208 -0
- package/package.json +25 -0
- package/panel.ts +338 -0
- package/render.ts +339 -0
- package/spawn.ts +286 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @pi-spice/minimal-subagents
|
|
2
|
+
|
|
3
|
+
One tool, `spawn_agents`: describe sub-agents inline, run them in parallel, block until every one finishes. No predefined agent files, no orchestration, no nesting. Each sub-agent is an isolated `pi` process with its own context window; the child-process machinery is adapted from pi's official subagent example.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@pi-spice/minimal-subagents
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Quick test from this repo: `pi -e ./extensions/minimal-subagents`
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
`spawn_agents({ agents: [spec...] })` — a single task is an array of one; up to 8 per call, 4 running at a time.
|
|
16
|
+
|
|
17
|
+
| Field | Required | Default |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `task` | ✓ | — |
|
|
20
|
+
| `systemPrompt` | — | child default; role/constraints go here, not the assignment |
|
|
21
|
+
| `model` | — | inherit the parent session's model |
|
|
22
|
+
| `thinking` | — | inherit the parent session's thinking level (`off`…`max`) |
|
|
23
|
+
| `tools` | — | child default tools; e.g. `["read","grep","find","ls"]` for read-only scouts |
|
|
24
|
+
| `name` | — | `agent-<index>` |
|
|
25
|
+
|
|
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-per-agent scoreboard in the transcript (status, turns, latest activity); `alt+a` for the live detail panel, `Ctrl+O` after completion for the full archive.
|
|
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
|
+
|
|
30
|
+
## Details panel (`alt+a`)
|
|
31
|
+
|
|
32
|
+
- One tab per sub-agent (`←`/`→` or `1`-`8`; the tab bar compacts automatically on narrow panels), labeled with name and live status (⏳/✓/✗).
|
|
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.
|
|
35
|
+
- 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
|
+
## No nesting
|
|
38
|
+
|
|
39
|
+
Children run with `PI_SUBAGENTS_CHILD=1` (the extension skips tool registration when it sees it) and are launched with `--exclude-tools spawn_agents` as a backstop. This is a guard, not a sandbox: a sub-agent with `bash` can still start arbitrary processes and work around both layers (e.g. `env -u PI_SUBAGENTS_CHILD pi ...`) — use `tools` restrictions or a container for hard isolation. Side effect: exporting `PI_SUBAGENTS_CHILD=1` in your own shell hides `spawn_agents` from your sessions.
|
package/index.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* minimal-subagents — spawn dynamically created sub-agents, in parallel
|
|
3
|
+
*
|
|
4
|
+
* Fork of the official pi example extension `examples/extensions/subagent`
|
|
5
|
+
* (pi v0.84.4, MIT License, https://github.com/earendil-works/pi), reshaped:
|
|
6
|
+
* agents are defined inline per invocation (no predefined .md files), there is
|
|
7
|
+
* a single `{ agents: [spec...] }` mode (no chain/orchestration), and nesting
|
|
8
|
+
* is prevented via an environment sentinel. The parent blocks until every
|
|
9
|
+
* sub-agent finished; each spec can pick its own model, thinking level and
|
|
10
|
+
* tool allowlist, inheriting the parent session's model/thinking by default.
|
|
11
|
+
*
|
|
12
|
+
* Process spawning lives in spawn.ts, TUI rendering in render.ts.
|
|
13
|
+
*
|
|
14
|
+
* Install: pi install npm:@pi-spice/minimal-subagents
|
|
15
|
+
* Quick test: pi -e ./extensions/minimal-subagents
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
19
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
import {
|
|
22
|
+
getResultOutput,
|
|
23
|
+
isFailedResult,
|
|
24
|
+
mapWithConcurrencyLimit,
|
|
25
|
+
runSpec,
|
|
26
|
+
truncateParallelOutput,
|
|
27
|
+
type SingleResult,
|
|
28
|
+
type SubagentDetails,
|
|
29
|
+
} from "./spawn.ts";
|
|
30
|
+
import { renderSpawnCall, renderSpawnResult } from "./render.ts";
|
|
31
|
+
import { openAgentPanel, setPanelDetails } from "./panel.ts";
|
|
32
|
+
|
|
33
|
+
const MAX_AGENTS = 8;
|
|
34
|
+
const MAX_CONCURRENCY = 4;
|
|
35
|
+
|
|
36
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
37
|
+
|
|
38
|
+
const AgentSpecSchema = Type.Object({
|
|
39
|
+
name: Type.Optional(
|
|
40
|
+
Type.String({
|
|
41
|
+
description:
|
|
42
|
+
'Heading for this agent result section ("### [name]"). Prefer a short role name like "scout" over agent-1.',
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
systemPrompt: Type.Optional(
|
|
46
|
+
Type.String({
|
|
47
|
+
description:
|
|
48
|
+
"Role, constraints and output-format rules for this agent (appended to the child's system prompt). Put standing instructions here, not the assignment.",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
task: Type.String({ minLength: 1, description: "The concrete assignment this agent must complete" }),
|
|
52
|
+
model: Type.Optional(
|
|
53
|
+
Type.String({ description: 'Model for this agent, e.g. "anthropic/claude-haiku-4-5". Default: inherit parent session model' }),
|
|
54
|
+
),
|
|
55
|
+
thinking: Type.Optional(
|
|
56
|
+
StringEnum(THINKING_LEVELS, {
|
|
57
|
+
description: "Reasoning effort for this agent. Default: inherit parent session thinking level",
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
tools: Type.Optional(
|
|
61
|
+
Type.Array(Type.String(), {
|
|
62
|
+
description: 'Tool allowlist for this agent, e.g. ["read","grep","find","ls"]. Default: child default tools',
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const SpawnAgentsParams = Type.Object({
|
|
68
|
+
agents: Type.Array(AgentSpecSchema, {
|
|
69
|
+
minItems: 1,
|
|
70
|
+
maxItems: MAX_AGENTS,
|
|
71
|
+
description: `Agents to create and run (1-${MAX_AGENTS}; at most ${MAX_CONCURRENCY} run at a time). A single task is an array of one. Blocks until every agent finishes.`,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export default function (pi: ExtensionAPI) {
|
|
76
|
+
// Nesting guard: sub-agent processes run with PI_SUBAGENTS_CHILD=1 (set in
|
|
77
|
+
// runSpec's spawn env). Refuse to register the tool there, so sub-agents
|
|
78
|
+
// cannot spawn their own sub-agents — while keeping every other extension
|
|
79
|
+
// available to them.
|
|
80
|
+
if (process.env.PI_SUBAGENTS_CHILD) return;
|
|
81
|
+
|
|
82
|
+
pi.registerTool({
|
|
83
|
+
name: "spawn_agents",
|
|
84
|
+
label: "Spawn agents",
|
|
85
|
+
description: [
|
|
86
|
+
"Create sub-agents dynamically and run them in parallel, each in an isolated pi process.",
|
|
87
|
+
"Each agent is defined inline: systemPrompt (role/constraints) + task (assignment), with optional model, thinking level and tool allowlist; unset fields inherit the parent session.",
|
|
88
|
+
"Blocks until all agents finish, then returns each agent's final output under ### [name] headings.",
|
|
89
|
+
"The parent's working directory and project context (AGENTS.md) are shared; agents cannot spawn further sub-agents.",
|
|
90
|
+
].join(" "),
|
|
91
|
+
promptGuidelines: [
|
|
92
|
+
"spawn_agents returns only each agent's final text under ### [name] headings; agents' intermediate steps stay hidden, so ask agents to put key findings in their final answer.",
|
|
93
|
+
"spawn_agents agents run isolated from each other; one failing does not stop the others, and the tool result reports every agent's status.",
|
|
94
|
+
],
|
|
95
|
+
parameters: SpawnAgentsParams,
|
|
96
|
+
|
|
97
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
98
|
+
if (params.agents.length > MAX_AGENTS) {
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: "text", text: `Too many agents (${params.agents.length}). Max is ${MAX_AGENTS}.` }],
|
|
101
|
+
details: { results: [] },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const dispatchDefaults = {
|
|
106
|
+
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
107
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const makeDetails = (results: SingleResult[]): SubagentDetails => ({ results });
|
|
111
|
+
|
|
112
|
+
// Track all results for streaming updates
|
|
113
|
+
const allResults: SingleResult[] = new Array(params.agents.length);
|
|
114
|
+
|
|
115
|
+
// Initialize placeholder results
|
|
116
|
+
for (let i = 0; i < params.agents.length; i++) {
|
|
117
|
+
allResults[i] = {
|
|
118
|
+
name: params.agents[i].name ?? `agent-${i + 1}`,
|
|
119
|
+
task: params.agents[i].task,
|
|
120
|
+
exitCode: -1, // -1 = still running
|
|
121
|
+
messages: [],
|
|
122
|
+
stderr: "",
|
|
123
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const emitParallelUpdate = () => {
|
|
128
|
+
setPanelDetails(makeDetails([...allResults]));
|
|
129
|
+
if (onUpdate) {
|
|
130
|
+
const running = allResults.filter((r) => r.exitCode === -1).length;
|
|
131
|
+
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
|
132
|
+
onUpdate({
|
|
133
|
+
content: [
|
|
134
|
+
{ type: "text", text: `Running: ${done}/${allResults.length} done, ${running} running...` },
|
|
135
|
+
],
|
|
136
|
+
details: makeDetails([...allResults]),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const results = await mapWithConcurrencyLimit(params.agents, MAX_CONCURRENCY, async (spec, index) => {
|
|
142
|
+
// On abort, return what exists instead of throwing finished work away.
|
|
143
|
+
const abortPlaceholder = (): SingleResult => {
|
|
144
|
+
const partial = allResults[index];
|
|
145
|
+
partial.exitCode = 1;
|
|
146
|
+
partial.stopReason = "aborted";
|
|
147
|
+
return partial;
|
|
148
|
+
};
|
|
149
|
+
if (signal?.aborted) return abortPlaceholder();
|
|
150
|
+
try {
|
|
151
|
+
const result = await runSpec(
|
|
152
|
+
ctx.cwd,
|
|
153
|
+
dispatchDefaults,
|
|
154
|
+
spec,
|
|
155
|
+
allResults[index].name,
|
|
156
|
+
signal,
|
|
157
|
+
(partial) => {
|
|
158
|
+
if (partial.details?.results[0]) {
|
|
159
|
+
allResults[index] = partial.details.results[0];
|
|
160
|
+
emitParallelUpdate();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
makeDetails,
|
|
164
|
+
);
|
|
165
|
+
allResults[index] = result;
|
|
166
|
+
emitParallelUpdate();
|
|
167
|
+
return result;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (signal?.aborted) return abortPlaceholder();
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const successCount = results.filter((r) => !isFailedResult(r)).length;
|
|
175
|
+
const aborted = signal?.aborted === true;
|
|
176
|
+
setPanelDetails(makeDetails(results));
|
|
177
|
+
const summaries = results.map((r) => {
|
|
178
|
+
const output = truncateParallelOutput(getResultOutput(r));
|
|
179
|
+
const status = isFailedResult(r)
|
|
180
|
+
? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}`
|
|
181
|
+
: "completed";
|
|
182
|
+
const usage =
|
|
183
|
+
r.usage.turns > 0 || r.usage.cost > 0
|
|
184
|
+
? `\n\n(${r.usage.turns} turns, $${r.usage.cost.toFixed(4)})`
|
|
185
|
+
: "";
|
|
186
|
+
return `### [${r.name}] ${status}\n\n${output}${usage}`;
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
content: [
|
|
190
|
+
{
|
|
191
|
+
type: "text",
|
|
192
|
+
text: `${successCount}/${results.length} succeeded${aborted ? " before abort" : ""}\n\n${summaries.join("\n\n---\n\n")}`,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
details: makeDetails(results),
|
|
196
|
+
isError: successCount === 0,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
renderCall: renderSpawnCall,
|
|
201
|
+
renderResult: renderSpawnResult,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
pi.registerShortcut("alt+a", {
|
|
205
|
+
description: "Open the sub-agent details panel (tabs per agent, full timeline)",
|
|
206
|
+
handler: (ctx) => openAgentPanel(ctx),
|
|
207
|
+
});
|
|
208
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-spice/minimal-subagents",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create sub-agents dynamically and run them in parallel; single blocking tool, no orchestration, nesting prevented",
|
|
5
|
+
"keywords": ["pi-package"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/0x2E/pi-spice.git",
|
|
10
|
+
"directory": "extensions/minimal-subagents"
|
|
11
|
+
},
|
|
12
|
+
"pi": {
|
|
13
|
+
"extensions": ["./index.ts"]
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@earendil-works/pi-agent-core": "*",
|
|
17
|
+
"@earendil-works/pi-ai": "*",
|
|
18
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
19
|
+
"@earendil-works/pi-tui": "*",
|
|
20
|
+
"typebox": "*"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/panel.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panel.ts — sub-agent details panel (overlay) for minimal-subagents
|
|
3
|
+
*
|
|
4
|
+
* Opened with alt+a (registered in index.ts). Shows the latest spawn_agents
|
|
5
|
+
* call: one tab per sub-agent, a full scrollable timeline per tab (task,
|
|
6
|
+
* tool calls, tool-result previews, assistant output rendered as markdown,
|
|
7
|
+
* usage), live-updating while agents run.
|
|
8
|
+
*
|
|
9
|
+
* Rendering is line-based: the timeline is flattened into styled lines and
|
|
10
|
+
* windowed by a hand-rolled viewport (offset math) — the overlay contract is
|
|
11
|
+
* `render(width) => string[]`, so we control exactly which slice is visible.
|
|
12
|
+
* Mouse wheel is parsed directly from SGR sequences reaching handleInput
|
|
13
|
+
* while the overlay is focused.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Markdown, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
17
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { type Message } from "@earendil-works/pi-ai";
|
|
19
|
+
import { formatToolCall, formatUsageStats, type RenderTheme } from "./render.ts";
|
|
20
|
+
import { isFailedResult, type SingleResult, type SubagentDetails } from "./spawn.ts";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Module state: the latest spawn_agents details, updated by index.ts
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
let currentDetails: SubagentDetails | null = null;
|
|
27
|
+
const listeners = new Set<() => void>();
|
|
28
|
+
|
|
29
|
+
/** Publish the latest (possibly still running) details; live panels re-render. */
|
|
30
|
+
export function setPanelDetails(details: SubagentDetails): void {
|
|
31
|
+
currentDetails = details;
|
|
32
|
+
for (const listener of listeners) listener();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Panel opening
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
let opening: Promise<unknown> | null = null;
|
|
40
|
+
|
|
41
|
+
export function openAgentPanel(ctx: { ui: any; hasUI?: boolean }): void {
|
|
42
|
+
if (opening) return; // already open; Esc closes
|
|
43
|
+
if (ctx.hasUI === false) return;
|
|
44
|
+
opening = ctx.ui
|
|
45
|
+
.custom(
|
|
46
|
+
(tui: any, theme: RenderTheme & { fg(c: string, t: string): string }, keybindings: any, done: () => void) =>
|
|
47
|
+
new AgentPanel(tui, theme, keybindings, done),
|
|
48
|
+
{
|
|
49
|
+
overlay: true,
|
|
50
|
+
overlayOptions: {
|
|
51
|
+
// Full-height right column: anchor top-right, zero margin, and
|
|
52
|
+
// render() always returns exactly `rows` lines (overlay height is
|
|
53
|
+
// content-driven, capped by maxHeight).
|
|
54
|
+
anchor: "top-right",
|
|
55
|
+
width: "50%",
|
|
56
|
+
minWidth: 50,
|
|
57
|
+
maxHeight: "100%",
|
|
58
|
+
margin: 0,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
.finally(() => {
|
|
63
|
+
opening = null;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Timeline construction
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const TOOL_RESULT_PREVIEW_LINES = 10;
|
|
72
|
+
const WHEEL_LINES = 3;
|
|
73
|
+
|
|
74
|
+
function isAgentRunning(result: SingleResult): boolean {
|
|
75
|
+
return result.exitCode === -1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function statusIcon(result: SingleResult, theme: RenderTheme): string {
|
|
79
|
+
if (isAgentRunning(result)) return theme.fg("warning", "⏳");
|
|
80
|
+
return isFailedResult(result) ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Flatten one agent's messages into styled lines (unwindowed). */
|
|
84
|
+
function buildTimeline(result: SingleResult, theme: RenderTheme, width: number): string[] {
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
const push = (line: string) => {
|
|
87
|
+
if (line.length === 0) lines.push("");
|
|
88
|
+
else lines.push(...wrapTextWithAnsi(line, width));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const status =
|
|
92
|
+
isAgentRunning(result)
|
|
93
|
+
? theme.fg("warning", "running")
|
|
94
|
+
: isFailedResult(result)
|
|
95
|
+
? theme.fg("error", `failed${result.stopReason && result.stopReason !== "end" ? ` (${result.stopReason})` : ""}`)
|
|
96
|
+
: theme.fg("success", "completed");
|
|
97
|
+
|
|
98
|
+
push(`${theme.fg("toolTitle", theme.bold(result.name))} ${status}${result.model ? theme.fg("dim", ` · ${result.model}`) : ""}`);
|
|
99
|
+
push(theme.fg("dim", `Task: ${result.task}`));
|
|
100
|
+
lines.push("");
|
|
101
|
+
|
|
102
|
+
const mdTheme = getMarkdownTheme();
|
|
103
|
+
|
|
104
|
+
for (const msg of result.messages as Message[]) {
|
|
105
|
+
if (msg.role === "user") continue; // the "Task: ..." prompt is already shown
|
|
106
|
+
|
|
107
|
+
if (msg.role === "toolResult") {
|
|
108
|
+
const text = (msg.content || [])
|
|
109
|
+
.filter((p: any) => p.type === "text")
|
|
110
|
+
.map((p: any) => p.text)
|
|
111
|
+
.join("\n");
|
|
112
|
+
if (!text.trim()) continue;
|
|
113
|
+
const all = text.split("\n");
|
|
114
|
+
const shown = all.slice(0, TOOL_RESULT_PREVIEW_LINES);
|
|
115
|
+
for (const l of shown) push(theme.fg("muted", ` ${l}`));
|
|
116
|
+
if (all.length > TOOL_RESULT_PREVIEW_LINES)
|
|
117
|
+
push(theme.fg("dim", ` [+${all.length - TOOL_RESULT_PREVIEW_LINES} more lines]`));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (msg.role === "assistant") {
|
|
122
|
+
for (const part of msg.content as any[]) {
|
|
123
|
+
if (part.type === "thinking") continue;
|
|
124
|
+
if (part.type === "toolCall") {
|
|
125
|
+
push(`${theme.fg("muted", "→ ")}${formatToolCall(part.name, part.arguments, theme.fg.bind(theme))}`);
|
|
126
|
+
} else if (part.type === "text" && part.text.trim()) {
|
|
127
|
+
lines.push(...new Markdown(part.text.trim(), 0, 0, mdTheme).render(width));
|
|
128
|
+
lines.push("");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const usage = formatUsageStats(result.usage, result.model);
|
|
135
|
+
if (usage) push(theme.fg("dim", usage));
|
|
136
|
+
if (result.errorMessage) push(theme.fg("error", `Error: ${result.errorMessage}`));
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// The panel component
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
class AgentPanel {
|
|
145
|
+
private tui: any;
|
|
146
|
+
private theme: RenderTheme;
|
|
147
|
+
private keybindings: any;
|
|
148
|
+
private close: () => void;
|
|
149
|
+
|
|
150
|
+
private activeTab = 0;
|
|
151
|
+
private offset = 0;
|
|
152
|
+
private follow = true;
|
|
153
|
+
private bodyHeight = 10;
|
|
154
|
+
private lineCount = 0;
|
|
155
|
+
private renderWidth = 60;
|
|
156
|
+
|
|
157
|
+
private notify: () => void;
|
|
158
|
+
|
|
159
|
+
constructor(tui: any, theme: RenderTheme, keybindings: any, done: (value: null) => void) {
|
|
160
|
+
this.tui = tui;
|
|
161
|
+
this.theme = theme;
|
|
162
|
+
this.keybindings = keybindings;
|
|
163
|
+
this.close = () => {
|
|
164
|
+
listeners.delete(this.notify);
|
|
165
|
+
done(null);
|
|
166
|
+
};
|
|
167
|
+
this.notify = () => this.tui.requestRender();
|
|
168
|
+
listeners.add(this.notify);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
render(width: number): string[] {
|
|
172
|
+
try {
|
|
173
|
+
return this.renderInner(width);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// A panel bug must never crash the host TUI.
|
|
176
|
+
return [this.theme.fg("error", `panel render failed: ${err instanceof Error ? err.message : String(err)}`)];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private renderInner(width: number): string[] {
|
|
181
|
+
const theme = this.theme;
|
|
182
|
+
const details = currentDetails;
|
|
183
|
+
if (!details || details.results.length === 0)
|
|
184
|
+
return [theme.fg("muted", "(no agent data yet — run spawn_agents first)")];
|
|
185
|
+
|
|
186
|
+
this.renderWidth = width;
|
|
187
|
+
// A newer spawn_agents call may have fewer agents — clamp the tab.
|
|
188
|
+
this.activeTab = Math.min(this.activeTab, details.results.length - 1);
|
|
189
|
+
|
|
190
|
+
// --- header: tab bar ------------------------------------------------
|
|
191
|
+
// Full labels (index + name + status) when they fit the panel width;
|
|
192
|
+
// otherwise degrade to compact slots (index + status) which always fit
|
|
193
|
+
// for up to MAX_AGENTS=8 tabs — the active agent's full identity stays
|
|
194
|
+
// visible as the timeline's first line either way.
|
|
195
|
+
const sep = theme.fg("muted", "│");
|
|
196
|
+
const renderSlots = (labels: string[]) =>
|
|
197
|
+
labels.map((label, i) => (i === this.activeTab ? theme.fg("accent", theme.bold(`[${label}]`)) : theme.fg("dim", ` ${label} `))).join(sep);
|
|
198
|
+
const fullLabels = details.results.map((r, i) => {
|
|
199
|
+
const name = r.name.length > 12 ? `${r.name.slice(0, 11)}…` : r.name;
|
|
200
|
+
return `${i + 1} ${name} ${statusIcon(r, theme)}`;
|
|
201
|
+
});
|
|
202
|
+
const fullFits =
|
|
203
|
+
7 + fullLabels.reduce((sum, l) => sum + visibleWidth(l) + 2, 0) + (fullLabels.length - 1) <= width;
|
|
204
|
+
const tabs = fullFits
|
|
205
|
+
? renderSlots(fullLabels)
|
|
206
|
+
: 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))];
|
|
208
|
+
|
|
209
|
+
// --- body: windowed timeline ----------------------------------------
|
|
210
|
+
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
|
|
213
|
+
this.bodyHeight = Math.max(4, rows - headerH - footerH);
|
|
214
|
+
|
|
215
|
+
const lines = buildTimeline(details.results[this.activeTab], theme, width);
|
|
216
|
+
this.lineCount = lines.length;
|
|
217
|
+
|
|
218
|
+
const maxOffset = Math.max(0, this.lineCount - this.bodyHeight);
|
|
219
|
+
if (this.follow) this.offset = maxOffset;
|
|
220
|
+
this.offset = Math.min(Math.max(0, this.offset), maxOffset);
|
|
221
|
+
const body = lines.slice(this.offset, this.offset + this.bodyHeight);
|
|
222
|
+
while (body.length < this.bodyHeight) body.push(""); // stable panel height
|
|
223
|
+
|
|
224
|
+
// --- footer: scroll position + hints ---------------------------------
|
|
225
|
+
const pos = this.lineCount > 0 ? `${this.offset + 1}-${Math.min(this.offset + this.bodyHeight, this.lineCount)}/${this.lineCount}` : "0";
|
|
226
|
+
const mode = this.follow ? "following" : "paused";
|
|
227
|
+
const footer =
|
|
228
|
+
theme.fg("dim", `${pos} ${mode}`) +
|
|
229
|
+
theme.fg("muted", " · ←/→ tab · ↑/↓ wheel scroll · End follow · Esc close");
|
|
230
|
+
|
|
231
|
+
return [...header, ...body, theme.fg("muted", "─".repeat(width)), footer];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
handleInput(data: string): void {
|
|
235
|
+
try {
|
|
236
|
+
this.handleInputInner(data);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// A panel bug must never crash the host TUI.
|
|
239
|
+
this.tui.requestRender();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private handleInputInner(data: string): void {
|
|
244
|
+
const details = currentDetails;
|
|
245
|
+
if (!details || details.results.length === 0) {
|
|
246
|
+
if (data === "\x1b") this.close();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const tabCount = details.results.length;
|
|
250
|
+
|
|
251
|
+
// SGR mouse wheel: \x1B[<64;col;rowM (up) / 65 (down)
|
|
252
|
+
if (data.startsWith("\x1b[<") && data.endsWith("M")) {
|
|
253
|
+
const b = Number.parseInt(data.slice(3, data.indexOf(";")), 10);
|
|
254
|
+
if (b === 64) this.scroll(-WHEEL_LINES);
|
|
255
|
+
else if (b === 65) this.scroll(WHEEL_LINES);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
switch (data) {
|
|
260
|
+
case "\x1b": // Esc alone
|
|
261
|
+
this.close();
|
|
262
|
+
return;
|
|
263
|
+
case "\x1b[D": // left
|
|
264
|
+
case "\x1b[1;5D": // ctrl+left
|
|
265
|
+
this.switchTab((this.activeTab - 1 + tabCount) % tabCount);
|
|
266
|
+
return;
|
|
267
|
+
case "\x1b[C": // right
|
|
268
|
+
case "\x1b[1;5C": // ctrl+right
|
|
269
|
+
this.switchTab((this.activeTab + 1) % tabCount);
|
|
270
|
+
return;
|
|
271
|
+
case "\x1b[A":
|
|
272
|
+
this.scroll(-1);
|
|
273
|
+
return;
|
|
274
|
+
case "\x1b[B":
|
|
275
|
+
this.scroll(1);
|
|
276
|
+
return;
|
|
277
|
+
case "\x1b[H":
|
|
278
|
+
case "g":
|
|
279
|
+
this.offset = 0;
|
|
280
|
+
this.follow = false;
|
|
281
|
+
this.tui.requestRender();
|
|
282
|
+
return;
|
|
283
|
+
case "\x1b[F":
|
|
284
|
+
case "G":
|
|
285
|
+
this.follow = true;
|
|
286
|
+
this.tui.requestRender();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Number keys 1..8 jump to a tab
|
|
291
|
+
if (/^[1-9]$/.test(data)) {
|
|
292
|
+
const idx = Number(data) - 1;
|
|
293
|
+
if (idx < tabCount) this.switchTab(idx);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Paging respects user keybindings (tui.altScreen.* ids)
|
|
298
|
+
const kb = this.keybindings;
|
|
299
|
+
if (kb?.matches) {
|
|
300
|
+
if (kb.matches(data, "tui.altScreen.pageUp")) {
|
|
301
|
+
this.scroll(-this.bodyHeight);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (kb.matches(data, "tui.altScreen.pageDown")) {
|
|
305
|
+
this.scroll(this.bodyHeight);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (kb.matches(data, "tui.altScreen.halfPageUp")) {
|
|
309
|
+
this.scroll(-Math.ceil(this.bodyHeight / 2));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (kb.matches(data, "tui.altScreen.halfPageDown")) {
|
|
313
|
+
this.scroll(Math.ceil(this.bodyHeight / 2));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private switchTab(idx: number): void {
|
|
320
|
+
this.activeTab = idx;
|
|
321
|
+
this.offset = 0;
|
|
322
|
+
this.follow = true;
|
|
323
|
+
this.tui.requestRender();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private scroll(delta: number): void {
|
|
327
|
+
const maxOffset = Math.max(0, this.lineCount - this.bodyHeight);
|
|
328
|
+
this.offset = Math.min(Math.max(0, this.offset + delta), maxOffset);
|
|
329
|
+
this.follow = this.offset >= maxOffset && delta > 0;
|
|
330
|
+
this.tui.requestRender();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
invalidate(): void {
|
|
334
|
+
// nothing cached across renders; theme changes are picked up next render
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
package/render.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render.ts — TUI presentation for minimal-subagents
|
|
3
|
+
*
|
|
4
|
+
* All rendering for the spawn_agents tool call and its (possibly still
|
|
5
|
+
* streaming) result: collapsed/expanded views per agent, tool-call lines
|
|
6
|
+
* mimicking pi's built-in tool formatting, usage stats, and markdown final
|
|
7
|
+
* output. Pure presentation — no spawning logic here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
12
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
13
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import {
|
|
16
|
+
getFinalOutput,
|
|
17
|
+
isFailedResult,
|
|
18
|
+
type AgentSpec,
|
|
19
|
+
type SingleResult,
|
|
20
|
+
type SubagentDetails,
|
|
21
|
+
} from "./spawn.ts";
|
|
22
|
+
|
|
23
|
+
/** Structural view of the tool's args — avoids a type-only import cycle with index.ts. */
|
|
24
|
+
export type SpawnAgentsArgs = { agents?: AgentSpec[] };
|
|
25
|
+
|
|
26
|
+
/** Structural slice of pi's theme object — keeps this file decoupled from theme internals. */
|
|
27
|
+
export interface RenderTheme {
|
|
28
|
+
fg(color: string, text: string): string;
|
|
29
|
+
bold(text: string): string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatTokens(count: number): string {
|
|
33
|
+
if (count < 1000) return count.toString();
|
|
34
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
35
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
36
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatUsageStats(
|
|
40
|
+
usage: {
|
|
41
|
+
input: number;
|
|
42
|
+
output: number;
|
|
43
|
+
cacheRead: number;
|
|
44
|
+
cacheWrite: number;
|
|
45
|
+
cost: number;
|
|
46
|
+
contextTokens?: number;
|
|
47
|
+
turns?: number;
|
|
48
|
+
},
|
|
49
|
+
model?: string,
|
|
50
|
+
): string {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
53
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
54
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
55
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
56
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
57
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
58
|
+
if (usage.contextTokens && usage.contextTokens > 0) {
|
|
59
|
+
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
60
|
+
}
|
|
61
|
+
if (model) parts.push(model);
|
|
62
|
+
return parts.join(" ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatToolCall(
|
|
66
|
+
toolName: string,
|
|
67
|
+
args: Record<string, unknown>,
|
|
68
|
+
themeFg: (color: any, text: string) => string,
|
|
69
|
+
): string {
|
|
70
|
+
const shortenPath = (p: string) => {
|
|
71
|
+
const home = os.homedir();
|
|
72
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
switch (toolName) {
|
|
76
|
+
case "bash": {
|
|
77
|
+
const command = (args.command as string) || "...";
|
|
78
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
79
|
+
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
|
|
80
|
+
}
|
|
81
|
+
case "read": {
|
|
82
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
83
|
+
const filePath = shortenPath(rawPath);
|
|
84
|
+
const offset = args.offset as number | undefined;
|
|
85
|
+
const limit = args.limit as number | undefined;
|
|
86
|
+
let text = themeFg("accent", filePath);
|
|
87
|
+
if (offset !== undefined || limit !== undefined) {
|
|
88
|
+
const startLine = offset ?? 1;
|
|
89
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
90
|
+
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
91
|
+
}
|
|
92
|
+
return themeFg("muted", "read ") + text;
|
|
93
|
+
}
|
|
94
|
+
case "write": {
|
|
95
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
96
|
+
const filePath = shortenPath(rawPath);
|
|
97
|
+
const content = (args.content || "") as string;
|
|
98
|
+
const lines = content.split("\n").length;
|
|
99
|
+
let text = themeFg("muted", "write ") + themeFg("accent", filePath);
|
|
100
|
+
if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
case "edit": {
|
|
104
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
105
|
+
return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
|
|
106
|
+
}
|
|
107
|
+
case "ls": {
|
|
108
|
+
const rawPath = (args.path || ".") as string;
|
|
109
|
+
return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
|
|
110
|
+
}
|
|
111
|
+
case "find": {
|
|
112
|
+
const pattern = (args.pattern || "*") as string;
|
|
113
|
+
const rawPath = (args.path || ".") as string;
|
|
114
|
+
return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
|
|
115
|
+
}
|
|
116
|
+
case "grep": {
|
|
117
|
+
const pattern = (args.pattern || "") as string;
|
|
118
|
+
const rawPath = (args.path || ".") as string;
|
|
119
|
+
return (
|
|
120
|
+
themeFg("muted", "grep ") +
|
|
121
|
+
themeFg("accent", `/${pattern}/`) +
|
|
122
|
+
themeFg("dim", ` in ${shortenPath(rawPath)}`)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
default: {
|
|
126
|
+
const argsStr = JSON.stringify(args);
|
|
127
|
+
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
|
128
|
+
return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
|
|
134
|
+
|
|
135
|
+
function getDisplayItems(messages: Message[]): DisplayItem[] {
|
|
136
|
+
const items: DisplayItem[] = [];
|
|
137
|
+
for (const msg of messages) {
|
|
138
|
+
if (msg.role === "assistant") {
|
|
139
|
+
for (const part of msg.content) {
|
|
140
|
+
if (part.type === "text") items.push({ type: "text", text: part.text });
|
|
141
|
+
else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return items;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderSpawnCall(args: SpawnAgentsArgs, theme: RenderTheme): Text {
|
|
149
|
+
if (args.agents?.length) {
|
|
150
|
+
let text =
|
|
151
|
+
theme.fg("toolTitle", theme.bold("spawn_agents ")) +
|
|
152
|
+
theme.fg("accent", `(${args.agents.length} agent${args.agents.length > 1 ? "s" : ""})`);
|
|
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`)}`;
|
|
159
|
+
return new Text(text, 0, 0);
|
|
160
|
+
}
|
|
161
|
+
return new Text(theme.fg("toolTitle", theme.bold("spawn_agents")), 0, 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function truncatePlain(text: string, max: number): string {
|
|
165
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isAgentRunning(details: SubagentDetails): boolean {
|
|
169
|
+
return details.results.some((r) => r.exitCode === -1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One glanceable scoreboard line: icon, name, turns, latest activity. */
|
|
173
|
+
function scoreboardLine(r: SingleResult, theme: RenderTheme): string {
|
|
174
|
+
const icon =
|
|
175
|
+
r.exitCode === -1 && r.messages.length === 0
|
|
176
|
+
? theme.fg("muted", "▢")
|
|
177
|
+
: r.exitCode === -1
|
|
178
|
+
? theme.fg("warning", "⏳")
|
|
179
|
+
: isFailedResult(r)
|
|
180
|
+
? theme.fg("error", "✗")
|
|
181
|
+
: theme.fg("success", "✓");
|
|
182
|
+
|
|
183
|
+
let activity: string;
|
|
184
|
+
if (r.exitCode === -1 && r.messages.length === 0) {
|
|
185
|
+
activity = theme.fg("muted", "queued");
|
|
186
|
+
} else if (isFailedResult(r)) {
|
|
187
|
+
const reason = (r.errorMessage || r.stderr || r.stopReason || "error").split("\n")[0];
|
|
188
|
+
activity = theme.fg("error", truncatePlain(reason, 60));
|
|
189
|
+
} else {
|
|
190
|
+
const items = getDisplayItems(r.messages);
|
|
191
|
+
const last = items[items.length - 1];
|
|
192
|
+
if (!last) activity = theme.fg("muted", r.exitCode === -1 ? "starting…" : "(no output)");
|
|
193
|
+
else if (last.type === "toolCall") activity = formatToolCall(last.name, last.args, theme.fg.bind(theme));
|
|
194
|
+
else activity = theme.fg("toolOutput", truncatePlain(last.text.split("\n")[0], 60));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const turns = r.usage.turns > 0 ? theme.fg("dim", ` ${r.usage.turns}t`) : "";
|
|
198
|
+
return ` ${icon} ${theme.fg("accent", r.name)}${turns} ${activity}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function renderSpawnResult(
|
|
202
|
+
result: AgentToolResult<SubagentDetails>,
|
|
203
|
+
{ expanded }: { expanded: boolean },
|
|
204
|
+
theme: RenderTheme,
|
|
205
|
+
): Text | Container {
|
|
206
|
+
const details = result.details as SubagentDetails | undefined;
|
|
207
|
+
if (!details || details.results.length === 0) {
|
|
208
|
+
const text = result.content[0];
|
|
209
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const mdTheme = getMarkdownTheme();
|
|
213
|
+
|
|
214
|
+
if (expanded && !isAgentRunning(details) && details.results.length === 1) {
|
|
215
|
+
const r = details.results[0];
|
|
216
|
+
const isError = isFailedResult(r);
|
|
217
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
218
|
+
const displayItems = getDisplayItems(r.messages);
|
|
219
|
+
const finalOutput = getFinalOutput(r.messages);
|
|
220
|
+
|
|
221
|
+
const container = new Container();
|
|
222
|
+
let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.name))}`;
|
|
223
|
+
if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
224
|
+
container.addChild(new Text(header, 0, 0));
|
|
225
|
+
if (isError && r.errorMessage)
|
|
226
|
+
container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
|
|
227
|
+
container.addChild(new Spacer(1));
|
|
228
|
+
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
229
|
+
container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
|
|
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));
|
|
296
|
+
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
|
|
297
|
+
|
|
298
|
+
// Show tool calls
|
|
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) {
|
|
309
|
+
container.addChild(new Spacer(1));
|
|
310
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const taskUsage = formatUsageStats(r.usage, r.model);
|
|
314
|
+
if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
|
318
|
+
if (usageStr) {
|
|
319
|
+
container.addChild(new Spacer(1));
|
|
320
|
+
container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
|
|
321
|
+
}
|
|
322
|
+
return container;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- scoreboard: one glanceable line per agent -----------------------
|
|
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);
|
|
339
|
+
}
|
package/spawn.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn.ts — process-spawning core for minimal-subagents
|
|
3
|
+
*
|
|
4
|
+
* Runs one dynamically defined agent spec as an isolated `pi -p --no-session`
|
|
5
|
+
* child process in NDJSON mode: builds the CLI invocation (model / thinking /
|
|
6
|
+
* tools / appended system prompt), parses the event stream into messages and
|
|
7
|
+
* usage stats, and honors abort by killing the child.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
14
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
15
|
+
|
|
16
|
+
const PER_TASK_OUTPUT_CAP = 50 * 1024;
|
|
17
|
+
|
|
18
|
+
export interface UsageStats {
|
|
19
|
+
input: number;
|
|
20
|
+
output: number;
|
|
21
|
+
cacheRead: number;
|
|
22
|
+
cacheWrite: number;
|
|
23
|
+
cost: number;
|
|
24
|
+
contextTokens: number;
|
|
25
|
+
turns: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SingleResult {
|
|
29
|
+
name: string;
|
|
30
|
+
task: string;
|
|
31
|
+
/** -1 while the agent is still running */
|
|
32
|
+
exitCode: number;
|
|
33
|
+
messages: Message[];
|
|
34
|
+
stderr: string;
|
|
35
|
+
usage: UsageStats;
|
|
36
|
+
model?: string;
|
|
37
|
+
stopReason?: string;
|
|
38
|
+
errorMessage?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SubagentDetails {
|
|
42
|
+
results: SingleResult[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AgentSpec {
|
|
46
|
+
name?: string;
|
|
47
|
+
systemPrompt?: string;
|
|
48
|
+
task: string;
|
|
49
|
+
model?: string;
|
|
50
|
+
thinking?: string;
|
|
51
|
+
tools?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DispatchDefaults {
|
|
55
|
+
model?: string;
|
|
56
|
+
thinkingLevel?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
|
60
|
+
|
|
61
|
+
export function getFinalOutput(messages: Message[]): string {
|
|
62
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
63
|
+
const msg = messages[i];
|
|
64
|
+
if (msg.role === "assistant") {
|
|
65
|
+
for (const part of msg.content) {
|
|
66
|
+
if (part.type === "text") return part.text;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isFailedResult(result: SingleResult): boolean {
|
|
74
|
+
// exitCode -1 means still running; a real non-zero exit (or an error/abort
|
|
75
|
+
// stop reason) is the failure signal.
|
|
76
|
+
return result.exitCode > 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getResultOutput(result: SingleResult): string {
|
|
80
|
+
if (isFailedResult(result)) {
|
|
81
|
+
return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
82
|
+
}
|
|
83
|
+
return getFinalOutput(result.messages) || "(no output)";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function truncateParallelOutput(output: string): string {
|
|
87
|
+
const byteLength = Buffer.byteLength(output, "utf8");
|
|
88
|
+
if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
|
|
89
|
+
|
|
90
|
+
let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
|
|
91
|
+
while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
|
|
92
|
+
truncated = truncated.slice(0, -1);
|
|
93
|
+
}
|
|
94
|
+
return `${truncated}\n\n[Output truncated at 50 KB]`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
98
|
+
items: TIn[],
|
|
99
|
+
concurrency: number,
|
|
100
|
+
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
101
|
+
): Promise<TOut[]> {
|
|
102
|
+
if (items.length === 0) return [];
|
|
103
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
104
|
+
const results: TOut[] = new Array(items.length);
|
|
105
|
+
let nextIndex = 0;
|
|
106
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
107
|
+
while (true) {
|
|
108
|
+
const current = nextIndex++;
|
|
109
|
+
if (current >= items.length) return;
|
|
110
|
+
results[current] = await fn(items[current], current);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
await Promise.all(workers);
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Re-invoke the pi binary that is running us. Prefers the exact interpreter +
|
|
119
|
+
* script pair (works for bundled binaries and npm installs); falls back to
|
|
120
|
+
* `pi` on PATH when running under a generic node/bun runtime we cannot pin.
|
|
121
|
+
*/
|
|
122
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
123
|
+
const currentScript = process.argv[1];
|
|
124
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
125
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
126
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
130
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
131
|
+
if (!isGenericRuntime) {
|
|
132
|
+
return { command: process.execPath, args };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { command: "pi", args };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runSpec(
|
|
139
|
+
defaultCwd: string,
|
|
140
|
+
dispatchDefaults: DispatchDefaults,
|
|
141
|
+
spec: AgentSpec,
|
|
142
|
+
displayName: string,
|
|
143
|
+
signal: AbortSignal | undefined,
|
|
144
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
145
|
+
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
146
|
+
): Promise<SingleResult> {
|
|
147
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
148
|
+
// Belt-and-braces: even if the env sentinel were scrubbed, the child's
|
|
149
|
+
// pi never sees the spawn_agents tool.
|
|
150
|
+
args.push("--exclude-tools", "spawn_agents");
|
|
151
|
+
const model = spec.model ?? dispatchDefaults.model;
|
|
152
|
+
if (model) args.push("--model", model);
|
|
153
|
+
const thinking = spec.thinking ?? dispatchDefaults.thinkingLevel;
|
|
154
|
+
if (thinking) args.push("--thinking", thinking);
|
|
155
|
+
if (spec.tools && spec.tools.length > 0) args.push("--tools", spec.tools.join(","));
|
|
156
|
+
if (spec.systemPrompt?.trim()) {
|
|
157
|
+
// Leading "\n" guarantees pi treats this as literal text, never a file path.
|
|
158
|
+
args.push("--append-system-prompt", `\n${spec.systemPrompt}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const currentResult: SingleResult = {
|
|
162
|
+
name: displayName,
|
|
163
|
+
task: spec.task,
|
|
164
|
+
exitCode: -1, // -1 = still running; real exit code set on close
|
|
165
|
+
messages: [],
|
|
166
|
+
stderr: "",
|
|
167
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
168
|
+
model,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const emitUpdate = () => {
|
|
172
|
+
if (onUpdate) {
|
|
173
|
+
onUpdate({
|
|
174
|
+
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
|
|
175
|
+
details: makeDetails([currentResult]),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
args.push(`Task: ${spec.task}`);
|
|
181
|
+
let wasAborted = false;
|
|
182
|
+
let closed = false;
|
|
183
|
+
let hardKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
184
|
+
|
|
185
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
186
|
+
const invocation = getPiInvocation(args);
|
|
187
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
188
|
+
cwd: defaultCwd,
|
|
189
|
+
env: { ...process.env, PI_SUBAGENTS_CHILD: "1" },
|
|
190
|
+
shell: false,
|
|
191
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
192
|
+
// Own process group on POSIX so abort can kill the whole tree
|
|
193
|
+
// (the child spawns grandchildren like bash).
|
|
194
|
+
detached: process.platform !== "win32",
|
|
195
|
+
});
|
|
196
|
+
let buffer = "";
|
|
197
|
+
|
|
198
|
+
const processLine = (line: string) => {
|
|
199
|
+
if (!line.trim()) return;
|
|
200
|
+
let event: any;
|
|
201
|
+
try {
|
|
202
|
+
event = JSON.parse(line);
|
|
203
|
+
} catch {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (event.type === "message_end" && event.message) {
|
|
208
|
+
const msg = event.message as Message;
|
|
209
|
+
currentResult.messages.push(msg);
|
|
210
|
+
|
|
211
|
+
if (msg.role === "assistant") {
|
|
212
|
+
currentResult.usage.turns++;
|
|
213
|
+
const usage = msg.usage;
|
|
214
|
+
if (usage) {
|
|
215
|
+
currentResult.usage.input += usage.input || 0;
|
|
216
|
+
currentResult.usage.output += usage.output || 0;
|
|
217
|
+
currentResult.usage.cacheRead += usage.cacheRead || 0;
|
|
218
|
+
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
|
|
219
|
+
currentResult.usage.cost += usage.cost?.total || 0;
|
|
220
|
+
currentResult.usage.contextTokens = usage.totalTokens || 0;
|
|
221
|
+
}
|
|
222
|
+
if (!currentResult.model && msg.model) currentResult.model = msg.model;
|
|
223
|
+
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
|
|
224
|
+
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
|
|
225
|
+
}
|
|
226
|
+
emitUpdate();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (event.type === "tool_result_end" && event.message) {
|
|
230
|
+
currentResult.messages.push(event.message as Message);
|
|
231
|
+
emitUpdate();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
proc.stdout.on("data", (data) => {
|
|
236
|
+
buffer += data.toString();
|
|
237
|
+
const lines = buffer.split("\n");
|
|
238
|
+
buffer = lines.pop() || "";
|
|
239
|
+
for (const line of lines) processLine(line);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
proc.stderr.on("data", (data) => {
|
|
243
|
+
currentResult.stderr += data.toString();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
proc.on("close", (code) => {
|
|
247
|
+
closed = true;
|
|
248
|
+
if (hardKillTimer) clearTimeout(hardKillTimer);
|
|
249
|
+
if (buffer.trim()) processLine(buffer);
|
|
250
|
+
// A null code means death by signal — count it as failure unless we
|
|
251
|
+
// aborted on purpose (the abort path throws below instead).
|
|
252
|
+
resolve(code ?? 1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
proc.on("error", (err) => {
|
|
256
|
+
currentResult.errorMessage = err instanceof Error ? err.message : String(err);
|
|
257
|
+
resolve(1);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (signal) {
|
|
261
|
+
const killTree = (sig: NodeJS.Signals) => {
|
|
262
|
+
try {
|
|
263
|
+
if (process.platform === "win32" || proc.pid === undefined) proc.kill(sig);
|
|
264
|
+
else process.kill(-proc.pid, sig); // negative pid = process group
|
|
265
|
+
} catch {
|
|
266
|
+
/* already gone */
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const killProc = () => {
|
|
270
|
+
wasAborted = true;
|
|
271
|
+
killTree("SIGTERM");
|
|
272
|
+
// NOTE: proc.killed flips true once the signal is *sent*, so it
|
|
273
|
+
// cannot gate the SIGKILL fallback — track `closed` instead.
|
|
274
|
+
hardKillTimer = setTimeout(() => {
|
|
275
|
+
if (!closed) killTree("SIGKILL");
|
|
276
|
+
}, 5000);
|
|
277
|
+
};
|
|
278
|
+
if (signal.aborted) killProc();
|
|
279
|
+
else signal.addEventListener("abort", killProc, { once: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
currentResult.exitCode = exitCode;
|
|
284
|
+
if (wasAborted) throw new Error("Subagent was aborted");
|
|
285
|
+
return currentResult;
|
|
286
|
+
}
|