pi-subagents 0.8.0 → 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,16 @@
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
+
10
+ ## [0.8.1] - 2026-02-10
11
+
12
+ ### Added
13
+ - **`chainDir` param** for persistent chain artifacts — specify a directory to keep artifacts beyond the default 24-hour `/tmp/` cleanup. Relative paths are resolved to absolute via `path.resolve()` for safe use in `{chain_dir}` template substitutions.
14
+
5
15
  ## [0.8.0] - 2026-02-09
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -474,6 +474,7 @@ Notes:
474
474
  | `model` | string | agent default | Override model for single agent |
475
475
  | `tasks` | `{agent, task, cwd?, skill?}[]` | - | Parallel tasks (sync only) |
476
476
  | `chain` | ChainItem[] | - | Sequential steps with behavior overrides (see below) |
477
+ | `chainDir` | string | `/tmp/pi-chain-runs/` | Persistent directory for chain artifacts (default auto-cleaned after 24h) |
477
478
  | `clarify` | boolean | true (chains) | Show TUI to preview/edit chain; implies sync mode |
478
479
  | `agentScope` | `"user" \| "project" \| "both"` | `user` | Agent discovery scope |
479
480
  | `async` | boolean | false | Background execution (requires `clarify: false` for chains) |
@@ -594,6 +595,22 @@ Press **Ctrl+O** to expand the full streaming view with complete output per step
594
595
 
595
596
  > **Note:** Chain visualization (the `✓scout → ●planner` line) is only shown for sequential chains. Chains with parallel steps show per-step cards instead.
596
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
+
597
614
  ## Async observability
598
615
 
599
616
  Async runs write a dedicated observability folder:
@@ -76,6 +76,7 @@ export interface ChainExecutionParams {
76
76
  clarify?: boolean;
77
77
  onUpdate?: (r: AgentToolResult<Details>) => void;
78
78
  chainSkills?: string[];
79
+ chainDir?: string;
79
80
  }
80
81
 
81
82
  export interface ChainExecutionResult {
@@ -103,6 +104,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
103
104
  clarify,
104
105
  onUpdate,
105
106
  chainSkills: chainSkillsParam,
107
+ chainDir: chainDirBase,
106
108
  } = params;
107
109
  const chainSkills = chainSkillsParam ?? [];
108
110
 
@@ -123,7 +125,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
123
125
  ?? (isParallelStep(firstStep) ? firstStep.parallel[0]!.task! : (firstStep as SequentialStep).task!);
124
126
 
125
127
  // Create chain directory
126
- const chainDir = createChainDir(runId);
128
+ const chainDir = createChainDir(runId, chainDirBase);
127
129
 
128
130
  // Check if chain has any parallel steps
129
131
  const hasParallelSteps = chainSteps.some(isParallelStep);
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;
@@ -377,6 +396,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
377
396
  clarify: params.clarify,
378
397
  onUpdate,
379
398
  chainSkills,
399
+ chainDir: params.chainDir,
380
400
  });
381
401
  }
382
402
 
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.8.0",
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",
package/schemas.ts CHANGED
@@ -75,6 +75,7 @@ export const SubagentParams = Type.Object({
75
75
  })),
76
76
  tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task}, ...]" })),
77
77
  chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. Use {task}, {previous}, {chain_dir} in task templates." })),
78
+ chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default: /tmp/pi-chain-runs/ (auto-cleaned after 24h)" })),
78
79
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
79
80
  agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'user')" })),
80
81
  cwd: Type.Optional(Type.String()),
package/settings.ts CHANGED
@@ -88,8 +88,8 @@ export function getStepAgents(step: ChainStep): string[] {
88
88
  // Chain Directory Management
89
89
  // =============================================================================
90
90
 
91
- export function createChainDir(runId: string): string {
92
- const chainDir = path.join(CHAIN_RUNS_DIR, runId);
91
+ export function createChainDir(runId: string, baseDir?: string): string {
92
+ const chainDir = path.join(baseDir ? path.resolve(baseDir) : CHAIN_RUNS_DIR, runId);
93
93
  fs.mkdirSync(chainDir, { recursive: true });
94
94
  return chainDir;
95
95
  }
@@ -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