@d3ara1n/pi-subagent 0.3.0 → 0.5.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
@@ -6,18 +6,16 @@
6
6
  * Fires onProgress on each event for streaming updates.
7
7
  */
8
8
 
9
- import { spawn } from "node:child_process";
9
+ import { spawn, type ChildProcess } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import type { SubagentMessage, SubagentResult } from "./types.ts";
15
15
 
16
- /** Maximum task length before writing to a temp file (avoids CLI arg limits). */
17
- const TASK_CHAR_LIMIT = 8000;
16
+ /** Max chars for an inline channel block (context or task) before it spills to a temp @file. */
17
+ const INLINE_LIMIT = 8000;
18
18
 
19
- /** Maximum output characters returned to the main model. Larger outputs are truncated. */
20
- const MAX_OUTPUT_CHARS = 50_000;
21
19
 
22
20
  const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
23
21
 
@@ -126,8 +124,15 @@ export async function spawnSubagent(
126
124
  cwd?: string;
127
125
  tools?: string[];
128
126
  systemPrompt?: string;
127
+ /** Extra context delivered as a separate channel from the task. */
128
+ context?: string;
129
+ /** Reference file paths injected as independent @file args (child reads them directly). */
130
+ contextFiles?: string[];
129
131
  subagentRoles?: string[];
130
132
  timeoutMs?: number;
133
+ depth?: number;
134
+ maxTurns?: number;
135
+ maxCost?: number;
131
136
  signal?: AbortSignal;
132
137
  onProgress?: (update: Partial<SubagentResult>) => void;
133
138
  },
@@ -144,7 +149,6 @@ export async function spawnSubagent(
144
149
  };
145
150
 
146
151
  let tmpDir: string | null = null;
147
- let tmpFile: string | null = null;
148
152
 
