@mystilleef/pi-subagent 0.3.0 → 0.4.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/src/progress.ts CHANGED
@@ -16,36 +16,19 @@
16
16
  * @module progress
17
17
  */
18
18
 
19
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
20
19
  import type { Component } from "@earendil-works/pi-tui";
21
- import { Text } from "@earendil-works/pi-tui";
20
+ import { Box, Text } from "@earendil-works/pi-tui";
22
21
  import {
22
+ formatHeaderStats,
23
23
  getProgressState,
24
24
  type ProgressStatus,
25
+ STATUS_BG,
26
+ STATUS_COLOR,
27
+ STATUS_ICON,
25
28
  type SubagentProgressState,
29
+ type ThemeBg,
26
30
  } from "./progress-state.js";
27
- import type { SubagentTheme, ThemeBg } from "./ui.js";
28
-
29
- const STATUS_COLOR: Record<ProgressStatus, ThemeColor> = {
30
- success: "success",
31
- error: "error",
32
- cancelled: "error",
33
- running: "accent",
34
- };
35
-
36
- const STATUS_ICON: Record<ProgressStatus, string> = {
37
- success: "✓",
38
- error: "✗",
39
- cancelled: "⊘",
40
- running: "⟳",
41
- };
42
-
43
- const STATUS_BG: Record<ProgressStatus, ThemeBg> = {
44
- success: "toolSuccessBg",
45
- error: "toolErrorBg",
46
- cancelled: "toolErrorBg",
47
- running: "toolPendingBg",
48
- };
31
+ import { formatSubagentTitle, type SubagentTheme } from "./ui.js";
49
32
 
50
33
  export { makeToolPreview } from "./normalize.js";
