@ferris1225/pi-subagents 0.1.0 → 0.3.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/package.json +1 -1
- package/src/index.ts +140 -30
- package/src/monitor.ts +238 -0
- package/src/spawn.ts +66 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Focused sub-agent delegation for pi: explore / plan / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
17
17
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
18
18
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
19
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { discoverAgents, type AgentConfig } from "./agents.ts";
|
|
22
22
|
import { getConfigPath, loadConfig } from "./config.ts";
|
|
@@ -32,12 +32,13 @@ import {
|
|
|
32
32
|
isFailedResult,
|
|
33
33
|
mapWithConcurrencyLimit,
|
|
34
34
|
runSingleAgent,
|
|
35
|
-
truncateParallelOutput,
|
|
36
35
|
type OnUpdateCallback,
|
|
37
36
|
type SingleResult,
|
|
38
37
|
type SubagentDetails,
|
|
38
|
+
type SubagentLiveEvent,
|
|
39
39
|
type UsageStats,
|
|
40
40
|
} from "./spawn.ts";
|
|
41
|
+
import { monitor, statusColor, statusIcon, statusLabel } from "./monitor.ts";
|
|
41
42
|
|
|
42
43
|
const TaskItem = Type.Object({
|
|
43
44
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
@@ -121,6 +122,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
121
122
|
parameters: SubagentParams,
|
|
122
123
|
|
|
123
124
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
125
|
+
monitor.beginTurn();
|
|
124
126
|
const config = await loadConfig(configPath);
|
|
125
127
|
const discovery = discoverAgents(ctx.cwd, {
|
|
126
128
|
scope: config.agentScope,
|
|
@@ -186,6 +188,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
186
188
|
};
|
|
187
189
|
|
|
188
190
|
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
191
|
+
const resolvedModel = agents.find((a) => a.name === t.agent)?.model;
|
|
192
|
+
const runId = monitor.addRun(t.agent, resolvedModel);
|
|
193
|
+
const onLive = (e: SubagentLiveEvent): void => {
|
|
194
|
+
switch (e.kind) {
|
|
195
|
+
case "status":
|
|
196
|
+
monitor.setStatus(runId, e.status);
|
|
197
|
+
break;
|
|
198
|
+
case "usage":
|
|
199
|
+
monitor.setUsage(runId, e.usage, e.model);
|
|
200
|
+
break;
|
|
201
|
+
case "tool_start":
|
|
202
|
+
monitor.appendTranscript(runId, { kind: "tool", text: `▸ ${e.toolName}(${typeof e.args === "object" ? JSON.stringify(e.args).slice(0, 120) : String(e.args).slice(0, 120)})` });
|
|
203
|
+
break;
|
|
204
|
+
case "tool_end":
|
|
205
|
+
monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
|
|
206
|
+
break;
|
|
207
|
+
case "text_delta":
|
|
208
|
+
monitor.appendTextDelta(runId, e.delta);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
189
212
|
const perTaskUpdate: OnUpdateCallback | undefined = onUpdate
|
|
190
213
|
? (partial) => {
|
|
191
214
|
const current = partial.details?.results[0];
|
|
@@ -195,16 +218,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
195
218
|
}
|
|
196
219
|
}
|
|
197
220
|
: undefined;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
221
|
+
let result: SingleResult;
|
|
222
|
+
try {
|
|
223
|
+
result = await runSingleAgent({
|
|
224
|
+
defaultCwd: ctx.cwd,
|
|
225
|
+
agent: agents.find((a) => a.name === t.agent),
|
|
226
|
+
agentName: t.agent,
|
|
227
|
+
task: t.task,
|
|
228
|
+
cwd: t.cwd,
|
|
229
|
+
signal,
|
|
230
|
+
onUpdate: perTaskUpdate,
|
|
231
|
+
onLive,
|
|
232
|
+
makeDetails: makeDetails("parallel"),
|
|
233
|
+
});
|
|
234
|
+
} catch (err) {
|
|
235
|
+
monitor.setStatus(runId, "failed");
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
208
238
|
allResults[index] = result;
|
|
209
239
|
emitParallelUpdate();
|
|
210
240
|
return result;
|
|
@@ -212,7 +242,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
212
242
|
|
|
213
243
|
const successCount = results.filter((r) => !isFailedResult(r)).length;
|
|
214
244
|
const summaries = results.map((r) => {
|
|
215
|
-
const output =
|
|
245
|
+
const output = getResultOutput(r);
|
|
216
246
|
const status = isFailedResult(r) ? "failed" : "completed";
|
|
217
247
|
const usage = formatUsage(r.usage);
|
|
218
248
|
return `### [${r.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${output}`;
|
|
@@ -229,16 +259,44 @@ export default function (pi: ExtensionAPI): void {
|
|
|
229
259
|
}
|
|
230
260
|
|
|
231
261
|
// ---- Single mode ----
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
262
|
+
const resolvedModel = agents.find((a) => a.name === params.agent)?.model;
|
|
263
|
+
const runId = monitor.addRun(params.agent as string, resolvedModel);
|
|
264
|
+
const onLive = (e: SubagentLiveEvent): void => {
|
|
265
|
+
switch (e.kind) {
|
|
266
|
+
case "status":
|
|
267
|
+
monitor.setStatus(runId, e.status);
|
|
268
|
+
break;
|
|
269
|
+
case "usage":
|
|
270
|
+
monitor.setUsage(runId, e.usage, e.model);
|
|
271
|
+
break;
|
|
272
|
+
case "tool_start":
|
|
273
|
+
monitor.appendTranscript(runId, { kind: "tool", text: `▸ ${e.toolName}(${typeof e.args === "object" ? JSON.stringify(e.args).slice(0, 120) : String(e.args).slice(0, 120)})` });
|
|
274
|
+
break;
|
|
275
|
+
case "tool_end":
|
|
276
|
+
monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
|
|
277
|
+
break;
|
|
278
|
+
case "text_delta":
|
|
279
|
+
monitor.appendTextDelta(runId, e.delta);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
let result: SingleResult;
|
|
284
|
+
try {
|
|
285
|
+
result = await runSingleAgent({
|
|
286
|
+
defaultCwd: ctx.cwd,
|
|
287
|
+
agent: agents.find((a) => a.name === params.agent),
|
|
288
|
+
agentName: params.agent as string,
|
|
289
|
+
task: params.task as string,
|
|
290
|
+
cwd: params.cwd,
|
|
291
|
+
signal,
|
|
292
|
+
onUpdate,
|
|
293
|
+
onLive,
|
|
294
|
+
makeDetails: makeDetails("single"),
|
|
295
|
+
});
|
|
296
|
+
} catch (err) {
|
|
297
|
+
monitor.setStatus(runId, "failed");
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
242
300
|
|
|
243
301
|
if (isFailedResult(result)) {
|
|
244
302
|
return {
|
|
@@ -275,13 +333,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
275
333
|
renderResult(result, _options, theme) {
|
|
276
334
|
const details = result.details as SubagentDetails | undefined;
|
|
277
335
|
if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
details.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
336
|
+
|
|
337
|
+
if (details.mode === "single") {
|
|
338
|
+
const r = details.results[0];
|
|
339
|
+
const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
|
|
340
|
+
const usage = formatUsage(r.usage);
|
|
341
|
+
const model = r.model ?? "?";
|
|
342
|
+
const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`;
|
|
343
|
+
return new Text(line, 0, 0);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Parallel mode: header + one compact line per agent
|
|
347
|
+
const lines: string[] = [
|
|
348
|
+
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
|
|
349
|
+
];
|
|
350
|
+
for (const r of details.results) {
|
|
351
|
+
const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
|
|
352
|
+
const usage = formatUsage(r.usage);
|
|
353
|
+
const model = r.model ?? "?";
|
|
354
|
+
lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`);
|
|
355
|
+
}
|
|
356
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
285
357
|
},
|
|
286
358
|
});
|
|
287
359
|
|
|
@@ -292,6 +364,44 @@ export default function (pi: ExtensionAPI): void {
|
|
|
292
364
|
},
|
|
293
365
|
});
|
|
294
366
|
|
|
367
|
+
// Persistent widget above the editor showing live sub-agent status.
|
|
368
|
+
pi.on("session_start", (_e, ctx) => {
|
|
369
|
+
if (ctx.mode !== "tui") return;
|
|
370
|
+
ctx.ui.setWidget(
|
|
371
|
+
"pi-subagents",
|
|
372
|
+
(tui, theme) => {
|
|
373
|
+
const unsub = monitor.subscribe(() => tui.requestRender());
|
|
374
|
+
// Tick once a second so elapsed time stays live while runs are active.
|
|
375
|
+
const timer = setInterval(() => {
|
|
376
|
+
if (monitor.getRuns().some((r) => r.status === "queued" || r.status === "running")) {
|
|
377
|
+
tui.requestRender();
|
|
378
|
+
}
|
|
379
|
+
}, 1000);
|
|
380
|
+
return {
|
|
381
|
+
render(width: number): string[] {
|
|
382
|
+
const runs = monitor.getRuns();
|
|
383
|
+
if (runs.length === 0) return [];
|
|
384
|
+
const lines: string[] = [];
|
|
385
|
+
for (const r of runs) {
|
|
386
|
+
const icon = statusIcon(r.status, theme);
|
|
387
|
+
const label = theme.fg(statusColor(r.status), statusLabel(r.status));
|
|
388
|
+
lines.push(truncateToWidth(` ${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
|
|
389
|
+
const activity = monitor.lastActivity(r);
|
|
390
|
+
if (activity) lines.push(truncateToWidth(theme.fg("dim", ` ${activity}`), width, ""));
|
|
391
|
+
}
|
|
392
|
+
return lines;
|
|
393
|
+
},
|
|
394
|
+
invalidate() {},
|
|
395
|
+
dispose() {
|
|
396
|
+
unsub();
|
|
397
|
+
clearInterval(timer);
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
{ placement: "aboveEditor" },
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
295
405
|
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
296
406
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
297
407
|
const config = await loadConfig(configPath);
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent monitor: a module-level singleton store that tracks subagent runs
|
|
3
|
+
* for the current turn.
|
|
4
|
+
*
|
|
5
|
+
* The store notifies subscribers on every mutation so the persistent widget
|
|
6
|
+
* above the editor can re-render. Each run carries timing information
|
|
7
|
+
* (started/ended) and a transcript whose most recent entry is surfaced as the
|
|
8
|
+
* run's current activity.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { UsageStats } from "./spawn.ts";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export type RunStatus = "queued" | "running" | "done" | "failed";
|
|
19
|
+
|
|
20
|
+
export interface TranscriptLine {
|
|
21
|
+
kind: "tool" | "text" | "status" | "error";
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunView {
|
|
26
|
+
id: number;
|
|
27
|
+
agent: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
status: RunStatus;
|
|
30
|
+
usage: UsageStats;
|
|
31
|
+
transcript: TranscriptLine[];
|
|
32
|
+
/** Epoch ms when the run started executing (set on first "running" status). */
|
|
33
|
+
startedAt?: number;
|
|
34
|
+
/** Epoch ms when the run finished (set on "done"/"failed"). */
|
|
35
|
+
endedAt?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Formatting helpers
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function formatTokens(count: number): string {
|
|
43
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
44
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
45
|
+
return String(count);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatUsageCompact(usage: UsageStats): string {
|
|
49
|
+
const parts: string[] = [];
|
|
50
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
51
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
52
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
53
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
54
|
+
return parts.join(" ");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatDuration(ms: number): string {
|
|
58
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
59
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
60
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
61
|
+
const seconds = totalSeconds % 60;
|
|
62
|
+
if (minutes < 60) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
63
|
+
const hours = Math.floor(minutes / 60);
|
|
64
|
+
return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Elapsed wall time of a run: live while running, final once finished. */
|
|
68
|
+
export function formatElapsed(run: RunView, now: number = Date.now()): string {
|
|
69
|
+
if (run.startedAt === undefined) return "";
|
|
70
|
+
const end = run.endedAt ?? now;
|
|
71
|
+
return formatDuration(end - run.startedAt);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// MonitorStore
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export class MonitorStore {
|
|
79
|
+
private runs: RunView[] = [];
|
|
80
|
+
private nextId = 1;
|
|
81
|
+
private subscribers = new Set<() => void>();
|
|
82
|
+
|
|
83
|
+
beginTurn(): void {
|
|
84
|
+
// Clear finished runs from a previous turn, but keep any still-active
|
|
85
|
+
// (queued/running) ones so a concurrent sub-agent call is not wiped.
|
|
86
|
+
this.runs = this.runs.filter((r) => r.status === "queued" || r.status === "running");
|
|
87
|
+
this.notify();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
addRun(agent: string, model?: string): number {
|
|
91
|
+
const id = this.nextId++;
|
|
92
|
+
this.runs.push({
|
|
93
|
+
id,
|
|
94
|
+
agent,
|
|
95
|
+
model,
|
|
96
|
+
status: "queued",
|
|
97
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
98
|
+
transcript: [],
|
|
99
|
+
});
|
|
100
|
+
this.notify();
|
|
101
|
+
return id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setStatus(id: number, status: RunStatus): void {
|
|
105
|
+
const run = this.find(id);
|
|
106
|
+
if (!run) return;
|
|
107
|
+
run.status = status;
|
|
108
|
+
if (status === "running" && run.startedAt === undefined) {
|
|
109
|
+
run.startedAt = Date.now();
|
|
110
|
+
} else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
111
|
+
run.endedAt = Date.now();
|
|
112
|
+
}
|
|
113
|
+
this.notify();
|
|
114
|
+
}
|
|
115
|
+
setUsage(id: number, usage: UsageStats, model?: string): void {
|
|
116
|
+
const run = this.find(id);
|
|
117
|
+
if (!run) return;
|
|
118
|
+
run.usage = { ...usage };
|
|
119
|
+
if (model) run.model = model;
|
|
120
|
+
this.notify();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
appendTranscript(id: number, line: TranscriptLine): void {
|
|
124
|
+
const run = this.find(id);
|
|
125
|
+
if (!run) return;
|
|
126
|
+
run.transcript.push(line);
|
|
127
|
+
this.notify();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Append streamed assistant text, merging consecutive deltas into coherent
|
|
131
|
+
* lines (split only on real newlines) instead of one line per token fragment. */
|
|
132
|
+
appendTextDelta(id: number, delta: string): void {
|
|
133
|
+
const run = this.find(id);
|
|
134
|
+
if (!run || delta.length === 0) return;
|
|
135
|
+
const segments = delta.split("\n");
|
|
136
|
+
for (let i = 0; i < segments.length; i++) {
|
|
137
|
+
const seg = segments[i];
|
|
138
|
+
const last = run.transcript[run.transcript.length - 1];
|
|
139
|
+
if (i === 0 && last && last.kind === "text") {
|
|
140
|
+
last.text += seg;
|
|
141
|
+
} else {
|
|
142
|
+
run.transcript.push({ kind: "text", text: seg });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.notify();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getRuns(): RunView[] {
|
|
149
|
+
return this.runs;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
subscribe(cb: () => void): () => void {
|
|
153
|
+
this.subscribers.add(cb);
|
|
154
|
+
return () => {
|
|
155
|
+
this.subscribers.delete(cb);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
summarize(run: RunView): string {
|
|
160
|
+
const usage = formatUsageCompact(run.usage);
|
|
161
|
+
const parts = [run.agent];
|
|
162
|
+
if (run.model) parts.push(run.model);
|
|
163
|
+
if (usage) parts.push(usage);
|
|
164
|
+
const elapsed = formatElapsed(run);
|
|
165
|
+
if (elapsed) parts.push(elapsed);
|
|
166
|
+
return parts.join(" · ");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Text of the most recent transcript entry (what the run is doing now). */
|
|
170
|
+
lastActivity(run: RunView): string | undefined {
|
|
171
|
+
for (let i = run.transcript.length - 1; i >= 0; i--) {
|
|
172
|
+
const text = run.transcript[i].text.trim();
|
|
173
|
+
if (text.length > 0) return text;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private find(id: number): RunView | undefined {
|
|
179
|
+
return this.runs.find((r) => r.id === id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private notify(): void {
|
|
183
|
+
for (const cb of this.subscribers) {
|
|
184
|
+
try {
|
|
185
|
+
cb();
|
|
186
|
+
} catch {
|
|
187
|
+
/* subscriber errors must not break the store */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const monitor = new MonitorStore();
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Status icons
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export function statusIcon(status: RunStatus, theme: Theme): string {
|
|
200
|
+
switch (status) {
|
|
201
|
+
case "running":
|
|
202
|
+
return theme.fg("accent", "●");
|
|
203
|
+
case "done":
|
|
204
|
+
return theme.fg("success", "✓");
|
|
205
|
+
case "failed":
|
|
206
|
+
return theme.fg("error", "✗");
|
|
207
|
+
default:
|
|
208
|
+
return theme.fg("dim", "○");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** User-facing status label shown in the widget. */
|
|
213
|
+
export function statusLabel(status: RunStatus): string {
|
|
214
|
+
switch (status) {
|
|
215
|
+
case "queued":
|
|
216
|
+
return "ready";
|
|
217
|
+
case "running":
|
|
218
|
+
return "running";
|
|
219
|
+
case "done":
|
|
220
|
+
return "done";
|
|
221
|
+
case "failed":
|
|
222
|
+
return "stopped";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Theme color matching the status label. */
|
|
227
|
+
export function statusColor(status: RunStatus): "accent" | "success" | "error" | "dim" {
|
|
228
|
+
switch (status) {
|
|
229
|
+
case "running":
|
|
230
|
+
return "accent";
|
|
231
|
+
case "done":
|
|
232
|
+
return "success";
|
|
233
|
+
case "failed":
|
|
234
|
+
return "error";
|
|
235
|
+
default:
|
|
236
|
+
return "dim";
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/spawn.ts
CHANGED
|
@@ -20,7 +20,6 @@ import type { AgentConfig, AgentSource } from "./agents.ts";
|
|
|
20
20
|
|
|
21
21
|
export const MAX_PARALLEL_TASKS = 8;
|
|
22
22
|
export const MAX_CONCURRENCY = 4;
|
|
23
|
-
export const PER_TASK_OUTPUT_CAP = 50 * 1024;
|
|
24
23
|
/** Max nesting depth for sub-agent -> sub-agent spawning (recursion guard). */
|
|
25
24
|
export const MAX_SUBAGENT_DEPTH = 2;
|
|
26
25
|
export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
|
|
@@ -55,6 +54,13 @@ export interface SubagentDetails {
|
|
|
55
54
|
|
|
56
55
|
export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
|
57
56
|
|
|
57
|
+
export type SubagentLiveEvent =
|
|
58
|
+
| { kind: "status"; status: "queued" | "running" | "done" | "failed" }
|
|
59
|
+
| { kind: "usage"; usage: UsageStats; model?: string }
|
|
60
|
+
| { kind: "tool_start"; toolName: string; args: unknown }
|
|
61
|
+
| { kind: "tool_end"; toolName: string; isError: boolean }
|
|
62
|
+
| { kind: "text_delta"; delta: string };
|
|
63
|
+
|
|
58
64
|
function emptyUsage(): UsageStats {
|
|
59
65
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
60
66
|
}
|
|
@@ -82,15 +88,6 @@ export function getResultOutput(result: SingleResult): string {
|
|
|
82
88
|
return getFinalOutput(result.messages) || "(no output)";
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
export function truncateParallelOutput(output: string): string {
|
|
86
|
-
const byteLength = Buffer.byteLength(output, "utf8");
|
|
87
|
-
if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
|
|
88
|
-
let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
|
|
89
|
-
while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) truncated = truncated.slice(0, -1);
|
|
90
|
-
const omitted = byteLength - Buffer.byteLength(truncated, "utf8");
|
|
91
|
-
return `${truncated}\n\n[Output truncated: ${omitted} bytes omitted. Full output preserved in tool details.]`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
91
|
export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
95
92
|
items: TIn[],
|
|
96
93
|
concurrency: number,
|
|
@@ -148,13 +145,14 @@ export interface RunSingleOptions {
|
|
|
148
145
|
cwd?: string;
|
|
149
146
|
signal?: AbortSignal;
|
|
150
147
|
onUpdate?: OnUpdateCallback;
|
|
148
|
+
onLive?: (e: SubagentLiveEvent) => void;
|
|
151
149
|
makeDetails: (results: SingleResult[]) => SubagentDetails;
|
|
152
150
|
env?: NodeJS.ProcessEnv;
|
|
153
151
|
}
|
|
154
152
|
|
|
155
153
|
/** Spawn one agent as an isolated pi child process and collect its output. */
|
|
156
154
|
export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
|
|
157
|
-
const { agent, agentName, task, cwd, signal, onUpdate, makeDetails } = options;
|
|
155
|
+
const { agent, agentName, task, cwd, signal, onUpdate, onLive, makeDetails } = options;
|
|
158
156
|
|
|
159
157
|
if (!agent) {
|
|
160
158
|
return {
|
|
@@ -230,6 +228,42 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
230
228
|
return;
|
|
231
229
|
}
|
|
232
230
|
|
|
231
|
+
// Live event: agent started
|
|
232
|
+
if (event.type === "agent_start" || event.type === "turn_start") {
|
|
233
|
+
if (onLive) {
|
|
234
|
+
try {
|
|
235
|
+
onLive({ kind: "status", status: "running" });
|
|
236
|
+
} catch { /* never throw from event handling */ }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Live event: streamed assistant text
|
|
241
|
+
if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
|
|
242
|
+
if (onLive) {
|
|
243
|
+
try {
|
|
244
|
+
onLive({ kind: "text_delta", delta: event.assistantMessageEvent.delta ?? "" });
|
|
245
|
+
} catch { /* never throw from event handling */ }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Live event: tool execution started
|
|
250
|
+
if (event.type === "tool_execution_start") {
|
|
251
|
+
if (onLive) {
|
|
252
|
+
try {
|
|
253
|
+
onLive({ kind: "tool_start", toolName: event.toolName ?? "unknown", args: event.args });
|
|
254
|
+
} catch { /* never throw from event handling */ }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Live event: tool execution ended
|
|
259
|
+
if (event.type === "tool_execution_end") {
|
|
260
|
+
if (onLive) {
|
|
261
|
+
try {
|
|
262
|
+
onLive({ kind: "tool_end", toolName: event.toolName ?? "unknown", isError: Boolean(event.isError) });
|
|
263
|
+
} catch { /* never throw from event handling */ }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
233
267
|
if (event.type === "message_end" && event.message) {
|
|
234
268
|
const msg = event.message as Message;
|
|
235
269
|
currentResult.messages.push(msg);
|
|
@@ -248,6 +282,12 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
248
282
|
if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason;
|
|
249
283
|
if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage;
|
|
250
284
|
}
|
|
285
|
+
// Live event: usage snapshot after accumulation
|
|
286
|
+
if (onLive) {
|
|
287
|
+
try {
|
|
288
|
+
onLive({ kind: "usage", usage: { ...currentResult.usage }, model: currentResult.model });
|
|
289
|
+
} catch { /* never throw from event handling */ }
|
|
290
|
+
}
|
|
251
291
|
emitUpdate();
|
|
252
292
|
}
|
|
253
293
|
|
|
@@ -256,7 +296,6 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
256
296
|
emitUpdate();
|
|
257
297
|
}
|
|
258
298
|
};
|
|
259
|
-
|
|
260
299
|
proc.stdout.on("data", (data) => {
|
|
261
300
|
buffer += data.toString();
|
|
262
301
|
const lines = buffer.split("\n");
|
|
@@ -270,6 +309,13 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
270
309
|
|
|
271
310
|
proc.on("close", (code) => {
|
|
272
311
|
if (buffer.trim()) processLine(buffer);
|
|
312
|
+
// Live event: final status derived from exit code
|
|
313
|
+
if (onLive) {
|
|
314
|
+
try {
|
|
315
|
+
const failed = (code ?? 0) !== 0 || currentResult.stopReason === "error" || currentResult.stopReason === "aborted";
|
|
316
|
+
onLive({ kind: "status", status: failed ? "failed" : "done" });
|
|
317
|
+
} catch { /* never throw from event handling */ }
|
|
318
|
+
}
|
|
273
319
|
resolve(code ?? 0);
|
|
274
320
|
});
|
|
275
321
|
|
|
@@ -289,7 +335,14 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
289
335
|
});
|
|
290
336
|
|
|
291
337
|
currentResult.exitCode = exitCode;
|
|
292
|
-
if (wasAborted)
|
|
338
|
+
if (wasAborted) {
|
|
339
|
+
if (onLive) {
|
|
340
|
+
try {
|
|
341
|
+
onLive({ kind: "status", status: "failed" });
|
|
342
|
+
} catch { /* never throw from event handling */ }
|
|
343
|
+
}
|
|
344
|
+
throw new Error("Subagent was aborted");
|
|
345
|
+
}
|
|
293
346
|
return currentResult;
|
|
294
347
|
} finally {
|
|
295
348
|
if (tmpPromptPath)
|