@bacnh85/pi-subagent 0.5.0 → 0.7.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.
@@ -16,15 +16,15 @@ import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.t
16
16
  // Safe type guards
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
- function asString(value: unknown, fallback = "..."): string {
19
+ export function asString(value: unknown, fallback = "..."): string {
20
20
  return typeof value === "string" ? value : fallback;
21
21
  }
22
22
 
23
- function asNumber(value: unknown): number | undefined {
23
+ export function asNumber(value: unknown): number | undefined {
24
24
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
25
25
  }
26
26
 
27
- function asRecord(value: unknown): Record<string, unknown> {
27
+ export function asRecord(value: unknown): Record<string, unknown> {
28
28
  return value && typeof value === "object" && !Array.isArray(value)
29
29
  ? (value as Record<string, unknown>)
30
30
  : {};
@@ -59,7 +59,7 @@ export function formatUsageStats(
59
59
  return parts.join(" ");
60
60
  }
61
61
 
62
- function formatToolCall(
62
+ export function formatToolCall(
63
63
  toolName: string,
64
64
  args: Record<string, unknown>,
65
65
  themeFg: (color: string, text: string) => string,
@@ -130,16 +130,16 @@ function formatToolCall(
130
130
  }
131
131
  }
132
132
 
133
- type DisplayItem =
133
+ export type DisplayItem =
134
134
  | { type: "text"; text: string }
135
135
  | { type: "toolCall"; name: string; args: Record<string, unknown> };
136
136
 
137
- function getDisplayItems(messages: Message[]): DisplayItem[] {
137
+ export function getDisplayItems(messages: Message[]): DisplayItem[] {
138
138
  const items: DisplayItem[] = [];
139
139
  for (const msg of messages) {
140
140
  if (msg.role === "assistant") {
141
141
  for (const part of msg.content) {
142
- if (part.type === "text") {
142
+ if (part.type === "text" && part.text.trim()) {
143
143
  items.push({ type: "text", text: part.text });
144
144
  } else if (part.type === "toolCall") {
145
145
  items.push({
@@ -25,6 +25,11 @@ import {
25
25
  SessionManager,
26
26
  SettingsManager,
27
27
  } from "@earendil-works/pi-coding-agent";
28
+ import {
29
+ classifyStopReason,
30
+ createCombinedAbortSignal,
31
+ type SubagentStatus,
32
+ } from "./security.ts";
28
33
 
29
34
  // ---------------------------------------------------------------------------
30
35
  // Types
@@ -50,6 +55,8 @@ export interface SubAgentResult {
50
55
  model?: string;
51
56
  stopReason?: string;
52
57
  errorMessage?: string;
58
+ /** Canonical result status (added in 0.6.0). */
59
+ status?: SubagentStatus;
53
60
  }
54
61
 
55
62
  // ---------------------------------------------------------------------------
@@ -67,8 +74,9 @@ export async function runSubAgent(options: {
67
74
  signal?: AbortSignal;
68
75
  agentName?: string;
69
76
  thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
70
- onUpdate?: (text: string) => void;
71
77
  onMessage?: (partialResult: SubAgentResult) => void;
78
+ /** Pre-validated timeout in ms. When provided, an abort signal will be created. */
79
+ timeoutMs?: number;
72
80
  }): Promise<SubAgentResult> {
73
81
  const {
74
82
  cwd,
@@ -81,8 +89,8 @@ export async function runSubAgent(options: {
81
89
  signal,
82
90
  agentName = "subagent",
83
91
  thinkingLevel = "off",
84
- onUpdate,
85
92
  onMessage,
93
+ timeoutMs,
86
94
  } = options;
87
95
 
88
96
  const result: SubAgentResult = {
@@ -93,6 +101,7 @@ export async function runSubAgent(options: {
93
101
  stderr: "",
94
102
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
95
103
  model: `${model.provider}/${model.id}`,
104
+ status: undefined,
96
105
  };
97
106
 
98
107
  // Build a minimal resource loader. The sub-agent sees ONLY the agent's
@@ -114,11 +123,31 @@ export async function runSubAgent(options: {
114
123
  retry: { enabled: false },
115
124
  });
116
125
 
126
+ // Hoisted so the outer catch can clean up on early failure.
127
+ let timeoutController: AbortController | undefined;
128
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
129
+ let cleanupCombined: (() => void) | undefined;
130
+
117
131
  try {
118
- if (signal?.aborted) {
132
+ // Build combined signal from parent signal and timeout
133
+ const signalsToCombine: (AbortSignal | undefined | null | false)[] = [signal];
134
+
135
+ // Create timeout controller
136
+ if (timeoutMs && timeoutMs > 0) {
137
+ timeoutController = new AbortController();
138
+ timeoutId = setTimeout(() => timeoutController!.abort(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
139
+ signalsToCombine.push(timeoutController.signal);
140
+ }
141
+
142
+ const { signal: combinedSignal, cleanup: cleanupCb } = createCombinedAbortSignal(signalsToCombine);
143
+ cleanupCombined = cleanupCb;
144
+
145
+ if (combinedSignal.aborted) {
119
146
  result.exitCode = 1;
120
- result.stopReason = "aborted";
121
- result.errorMessage = "Sub-agent aborted before start";
147
+ const isTimeout = timeoutController?.signal.aborted === true && signal?.aborted !== true;
148
+ result.stopReason = isTimeout ? "timeout" : "aborted";
149
+ result.errorMessage = combinedSignal.reason instanceof Error ? combinedSignal.reason.message : "Sub-agent aborted before start";
150
+ result.status = classifyStopReason(result.stopReason, !isTimeout, isTimeout);
122
151
  return result;
123
152
  }
124
153
 
@@ -136,21 +165,22 @@ export async function runSubAgent(options: {
136
165
 
137
166
  let cleanupAbort: (() => void) | undefined;
138
167
  let cleanupEventAbort: (() => void) | undefined;
168
+ let abortedBySignal = false;
169
+ let timedOut = false;
170
+
139
171
  try {
140
- // Wire abort signal
141
- if (signal) {
142
- const onAbort = () => session.abort();
143
- if (signal.aborted) {
144
- // Already aborted — shortcut
145
- result.exitCode = 1;
146
- result.stopReason = "aborted";
147
- result.errorMessage = "Sub-agent aborted before start";
148
- onAbort();
149
- return result;
150
- }
151
- signal.addEventListener("abort", onAbort, { once: true });
152
- cleanupAbort = () => signal.removeEventListener("abort", onAbort);
172
+ // Wire combined abort signal to session
173
+ const onAbort = () => {
174
+ session.abort();
175
+ };
176
+ if (combinedSignal.aborted) {
177
+ abortedBySignal = true;
178
+ timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
179
+ onAbort();
180
+ return result;
153
181
  }
182
+ combinedSignal.addEventListener("abort", onAbort, { once: true });
183
+ cleanupAbort = () => combinedSignal.removeEventListener("abort", onAbort);
154
184
 
155
185
  // Collect all messages and usage stats from events
156
186
  const eventPromise = new Promise<void>((resolve, reject) => {
@@ -208,19 +238,16 @@ export async function runSubAgent(options: {
208
238
  });
209
239
 
210
240
  // Resolve on abort so the eventPromise doesn't hang
211
- if (signal) {
212
- const onAbortResolve = () => {
213
- finish(() => {
214
- result.exitCode = 1;
215
- result.stopReason = "aborted";
216
- if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
217
- unsubscribe();
218
- resolve();
219
- });
220
- };
221
- signal.addEventListener("abort", onAbortResolve, { once: true });
222
- cleanupEventAbort = () => signal.removeEventListener("abort", onAbortResolve);
223
- }
241
+ const onAbortResolve = () => {
242
+ finish(() => {
243
+ result.exitCode = 1;
244
+ if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
245
+ unsubscribe();
246
+ resolve();
247
+ });
248
+ };
249
+ combinedSignal.addEventListener("abort", onAbortResolve, { once: true });
250
+ cleanupEventAbort = () => combinedSignal.removeEventListener("abort", onAbortResolve);
224
251
  });
225
252
 
226
253
  await Promise.race([
@@ -228,13 +255,31 @@ export async function runSubAgent(options: {
228
255
  eventPromise,
229
256
  ]);
230
257
 
231
- if (result.stopReason !== "aborted") {
258
+ // Detect timeout vs. parent abort.
259
+ timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
260
+ abortedBySignal = combinedSignal.aborted && !timedOut;
261
+
262
+ if (timedOut) {
263
+ result.exitCode = 1;
264
+ result.stopReason = "timeout";
265
+ result.errorMessage ||= `Timeout after ${timeoutMs}ms`;
266
+ } else if (abortedBySignal) {
267
+ result.exitCode = 1;
268
+ result.stopReason = "aborted";
269
+ result.errorMessage ||= "Sub-agent aborted";
270
+ } else {
232
271
  result.exitCode = 0;
233
272
  }
273
+
274
+ // Classify canonical status.
275
+ result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
276
+
234
277
  return result;
235
278
  } finally {
236
279
  cleanupAbort?.();
237
280
  cleanupEventAbort?.();
281
+ cleanupCombined();
282
+ if (timeoutId) clearTimeout(timeoutId);
238
283
  try {
239
284
  session.dispose();
240
285
  } catch {
@@ -246,6 +291,10 @@ export async function runSubAgent(options: {
246
291
  result.exitCode = 1;
247
292
  result.errorMessage = message;
248
293
  if (!result.stopReason) result.stopReason = "error";
294
+ result.status = classifyStopReason("error", false, false);
295
+ // Ensure cleanup runs even when the outer try fails before the inner finally.
296
+ cleanupCombined?.();
297
+ if (timeoutId) clearTimeout(timeoutId);
249
298
  return result;
250
299
  }
251
300
  }
@@ -255,31 +304,25 @@ export async function runSubAgent(options: {
255
304
  // ---------------------------------------------------------------------------
256
305
 
257
306
  export function getFinalOutput(messages: Message[]): string {
258
- // Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
259
307
  for (let i = messages.length - 1; i >= 0; i--) {
260
308
  const msg = messages[i];
261
309
  if (msg.role !== "assistant") continue;
262
310
  const texts: string[] = [];
263
- let hasToolCalls = false;
264
311
  for (const part of msg.content) {
265
312
  if (part.type === "text" && part.text.trim()) texts.push(part.text);
266
- else if (part.type === "toolCall") hasToolCalls = true;
267
313
  }
268
- if (texts.length > 0 && !hasToolCalls) return texts.join("");
269
- }
270
- // Fallback: last assistant message with any non-empty text (even if it also has tool calls).
271
- for (let i = messages.length - 1; i >= 0; i--) {
272
- const msg = messages[i];
273
- if (msg.role !== "assistant") continue;
274
- const texts = msg.content
275
- .filter((p): p is { type: "text"; text: string } => p.type === "text" && p.text.trim().length > 0)
276
- .map((p) => p.text);
277
- if (texts.length > 0) return texts.join("");
314
+ if (texts.length === 0) continue;
315
+ return texts.join("");
278
316
  }
279
317
  return "";
280
318
  }
281
319
 
282
320
  export function isFailedResult(result: SubAgentResult): boolean {
321
+ // Use canonical status if available.
322
+ if (result.status) {
323
+ return result.status === "error" || result.status === "aborted" || result.status === "timeout";
324
+ }
325
+ // Fall back to legacy heuristics.
283
326
  return (
284
327
  result.exitCode !== 0 ||
285
328
  result.stopReason === "error" ||