51
34
  export {
@@ -55,64 +38,21 @@ export {
55
38
  extractProgressFromDetails,
56
39
  failProgressState,
57
40
  finalizeProgressState,
41
+ formatContextPercent,
42
+ formatElapsed,
43
+ formatHeaderStats,
44
+ formatTokenCount,
58
45
  getProgressState,
59
46
  makeTaskPreview,
60
47
  type ProgressStatus,
61
48
  patchProgressState,
62
49
  resetProgressStore,
50
+ STATUS_COLOR,
51
+ STATUS_ICON,
63
52
  type SubagentProgressState,
53
+ type ThemeBg,
64
54
  } from "./progress-state.js";
65
55
 
66
- /**
67
- * Format a millisecond duration for compact display.
68
- * Renders sub-minute durations as decimal seconds (`45.2s`),
69
- * longer durations as minutes and whole seconds (`2m 15s`).
70
- */
71
- export function formatElapsed(ms: number): string {
72
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
73
- const mins = Math.floor(ms / 60000);
74
- const secs = Math.floor((ms % 60000) / 1000);
75
- return `${mins}m ${secs}s`;
76
- }
77
-
78
- /**
79
- * Format a raw token count for compact inline display.
80
- * Values below 1000 rendered as-is. Larger counts use `k`
81
- * or `M` suffixes with one decimal place, stripping trailing `.0`.
82
- */
83
- export function formatTokenCount(count: number): string {
84
- if (count < 1000) return String(count);
85
- const unit = count >= 1_000_000 ? "M" : "k";
86
- const divisor = count >= 1_000_000 ? 1_000_000 : 1000;
87
- return `${trimTrailingZero((count / divisor).toFixed(1))}${unit}`;
88
- }
89
-
90
- /**
91
- * Format the one-line statistics header for a subagent progress display.
92
- * Includes tool count, context window usage, and elapsed time.
93
- * When the subagent is still running (`durationMs` unset), elapsed is
94
- * computed live from `startTime`.
95
- *
96
- * @returns Single line ending in `\n`, e.g. `"3 tools · 45% ctx · 12.3s\n"`
97
- */
98
- export function formatHeaderStats(state: SubagentProgressState): string {
99
- const elapsedMs = state.durationMs ?? Date.now() - state.startTime;
100
- const toolLabel = state.toolCount === 1 ? "tool" : "tools";
101
- return `${state.toolCount} ${toolLabel} · ${formatContextPercent(state)} ctx · ${formatElapsed(elapsedMs)}\n`;
102
- }
103
-
104
- function formatContextPercent(state: SubagentProgressState): string {
105
- const d = state.contextWindowTokens;
106
- if (!d || d <= 0 || !Number.isFinite(d)) return "--%";
107
- const n = state.contextTokens;
108
- if (!n || n <= 0 || !Number.isFinite(n)) return "0%";
109
- return `${Math.round((n / d) * 100)}%`;
110
- }
111
-
112
- function trimTrailingZero(value: string): string {
113
- return value.endsWith(".0") ? value.slice(0, -2) : value;
114
- }
115
-
116
56
  /**
117
57
  * Create a live-updating TUI progress component from a pi message.
118
58
  *
@@ -156,11 +96,7 @@ class DynamicSubagentProgressText implements Component {
156
96
  render(width: number): string[] {
157
97
  const state = getProgressState(this.requestId);
158
98
  if (!state) return [];
159
- const text = formatProgressText(this.requestId, this.options, this.theme);
160
- const bg = getProgressBackground(state.status);
161
- return text
162
- ? new Text(text, 1, 1, (line) => this.theme.bg(bg, line)).render(width)
163
- : [];
99
+ return renderProgressBox(state, this.options, this.theme).render(width);
164
100
  }
165
101
  }
166
102
 
@@ -168,44 +104,97 @@ function getProgressBackground(status: ProgressStatus): ThemeBg {
168
104
  return STATUS_BG[status];
169
105
  }
170
106
 
171
- function formatProgressText(
172
- requestId: string,
107
+ function renderProgressBox(
108
+ state: SubagentProgressState,
173
109
  options: { expanded: boolean },
174
110
  theme: SubagentTheme,
175
- ): string | undefined {
176
- const state = getProgressState(requestId);
177
- if (!state) return undefined;
111
+ ): Box {
178
112
  const status = state.status;
179
- const header = `${theme.fg(STATUS_COLOR[status], STATUS_ICON[status])} ${theme.fg("toolTitle", theme.bold(state.agent))} ${theme.fg("dim", `[${status}]`)} ${theme.fg("muted", formatHeaderStats(state))}`;
180
- if (status === "running") {
181
- const toolLine = state.lastToolPreview
182
- ? `\n ${formatRunningToolPreview(state.lastToolPreview, theme)}`
183
- : "";
184
- const taskLine = options.expanded
185
- ? `\n ${theme.fg("dim", state.taskPreview)}`
186
- : "";
187
- return header + toolLine + taskLine;
113
+ const title = formatSubagentTitle(state.agent, state.instanceName, theme);
114
+ const header = `${theme.fg(STATUS_COLOR[status], STATUS_ICON[status])} ${title} ${theme.fg("dim", `[${status}]`)} ${theme.fg("muted", formatHeaderStats(state))}`;
115
+ const box = new Box(1, 1, (line) =>
116
+ theme.bg(getProgressBackground(status), line),
117
+ );
118
+ box.addChild(new Text(header, 0, 0));
119
+ addProgressBody(box, state, options, theme);
120
+ return box;
121
+ }
122
+
123
+ function addProgressBody(
124
+ box: Box,
125
+ state: SubagentProgressState,
126
+ options: { expanded: boolean },
127
+ theme: SubagentTheme,
128
+ ): void {
129
+ const body = makeProgressBody(state, options, theme);
130
+ if (body.length === 0) return;
131
+ for (const line of body) box.addChild(line);
132
+ }
133
+
134
+ function makeProgressBody(
135
+ state: SubagentProgressState,
136
+ options: { expanded: boolean },
137
+ theme: SubagentTheme,
138
+ ): Text[] {
139
+ if (state.status === "running")
140
+ return makeRunningProgressBody(state, options, theme);
141
+ if (state.status === "error" || state.status === "cancelled") {
142
+ return makeStoppedProgressBody(state, options, theme);
188
143
  }
189
- if (status === "error" || status === "cancelled") {
190
- const errorLine = state.errorText
191
- ? `\n ${theme.fg("error", state.errorText)}`
192
- : "";
193
- const taskLine = options.expanded
194
- ? `\n ${theme.fg("dim", state.taskPreview)}`
195
- : "";
196
- return header + errorLine + taskLine;
144
+ if (state.status === "success")
145
+ return makeSuccessProgressBody(state, options, theme);
146
+ return [];
147
+ }
148
+
149
+ function makeRunningProgressBody(
150
+ state: SubagentProgressState,
151
+ options: { expanded: boolean },
152
+ theme: SubagentTheme,
153
+ ): Text[] {
154
+ const body: Text[] = [];
155
+ if (state.lastToolPreview) {
156
+ body.push(
157
+ new Text(formatRunningToolPreview(state.lastToolPreview, theme), 2, 0),
158
+ );
197
159
  }
198
- if (status === "success") {
199
- const output = state.finalOutput?.trim().split("\n")[0] ?? "";
200
- if (!options.expanded) {
201
- return output ? `${header}\n ${theme.fg("toolOutput", output)}` : header;
202
- }
203
- const outputSection = output
204
- ? `\n${theme.fg("muted", "─── Output ───")}\n${theme.fg("toolOutput", output)}`
205
- : `\n${theme.fg("muted", "(no output)")}`;
206
- return `${header}\n ${theme.fg("dim", state.taskPreview)}${outputSection}`;
160
+ if (options.expanded)
161
+ body.push(new Text(theme.fg("dim", state.taskPreview), 2, 0));
162
+ return body;
163
+ }
164
+
165
+ function makeStoppedProgressBody(
166
+ state: SubagentProgressState,
167
+ options: { expanded: boolean },
168
+ theme: SubagentTheme,
169
+ ): Text[] {
170
+ const body: Text[] = [];
171
+ if (state.errorText)
172
+ body.push(new Text(theme.fg("error", state.errorText), 2, 0));
173
+ if (options.expanded)
174
+ body.push(new Text(theme.fg("dim", state.taskPreview), 2, 0));
175
+ return body;
176
+ }
177
+
178
+ function makeSuccessProgressBody(
179
+ state: SubagentProgressState,
180
+ options: { expanded: boolean },
181
+ theme: SubagentTheme,
182
+ ): Text[] {
183
+ const output = state.finalOutput?.trim().split("\n")[0] ?? "";
184
+ if (!options.expanded) {
185
+ return output ? [new Text(theme.fg("toolOutput", output), 2, 0)] : [];
207
186
  }
208
- return header;
187
+ const body = [new Text(theme.fg("dim", state.taskPreview), 2, 0)];
188
+ body.push(
189
+ output
190
+ ? new Text(
191
+ `${theme.fg("muted", "─── Output ───")}\n${theme.fg("toolOutput", output)}`,
192
+ 0,
193
+ 0,
194
+ )
195
+ : new Text(theme.fg("muted", "(no output)"), 0, 0),
196
+ );
197
+ return body;
209
198
  }
210
199
 
211
200
  function formatRunningToolPreview(
@@ -1,6 +1,7 @@
1
1
  export type RunJob = {
2
2
  requestId: string;
3
3
  agentName: string;
4
+ instanceName: string;
4
5
  controller: AbortController;
5
6
  startedAt: number;
6
7
  cancelReason?: string;
@@ -11,12 +11,15 @@ import {
11
11
  discoverAgents,
12
12
  type ThinkingLevel,
13
13
  } from "./agents.js";
14
+ import { generateSubagentInstanceName } from "./instance-name.js";
14
15
  import { runSingleAgent } from "./process.js";
15
16
  import {
16
17
  cancelProgressState,
17
18
  createProgressState,
18
19
  failProgressState,
19
20
  finalizeProgressState,
21
+ formatElapsed,
22
+ getProgressState,
20
23
  } from "./progress.js";
21
24
  import {
22
25
  createSubagentError,
@@ -26,7 +29,12 @@ import {
26
29
  patchProgressFromDetails,
27
30
  sanitizeDetailsForDisplay,
28
31
  } from "./result-details.js";
29
- import { type RunJob, registerRunJob, removeRunJob } from "./run-registry.js";
32
+ import {
33
+ listRunJobs,
34
+ type RunJob,
35
+ registerRunJob,
36
+ removeRunJob,
37
+ } from "./run-registry.js";
30
38
  import { formatSubagentResultForParent } from "./summary.js";
31
39
  import type {
32
40
  OnUpdateCallback,
@@ -167,6 +175,32 @@ function sendSubagentResultMessage(
167
175
  });
168
176
  }
169
177
 
178
+ export function emitCompletionNotification(
179
+ ctx: ExtensionContext,
180
+ state: ReturnType<typeof getProgressState>,
181
+ ): void {
182
+ if (!state) return;
183
+ if (state.status === "cancelled") return;
184
+ if (!ctx.ui?.notify) return;
185
+ const tty = (process.stdout as { isTTY?: boolean }).isTTY;
186
+ if (!tty) return;
187
+ const icon = state.status === "error" ? "✗" : "✓";
188
+ const severity =
189
+ state.status === "error" ? ("error" as const) : ("info" as const);
190
+ const elapsed = formatElapsed(
191
+ state.durationMs ?? Date.now() - state.startTime,
192
+ );
193
+ const raw = state.errorText ?? state.finalOutput ?? "";
194
+ const preview = raw.trim().slice(0, 80);
195
+ const namePart = state.instanceName
196
+ ? `${state.agent} ${state.instanceName}`
197
+ : state.agent;
198
+ const toast = `${namePart} ${icon} ${elapsed} — "${preview}"`;
199
+ // Terminal bell to draw attention when all jobs complete
200
+ process.stdout.write("\x07");
201
+ ctx.ui.notify(toast, severity);
202
+ }
203
+
170
204
  async function runSubagentWorker(
171
205
  pi: ExtensionAPI,
172
206
  ctx: ExtensionContext,
@@ -252,11 +286,22 @@ async function runSubagentWorker(
252
286
  } finally {
253
287
  requestProgressRender();
254
288
  removeRunJob(requestId);
289
+ if (listRunJobs().length === 0) {
290
+ const state = getProgressState(requestId);
291
+ if (state) {
292
+ emitCompletionNotification(ctx, state);
293
+ }
294
+ }
255
295
  }
256
296
  }
257
297
 
258
298
  export type StartJobResult =
259
- | { kind: "started"; requestId: string; makeDetails: DetailsBuilder }
299
+ | {
300
+ kind: "started";
301
+ requestId: string;
302
+ instanceName: string;
303
+ makeDetails: DetailsBuilder;
304
+ }
260
305
  | { kind: "cancelled"; makeDetails: DetailsBuilder }
261
306
  | { kind: "not_found"; makeDetails: DetailsBuilder };
262
307
 
@@ -266,7 +311,7 @@ export function formatStartJobStatus(
266
311
  ): string {
267
312
  if (result.kind === "not_found") return `Unknown agent: "${agentName}"`;
268
313
  if (result.kind === "cancelled") return "Canceled";
269
- return `Subagent ${agentName} started (job: ${result.requestId})`;
314
+ return `Subagent ${agentName} ${result.instanceName} started (job: ${result.requestId})`;
270
315
  }
271
316
 
272
317
  function needsProjectAgentConfirmation(
@@ -305,6 +350,16 @@ export async function startSubagentJob(
305
350
  );
306
351
  const requested = agents.find((a) => a.name === params.agent);
307
352
  if (!requested) return { kind: "not_found", makeDetails };
353
+ if (hostSignal?.aborted) return { kind: "cancelled", makeDetails };
354
+ const task = params.task?.trim() ?? "";
355
+ if (needsProjectAgentConfirmation(ctx, requested)) {
356
+ const confirmed = await confirmProjectAgentRun(
357
+ ctx,
358
+ requested,
359
+ discovery.projectAgentsDir,
360
+ );
361
+ if (!confirmed) return { kind: "cancelled", makeDetails };
362
+ }
308
363
  if (requested.source === "project") {
309
364
  const userAgents = discoverAgents(ctx.cwd, "user");
310
365
  const hasUserCollision = userAgents.agents.some(
@@ -319,36 +374,34 @@ export async function startSubagentJob(
319
374
  });
320
375
  }
321
376
  }
322
- const task = params.task?.trim() ?? "";
323
- if (needsProjectAgentConfirmation(ctx, requested)) {
324
- const confirmed = await confirmProjectAgentRun(
325
- ctx,
326
- requested,
327
- discovery.projectAgentsDir,
328
- );
329
- if (!confirmed) return { kind: "cancelled", makeDetails };
330
- }
331
377
  const parentModel = ctx.model
332
378
  ? { provider: ctx.model.provider, id: ctx.model.id }
333
379
  : undefined;
334
380
  const parentThinking = pi.getThinkingLevel() as ThinkingLevel;
335
381
  const requestId = crypto.randomUUID();
382
+ const instanceName = generateSubagentInstanceName();
336
383
  const controller = new AbortController();
337
384
  const job: RunJob = registerRunJob({
338
385
  requestId,
339
386
  agentName: params.agent,
387
+ instanceName,
340
388
  controller,
341
389
  startedAt: Date.now(),
342
390
  });
343
391
  const mergedSignal = hostSignal
344
392
  ? AbortSignal.any([hostSignal, job.controller.signal])
345
393
  : job.controller.signal;
346
- createProgressState(requestId, params.agent, task);
394
+ const makeStartedDetails: DetailsBuilder = (results, options) =>
395
+ makeDetails(
396
+ results.map((result) => ({ ...result, instanceName })),
397
+ options,
398
+ );
399
+ createProgressState(requestId, params.agent, task, instanceName);
347
400
  pi.sendMessage({
348
401
  customType: "subagent-progress",
349
402
  content: "",
350
403
  display: true,
351
- details: { requestId },
404
+ details: { agent: params.agent, instanceName, requestId },
352
405
  });
353
406
  const requestProgressRender = createProgressRenderRequester(ctx, requestId);
354
407
  setImmediate(() => {
@@ -366,12 +419,17 @@ export async function startSubagentJob(
366
419
  debug,
367
420
  parentModel,
368
421
  parentThinking,
369
- makeDetails,
422
+ makeStartedDetails,
370
423
  requestId,
371
424
  job,
372
425
  mergedSignal,
373
426
  );
374
427
  });
375
428
  if (mergedSignal.aborted) return { kind: "cancelled", makeDetails };
376
- return { kind: "started", requestId, makeDetails };
429
+ return {
430
+ kind: "started",
431
+ requestId,
432
+ instanceName,
433
+ makeDetails: makeStartedDetails,
434
+ };
377
435
  }
@@ -124,10 +124,13 @@ function sendTreeSignal(
124
124
  sendDirectSignal(proc, signal, state, options);
125
125
  return;
126
126
  }
127
- if (options.killProcessTree) {
128
- options.killProcessTree(proc, signal, platform);
127
+ const markTreeKilled = () => {
129
128
  state.metadata.target = "tree";
130
129
  state.metadata.processTreeKilled = true;
130
+ };
131
+ if (options.killProcessTree) {
132
+ options.killProcessTree(proc, signal, platform);
133
+ markTreeKilled();
131
134
  return;
132
135
  }
133
136
  if (platform !== "win32") {
@@ -137,8 +140,7 @@ function sendTreeSignal(
137
140
  options.killProcessGroup ??
138
141
  ((pid, nextSignal) => process.kill(pid, nextSignal))
139
142
  )(-pid, signal);
140
- state.metadata.target = "tree";
141
- state.metadata.processTreeKilled = true;
143
+ markTreeKilled();
142
144
  return;
143
145
  }
144
146
  if (signal === "SIGKILL") {
@@ -148,8 +150,7 @@ function sendTreeSignal(
148
150
  "/t",
149
151
  "/f",
150
152
  ]);
151
- state.metadata.target = "tree";
152
- state.metadata.processTreeKilled = true;
153
+ markTreeKilled();
153
154
  return;
154
155
  }
155
156
  throw new Error("unsupported tree termination platform");
package/src/types.ts CHANGED
@@ -26,6 +26,7 @@ export interface StreamingProgress {
26
26
 
27
27
  export interface SingleResult {
28
28
  agent: string;
29
+ instanceName?: string;
29
30
  agentSource: "user" | "project" | "unknown";
30
31
  task: string;
31
32
  exitCode: number;