@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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +49 -48
  3. 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.2.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, openSubagentOverlay, statusIcon } from "./monitor.ts";
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
- const result = await runSingleAgent({
222
- defaultCwd: ctx.cwd,
223
- agent: agents.find((a) => a.name === t.agent),
224
- agentName: t.agent,
225
- task: t.task,
226
- cwd: t.cwd,
227
- signal,
228
- onUpdate: perTaskUpdate,
229
- onLive,
230
- makeDetails: makeDetails("parallel"),
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
- const result = await runSingleAgent({
278
- defaultCwd: ctx.cwd,
279
- agent: agents.find((a) => a.name === params.agent),
280
- agentName: params.agent as string,
281
- task: params.task as string,
282
- cwd: params.cwd,
283
- signal,
284
- onUpdate,
285
- onLive,
286
- makeDetails: makeDetails("single"),
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 = runs.map((r) => {
384
+ const lines: string[] = [];
385
+ for (const r of runs) {
367
386
  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, ""));
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, plus a drill-down overlay component for live inspection.
3
+ * for the current turn.
4
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).
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
- parts.push(run.status);
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
- // 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();
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
- // 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
- });
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
  }