@d3ara1n/pi-subagent 0.10.3 → 1.0.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/spawn.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Spawn a pi child process and collect structured output with real-time progress.
3
3
  *
4
- * Uses pi's --mode json to get a JSON event stream.
5
- * Collects all messages (assistant + tool results) for TUI rendering.
6
- * Fires onProgress on each event for streaming updates.
4
+ * Uses pi's --mode json to get a JSON event stream. Fires onProgress on each
5
+ * event for streaming TUI updates; the message stream is parsed for
6
+ * usage/output extraction, with thinking blocks and tool calls mirrored into
7
+ * the activity log for rendering.
7
8
  */
8
9
 
9
10
  import { spawn, type ChildProcess } from "node:child_process";
@@ -134,7 +135,9 @@ export async function spawnSubagent(
134
135
  subagentRoles?: string[];
135
136
  timeoutMs?: number;
136
137
  depth?: number;
138
+ /** Kill after this many assistant turns; 0/undefined = unlimited (negatives normalize to 0). */
137
139
  maxTurns?: number;
140
+ /** Kill after this cumulative cost in USD; 0/undefined = unlimited (negatives normalize to 0). */
138
141
  maxCost?: number;
139
142
  signal?: AbortSignal;
140
143
  onProgress?: (update: Partial<SubagentResult>) => void;
@@ -144,7 +147,6 @@ export async function spawnSubagent(
144
147
  role: "",
145
148
  task,
146
149
  exitCode: 0,
147
- messages: [],
148
150
  output: "",
149
151
  stderr: "",
150
152
  usage: {
@@ -161,17 +163,17 @@ export async function spawnSubagent(
161
163
 
162
164
  // ── Active-time timeout accounting ──
163
165
  // The parent's timeout clock PAUSES while the child is inside a nested
164
- // `delegate` tool call, so each nested subagent gets its own full timeout
166
+ // `subagent_delegate` tool call, so each nested subagent gets its own full timeout
165
167
  // budget instead of racing the parent's wall clock. `graceMs` is the
166
168
  // accumulated paused time — display only; the verdict is always
167
169
  // "active elapsed >= budget" (pausing grants no extra active time).
168
170
  const budgetMs = Number.isFinite(options.timeoutMs) ? Math.max(0, options.timeoutMs ?? 0) : 0;
169
171
  let activeElapsedAccum = 0; // settled active ms (excludes suspended spans)
170
172
  let segmentStart = 0; // wall-clock start of the current active segment; 0 = no active segment
171
- let isSuspended = false; // true while a child `delegate` call is in flight
173
+ let isSuspended = false; // true while a child `subagent_delegate` call is in flight
172
174
  let pauseStart = 0; // wall-clock mark when the current suspend began
173
175
  let graceMs = 0; // accumulated suspended ms (display only)
174
- /** toolCallIds of in-flight `delegate` calls — end events lack toolName, so we pair by id. */
176
+ /** toolCallIds of in-flight `subagent_delegate` calls — end events lack toolName, so we pair by id. */
175
177
  const delegateCallIds = new Set<string>();
176
178
 
177
179
  let tmpDir: string | null = null;
@@ -264,7 +266,6 @@ export async function spawnSubagent(
264
266
  const emitProgress = () => {
265
267
  options.onProgress?.({
266
268
  output: result.output,
267
- messages: [...result.messages],
268
269
  usage: { ...result.usage },
269
270
  model: result.model,
270
271
  stopReason: result.stopReason,
@@ -278,13 +279,16 @@ export async function spawnSubagent(
278
279
  // O(1) lookup from toolCallId → activityLog index.
279
280
  const toolCallIndex = new Map<string, number>();
280
281
 
282
+ // Normalized turn/cost budgets (0 = unlimited). Role overrides can arrive
283
+ // raw from settings.json, so negatives/non-finites normalize here.
284
+ const maxTurns = Number.isFinite(options.maxTurns) ? Math.max(0, options.maxTurns ?? 0) : 0;
285
+ const maxCost = Number.isFinite(options.maxCost) ? Math.max(0, options.maxCost ?? 0) : 0;
286
+
281
287
  // Kill the child when the configured turn/cost budget is exceeded.
282
288
  // Called after each assistant message_end (usage already accumulated).
283
289
  const checkBudget = () => {
284
- const mt = Number.isFinite(options.maxTurns) ? Math.max(0, options.maxTurns ?? 0) : 0;
285
- const mc = Number.isFinite(options.maxCost) ? Math.max(0, options.maxCost ?? 0) : 0;
286
290
  if (budgetExceeded || wasTimeout) return;
287
- if ((mt > 0 && result.usage.turns >= mt) || (mc > 0 && result.usage.cost >= mc)) {
291
+ if ((maxTurns > 0 && result.usage.turns >= maxTurns) || (maxCost > 0 && result.usage.cost >= maxCost)) {
288
292
  budgetExceeded = true;
289
293
  killProc("budget");
290
294
  }
@@ -301,7 +305,6 @@ export async function spawnSubagent(
301
305
 
302
306
  if (event.type === "message_end" && event.message) {
303
307
  const msg = event.message as SubagentMessage;
304
- result.messages.push(msg);
305
308
 
306
309
  if (msg.role === "assistant") {
307
310
  result.usage.turns++;
@@ -348,9 +351,9 @@ export async function spawnSubagent(
348
351
  });
349
352
  // Pause the parent timeout clock while the child delegates — nested
350
353
  // subagents get their own full budget instead of racing this clock.
351
- // Ref-counted: concurrent delegate calls pause once and resume only when
352
- // the last in-flight delegate returns.
353
- if (event.toolName === "delegate") {
354
+ // Ref-counted: concurrent subagent_delegate calls pause once and resume
355
+ // only when the last in-flight one returns.
356
+ if (event.toolName === "subagent_delegate") {
354
357
  const first = delegateCallIds.size === 0;
355
358
  delegateCallIds.add(event.toolCallId);
356
359
  if (first) suspendTimeout();
@@ -395,14 +398,13 @@ export async function spawnSubagent(
395
398
  }
396
399
  };
397
400
 
398
- // Build env with optional subagent allowlist and tmpdir for researcher role
401
+ // Child env: the role allowlist for nested delegation, the shared scratch
402
+ // tmpdir for subagent bash work (e.g. git clone), and the nesting depth.
399
403
  const childEnv: NodeJS.ProcessEnv = { ...process.env };
400
404
  if (options.subagentRoles && options.subagentRoles.length > 0) {
401
405
  childEnv.PI_SUBAGENT_ALLOWED = options.subagentRoles.join(",");
402
406
  }
403
- // Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
404
407
  childEnv.PI_SUBAGENT_TMPDIR = tmpDir;
405
- // Propagate nesting depth so child delegate calls can bound recursion
406
408
  childEnv.PI_SUBAGENT_DEPTH = String(options.depth ?? 0);
407
409
 
408
410
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
@@ -427,9 +429,8 @@ export async function spawnSubagent(
427
429
  else if (reason === "budget") {
428
430
  result.stopReason = "budget_exceeded";
429
431
  // Human-readable so the caller/TUI never falls back to raw stderr noise.
430
- const mt = Number.isFinite(options.maxTurns) ? Math.max(0, options.maxTurns ?? 0) : 0;
431
432
  const why =
432
- mt > 0 && result.usage.turns >= mt
433
+ maxTurns > 0 && result.usage.turns >= maxTurns
433
434
  ? `${result.usage.turns} turns`
434
435
  : `$${result.usage.cost.toFixed(4)}`;
435
436
  result.errorMessage = `Budget exceeded (${why}; partial output returned)`;
@@ -457,7 +458,7 @@ export async function spawnSubagent(
457
458
  }, 5000);
458
459
  };
459
460
 
460
- /** Pause the active-time clock (called on child `delegate` start). */
461
+ /** Pause the active-time clock (called on child `subagent_delegate` start). */
461
462
  const suspendTimeout = () => {
462
463
  if (isSuspended) return;
463
464
  if (segmentStart > 0) {
@@ -471,7 +472,7 @@ export async function spawnSubagent(
471
472
  pauseStart = Date.now();
472
473
  isSuspended = true;
473
474
  };
474
- /** Resume the active-time clock (called on child `delegate` end). */
475
+ /** Resume the active-time clock (called on child `subagent_delegate` end). */
475
476
  const resumeTimeout = () => {
476
477
  if (!isSuspended) return;
477
478
  graceMs += Date.now() - pauseStart;
@@ -540,7 +541,7 @@ export async function spawnSubagent(
540
541
  result.stopReason = "error";
541
542
  }
542
543
 
543
- // Budget stops are intentional (success); timeouts and external kills
544
+ // Budget stops are intentional (finished); timeouts and external kills
544
545
  // are failures (non-zero); otherwise use the real exit code.
545
546
  resolve(budgetExceeded ? 0 : wasTimeout || externalKill ? (code ?? 128) : (code ?? 0));
546
547
  });
@@ -556,7 +557,7 @@ export async function spawnSubagent(
556
557
  });
557
558
 
558
559
  // Start the active-time clock. segmentStart marks the first active span;
559
- // it pauses/resumes around child `delegate` calls (see suspend/resumeTimeout).
560
+ // it pauses/resumes around child `subagent_delegate` calls (see suspend/resumeTimeout).
560
561
  // No wall-clock fallback needed: each nested subagent has its own timeout,
561
562
  // so a stuck inner run is killed by its own clock and this layer resumes.
562
563
  segmentStart = Date.now();
@@ -567,8 +568,7 @@ export async function spawnSubagent(
567
568
 
568
569
  result.exitCode = exitCode;
569
570
  if (wasAborted) throw new Error("Subagent was aborted");
570
- // NOTE: large outputs are kept raw here — compression/truncation happens in
571
- // the extension layer (index.ts) so the summary model can compress first.
571
+ // Large outputs stay raw here — compression/summary run in the engine (run.ts).
572
572
  } finally {
573
573
  // Cleanup temp directory and all contents
574
574
  if (tmpDir)
package/src/types.ts CHANGED
@@ -12,7 +12,7 @@ export interface SubagentConfig {
12
12
  maxTurns: number;
13
13
  /** Default cumulative cost budget in USD. `0` means unlimited; negative values are normalized to `0`. Per-role maxCost overrides this. */
14
14
  maxCost: number;
15
- /** Persist each delegate run to ~/.pi/subagent/history/{sessionId}/{id}.json for auditing. */
15
+ /** Persist each delegate run to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. */
16
16
  history: SubagentHistoryConfig;
17
17
  summary: SubagentSummaryConfig;
18
18
  /**
@@ -64,7 +64,7 @@ export interface SubagentRole {
64
64
  maxTurns?: number;
65
65
  /** Max cumulative cost in USD. `0` means unlimited; negative values are normalized to `0`. */
66
66
  maxCost?: number;
67
- /** Fallback pi-model-roles role name when this role's model is unavailable (provider error). Defaults to "default". */
67
+ /** Fallback pi-model-roles role name to retry the whole run on when this role's model hits a provider error. Unset = no retry (the failure stands). */
68
68
  fallbackRole?: string;
69
69
  }
70
70
 
@@ -93,7 +93,7 @@ export interface SubagentUsage {
93
93
  turns: number;
94
94
  }
95
95
 
96
- /** A message from the subagent's JSON event stream. */
96
+ /** A message from the child's JSON event stream (parsed for usage/output extraction). */
97
97
  export interface SubagentMessage {
98
98
  role: string;
99
99
  content: Array<{
@@ -129,8 +129,6 @@ export interface SubagentResult {
129
129
  queued?: boolean;
130
130
  /** How `output` was prepared for display: raw, compressed by summary model, or mechanically truncated. */
131
131
  outputMethod?: "raw" | "compressed" | "truncated";
132
- /** All messages from the event stream (assistant + tool results) */
133
- messages: SubagentMessage[];
134
132
  /** Last assistant text output */
135
133
  output: string;
136
134
  /** AI-generated one-line summary for TUI display */
@@ -145,6 +143,8 @@ export interface SubagentResult {
145
143
  stopReason?: string;
146
144
  /** Error message if failed */
147
145
  errorMessage?: string;
146
+ /** Present when the first attempt hit a provider error and the whole run was retried on the fallback role: what the first attempt ran on and why it failed. The retry overwrites every other trace, so this is the only record of the first attempt. */
147
+ fallbackFrom?: FallbackFrom;
148
148
  /** Real-time activity log: thinking blocks and tool calls in arrival order. */
149
149
  activityLog: ActivityEntry[];
150
150
 
@@ -165,8 +165,56 @@ export interface SubagentResult {
165
165
  context?: string;
166
166
  }
167
167
 
168
- /** TUI details structure passed via tool result details. */
168
+ /** Snapshot of a failed first attempt that was retried on the fallback role. */
169
+ export interface FallbackFrom {
170
+ /** Model identifier the first attempt ran on. */
171
+ model?: string;
172
+ /** Stop reason of the first attempt. */
173
+ stopReason?: string;
174
+ /** Human-readable error message of the first attempt. */
175
+ errorMessage?: string;
176
+ /** Tail of the first attempt's stderr, truncated for diagnostics. */
177
+ stderrTail?: string;
178
+ }
179
+
180
+ /** TUI details for a foreground delegate tool result/update: one result per call. */
169
181
  export interface SubagentDetails {
170
- mode: "single";
171
182
  results: SubagentResult[];
172
183
  }
184
+
185
+ // ── Background delegation (delegate background:true / wait / check) ──────
186
+
187
+ /** Lifecycle state of a delegation run, derived from the latest snapshot frame. */
188
+ export type RunState = "queued" | "running" | "finished" | "failed";
189
+
190
+ /** Details for a background delegate result — the input snapshot for the TUI's static input block. */
191
+ export interface BackgroundDelegateDetails {
192
+ /** Registry id (sub-N) the model uses with wait/check. */
193
+ id: string;
194
+ role: string;
195
+ task: string;
196
+ context?: string;
197
+ files?: string[];
198
+ }
199
+
200
+ /** One watched run inside a wait/check view. */
201
+ export interface RunViewEntry {
202
+ id: string;
203
+ role: string;
204
+ /** Live frame while running, terminal result once finished. */
205
+ result: SubagentResult;
206
+ }
207
+
208
+ /** Details for wait tool updates/results — the combined live view of all watched runs. */
209
+ export interface WaitDetails {
210
+ entries: RunViewEntry[];
211
+ /** True when the wait timed out with unfinished runs (marks the tool result as an error). */
212
+ timedOut?: boolean;
213
+ }
214
+
215
+ /** Details for a check tool result — a frozen one-shot snapshot of a single run. */
216
+ export interface CheckDetails {
217
+ id: string;
218
+ role: string;
219
+ result: SubagentResult;
220
+ }