@mystilleef/pi-subagent 0.8.0 → 0.10.1

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.
@@ -1,5 +1,5 @@
1
1
  import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
2
- import { type Message, StringEnum } from "@earendil-works/pi-ai";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import type {
4
4
  ExtensionAPI,
5
5
  ExtensionContext,
@@ -12,6 +12,12 @@ import type {
12
12
  ThinkingLevel,
13
13
  } from "../agent/agents.js";
14
14
  import { runSingleAgent, SubagentAbortError } from "../child/process.js";
15
+ import { deliverNotification } from "../notification/delivery.js";
16
+ import {
17
+ buildNotificationRequest,
18
+ isDesktopNotificationsEnabled,
19
+ isPerJobNotificationEnabled,
20
+ } from "../notification/desktop-notification.js";
15
21
  import { formatSubagentResultForParent } from "../output/summary.js";
16
22
  import {
17
23
  cancelProgressState,
@@ -22,12 +28,13 @@ import {
22
28
  } from "../progress/progress.js";
23
29
  import {
24
30
  createSubagentError,
31
+ type DetailsOptions,
25
32
  getFeedbackSummaryText,
26
33
  getLatestResult,
27
34
  getResultDisplayText,
28
- hasSubagentFailed,
29
35
  patchProgressFromDetails,
30
36
  sanitizeDetailsForDisplay,
37
+ sanitizeResultDetails,
31
38
  } from "../progress/result-details.js";
32
39
  import { generateSubagentInstanceName } from "../shared/instance-name.js";
33
40
  import type {
@@ -36,7 +43,7 @@ import type {
36
43
  SubagentDetails,
37
44
  SubagentToolResult,
38
45
  } from "../shared/types.js";
39
- import { getSubagentDepth } from "../shared/utils.js";
46
+ import { getSubagentDepth, hasSubagentFailed } from "../shared/utils.js";
40
47
  import {
41
48
  listRunJobs,
42
49
  type RunJob,
@@ -71,12 +78,15 @@ export const SubagentParams = Type.Object({
71
78
 
72
79
  export type { SubagentToolResult };
73
80
 
74
- type DetailsOptions = { includeMessages?: boolean; recentMessages?: Message[] };
75
81
  type DetailsBuilder = (
76
82
  results: SingleResult[],
77
83
  options?: DetailsOptions,
78
84
  ) => SubagentDetails;
79
85
 
86
+ function isDebugDetailsAuthorized(debugRequested: boolean): boolean {
87
+ return debugRequested && process.env.PI_SUBAGENT_DEBUG_ENABLED === "1";
88
+ }
89
+
80
90
  interface LifecycleContext {
81
91
  pi: ExtensionAPI;
82
92
  ctx: ExtensionContext;
@@ -90,7 +100,7 @@ interface LifecycleContext {
90
100
  task: string;
91
101
  parentModel: { provider: string; id: string } | undefined;
92
102
  parentThinking: ThinkingLevel;
93
- hostOnUpdate?: AgentToolUpdateCallback<SubagentDetails>;
103
+ hostOnUpdate?: AgentToolUpdateCallback<SubagentDetails> | undefined;
94
104
  }
95
105
 
96
106
  function createDetailsBuilder(
@@ -108,63 +118,6 @@ function createDetailsBuilder(
108
118
  });
109
119
  }
110
120
 
111
- function sanitizeResultDetails(
112
- result: SingleResult,
113
- includeDebugMessages: boolean,
114
- options: DetailsOptions | undefined,
115
- ): SingleResult {
116
- const includeMessages =
117
- includeDebugMessages && (options?.includeMessages ?? true);
118
- const { messages, termination, progress, stderr, usage, ...core } = result;
119
- const { contextWindowTokens, ...usageBase } = usage;
120
- const sanitized: Record<string, unknown> = {
121
- ...core,
122
- stderr: includeDebugMessages ? stderr : "",
123
- usage: { ...usageBase },
124
- };
125
- if (contextWindowTokens !== undefined) {
126
- (sanitized.usage as Record<string, unknown>).contextWindowTokens =
127
- contextWindowTokens;
128
- }
129
- if (progress !== undefined) {
130
- const {
131
- activityText,
132
- activeToolActivity,
133
- lastToolPreview,
134
- toolResultCompleted,
135
- ...progBase
136
- } = progress;
137
- sanitized.progress = {
138
- toolCalls: progBase.toolCalls.map((tc) => ({
139
- id: tc.id,
140
- preview: tc.preview,
141
- })),
142
- ...(activityText !== undefined && { activityText }),
143
- ...(activeToolActivity !== undefined && { activeToolActivity }),
144
- ...(lastToolPreview !== undefined && { lastToolPreview }),
145
- ...(toolResultCompleted !== undefined && { toolResultCompleted }),
146
- };
147
- }
148
- if (includeMessages) {
149
- sanitized.messages = options?.recentMessages
150
- ? [...options.recentMessages]
151
- : messages !== undefined
152
- ? [...messages]
153
- : undefined;
154
- if (includeDebugMessages && termination !== undefined) {
155
- const { cancelReason, terminationSignal, fallbackCause, ...termBase } =
156
- termination;
157
- sanitized.termination = {
158
- ...termBase,
159
- ...(cancelReason !== undefined && { cancelReason }),
160
- ...(terminationSignal !== undefined && { terminationSignal }),
161
- ...(fallbackCause !== undefined && { fallbackCause }),
162
- };
163
- }
164
- }
165
- return sanitized as unknown as SingleResult;
166
- }
167
-
168
121
  function createProgressRenderRequester(
169
122
  ctx: ExtensionContext,
170
123
  requestId: string,
@@ -194,11 +147,26 @@ function sendSubagentResultMessage(
194
147
  });
195
148
  }
196
149
 
150
+ function deliverDesktopCompletionNotification(
151
+ state: NonNullable<ReturnType<typeof getProgressState>>,
152
+ ): void {
153
+ if (state.status === "cancelled") return;
154
+ const request = buildNotificationRequest(state);
155
+ deliverNotification(request).catch(() => {});
156
+ }
157
+
197
158
  export function emitCompletionAlert(
198
159
  state: ReturnType<typeof getProgressState>,
199
160
  ): void {
200
161
  if (!state) return;
201
162
  if (state.status === "cancelled") return;
163
+ if (
164
+ isDesktopNotificationsEnabled() &&
165
+ !isPerJobNotificationEnabled() &&
166
+ getSubagentDepth() === 0
167
+ ) {
168
+ deliverDesktopCompletionNotification(state);
169
+ }
202
170
  const tty = (process.stdout as { isTTY?: boolean }).isTTY;
203
171
  if (!tty) return;
204
172
  process.stdout.write("\x07");
@@ -268,10 +236,11 @@ function createPayloadFingerprint(payload: {
268
236
  const contentText = payload.content[0]?.text ?? "";
269
237
  const latestResult = payload.details.results[0];
270
238
  const activityText = latestResult?.progress?.activityText ?? "";
271
- const toolCallIds =
272
- [...new Set(latestResult?.progress?.toolCalls?.map((tc) => tc.id))]
273
- .sort()
274
- .join(",") ?? "";
239
+ const toolCallIds = [
240
+ ...new Set(latestResult?.progress?.toolCalls?.map((tc) => tc.id)),
241
+ ]
242
+ .sort()
243
+ .join(",");
275
244
  const exitCode = latestResult?.exitCode ?? 0;
276
245
  const stopReason = latestResult?.stopReason ?? "";
277
246
  return `${contentText}|${activityText}|${toolCallIds}|${exitCode}|${stopReason}`;
@@ -318,6 +287,7 @@ async function runSubagentLifecycle(
318
287
  lc.makeDetails,
319
288
  lc.parentModel,
320
289
  lc.parentThinking,
290
+ lc.debug,
321
291
  );
322
292
  return finishLifecycleResult(lc, result);
323
293
  } catch (error) {
@@ -332,15 +302,25 @@ async function runSubagentLifecycle(
332
302
  clearInterval(timerTick);
333
303
  requestProgressRender();
334
304
  removeRunJob(lc.requestId);
335
- if (listRunJobs().length === 0) {
336
- const state = getProgressState(lc.requestId);
337
- if (state) {
338
- emitCompletionAlert(state);
339
- }
340
- }
305
+ const state = getProgressState(lc.requestId);
306
+ if (state) deliverLifecycleCompletionNotification(state);
341
307
  }
342
308
  }
343
309
 
310
+ function deliverLifecycleCompletionNotification(
311
+ state: NonNullable<ReturnType<typeof getProgressState>>,
312
+ ): void {
313
+ if (
314
+ isDesktopNotificationsEnabled() &&
315
+ isPerJobNotificationEnabled() &&
316
+ getSubagentDepth() === 0
317
+ ) {
318
+ deliverDesktopCompletionNotification(state);
319
+ return;
320
+ }
321
+ if (listRunJobs().length === 0) emitCompletionAlert(state);
322
+ }
323
+
344
324
  type StartJobResult =
345
325
  | {
346
326
  kind: "started";
@@ -358,11 +338,10 @@ type PrepareSubagentJobResult =
358
338
  lc: LifecycleContext;
359
339
  instanceName: string;
360
340
  requestProgressRender: () => void;
361
- hostOnUpdate?: AgentToolUpdateCallback<SubagentDetails>;
341
+ hostOnUpdate?: AgentToolUpdateCallback<SubagentDetails> | undefined;
362
342
  }
363
343
  | { kind: "not_found"; makeDetails: DetailsBuilder }
364
- | { kind: "cancelled"; makeDetails: DetailsBuilder }
365
- | { kind: "aborted"; makeDetails: DetailsBuilder };
344
+ | { kind: "cancelled"; makeDetails: DetailsBuilder };
366
345
 
367
346
  export function formatSubagentToolResult(
368
347
  agentName: string,
@@ -409,7 +388,7 @@ async function prepareSubagentJob(
409
388
  const agentScope: AgentScope = params.agentScope ?? "both";
410
389
  const discovery = await getCachedAgentDiscovery(ctx.cwd, agentScope);
411
390
  const agents = discovery.agents;
412
- const debug = params.debug === true;
391
+ const debug = isDebugDetailsAuthorized(params.debug === true);
413
392
  const makeDetails = createDetailsBuilder(
414
393
  agentScope,
415
394
  discovery.projectAgentsDir,
@@ -474,7 +453,7 @@ async function prepareSubagentJob(
474
453
  if (mergedSignal.aborted) {
475
454
  cancelStartedJob(job, job.cancelReason ?? "Aborted");
476
455
  requestProgressRender();
477
- return { kind: "aborted", makeDetails: makeStartedDetails };
456
+ return { kind: "cancelled", makeDetails: makeStartedDetails };
478
457
  }
479
458
  return {
480
459
  kind: "ready",
@@ -513,11 +492,7 @@ export async function startSubagentJob(
513
492
  hostSignal,
514
493
  hostOnUpdate,
515
494
  );
516
- if (prepared.kind !== "ready") {
517
- if (prepared.kind === "aborted")
518
- return { kind: "cancelled", makeDetails: prepared.makeDetails };
519
- return prepared;
520
- }
495
+ if (prepared.kind !== "ready") return prepared;
521
496
  const { lc, instanceName, requestProgressRender } = prepared;
522
497
  if (getSubagentDepth() > 0) {
523
498
  const result = await runSubagentLifecycle(lc);
@@ -1,4 +1,4 @@
1
- export const TERMINAL_SENTENCE_MAX_CHARS = 100;
1
+ const TERMINAL_SENTENCE_MAX_CHARS = 100;
2
2
 
3
3
  export function normalizeSummaryValue(value: string): string {
4
4
  const normalized = value.trim().replace(/\s+/g, " ");
@@ -67,7 +67,7 @@ export function normalizeTerminalSentence(
67
67
  return truncateText(collapsed, limit);
68
68
  }
69
69
 
70
- export const TOOL_PREVIEW_MAX_CHARS = 120;
70
+ const TOOL_PREVIEW_MAX_CHARS = 120;
71
71
 
72
72
  export function normalizeAndTruncate(
73
73
  text: string,
package/src/output/ui.ts CHANGED
@@ -11,6 +11,8 @@ import type { AgentScope } from "../agent/agents.js";
11
11
  import {
12
12
  formatContextPercent,
13
13
  formatElapsed,
14
+ } from "../progress/progress-format.js";
15
+ import {
14
16
  type ProgressStatus,
15
17
  STATUS_BG,
16
18
  STATUS_COLOR,
@@ -18,8 +20,8 @@ import {
18
20
  type SubagentProgressState,
19
21
  type ThemeBg,
20
22
  } from "../progress/progress-state.js";
21
- import { hasSubagentFailed } from "../progress/result-details.js";
22
23
  import type { SubagentDetails, UsageStats } from "../shared/types.js";
24
+ import { hasSubagentFailed } from "../shared/utils.js";
23
25
  import {
24
26
  extractSemanticToolTarget,
25
27
  normalizeSummaryValue,
@@ -115,9 +117,6 @@ export function formatResultFooter(usage: UsageStats, model?: string): string {
115
117
  return parts.join(" · ");
116
118
  }
117
119
 
118
- /**
119
- * Formats a tool call for the UI, optionally extracting a semantic target for clarity.
120
- */
121
120
  export function formatToolCall(
122
121
  toolName: string,
123
122
  args: Record<string, unknown>,
@@ -131,9 +130,6 @@ export function formatToolCall(
131
130
  return themeFg("accent", toolName) + themeFg("dim", ` ${target}`);
132
131
  }
133
132
 
134
- /**
135
- * Extracts the final text response from an array of assistant messages.
136
- */
137
133
  export function getFinalOutput(messages: Message[]): string {
138
134
  const lastAsst = messages.findLast((m) => m.role === "assistant");
139
135
  const lastText = lastAsst?.content.findLast((p) => p.type === "text");
@@ -148,9 +144,6 @@ function stripOutcomeLineForResultUi(output: string): string {
148
144
  return stripped.trim() ? stripped : output;
149
145
  }
150
146
 
151
- /**
152
- * Maps subagent theme colors to Markdown rendering components.
153
- */
154
147
  function makeMarkdownTheme(theme: SubagentTheme): MarkdownTheme {
155
148
  const fg = (c: ThemeColor) => (text: string) => theme.fg(c, text);
156
149
  return {
@@ -172,9 +165,6 @@ function makeMarkdownTheme(theme: SubagentTheme): MarkdownTheme {
172
165
  };
173
166
  }
174
167
 
175
- /**
176
- * Renders the pending subagent call UI component.
177
- */
178
168
  export function renderSubagentCall(
179
169
  args: { agent?: string; task?: string; agentScope?: AgentScope },
180
170
  theme: SubagentTheme,
@@ -205,7 +195,7 @@ export function renderSubagentToolResult(
205
195
  export function renderSubagentResult(
206
196
  result: { content: { type: string; text?: string }[]; details?: unknown },
207
197
  theme: SubagentTheme,
208
- _display?: { isPartial?: boolean },
198
+ display?: { isPartial?: boolean },
209
199
  bodyOverride?: string,
210
200
  ): Component {
211
201
  const details = result.details as SubagentDetails | undefined;
@@ -228,7 +218,7 @@ export function renderSubagentResult(
228
218
  const finalOutput = r.finalOutput ?? getFinalOutput(r.messages ?? []);
229
219
  const title = formatSubagentTitle(r.agent, r.instanceName, theme);
230
220
  let effectiveBody = bodyOverride ?? finalOutput;
231
- if (_display?.isPartial && !finalOutput?.trim() && !bodyOverride) {
221
+ if (display?.isPartial && !finalOutput?.trim() && !bodyOverride) {
232
222
  effectiveBody =
233
223
  result.content[0]?.text ||
234
224
  r.progress?.activityText ||
@@ -241,7 +231,7 @@ export function renderSubagentResult(
241
231
  const ctxPercent = formatContextPercent({
242
232
  contextTokens: r.usage.contextTokens,
243
233
  contextWindowTokens: r.usage.contextWindowTokens,
244
- } as SubagentProgressState);
234
+ });
245
235
  const metadata = `${toolLabel} · ${ctxPercent} ctx · ${formatElapsed(r.durationMs ?? 0)}`;
246
236
  const usageStr = formatResultFooter(r.usage, r.model);
247
237
  return renderStatusCard(
@@ -285,7 +275,7 @@ function renderStatusCard(
285
275
  : "";
286
276
  box.addChild(new Text(`${icon} ${options.title} ${status}${metadata}`, 0, 0));
287
277
  box.addChild(makeStatusCardBody(options, theme));
288
- if (options.variant === "full" && options.footer)
278
+ if (options.footer)
289
279
  box.addChild(new Text(theme.fg("dim", options.footer), 0, 0));
290
280
  return box;
291
281
  }
@@ -294,7 +284,7 @@ function makeStatusCardBody(
294
284
  options: StatusCardOptions,
295
285
  theme: SubagentTheme,
296
286
  ): Box {
297
- const body = new Box(2, options.variant === "full" ? 1 : 0);
287
+ const body = new Box(2, options.variant === "full" || options.footer ? 1 : 0);
298
288
  const bodyText = options.body ?? "";
299
289
  if (bodyText && options.variant === "full") {
300
290
  body.addChild(
@@ -336,16 +326,15 @@ function renderJobCard(
336
326
  bodyText.length > BODY_PREVIEW_MAX
337
327
  ? `${bodyText.slice(0, BODY_PREVIEW_MAX - 1)}…`
338
328
  : bodyText;
339
- return renderStatusCard(
340
- {
341
- status: state.status,
342
- title,
343
- variant: "abridged",
344
- metadata,
345
- body: preview,
346
- },
347
- theme,
348
- );
329
+ const options: StatusCardOptions = {
330
+ status: state.status,
331
+ title,
332
+ variant: "abridged",
333
+ metadata,
334
+ body: preview,
335
+ };
336
+ if (state.modelDisplay) options.footer = state.modelDisplay;
337
+ return renderStatusCard(options, theme);
349
338
  }
350
339
 
351
340
  function sortByStartTimeDesc(
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Formatting and rendering helpers for subagent progress display.
3
+ *
4
+ * Pure presentation logic extracted from progress-state.js. No state
5
+ * management or store access — only transforms data into display strings.
6
+ */
7
+
8
+ import {
9
+ normalizeAndTruncate,
10
+ normalizeSummaryValue,
11
+ truncateText,
12
+ } from "../output/normalize.js";
13
+ import type { ToolActivity } from "../shared/types.js";
14
+ import type { SubagentProgressState } from "./progress-state.js";
15
+
16
+ export const SENSITIVE_PATTERN = /secret|token|password/i;
17
+
18
+ const REDACTED_PLACEHOLDER = "(running...)";
19
+ const REDACTED_PLACEHOLDER_LENGTH = REDACTED_PLACEHOLDER.length;
20
+
21
+ function redactOrTruncate(text: string, maxChars: number): string {
22
+ if (SENSITIVE_PATTERN.test(text))
23
+ return maxChars >= REDACTED_PLACEHOLDER_LENGTH ? REDACTED_PLACEHOLDER : "";
24
+ return truncateText(text, maxChars);
25
+ }
26
+
27
+ function walkActivityTree(activity: ToolActivity): string[] {
28
+ const parts: string[] = [];
29
+ let current: ToolActivity | undefined = activity;
30
+ while (current) {
31
+ if (current.inputSummary) {
32
+ const annotated = current.instanceName
33
+ ? `${current.inputSummary} [${current.instanceName}]`
34
+ : current.inputSummary;
35
+ parts.push(annotated);
36
+ }
37
+ current = current.child;
38
+ }
39
+ return parts;
40
+ }
41
+
42
+ /**
43
+ * Format a millisecond duration for compact display.
44
+ * Renders sub-minute durations as decimal seconds (`45.2s`),
45
+ * longer durations as minutes and whole seconds (`2m 15s`).
46
+ */
47
+ export function formatElapsed(ms: number): string {
48
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
49
+ const mins = Math.floor(ms / 60000);
50
+ const secs = Math.floor((ms % 60000) / 1000);
51
+ return `${mins}m ${secs}s`;
52
+ }
53
+
54
+ export function formatContextPercent(state: {
55
+ contextTokens?: number | undefined;
56
+ contextWindowTokens?: number | undefined;
57
+ }): string {
58
+ const windowTokens = state.contextWindowTokens;
59
+ if (!windowTokens || windowTokens <= 0 || !Number.isFinite(windowTokens))
60
+ return "--%";
61
+ const usedTokens = state.contextTokens;
62
+ if (!usedTokens || usedTokens <= 0 || !Number.isFinite(usedTokens))
63
+ return "0%";
64
+ return `${Math.round((usedTokens / windowTokens) * 100)}%`;
65
+ }
66
+
67
+ /**
68
+ * Format the one-line statistics header for a subagent progress display.
69
+ * Includes tool count, context window usage, and elapsed time.
70
+ * When the subagent is still running (`durationMs` unset), elapsed is
71
+ * computed live from `startTime`.
72
+ *
73
+ * @returns Single line, e.g. `"3 tools · 45% ctx · 12.3s"`
74
+ */
75
+ export function formatHeaderStats(state: SubagentProgressState): string {
76
+ const elapsedMs = state.durationMs ?? Date.now() - state.startTime;
77
+ const toolLabel = state.toolCount === 1 ? "tool" : "tools";
78
+ return `${state.toolCount} ${toolLabel} · ${formatContextPercent(state)} ctx · ${formatElapsed(elapsedMs)}`;
79
+ }
80
+
81
+ /**
82
+ * Renders a ToolActivity tree for storage. Each segment is
83
+ * independently normalized and truncated to TOOL_PREVIEW_MAX_CHARS (120).
84
+ */
85
+ export function renderToolActivity(
86
+ activity: ToolActivity | undefined,
87
+ ): string | undefined {
88
+ if (!activity) return undefined;
89
+ const parts = walkActivityTree(activity);
90
+ if (parts.length === 0) return activity.toolName;
91
+ const result = parts.map((p) => normalizeAndTruncate(p)).join(" - ");
92
+ if (SENSITIVE_PATTERN.test(result)) return REDACTED_PLACEHOLDER;
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * Renders a ToolActivity tree for display with a caller-provided truncation
98
+ * budget. Segments are normalized without individual truncation so the
99
+ * joined result shares one post-join display budget.
100
+ */
101
+ export function renderToolActivityForDisplay(
102
+ activity: ToolActivity | undefined,
103
+ maxChars: number,
104
+ ): string | undefined {
105
+ if (!activity) return undefined;
106
+ if (maxChars <= 0) return "";
107
+ const parts = walkActivityTree(activity);
108
+ if (parts.length === 0) return redactOrTruncate(activity.toolName, maxChars);
109
+ const joined = parts.map((p) => normalizeSummaryValue(p)).join(" - ");
110
+ return redactOrTruncate(joined, maxChars);
111
+ }