pi-subagents 0.13.4 → 0.14.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.
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import * as path from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
+ import type { Message } from "@mariozechner/pi-ai";
6
7
  import { appendJsonl, getArtifactPaths } from "./artifacts.ts";
7
8
  import { getPiSpawnCommand } from "./pi-spawn.ts";
8
9
  import { captureSingleOutputSnapshot, resolveSingleOutput } from "./single-output.ts";
@@ -27,6 +28,7 @@ import {
27
28
  } from "./parallel-utils.ts";
28
29
  import { buildPiArgs, cleanupTempDir } from "./pi-args.ts";
29
30
  import { formatModelAttemptNote, isRetryableModelFailure } from "./model-fallback.ts";
31
+ import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput } from "./utils.ts";
30
32
  import {
31
33
  cleanupWorktrees,
32
34
  createWorktrees,
@@ -123,32 +125,44 @@ function emptyUsage(): Usage {
123
125
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
124
126
  }
125
127
 
126
- function parseRunOutput(output: string): { usage: Usage; model?: string; error?: string } {
127
- const usage = emptyUsage();
128
- let model: string | undefined;
129
- let error: string | undefined;
130
- for (const line of output.split("\n")) {
131
- if (!line.trim()) continue;
132
- try {
133
- const evt = JSON.parse(line) as { type?: string; message?: { role?: string; model?: string; errorMessage?: string; usage?: any } };
134
- if (evt.type !== "message_end" || evt.message?.role !== "assistant") continue;
135
- const msg = evt.message;
136
- if (msg.model) model = msg.model;
137
- if (msg.errorMessage) error = msg.errorMessage;
138
- const u = msg.usage;
139
- if (u) {
140
- usage.turns++;
141
- usage.input += u.input ?? u.inputTokens ?? 0;
142
- usage.output += u.output ?? u.outputTokens ?? 0;
143
- usage.cacheRead += u.cacheRead ?? 0;
144
- usage.cacheWrite += u.cacheWrite ?? 0;
145
- usage.cost += u.cost?.total ?? 0;
146
- }
147
- } catch {
148
- // Ignore malformed stdout lines.
149
- }
150
- }
151
- return { usage, model, error };
128
+ interface ChildEventContext {
129
+ eventsPath: string;
130
+ runId: string;
131
+ stepIndex: number;
132
+ agent: string;
133
+ }
134
+
135
+ interface ChildUsage {
136
+ input?: number;
137
+ inputTokens?: number;
138
+ output?: number;
139
+ outputTokens?: number;
140
+ cacheRead?: number;
141
+ cacheWrite?: number;
142
+ cost?: { total?: number };
143
+ }
144
+
145
+ type ChildMessage = Message & {
146
+ model?: string;
147
+ errorMessage?: string;
148
+ usage?: ChildUsage;
149
+ };
150
+
151
+ interface ChildEvent {
152
+ type?: string;
153
+ message?: ChildMessage;
154
+ toolName?: string;
155
+ args?: Record<string, unknown>;
156
+ }
157
+
158
+ interface RunPiStreamingResult {
159
+ stderr: string;
160
+ exitCode: number | null;
161
+ messages: Message[];
162
+ usage: Usage;
163
+ model?: string;
164
+ error?: string;
165
+ finalOutput: string;
152
166
  }
153
167
 
154
168
  function runPiStreaming(
@@ -159,7 +173,8 @@ function runPiStreaming(
159
173
  piPackageRoot?: string,
160
174
  piArgv1?: string,
161
175
  maxSubagentDepth?: number,
162
- ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
176
+ childEventContext?: ChildEventContext,
177
+ ): Promise<RunPiStreamingResult> {
163
178
  return new Promise((resolve) => {
164
179
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
165
180
  const spawnEnv = { ...process.env, ...(env ?? {}), ...getSubagentDepthEnv(maxSubagentDepth) };
@@ -168,29 +183,119 @@ function runPiStreaming(
168
183
  ...(piArgv1 ? { argv1: piArgv1 } : {}),
169
184
  });
170
185
  const child = spawn(spawnSpec.command, spawnSpec.args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: spawnEnv });
171
- let stdout = "";
172
186
  let stderr = "";
187
+ let stdoutBuf = "";
188
+ let stderrBuf = "";
189
+ const messages: Message[] = [];
190
+ const usage = emptyUsage();
191
+ let model: string | undefined;
192
+ let error: string | undefined;
193
+ const rawStdoutLines: string[] = [];
194
+
195
+ const writeOutputLine = (line: string) => {
196
+ if (!line.trim()) return;
197
+ outputStream.write(`${line}\n`);
198
+ };
199
+
200
+ const writeOutputText = (text: string) => {
201
+ for (const line of text.split("\n")) {
202
+ writeOutputLine(line);
203
+ }
204
+ };
205
+
206
+ const appendChildEvent = (event: Record<string, unknown>) => {
207
+ if (!childEventContext) return;
208
+ appendJsonl(childEventContext.eventsPath, JSON.stringify({
209
+ ...event,
210
+ subagentSource: "child",
211
+ subagentRunId: childEventContext.runId,
212
+ subagentStepIndex: childEventContext.stepIndex,
213
+ subagentAgent: childEventContext.agent,
214
+ observedAt: Date.now(),
215
+ }));
216
+ };
217
+
218
+ const appendChildLine = (type: "subagent.child.stdout" | "subagent.child.stderr", line: string) => {
219
+ appendChildEvent({ type, line });
220
+ };
221
+
222
+ const processStdoutLine = (line: string) => {
223
+ if (!line.trim()) return;
224
+ let event: ChildEvent;
225
+ try {
226
+ event = JSON.parse(line) as ChildEvent;
227
+ } catch {
228
+ rawStdoutLines.push(line);
229
+ writeOutputLine(line);
230
+ appendChildLine("subagent.child.stdout", line);
231
+ return;
232
+ }
233
+
234
+ appendChildEvent(event);
235
+
236
+ if (event.type === "tool_execution_start" && event.toolName) {
237
+ const toolArgs = extractToolArgsPreview(event.args ?? {});
238
+ writeOutputLine(toolArgs ? `${event.toolName}: ${toolArgs}` : event.toolName);
239
+ return;
240
+ }
241
+
242
+ if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
243
+ messages.push(event.message);
244
+ const text = extractTextFromContent(event.message.content);
245
+ if (text) writeOutputText(text);
246
+
247
+ if (event.type !== "message_end" || event.message.role !== "assistant") return;
248
+ if (event.message.model) model = event.message.model;
249
+ if (event.message.errorMessage) error = event.message.errorMessage;
250
+ const eventUsage = event.message.usage;
251
+ if (!eventUsage) return;
252
+ usage.turns++;
253
+ usage.input += eventUsage.input ?? eventUsage.inputTokens ?? 0;
254
+ usage.output += eventUsage.output ?? eventUsage.outputTokens ?? 0;
255
+ usage.cacheRead += eventUsage.cacheRead ?? 0;
256
+ usage.cacheWrite += eventUsage.cacheWrite ?? 0;
257
+ usage.cost += eventUsage.cost?.total ?? 0;
258
+ }
259
+ };
260
+
261
+ const processStderrText = (text: string) => {
262
+ stderr += text;
263
+ stderrBuf += text;
264
+ outputStream.write(text);
265
+ if (!childEventContext) return;
266
+ const lines = stderrBuf.split("\n");
267
+ stderrBuf = lines.pop() || "";
268
+ for (const line of lines) {
269
+ if (!line.trim()) continue;
270
+ appendChildLine("subagent.child.stderr", line);
271
+ }
272
+ };
173
273
 
174
274
  child.stdout.on("data", (chunk: Buffer) => {
175
275
  const text = chunk.toString();
176
- stdout += text;
177
- outputStream.write(text);
276
+ stdoutBuf += text;
277
+ const lines = stdoutBuf.split("\n");
278
+ stdoutBuf = lines.pop() || "";
279
+ for (const line of lines) processStdoutLine(line);
178
280
  });
179
281
 
180
282
  child.stderr.on("data", (chunk: Buffer) => {
181
- const text = chunk.toString();
182
- stderr += text;
183
- outputStream.write(text);
283
+ processStderrText(chunk.toString());
184
284
  });
185
285
 
186
286
  child.on("close", (exitCode) => {
287
+ if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
288
+ if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
187
289
  outputStream.end();
188
- resolve({ stdout, stderr, exitCode });
290
+ const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
291
+ resolve({ stderr, exitCode, messages, usage, model, error, finalOutput });
189
292
  });
190
293
 
191
- child.on("error", () => {
294
+ child.on("error", (spawnError) => {
192
295
  outputStream.end();
193
- resolve({ stdout, stderr, exitCode: 1 });
296
+ const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
297
+ const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
298
+ resolve({ stderr, exitCode: 1, messages, usage, model, error: error ?? spawnErrorMessage, finalOutput });
194
299
  });
195
300
  });
196
301
  }
@@ -386,14 +491,13 @@ async function runSingleStep(
386
491
  const attemptedModels: string[] = [];
387
492
  const modelAttempts: ModelAttempt[] = [];
388
493
  const attemptNotes: string[] = [];
389
- let finalResult:
390
- | { stdout: string; stderr: string; exitCode: number | null; usage: Usage; model?: string; error?: string }
391
- | undefined;
494
+ const eventsPath = path.join(path.dirname(ctx.outputFile), "events.jsonl");
495
+ let finalResult: RunPiStreamingResult | undefined;
392
496
 
393
497
  for (let index = 0; index < candidates.length; index++) {
394
498
  const candidate = candidates[index];
395
499
  const { args, env, tempDir } = buildPiArgs({
396
- baseArgs: ["-p"],
500
+ baseArgs: ["--mode", "json", "-p"],
397
501
  task,
398
502
  sessionEnabled,
399
503
  sessionDir,
@@ -406,28 +510,41 @@ async function runSingleStep(
406
510
  mcpDirectTools: step.mcpDirectTools,
407
511
  promptFileStem: step.agent,
408
512
  });
409
- const outputFile = index === 0 ? ctx.outputFile : `${ctx.outputFile}.attempt-${index + 1}`;
410
- const run = await runPiStreaming(args, step.cwd ?? ctx.cwd, outputFile, env, ctx.piPackageRoot, ctx.piArgv1, step.maxSubagentDepth);
513
+ const run = await runPiStreaming(
514
+ args,
515
+ step.cwd ?? ctx.cwd,
516
+ ctx.outputFile,
517
+ env,
518
+ ctx.piPackageRoot,
519
+ ctx.piArgv1,
520
+ step.maxSubagentDepth,
521
+ { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
522
+ );
411
523
  cleanupTempDir(tempDir);
412
524
 
413
- const parsed = parseRunOutput(run.stdout);
414
- const error = parsed.error || (run.exitCode !== 0 && run.stderr.trim() ? run.stderr.trim() : undefined);
525
+ const hiddenError = run.exitCode === 0 && !run.error ? detectSubagentError(run.messages) : null;
526
+ const effectiveExitCode = hiddenError?.hasError ? (hiddenError.exitCode ?? 1) : run.exitCode;
527
+ const error = hiddenError?.hasError
528
+ ? hiddenError.details
529
+ ? `${hiddenError.errorType} failed (exit ${effectiveExitCode}): ${hiddenError.details}`
530
+ : `${hiddenError.errorType} failed with exit code ${effectiveExitCode}`
531
+ : run.error || (run.exitCode !== 0 && run.stderr.trim() ? run.stderr.trim() : undefined);
415
532
  const attempt: ModelAttempt = {
416
- model: candidate ?? parsed.model ?? step.model ?? "default",
417
- success: run.exitCode === 0 && !error,
418
- exitCode: run.exitCode,
533
+ model: candidate ?? run.model ?? step.model ?? "default",
534
+ success: effectiveExitCode === 0 && !error,
535
+ exitCode: effectiveExitCode,
419
536
  error,
420
- usage: parsed.usage,
537
+ usage: run.usage,
421
538
  };
422
539
  modelAttempts.push(attempt);
423
540
  if (candidate) attemptedModels.push(candidate);
424
- finalResult = { ...run, usage: parsed.usage, model: candidate ?? parsed.model, error };
541
+ finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error };
425
542
  if (attempt.success) break;
426
543
  if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
427
544
  attemptNotes.push(formatModelAttemptNote(attempt, candidates[index + 1]));
428
545
  }
429
546
 
430
- const rawOutput = (finalResult?.stdout || "").trim();
547
+ const rawOutput = finalResult?.finalOutput ?? "";
431
548
  const resolvedOutput = step.outputPath && finalResult?.exitCode === 0
432
549
  ? resolveSingleOutput(step.outputPath, rawOutput, outputSnapshot)
433
550
  : { fullOutput: rawOutput };
package/types.ts CHANGED
@@ -40,17 +40,6 @@ export interface TokenUsage {
40
40
  total: number;
41
41
  }
42
42
 
43
- // ============================================================================
44
- // Skills
45
- // ============================================================================
46
-
47
- export interface ResolvedSkill {
48
- name: string;
49
- path: string;
50
- content: string;
51
- source: "project" | "user";
52
- }
53
-
54
43
  // ============================================================================
55
44
  // Progress Tracking
56
45
  // ============================================================================
@@ -271,6 +260,8 @@ export interface RunSyncOptions {
271
260
  modelOverride?: string;
272
261
  /** Registry models available for heuristic bare-model resolution */
273
262
  availableModels?: Array<{ provider: string; id: string; fullId: string }>;
263
+ /** Current parent-session provider to prefer for ambiguous bare model ids */
264
+ preferredModelProvider?: string;
274
265
  /** Skills to inject (overrides agent default if provided) */
275
266
  skills?: string[];
276
267
  }
@@ -309,10 +300,66 @@ export const DEFAULT_ARTIFACT_CONFIG: ArtifactConfig = {
309
300
  cleanupDays: 7,
310
301
  };
311
302
 
303
+ function sanitizeTempScopeSegment(value: string): string {
304
+ const sanitized = value
305
+ .trim()
306
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
307
+ .replace(/^-+|-+$/g, "");
308
+ return sanitized || "unknown";
309
+ }
310
+
311
+ export function resolveTempScopeId(options?: {
312
+ env?: NodeJS.ProcessEnv;
313
+ getuid?: (() => number) | undefined;
314
+ userInfo?: (() => { username?: string | null }) | undefined;
315
+ homedir?: (() => string) | undefined;
316
+ }): string {
317
+ const env = options?.env ?? process.env;
318
+ const getuid = options && Object.hasOwn(options, "getuid")
319
+ ? options.getuid
320
+ : process.getuid?.bind(process);
321
+ if (typeof getuid === "function") {
322
+ return `uid-${getuid()}`;
323
+ }
324
+
325
+ for (const key of ["USERNAME", "USER", "LOGNAME"] as const) {
326
+ const value = env[key];
327
+ if (value) return `user-${sanitizeTempScopeSegment(value)}`;
328
+ }
329
+
330
+ const userInfo = options && Object.hasOwn(options, "userInfo")
331
+ ? options.userInfo
332
+ : os.userInfo;
333
+ try {
334
+ const username = userInfo?.().username;
335
+ if (username) return `user-${sanitizeTempScopeSegment(username)}`;
336
+ } catch {
337
+ // Fall through to home-directory-based scoping.
338
+ }
339
+
340
+ const homedir = env.USERPROFILE ?? env.HOME;
341
+ if (homedir) return `home-${sanitizeTempScopeSegment(homedir)}`;
342
+
343
+ const resolveHomedir = options && Object.hasOwn(options, "homedir")
344
+ ? options.homedir
345
+ : os.homedir;
346
+ try {
347
+ const fallbackHomedir = resolveHomedir?.();
348
+ if (fallbackHomedir) return `home-${sanitizeTempScopeSegment(fallbackHomedir)}`;
349
+ } catch {
350
+ // Fall through to the last-resort shared scope.
351
+ }
352
+
353
+ return "shared";
354
+ }
355
+
312
356
  export const MAX_PARALLEL = 8;
313
357
  export const MAX_CONCURRENCY = 4;
314
- export const RESULTS_DIR = path.join(os.tmpdir(), "pi-async-subagent-results");
315
- export const ASYNC_DIR = path.join(os.tmpdir(), "pi-async-subagent-runs");
358
+ export const TEMP_ROOT_DIR = path.join(os.tmpdir(), `pi-subagents-${resolveTempScopeId()}`);
359
+ export const RESULTS_DIR = path.join(TEMP_ROOT_DIR, "async-subagent-results");
360
+ export const ASYNC_DIR = path.join(TEMP_ROOT_DIR, "async-subagent-runs");
361
+ export const CHAIN_RUNS_DIR = path.join(TEMP_ROOT_DIR, "chain-runs");
362
+ export const TEMP_ARTIFACTS_DIR = path.join(TEMP_ROOT_DIR, "artifacts");
316
363
  export const WIDGET_KEY = "subagent-async";
317
364
  export const SLASH_RESULT_TYPE = "subagent-slash-result";
318
365
  export const SLASH_SUBAGENT_REQUEST_EVENT = "subagent:slash:request";
@@ -329,6 +376,10 @@ export const DEFAULT_FORK_PREAMBLE =
329
376
  "Your sole job is to execute the task below. Do not continue or respond to the prior conversation " +
330
377
  "— focus exclusively on completing this task using your tools.";
331
378
 
379
+ export function getAsyncConfigPath(suffix: string): string {
380
+ return path.join(TEMP_ROOT_DIR, `async-cfg-${suffix}.json`);
381
+ }
382
+
332
383
  export function wrapForkTask(task: string, preamble?: string | false): string {
333
384
  if (preamble === false) return task;
334
385
  const effectivePreamble = preamble ?? DEFAULT_FORK_PREAMBLE;
package/utils.ts CHANGED
@@ -224,15 +224,12 @@ export function getDisplayItems(messages: Message[]): DisplayItem[] {
224
224
  * Detect errors in subagent execution from messages (only errors with no subsequent success)
225
225
  */
226
226
  export function detectSubagentError(messages: Message[]): ErrorInfo {
227
- // Step 1: Find the last assistant message with text content.
228
- // If the agent produced a text response after encountering errors,
229
- // it had a chance to recover — only errors AFTER this point matter.
230
227
  let lastAssistantTextIndex = -1;
231
228
  for (let i = messages.length - 1; i >= 0; i--) {
232
229
  const msg = messages[i];
233
230
  if (msg.role === "assistant") {
234
231
  const hasText = Array.isArray(msg.content) && msg.content.some(
235
- (c) => c.type === "text" && "text" in c && (c.text as string).trim().length > 0,
232
+ (c) => c.type === "text" && "text" in c && typeof c.text === "string" && c.text.trim().length > 0,
236
233
  );
237
234
  if (hasText) {
238
235
  lastAssistantTextIndex = i;
@@ -241,28 +238,26 @@ export function detectSubagentError(messages: Message[]): ErrorInfo {
241
238
  }
242
239
  }
243
240
 
244
- // Step 2: Only scan tool results AFTER the last assistant text message.
245
- // Errors before the agent's final response are implicitly recovered.
246
241
  const scanStart = lastAssistantTextIndex >= 0 ? lastAssistantTextIndex + 1 : 0;
247
242
 
248
- // Step 3: Check tool results in the post-response window
249
243
  for (let i = messages.length - 1; i >= scanStart; i--) {
250
244
  const msg = messages[i];
251
245
  if (msg.role !== "toolResult") continue;
246
+ const toolName = "toolName" in msg && typeof msg.toolName === "string" ? msg.toolName : undefined;
247
+ const isError = "isError" in msg && msg.isError === true;
252
248
 
253
- if ((msg as any).isError) {
249
+ if (isError) {
254
250
  const text = msg.content.find((c) => c.type === "text");
255
251
  const details = text && "text" in text ? text.text : undefined;
256
252
  const exitMatch = details?.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
257
253
  return {
258
254
  hasError: true,
259
255
  exitCode: exitMatch ? parseInt(exitMatch[1], 10) : 1,
260
- errorType: (msg as any).toolName || "tool",
256
+ errorType: toolName || "tool",
261
257
  details: details?.slice(0, 200),
262
258
  };
263
259
  }
264
260
 
265
- const toolName = (msg as any).toolName;
266
261
  if (toolName !== "bash") continue;
267
262
 
268
263
  const text = msg.content.find((c) => c.type === "text");