149
153
  try {
150
154
  // Build CLI args
@@ -154,27 +158,77 @@ export async function spawnSubagent(
154
158
  args.push("--tools", options.tools.join(","));
155
159
  }
156
160
 
157
- // Always create temp dir — used for prompt file, long task file, and as PI_SUBAGENT_TMPDIR for subagent work (e.g. git clone)
161
+ // Temp dir for: large-context/task spill files, and as PI_SUBAGENT_TMPDIR
162
+ // for subagent bash work (e.g. git clone). The system prompt no longer uses it.
158
163
  tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
159
164
 
160
- const promptContent = options.systemPrompt?.trim()
161
- ? options.systemPrompt + `\n\nPI_SUBAGENT_TMPDIR=${tmpDir}`
162
- : `PI_SUBAGENT_TMPDIR=${tmpDir}`;
163
- tmpFile = path.join(tmpDir, "prompt.md");
164
- await fs.promises.writeFile(tmpFile, promptContent, { encoding: "utf-8", mode: 0o600 });
165
- args.push("--append-system-prompt", tmpFile);
165
+ // ── System prompt channel: inline text via --append-system-prompt ──
166
+ // pi's resolvePromptInput treats an existing path as a file to read and any
167
+ // non-path string as literal text, so we pass structured blocks directly —
168
+ // no temp file, zero disk I/O. Multiple flags are joined with "\n\n".
169
+ if (options.systemPrompt?.trim()) {
170
+ args.push(
171
+ "--append-system-prompt",
172
+ `<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
173
+ );
174
+ }
175
+ args.push(
176
+ "--append-system-prompt",
177
+ `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
178
+ );
179
+
180
+ // ── Context channel: independent size gate ──
181
+ // Large context spills to @ctx.md (pi auto-wraps in <file>); small context
182
+ // inlines as a structured <context> tag. Decoupled from the task gate so a
183
+ // large context never drags a short task into a spill file.
184
+ let contextInline = false;
185
+ if (options.context && options.context.trim()) {
186
+ if (options.context.length > INLINE_LIMIT) {
187
+ const ctxPath = path.join(tmpDir, "context.md");
188
+ await fs.promises.writeFile(ctxPath, options.context, { encoding: "utf-8", mode: 0o600 });
189
+ args.push(`@${ctxPath}`);
190
+ } else {
191
+ contextInline = true;
192
+ }
193
+ }
166
194
 
167
- if (task.length > TASK_CHAR_LIMIT) {
195
+ // ── Reference files channel: each as an independent @ argument ──
196
+ // pi reads each and wraps in <file name="...">. Content never enters the
197
+ // parent model's context — the child reads it directly.
198
+ if (options.contextFiles) {
199
+ for (const f of options.contextFiles) {
200
+ args.push(`@${f}`);
201
+ }
202
+ }
203
+
204
+ // ── Task channel: always inline, always the final block ──
205
+ // The task is an instruction, not reference material — it stays inline so the
206
+ // child sees it as the primary directive. A pathologically long task spills.
207
+ let taskInline = true;
208
+ if (task.length > INLINE_LIMIT) {
168
209
  const taskPath = path.join(tmpDir, "task.md");
169
210
  await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
170
211
  args.push(`@${taskPath}`);
171
- } else {
172
- args.push(`Task: ${task}`);
212
+ taskInline = false;
173
213
  }
174
214
 
215
+ // ── Compose the message body (inline context + task) ──
216
+ // @file args are injected by pi BEFORE this message (buildInitialMessage),
217
+ // so the final shape the child sees is:
218
+ // [<file>...spilled context / reference files...</file>]
219
+ // [<context>...inline context...</context>]
220
+ // [<task>...task...</task>]
221
+ const messageParts: string[] = [];
222
+ if (contextInline) messageParts.push(`<context>\n${options.context}\n</context>`);
223
+ if (taskInline) messageParts.push(`<task>\n${task}\n</task>`);
224
+ const message = messageParts.join("\n\n");
225
+ if (message) args.push(message);
226
+
175
227
  // Spawn process
176
228
  const invocation = getPiInvocation(args);
177
229
  let wasAborted = false;
230
+ let budgetExceeded = false;
231
+ let wasTimeout = false;
178
232
  let buffer = "";
179
233
 
180
234
  const emitProgress = () => {
@@ -189,6 +243,20 @@ export async function spawnSubagent(
189
243
  };
190
244
 
191
245
  let thinkingCounter = 0;
246
+ // O(1) lookup from toolCallId → activityLog index (was linear find → O(n²) on busy runs)
247
+ const toolCallIndex = new Map<string, number>();
248
+
249
+ // Kill the child when the configured turn/cost budget is exceeded.
250
+ // Called after each assistant message_end (usage already accumulated).
251
+ const checkBudget = () => {
252
+ const mt = options.maxTurns ?? 0;
253
+ const mc = options.maxCost ?? 0;
254
+ if (budgetExceeded || wasTimeout) return;
255
+ if ((mt > 0 && result.usage.turns >= mt) || (mc > 0 && result.usage.cost >= mc)) {
256
+ budgetExceeded = true;
257
+ killProc("budget");
258
+ }
259
+ };
192
260
 
193
261
  const processLine = (line: string) => {
194
262
  if (!line.trim()) return;
@@ -212,7 +280,8 @@ export async function spawnSubagent(
212
280
  result.usage.cacheRead += usage.cacheRead || 0;
213
281
  result.usage.cacheWrite += usage.cacheWrite || 0;
214
282
  result.usage.cost += usage.cost?.total || 0;
215
- result.usage.contextTokens = usage.totalTokens || 0;
283
+ // Peak context size, not last-turn size (accumulating is meaningless; max tells how close to the limit)
284
+ result.usage.contextTokens = Math.max(result.usage.contextTokens, usage.totalTokens || 0);
216
285
  }
217
286
  if (!result.model && msg.model) result.model = msg.model;
218
287
  if (msg.stopReason) result.stopReason = msg.stopReason;
@@ -224,6 +293,8 @@ export async function spawnSubagent(
224
293
  result.output = part.text;
225
294
  }
226
295
  }
296
+
297
+ checkBudget();
227
298
  }
228
299
 
229
300
  emitProgress();
@@ -232,6 +303,7 @@ export async function spawnSubagent(
232
303
  // Activity log: track thinking blocks and tool calls in arrival order.
233
304
  // Both update in place so the TUI reflects real-time state.
234
305
  if (event.type === "tool_execution_start" && event.toolCallId) {
306
+ toolCallIndex.set(event.toolCallId, result.activityLog.length);
235
307
  result.activityLog.push({
236
308
  kind: "toolCall",
237
309
  id: event.toolCallId,
@@ -241,8 +313,8 @@ export async function spawnSubagent(
241
313
  });
242
314
  emitProgress();
243
315
  } else if (event.type === "tool_execution_end" && event.toolCallId) {
244
- const entry = result.activityLog.find((a) => a.id === event.toolCallId);
245
- if (entry) entry.status = event.isError ? "failed" : "done";
316
+ const idx = toolCallIndex.get(event.toolCallId);
317
+ if (idx !== undefined) result.activityLog[idx].status = event.isError ? "failed" : "done";
246
318
  emitProgress();
247
319
  }
248
320
 
@@ -279,73 +351,106 @@ export async function spawnSubagent(
279
351
  }
280
352
  // Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
281
353
  childEnv.PI_SUBAGENT_TMPDIR = tmpDir;
354
+ // Propagate nesting depth so child delegate calls can bound recursion
355
+ childEnv.PI_SUBAGENT_DEPTH = String(options.depth ?? 0);
282
356
 
283
357
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
358
+ let proc: ChildProcess | undefined;
359
+
360
+ // Shared kill helper used by abort, budget, and timeout paths.
361
+ // Centralizes reason → stopReason mapping and the SIGTERM → 5s → SIGKILL escalation.
362
+ const escalationTimers: ReturnType<typeof setTimeout>[] = [];
363
+ const killProc = (reason: "abort" | "budget" | "timeout") => {
364
+ if (reason === "abort") wasAborted = true;
365
+ else if (reason === "budget") {
366
+ result.stopReason = "budget_exceeded";
367
+ // Human-readable so the caller/TUI never falls back to raw stderr noise.
368
+ const mt = options.maxTurns ?? 0;
369
+ const mc = options.maxCost ?? 0;
370
+ const why = mt > 0 && result.usage.turns >= mt ? `${result.usage.turns} turns` : `$${result.usage.cost.toFixed(4)}`;
371
+ result.errorMessage = `Budget exceeded (${why}; partial output returned)`;
372
+ }
373
+ else if (reason === "timeout") {
374
+ result.stopReason = "timeout";
375
+ wasTimeout = true;
376
+ // Human-readable message so the caller/TUI never falls back to the
377
+ // raw stderr (which is full of TUI teardown escape sequences).
378
+ const secs = Math.round((options.timeoutMs ?? 0) / 1000);
379
+ result.errorMessage = `Timed out after ${secs}s (completed ${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"})`;
380
+ }
381
+ try { proc?.kill("SIGTERM"); } catch { /* ignore */ }
382
+ escalationTimers.push(setTimeout(() => {
383
+ try { if (proc && !proc.killed) proc.kill("SIGKILL"); } catch { /* ignore */ }
384
+ }, 5000));
385
+ };
284
386
 
285
387
  const exitCode = await new Promise<number>((resolve) => {
286
- const proc = spawn(invocation.command, invocation.args, {
388
+ // Register abort BEFORE spawning to close the (tiny) registration window
389
+ let onAbort: (() => void) | undefined;
390
+ if (options.signal) {
391
+ if (options.signal.aborted) { wasAborted = true; resolve(0); return; }
392
+ onAbort = () => killProc("abort");
393
+ options.signal.addEventListener("abort", onAbort, { once: true });
394
+ }
395
+
396
+ const p = spawn(invocation.command, invocation.args, {
287
397
  cwd: options.cwd,
288
398
  env: childEnv,
289
399
  shell: false,
290
400
  stdio: ["ignore", "pipe", "pipe"],
291
401
  });
402
+ proc = p;
292
403
 
293
- proc.stdout.on("data", (data: Buffer) => {
404
+ p.stdout.on("data", (data: Buffer) => {
294
405
  buffer += data.toString();
295
406
  const lines = buffer.split("\n");
296
407
  buffer = lines.pop() || "";
297
408
  for (const line of lines) processLine(line);
298
409
  });
299
410
 
300
- proc.stderr.on("data", (data: Buffer) => {
411
+ p.stderr.on("data", (data: Buffer) => {
301
412
  result.stderr += data.toString();
302
413
  });
303
414
 
304
- proc.on("close", (code) => {
415
+ p.on("close", (code, signal) => {
305
416
  if (timeoutHandle) clearTimeout(timeoutHandle);
417
+ for (const t of escalationTimers) clearTimeout(t);
418
+ if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
306
419
  if (buffer.trim()) processLine(buffer);
307
- resolve(code ?? 0);
420
+
421
+ // External signal death (OOM killer, segfault, kill -9 from elsewhere)
422
+ // that we didn't trigger. Distinguish from our own budget/timeout/abort kills
423
+ // which set the flags before we send the signal.
424
+ const externalKill = signal !== null && !budgetExceeded && !wasTimeout && !wasAborted;
425
+ if (externalKill) {
426
+ result.errorMessage = result.errorMessage || `Subagent killed by signal ${signal}`;
427
+ result.stopReason = "error";
428
+ }
429
+
430
+ // Budget stops are intentional (success); timeouts and external kills
431
+ // are failures (non-zero); otherwise use the real exit code.
432
+ resolve(budgetExceeded ? 0 : (wasTimeout || externalKill ? (code ?? 128) : (code ?? 0)));
308
433
  });
309
434
 
310
- proc.on("error", () => {
435
+ p.on("error", (err) => {
436
+ if (timeoutHandle) clearTimeout(timeoutHandle);
437
+ for (const t of escalationTimers) clearTimeout(t);
438
+ if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
439
+ // Surface the real cause (e.g. ENOENT when pi is not in PATH) instead of "unknown error".
440
+ result.errorMessage = err?.message || String(err);
311
441
  resolve(1);
312
442
  });
313
443
 
314
- // Handle abort signal
315
- if (options.signal) {
316
- const killProc = () => {
317
- wasAborted = true;
318
- proc.kill("SIGTERM");
319
- setTimeout(() => {
320
- if (!proc.killed) proc.kill("SIGKILL");
321
- }, 5000);
322
- };
323
- if (options.signal.aborted) killProc();
324
- else options.signal.addEventListener("abort", killProc, { once: true });
325
- }
326
-
327
444
  // Handle timeout
328
445
  if (options.timeoutMs && options.timeoutMs > 0) {
329
- timeoutHandle = setTimeout(() => {
330
- if (!proc.killed) {
331
- proc.kill("SIGTERM");
332
- setTimeout(() => {
333
- if (!proc.killed) proc.kill("SIGKILL");
334
- }, 5000);
335
- }
336
- }, options.timeoutMs);
446
+ timeoutHandle = setTimeout(() => killProc("timeout"), options.timeoutMs);
337
447
  }
338
448
  });
339
449
 
340
450
  result.exitCode = exitCode;
341
451
  if (wasAborted) throw new Error("Subagent was aborted");
342
-
343
- // Truncate large outputs: keep head (findings) + tail (summary), drop middle
344
- if (result.output.length > MAX_OUTPUT_CHARS) {
345
- const head = result.output.slice(0, 30_000);
346
- const tail = result.output.slice(-(MAX_OUTPUT_CHARS - 30_050));
347
- result.output = `[Output truncated — ${result.output.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
348
- }
452
+ // NOTE: large outputs are kept raw here — compression/truncation happens in
453
+ // the extension layer (index.ts) so the summary model can compress first.
349
454
  } finally {
350
455
  // Cleanup temp directory and all contents
351
456
  if (tmpDir) try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
package/src/types.ts CHANGED
@@ -4,7 +4,18 @@
4
4
 
5
5
  /** Configuration for the subagent extension. */
6
6
  export interface SubagentConfig {
7
- timeoutMs: number;
7
+ /** Per-subagent timeout in seconds. Roles that can `delegate` get 2× automatically when no per-role timeout is set. */
8
+ timeout: number;
9
+ /** Max number of subagents allowed to run concurrently. Extras queue with a TUI hint. */
10
+ maxConcurrency: number;
11
+ /** Max subagent nesting depth (the top-level session is depth 0). */
12
+ maxDepth: number;
13
+ /** Default turn budget (0 = unlimited). Per-role maxTurns overrides this. */
14
+ maxTurns: number;
15
+ /** Default cost budget in USD (0 = unlimited). Per-role maxCost overrides this. */
16
+ maxCost: number;
17
+ /** Persist each delegate run to .pi/subagent/history/{sessionId}/{id}.json for auditing. */
18
+ history: SubagentHistoryConfig;
8
19
  summary: SubagentSummaryConfig;
9
20
  /**
10
21
  * Per-role overrides from settings.json. Keyed by role name.
@@ -14,13 +25,22 @@ export interface SubagentConfig {
14
25
  agentOverrides: Record<string, Partial<SubagentRole> & { disabled?: boolean }>;
15
26
  }
16
27
 
28
+ export interface SubagentHistoryConfig {
29
+ enabled: boolean;
30
+ }
31
+
17
32
  export interface SubagentSummaryConfig {
18
33
  role: string;
19
34
  enabled: boolean;
20
35
  }
21
36
 
22
37
  export const DEFAULT_CONFIG: SubagentConfig = {
23
- timeoutMs: 300_000,
38
+ timeout: 600,
39
+ maxConcurrency: 4,
40
+ maxDepth: 3,
41
+ maxTurns: 0,
42
+ maxCost: 0,
43
+ history: { enabled: true },
24
44
  summary: { role: "utility", enabled: true },
25
45
  agentOverrides: {},
26
46
  };
@@ -41,6 +61,12 @@ export interface SubagentRole {
41
61
  tools: string[];
42
62
  /** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
43
63
  subagentRoles?: string[];
64
+ /** Per-role timeout override in seconds. Falls back to config.timeout when unset. */
65
+ timeout?: number;
66
+ /** Max assistant turns before the run is killed (0 = use config default; unset = unlimited). */
67
+ maxTurns?: number;
68
+ /** Max cumulative cost (USD) before the run is killed (0 = use config default; unset = unlimited). */
69
+ maxCost?: number;
44
70
  /** Fallback pi-model-roles role name when this role's model is unavailable (provider error). Defaults to "default". */
45
71
  fallbackRole?: string;
46
72
  }
@@ -102,6 +128,10 @@ export interface SubagentResult {
102
128
  task: string;
103
129
  /** Process exit code (-1 = still running for streaming) */
104
130
  exitCode: number;
131
+ /** True while waiting for a concurrency slot (TUI hint only). */
132
+ queued?: boolean;
133
+ /** How `output` was prepared for display: raw, compressed by summary model, or mechanically truncated. */
134
+ outputMethod?: "raw" | "compressed" | "truncated";
105
135
  /** All messages from the event stream (assistant + tool results) */
106
136
  messages: SubagentMessage[];
107
137
  /** Last assistant text output */
@@ -120,6 +150,16 @@ export interface SubagentResult {
120
150
  errorMessage?: string;
121
151
  /** Real-time activity log: thinking blocks and tool calls in arrival order. */
122
152
  activityLog: ActivityEntry[];
153
+
154
+ // ── TUI 渲染辅助字段(非子进程产出,由 execute 层填入)──────────
155
+ /** 运行开始墙钟时间;仅 queued/running 帧存在,供 TUI 实时算耗时。终态帧无此字段。 */
156
+ startTime?: number;
157
+ /** 终态总耗时(ms),由 execute 在结束时写入;覆盖整个 delegate 区间(含 fallback 重试)。 */
158
+ elapsedMs?: number;
159
+ /** delegate 传入的引用文件路径(params.files),展开视图渲染用。 */
160
+ files?: string[];
161
+ /** delegate 传入的额外上下文(params.context),展开视图渲染用。 */
162
+ context?: string;
123
163
  }
124
164
 
125
165
  /** TUI details structure passed via tool result details. */