@ferris1225/pi-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/package.json +1 -1
- package/src/index.ts +119 -10
- package/src/monitor.ts +370 -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.2.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, openSubagentOverlay, statusIcon } 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];
|
|
@@ -203,6 +226,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
203
226
|
cwd: t.cwd,
|
|
204
227
|
signal,
|
|
205
228
|
onUpdate: perTaskUpdate,
|
|
229
|
+
onLive,
|
|
206
230
|
makeDetails: makeDetails("parallel"),
|
|
207
231
|
});
|
|
208
232
|
allResults[index] = result;
|
|
@@ -212,7 +236,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
212
236
|
|
|
213
237
|
const successCount = results.filter((r) => !isFailedResult(r)).length;
|
|
214
238
|
const summaries = results.map((r) => {
|
|
215
|
-
const output =
|
|
239
|
+
const output = getResultOutput(r);
|
|
216
240
|
const status = isFailedResult(r) ? "failed" : "completed";
|
|
217
241
|
const usage = formatUsage(r.usage);
|
|
218
242
|
return `### [${r.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${output}`;
|
|
@@ -229,6 +253,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
229
253
|
}
|
|
230
254
|
|
|
231
255
|
// ---- Single mode ----
|
|
256
|
+
const resolvedModel = agents.find((a) => a.name === params.agent)?.model;
|
|
257
|
+
const runId = monitor.addRun(params.agent as string, resolvedModel);
|
|
258
|
+
const onLive = (e: SubagentLiveEvent): void => {
|
|
259
|
+
switch (e.kind) {
|
|
260
|
+
case "status":
|
|
261
|
+
monitor.setStatus(runId, e.status);
|
|
262
|
+
break;
|
|
263
|
+
case "usage":
|
|
264
|
+
monitor.setUsage(runId, e.usage, e.model);
|
|
265
|
+
break;
|
|
266
|
+
case "tool_start":
|
|
267
|
+
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)})` });
|
|
268
|
+
break;
|
|
269
|
+
case "tool_end":
|
|
270
|
+
monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
|
|
271
|
+
break;
|
|
272
|
+
case "text_delta":
|
|
273
|
+
monitor.appendTextDelta(runId, e.delta);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
232
277
|
const result = await runSingleAgent({
|
|
233
278
|
defaultCwd: ctx.cwd,
|
|
234
279
|
agent: agents.find((a) => a.name === params.agent),
|
|
@@ -237,6 +282,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
237
282
|
cwd: params.cwd,
|
|
238
283
|
signal,
|
|
239
284
|
onUpdate,
|
|
285
|
+
onLive,
|
|
240
286
|
makeDetails: makeDetails("single"),
|
|
241
287
|
});
|
|
242
288
|
|
|
@@ -275,13 +321,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
275
321
|
renderResult(result, _options, theme) {
|
|
276
322
|
const details = result.details as SubagentDetails | undefined;
|
|
277
323
|
if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
details.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
324
|
+
|
|
325
|
+
if (details.mode === "single") {
|
|
326
|
+
const r = details.results[0];
|
|
327
|
+
const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
|
|
328
|
+
const usage = formatUsage(r.usage);
|
|
329
|
+
const model = r.model ?? "?";
|
|
330
|
+
const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`;
|
|
331
|
+
return new Text(line, 0, 0);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Parallel mode: header + one compact line per agent
|
|
335
|
+
const lines: string[] = [
|
|
336
|
+
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
|
|
337
|
+
];
|
|
338
|
+
for (const r of details.results) {
|
|
339
|
+
const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
|
|
340
|
+
const usage = formatUsage(r.usage);
|
|
341
|
+
const model = r.model ?? "?";
|
|
342
|
+
lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`);
|
|
343
|
+
}
|
|
344
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
285
345
|
},
|
|
286
346
|
});
|
|
287
347
|
|
|
@@ -292,6 +352,55 @@ export default function (pi: ExtensionAPI): void {
|
|
|
292
352
|
},
|
|
293
353
|
});
|
|
294
354
|
|
|
355
|
+
// Persistent widget above the editor showing live sub-agent status.
|
|
356
|
+
pi.on("session_start", (_e, ctx) => {
|
|
357
|
+
if (ctx.mode !== "tui") return;
|
|
358
|
+
ctx.ui.setWidget(
|
|
359
|
+
"pi-subagents",
|
|
360
|
+
(tui, theme) => {
|
|
361
|
+
const unsub = monitor.subscribe(() => tui.requestRender());
|
|
362
|
+
return {
|
|
363
|
+
render(width: number): string[] {
|
|
364
|
+
const runs = monitor.getRuns();
|
|
365
|
+
if (runs.length === 0) return [];
|
|
366
|
+
const lines = runs.map((r) => {
|
|
367
|
+
const icon = statusIcon(r.status, theme);
|
|
368
|
+
return truncateToWidth(` ${icon} ${monitor.summarize(r)}`, width, "");
|
|
369
|
+
});
|
|
370
|
+
lines.push(truncateToWidth(theme.fg("dim", " ctrl+shift+a / /subagents to inspect"), width, ""));
|
|
371
|
+
return lines;
|
|
372
|
+
},
|
|
373
|
+
invalidate() {},
|
|
374
|
+
dispose() {
|
|
375
|
+
unsub();
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
{ placement: "aboveEditor" },
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Drill-down overlay command.
|
|
384
|
+
pi.registerCommand("subagents", {
|
|
385
|
+
description: "Inspect running/recent sub-agents (model, tokens, live transcript)",
|
|
386
|
+
handler: async (_args, ctx) => {
|
|
387
|
+
if (ctx.mode !== "tui") {
|
|
388
|
+
ctx.ui.notify("The sub-agent monitor requires Pi's interactive TUI.", "warning");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
await openSubagentOverlay(ctx);
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Keyboard shortcut for the overlay.
|
|
396
|
+
pi.registerShortcut("ctrl+shift+a", {
|
|
397
|
+
description: "Open sub-agent monitor",
|
|
398
|
+
handler: async (ctx) => {
|
|
399
|
+
if (ctx.mode !== "tui") return;
|
|
400
|
+
await openSubagentOverlay(ctx);
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
295
404
|
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
296
405
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
297
406
|
const config = await loadConfig(configPath);
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent monitor: a module-level singleton store that tracks subagent runs
|
|
3
|
+
* for the current turn, plus a drill-down overlay component for live inspection.
|
|
4
|
+
*
|
|
5
|
+
* The store notifies subscribers on every mutation so the persistent widget and
|
|
6
|
+
* the overlay can re-render. The overlay shows a list of runs (↑/↓ + Enter) and
|
|
7
|
+
* a detail view with the live transcript (auto-tailing, scrollable).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
matchesKey,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
type Component,
|
|
14
|
+
type Focusable,
|
|
15
|
+
type TUI,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
17
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { UsageStats } from "./spawn.ts";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export type RunStatus = "queued" | "running" | "done" | "failed";
|
|
25
|
+
|
|
26
|
+
export interface TranscriptLine {
|
|
27
|
+
kind: "tool" | "text" | "status" | "error";
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RunView {
|
|
32
|
+
id: number;
|
|
33
|
+
agent: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
status: RunStatus;
|
|
36
|
+
usage: UsageStats;
|
|
37
|
+
transcript: TranscriptLine[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Formatting helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function formatTokens(count: number): string {
|
|
45
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
46
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
47
|
+
return String(count);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatUsageCompact(usage: UsageStats): string {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
53
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
54
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
55
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
56
|
+
return parts.join(" ");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// MonitorStore
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export class MonitorStore {
|
|
64
|
+
private runs: RunView[] = [];
|
|
65
|
+
private nextId = 1;
|
|
66
|
+
private subscribers = new Set<() => void>();
|
|
67
|
+
|
|
68
|
+
beginTurn(): void {
|
|
69
|
+
// Clear finished runs from a previous turn, but keep any still-active
|
|
70
|
+
// (queued/running) ones so a concurrent sub-agent call is not wiped.
|
|
71
|
+
this.runs = this.runs.filter((r) => r.status === "queued" || r.status === "running");
|
|
72
|
+
this.notify();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
addRun(agent: string, model?: string): number {
|
|
76
|
+
const id = this.nextId++;
|
|
77
|
+
this.runs.push({
|
|
78
|
+
id,
|
|
79
|
+
agent,
|
|
80
|
+
model,
|
|
81
|
+
status: "queued",
|
|
82
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
83
|
+
transcript: [],
|
|
84
|
+
});
|
|
85
|
+
this.notify();
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setStatus(id: number, status: RunStatus): void {
|
|
90
|
+
const run = this.find(id);
|
|
91
|
+
if (!run) return;
|
|
92
|
+
run.status = status;
|
|
93
|
+
this.notify();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setUsage(id: number, usage: UsageStats, model?: string): void {
|
|
97
|
+
const run = this.find(id);
|
|
98
|
+
if (!run) return;
|
|
99
|
+
run.usage = { ...usage };
|
|
100
|
+
if (model) run.model = model;
|
|
101
|
+
this.notify();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
appendTranscript(id: number, line: TranscriptLine): void {
|
|
105
|
+
const run = this.find(id);
|
|
106
|
+
if (!run) return;
|
|
107
|
+
run.transcript.push(line);
|
|
108
|
+
this.notify();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Append streamed assistant text, merging consecutive deltas into coherent
|
|
112
|
+
* lines (split only on real newlines) instead of one line per token fragment. */
|
|
113
|
+
appendTextDelta(id: number, delta: string): void {
|
|
114
|
+
const run = this.find(id);
|
|
115
|
+
if (!run || delta.length === 0) return;
|
|
116
|
+
const segments = delta.split("\n");
|
|
117
|
+
for (let i = 0; i < segments.length; i++) {
|
|
118
|
+
const seg = segments[i];
|
|
119
|
+
const last = run.transcript[run.transcript.length - 1];
|
|
120
|
+
if (i === 0 && last && last.kind === "text") {
|
|
121
|
+
last.text += seg;
|
|
122
|
+
} else {
|
|
123
|
+
run.transcript.push({ kind: "text", text: seg });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
this.notify();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getRuns(): RunView[] {
|
|
130
|
+
return this.runs;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
subscribe(cb: () => void): () => void {
|
|
134
|
+
this.subscribers.add(cb);
|
|
135
|
+
return () => {
|
|
136
|
+
this.subscribers.delete(cb);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
summarize(run: RunView): string {
|
|
141
|
+
const usage = formatUsageCompact(run.usage);
|
|
142
|
+
const parts = [run.agent];
|
|
143
|
+
if (run.model) parts.push(run.model);
|
|
144
|
+
if (usage) parts.push(usage);
|
|
145
|
+
parts.push(run.status);
|
|
146
|
+
return parts.join(" · ");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private find(id: number): RunView | undefined {
|
|
150
|
+
return this.runs.find((r) => r.id === id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private notify(): void {
|
|
154
|
+
for (const cb of this.subscribers) {
|
|
155
|
+
try {
|
|
156
|
+
cb();
|
|
157
|
+
} catch {
|
|
158
|
+
/* subscriber errors must not break the store */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const monitor = new MonitorStore();
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Status icons
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export function statusIcon(status: RunStatus, theme: Theme): string {
|
|
171
|
+
switch (status) {
|
|
172
|
+
case "running":
|
|
173
|
+
return theme.fg("accent", "●");
|
|
174
|
+
case "done":
|
|
175
|
+
return theme.fg("success", "✓");
|
|
176
|
+
case "failed":
|
|
177
|
+
return theme.fg("error", "✗");
|
|
178
|
+
default:
|
|
179
|
+
return theme.fg("dim", "○");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Overlay component
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
class SubagentOverlay implements Component, Focusable {
|
|
188
|
+
private _focused = false;
|
|
189
|
+
private mode: "list" | "detail" = "list";
|
|
190
|
+
private cursor = 0;
|
|
191
|
+
private scroll = 0;
|
|
192
|
+
private cachedWidth = -1;
|
|
193
|
+
private cachedLines: string[] = [];
|
|
194
|
+
private closed = false;
|
|
195
|
+
|
|
196
|
+
private readonly unsub: () => void;
|
|
197
|
+
|
|
198
|
+
constructor(
|
|
199
|
+
private readonly tui: TUI,
|
|
200
|
+
private readonly theme: Theme,
|
|
201
|
+
private readonly done: (result: void) => void,
|
|
202
|
+
) {
|
|
203
|
+
this.unsub = monitor.subscribe(() => {
|
|
204
|
+
this.invalidate();
|
|
205
|
+
this.tui.requestRender();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get focused(): boolean {
|
|
210
|
+
return this._focused;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
set focused(value: boolean) {
|
|
214
|
+
this._focused = value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
invalidate(): void {
|
|
218
|
+
this.cachedWidth = -1;
|
|
219
|
+
this.cachedLines = [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
handleInput(data: string): void {
|
|
223
|
+
if (this.mode === "list") {
|
|
224
|
+
const runs = monitor.getRuns();
|
|
225
|
+
if (matchesKey(data, "up")) {
|
|
226
|
+
if (runs.length > 0) this.cursor = this.cursor === 0 ? runs.length - 1 : this.cursor - 1;
|
|
227
|
+
} else if (matchesKey(data, "down")) {
|
|
228
|
+
if (runs.length > 0) this.cursor = this.cursor === runs.length - 1 ? 0 : this.cursor + 1;
|
|
229
|
+
} else if (matchesKey(data, "return")) {
|
|
230
|
+
if (runs.length > 0) {
|
|
231
|
+
this.mode = "detail";
|
|
232
|
+
this.scrollToBottom();
|
|
233
|
+
}
|
|
234
|
+
} else if (matchesKey(data, "escape")) {
|
|
235
|
+
this.close();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
if (matchesKey(data, "up")) {
|
|
240
|
+
this.scroll = Math.max(0, this.scroll - 1);
|
|
241
|
+
} else if (matchesKey(data, "down")) {
|
|
242
|
+
this.scroll++;
|
|
243
|
+
} else if (matchesKey(data, "escape")) {
|
|
244
|
+
this.mode = "list";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
this.invalidate();
|
|
248
|
+
this.tui.requestRender();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
render(width: number): string[] {
|
|
252
|
+
if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
|
|
253
|
+
|
|
254
|
+
const t = this.theme;
|
|
255
|
+
const fit = (line: string): string => truncateToWidth(line, width, "");
|
|
256
|
+
const border = fit(t.fg("border", "─".repeat(Math.max(1, width))));
|
|
257
|
+
|
|
258
|
+
const lines: string[] = [];
|
|
259
|
+
|
|
260
|
+
if (this.mode === "list") {
|
|
261
|
+
lines.push(border);
|
|
262
|
+
lines.push(fit(t.fg("accent", t.bold(" Sub-agents"))));
|
|
263
|
+
lines.push(border);
|
|
264
|
+
|
|
265
|
+
const runs = monitor.getRuns();
|
|
266
|
+
if (runs.length === 0) {
|
|
267
|
+
lines.push(fit(t.fg("dim", " (no sub-agent runs this turn)")));
|
|
268
|
+
} else {
|
|
269
|
+
this.cursor = Math.max(0, Math.min(this.cursor, runs.length - 1));
|
|
270
|
+
for (let i = 0; i < runs.length; i++) {
|
|
271
|
+
const run = runs[i];
|
|
272
|
+
const isCursor = i === this.cursor;
|
|
273
|
+
const mark = isCursor ? t.fg("accent", "❯ ") : " ";
|
|
274
|
+
const icon = statusIcon(run.status, t);
|
|
275
|
+
const usage = formatUsageCompact(run.usage);
|
|
276
|
+
const parts = [run.agent];
|
|
277
|
+
if (run.model) parts.push(run.model);
|
|
278
|
+
if (usage) parts.push(usage);
|
|
279
|
+
parts.push(run.status);
|
|
280
|
+
const label = isCursor ? t.fg("accent", t.bold(parts.join(" · "))) : parts.join(" · ");
|
|
281
|
+
lines.push(fit(`${mark}${icon} ${label}`));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
lines.push(border);
|
|
286
|
+
lines.push(fit(t.fg("dim", " ↑↓ select · enter open · esc close")));
|
|
287
|
+
lines.push(border);
|
|
288
|
+
} else {
|
|
289
|
+
const runs = monitor.getRuns();
|
|
290
|
+
const run = runs[this.cursor];
|
|
291
|
+
|
|
292
|
+
lines.push(border);
|
|
293
|
+
if (run) {
|
|
294
|
+
const headerParts = [run.agent];
|
|
295
|
+
if (run.model) headerParts.push(run.model);
|
|
296
|
+
lines.push(fit(t.fg("accent", t.bold(` ${headerParts.join(" · ")}`))));
|
|
297
|
+
const usage = formatUsageCompact(run.usage);
|
|
298
|
+
const statusLine = ` ${statusIcon(run.status, t)} ${run.status}${usage ? ` · ${usage}` : ""}`;
|
|
299
|
+
lines.push(fit(statusLine));
|
|
300
|
+
} else {
|
|
301
|
+
lines.push(fit(t.fg("dim", " (no run selected)")));
|
|
302
|
+
}
|
|
303
|
+
lines.push(border);
|
|
304
|
+
|
|
305
|
+
if (run) {
|
|
306
|
+
// Available height for transcript: total minus header(4) + footer(2)
|
|
307
|
+
const transcriptLines = run.transcript;
|
|
308
|
+
const availHeight = Math.max(1, 40 - 6); // reasonable default; actual height varies
|
|
309
|
+
this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, transcriptLines.length - availHeight)));
|
|
310
|
+
|
|
311
|
+
// Auto-tail: if scroll is at the bottom, keep it there
|
|
312
|
+
const maxScroll = Math.max(0, transcriptLines.length - availHeight);
|
|
313
|
+
if (this.scroll >= maxScroll - 1) this.scroll = maxScroll;
|
|
314
|
+
|
|
315
|
+
const visible = transcriptLines.slice(this.scroll, this.scroll + availHeight);
|
|
316
|
+
for (const entry of visible) {
|
|
317
|
+
const color =
|
|
318
|
+
entry.kind === "tool"
|
|
319
|
+
? "accent"
|
|
320
|
+
: entry.kind === "error"
|
|
321
|
+
? "error"
|
|
322
|
+
: entry.kind === "status"
|
|
323
|
+
? "dim"
|
|
324
|
+
: "text";
|
|
325
|
+
lines.push(fit(t.fg(color, ` ${entry.text}`)));
|
|
326
|
+
}
|
|
327
|
+
if (transcriptLines.length === 0) {
|
|
328
|
+
lines.push(fit(t.fg("dim", " (waiting for output…)")));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
lines.push(border);
|
|
333
|
+
lines.push(fit(t.fg("dim", " ↑↓ scroll · esc back")));
|
|
334
|
+
lines.push(border);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.cachedWidth = width;
|
|
338
|
+
this.cachedLines = lines;
|
|
339
|
+
return lines;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
dispose(): void {
|
|
343
|
+
if (!this.closed) {
|
|
344
|
+
this.closed = true;
|
|
345
|
+
this.unsub();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private scrollToBottom(): void {
|
|
350
|
+
const runs = monitor.getRuns();
|
|
351
|
+
const run = runs[this.cursor];
|
|
352
|
+
if (run) this.scroll = Math.max(0, run.transcript.length);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private close(): void {
|
|
356
|
+
this.closed = true;
|
|
357
|
+
this.unsub();
|
|
358
|
+
this.done();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Public entry point
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
export async function openSubagentOverlay(ctx: ExtensionContext): Promise<void> {
|
|
367
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => new SubagentOverlay(tui, theme, done), {
|
|
368
|
+
overlay: true,
|
|
369
|
+
});
|
|
370
|
+
}
|
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)
|