@d3ara1n/pi-subagent 0.10.4 → 1.0.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.
- package/README.md +62 -13
- package/package.json +1 -1
- package/src/history.ts +8 -3
- package/src/index.ts +382 -341
- package/src/output.ts +10 -11
- package/src/render-async.ts +324 -0
- package/src/render.ts +54 -135
- package/src/roles.ts +8 -8
- package/src/run.test.ts +276 -0
- package/src/run.ts +363 -0
- package/src/spawn.ts +26 -26
- package/src/types.ts +55 -7
- package/src/utils.test.ts +321 -49
- package/src/utils.ts +336 -12
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
// `
|
|
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 `
|
|
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 `
|
|
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 ((
|
|
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
|
|
352
|
-
// the last in-flight
|
|
353
|
-
if (event.toolName === "
|
|
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
|
-
//
|
|
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
|
-
|
|
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 `
|
|
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 `
|
|
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 (
|
|
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 `
|
|
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
|
-
//
|
|
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
|
|
15
|
+
/** Persist every spawned delegate run (finished/failed/aborted alike) to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. Pre-run failures that never spawned are not recorded. */
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
+
}
|