pi-subagents 0.8.1 → 0.8.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.2] - 2026-02-11
6
+
7
+ ### Added
8
+ - Recursion depth guard (`PI_SUBAGENT_MAX_DEPTH`) to prevent runaway nested subagent spawning. Default max depth is 2 (main -> subagent -> sub-subagent). Deeper calls are blocked with guidance to the calling agent.
9
+
5
10
  ## [0.8.1] - 2026-02-10
6
11
 
7
12
  ### Added
package/README.md CHANGED
@@ -595,6 +595,22 @@ Press **Ctrl+O** to expand the full streaming view with complete output per step
595
595
 
596
596
  > **Note:** Chain visualization (the `✓scout → ●planner` line) is only shown for sequential chains. Chains with parallel steps show per-step cards instead.
597
597
 
598
+ ## Nested subagent recursion guard
599
+
600
+ Subagents can themselves call the `subagent` tool, which risks unbounded recursive spawning (slow, expensive, hard to observe). A depth guard prevents this.
601
+
602
+ By default nesting is limited to **2 levels**: `main session → subagent → sub-subagent`. Any deeper `subagent` calls are blocked and return an error with guidance to the calling agent.
603
+
604
+ Override the limit with `PI_SUBAGENT_MAX_DEPTH` **set before starting `pi`**:
605
+
606
+ ```bash
607
+ export PI_SUBAGENT_MAX_DEPTH=3 # allow one more level (use with caution)
608
+ export PI_SUBAGENT_MAX_DEPTH=1 # only allow direct subagents, no nesting
609
+ export PI_SUBAGENT_MAX_DEPTH=0 # disable the subagent tool entirely
610
+ ```
611
+
612
+ `PI_SUBAGENT_DEPTH` is an internal variable propagated automatically to child processes -- don't set it manually.
613
+
598
614
  ## Async observability
599
615
 
600
616
  Async runs write a dedicated observability folder:
package/execution.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type SingleResult,
21
21
  DEFAULT_MAX_OUTPUT,
22
22
  truncateOutput,
23
+ getSubagentDepthEnv,
23
24
  } from "./types.js";
24
25
  import {
25
26
  writePrompt,
@@ -150,7 +151,7 @@ export async function runSync(
150
151
  }
151
152
  }
152
153
 
153
- const spawnEnv = { ...process.env };
154
+ const spawnEnv = { ...process.env, ...getSubagentDepthEnv() };
154
155
  const mcpDirect = agent.mcpDirectTools;
155
156
  if (mcpDirect?.length) {
156
157
  spawnEnv.MCP_DIRECT_TOOLS = mcpDirect.join(",");
package/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  POLL_INTERVAL_MS,
39
39
  RESULTS_DIR,
40
40
  WIDGET_KEY,
41
+ checkSubagentDepth,
41
42
  } from "./types.js";
42
43
  import { readStatus, findByPrefix, getFinalOutput, mapConcurrent } from "./utils.js";
43
44
  import { runSync } from "./execution.js";
@@ -189,6 +190,24 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
189
190
  }
190
191
  return handleManagementAction(params.action, params, ctx);
191
192
  }
193
+
194
+ const { blocked, depth, maxDepth } = checkSubagentDepth();
195
+ if (blocked) {
196
+ return {
197
+ content: [
198
+ {
199
+ type: "text",
200
+ text:
201
+ `Nested subagent call blocked (depth=${depth}, max=${maxDepth}). ` +
202
+ "You are running at the maximum subagent nesting depth. " +
203
+ "Complete your current task directly without delegating to further subagents.",
204
+ },
205
+ ],
206
+ isError: true,
207
+ details: { mode: "single" as const, results: [] },
208
+ };
209
+ }
210
+
192
211
  const scope: AgentScope = params.agentScope ?? "user";
193
212
  currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
194
213
  const agents = discoverAgents(ctx.cwd, scope).agents;
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
7
+ "type": "module",
7
8
  "repository": {
8
9
  "type": "git",
9
10
  "url": "git+https://github.com/nicobailon/pi-subagents.git"
@@ -26,10 +27,14 @@
26
27
  },
27
28
  "files": [
28
29
  "*.ts",
30
+ "!*.test.ts",
29
31
  "*.mjs",
30
32
  "README.md",
31
33
  "CHANGELOG.md"
32
34
  ],
35
+ "scripts": {
36
+ "test": "node --experimental-strip-types --test recursion-guard.test.ts"
37
+ },
33
38
  "pi": {
34
39
  "extensions": [
35
40
  "./index.ts",
@@ -11,6 +11,7 @@ import {
11
11
  DEFAULT_MAX_OUTPUT,
12
12
  type MaxOutputConfig,
13
13
  truncateOutput,
14
+ getSubagentDepthEnv,
14
15
  } from "./types.js";
15
16
 
16
17
  interface SubagentStep {
@@ -102,8 +103,8 @@ function runPiStreaming(
102
103
  ): Promise<{ stdout: string; exitCode: number | null }> {
103
104
  return new Promise((resolve) => {
104
105
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
105
- const spawnEnv = env ? { ...process.env, ...env } : undefined;
106
- const child = spawn("pi", args, { cwd, stdio: ["ignore", "pipe", "pipe"], ...(spawnEnv && { env: spawnEnv }) });
106
+ const spawnEnv = { ...process.env, ...(env ?? {}), ...getSubagentDepthEnv() };
107
+ const child = spawn("pi", args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: spawnEnv });
107
108
  let stdout = "";
108
109
 
109
110
  child.stdout.on("data", (chunk: Buffer) => {
package/types.ts CHANGED
@@ -242,6 +242,27 @@ export const ASYNC_DIR = "/tmp/pi-async-subagent-runs";
242
242
  export const WIDGET_KEY = "subagent-async";
243
243
  export const POLL_INTERVAL_MS = 250;
244
244
  export const MAX_WIDGET_JOBS = 4;
245
+ export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
246
+
247
+ // ============================================================================
248
+ // Recursion Depth Guard
249
+ // ============================================================================
250
+
251
+ export function checkSubagentDepth(): { blocked: boolean; depth: number; maxDepth: number } {
252
+ const depth = Number(process.env.PI_SUBAGENT_DEPTH ?? "0");
253
+ const maxDepth = Number(process.env.PI_SUBAGENT_MAX_DEPTH ?? String(DEFAULT_SUBAGENT_MAX_DEPTH));
254
+ const blocked = Number.isFinite(depth) && Number.isFinite(maxDepth) && depth >= maxDepth;
255
+ return { blocked, depth, maxDepth };
256
+ }
257
+
258
+ export function getSubagentDepthEnv(): Record<string, string> {
259
+ const parentDepth = Number(process.env.PI_SUBAGENT_DEPTH ?? "0");
260
+ const nextDepth = Number.isFinite(parentDepth) ? parentDepth + 1 : 1;
261
+ return {
262
+ PI_SUBAGENT_DEPTH: String(nextDepth),
263
+ PI_SUBAGENT_MAX_DEPTH: process.env.PI_SUBAGENT_MAX_DEPTH ?? String(DEFAULT_SUBAGENT_MAX_DEPTH),
264
+ };
265
+ }
245
266
 
246
267
  // ============================================================================
247
268
  // Utility Functions