@ferris1225/pi-subagents 0.2.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 +49 -48
- package/src/monitor.ts +66 -198
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
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type SubagentLiveEvent,
|
|
39
39
|
type UsageStats,
|
|
40
40
|
} from "./spawn.ts";
|
|
41
|
-
import { monitor,
|
|
41
|
+
import { monitor, statusColor, statusIcon, statusLabel } from "./monitor.ts";
|
|
42
42
|
|
|
43
43
|
const TaskItem = Type.Object({
|
|
44
44
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
@@ -218,17 +218,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
: undefined;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
}
|
|
232
238
|
allResults[index] = result;
|
|
233
239
|
emitParallelUpdate();
|
|
234
240
|
return result;
|
|
@@ -274,17 +280,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
274
280
|
break;
|
|
275
281
|
}
|
|
276
282
|
};
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
}
|
|
288
300
|
|
|
289
301
|
if (isFailedResult(result)) {
|
|
290
302
|
return {
|
|
@@ -359,20 +371,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
359
371
|
"pi-subagents",
|
|
360
372
|
(tui, theme) => {
|
|
361
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);
|
|
362
380
|
return {
|
|
363
381
|
render(width: number): string[] {
|
|
364
382
|
const runs = monitor.getRuns();
|
|
365
383
|
if (runs.length === 0) return [];
|
|
366
|
-
const lines =
|
|
384
|
+
const lines: string[] = [];
|
|
385
|
+
for (const r of runs) {
|
|
367
386
|
const icon = statusIcon(r.status, theme);
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
}
|
|
371
392
|
return lines;
|
|
372
393
|
},
|
|
373
394
|
invalidate() {},
|
|
374
395
|
dispose() {
|
|
375
396
|
unsub();
|
|
397
|
+
clearInterval(timer);
|
|
376
398
|
},
|
|
377
399
|
};
|
|
378
400
|
},
|
|
@@ -380,27 +402,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
380
402
|
);
|
|
381
403
|
});
|
|
382
404
|
|
|
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
|
-
|
|
404
405
|
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
405
406
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
406
407
|
const config = await loadConfig(configPath);
|
package/src/monitor.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sub-agent monitor: a module-level singleton store that tracks subagent runs
|
|
3
|
-
* for the current turn
|
|
3
|
+
* for the current turn.
|
|
4
4
|
*
|
|
5
|
-
* The store notifies subscribers on every mutation so the persistent widget
|
|
6
|
-
* the
|
|
7
|
-
* a
|
|
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.
|
|
8
9
|
*/
|
|
9
10
|
|
|
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";
|
|
11
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
18
12
|
import type { UsageStats } from "./spawn.ts";
|
|
19
13
|
|
|
20
14
|
// ---------------------------------------------------------------------------
|
|
@@ -35,6 +29,10 @@ export interface RunView {
|
|
|
35
29
|
status: RunStatus;
|
|
36
30
|
usage: UsageStats;
|
|
37
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;
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
// ---------------------------------------------------------------------------
|
|
@@ -56,6 +54,23 @@ export function formatUsageCompact(usage: UsageStats): string {
|
|
|
56
54
|
return parts.join(" ");
|
|
57
55
|
}
|
|
58
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
|
+
|
|
59
74
|
// ---------------------------------------------------------------------------
|
|
60
75
|
// MonitorStore
|
|
61
76
|
// ---------------------------------------------------------------------------
|
|
@@ -90,9 +105,13 @@ export class MonitorStore {
|
|
|
90
105
|
const run = this.find(id);
|
|
91
106
|
if (!run) return;
|
|
92
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
|
+
}
|
|
93
113
|
this.notify();
|
|
94
114
|
}
|
|
95
|
-
|
|
96
115
|
setUsage(id: number, usage: UsageStats, model?: string): void {
|
|
97
116
|
const run = this.find(id);
|
|
98
117
|
if (!run) return;
|
|
@@ -142,10 +161,20 @@ export class MonitorStore {
|
|
|
142
161
|
const parts = [run.agent];
|
|
143
162
|
if (run.model) parts.push(run.model);
|
|
144
163
|
if (usage) parts.push(usage);
|
|
145
|
-
|
|
164
|
+
const elapsed = formatElapsed(run);
|
|
165
|
+
if (elapsed) parts.push(elapsed);
|
|
146
166
|
return parts.join(" · ");
|
|
147
167
|
}
|
|
148
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
|
+
|
|
149
178
|
private find(id: number): RunView | undefined {
|
|
150
179
|
return this.runs.find((r) => r.id === id);
|
|
151
180
|
}
|
|
@@ -180,191 +209,30 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
|
|
|
180
209
|
}
|
|
181
210
|
}
|
|
182
211
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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();
|
|
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";
|
|
359
223
|
}
|
|
360
224
|
}
|
|
361
225
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
+
}
|
|
370
238
|
}
|