@narumitw/pi-subagents 0.13.1 → 0.14.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/README.md CHANGED
@@ -2,21 +2,26 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-subagents)](https://www.npmjs.com/package/@narumitw/pi-subagents) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension that adds a `subagent` tool for delegating work to specialized agents running in isolated Pi subprocesses.
5
+ `@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension for delegating work to specialized agents. The batch `subagent` tool keeps isolated Pi subprocesses, while addressable lifecycle tools can run reusable agents either as subprocess-backed logical sessions or as public-SDK in-process child sessions.
6
6
 
7
- Use it to split research, planning, implementation, and review work across focused workers while keeping each subprocess context, tools, and prompt boundary separate from the main conversation.
7
+ Use it to split independent research, planning, implementation, and review work across focused workers. Background delegation is intended for sidecar work the main agent can overlap with useful local work—not for handing off one critical-path task and waiting.
8
8
 
9
9
  ## ✨ Features
10
10
 
11
11
  - Registers a `subagent` tool for single-agent, parallel, fan-in, and chained delegation.
12
- - Runs workers as isolated `pi --mode json -p --no-session` subprocesses.
12
+ - Keeps batch workers isolated in `pi --mode json -p --no-session` subprocesses.
13
+ - Registers detached stateful lifecycle tools by default; spawn returns immediately and completion is delivered asynchronously without starting a new root turn.
14
+ - Supports an opt-in public-SDK `in-process` stateful transport with one reusable child `AgentSession` per `agentId`.
13
15
  - Supports built-in `scout`, `planner`, `reviewer`, and `worker` agents.
14
16
  - Loads custom user agents from `~/.pi/agent/agents/*.md`.
15
17
  - Optionally loads project agents from `.pi/agents/*.md` with confirmation.
16
18
  - Provides `/subagents:config` to persist per-agent tool allow-lists.
17
19
  - Supports per-task `cwd`, hard subprocess `timeoutMs`, `thinkingLevel`, abort propagation, and streaming progress.
20
+ - Bounds JSON lines, captured messages, stderr, final output, chain substitution, and fan-in context.
21
+ - Enforces a recursion-depth guard and deterministic process-group termination.
22
+ - Provides addressable stateful agents with follow-up, wait, list, interrupt, close, context selection, and persistence.
18
23
  - Publishes transient runtime status through Pi's generic extension status API while subagents are running.
19
- - Returns complete worker output in tool details and a concise result for the main agent.
24
+ - Returns complete bounded worker output in tool details and a concise result for the main agent.
20
25
 
21
26
  ## 📦 Install
22
27
 
@@ -38,9 +43,18 @@ pi -e ./extensions/pi-subagents
38
43
 
39
44
  ## 🛠️ Pi tool
40
45
 
41
- `pi-subagents` registers one tool:
46
+ `pi-subagents` registers a primary batch tool plus the stateful lifecycle tools documented below:
42
47
 
43
- - `subagent` — delegate work to one or more specialized agents.
48
+ - `subagent` — delegate blocking single, parallel, fan-in, or chained batch work.
49
+ - `subagent_spawn` and related lifecycle tools — start reusable detached work, return immediately, and receive bounded completion messages automatically.
50
+
51
+ Choose the API by lifecycle:
52
+
53
+ | Need | Use |
54
+ | --- | --- |
55
+ | One or more independent, one-shot results needed before the root responds | One blocking `subagent` call (`tasks` for parallel work) |
56
+ | Reusable history, follow-ups, mailboxes, or work that overlaps useful root work | `subagent_spawn` and lifecycle tools |
57
+ | One simple or critical-path action the root can perform directly | No subagent |
44
58
 
45
59
  Execution modes:
46
60
 
@@ -64,8 +78,12 @@ Count-selection guidance:
64
78
 
65
79
  - Use **no subagent** for simple answers, quick targeted edits, latency-sensitive one-step work, or
66
80
  tasks that need frequent user back-and-forth.
67
- - Use **one subagent** for isolated research, high-volume command output, planning, or independent
68
- review/verification after implementation.
81
+ - A single `subagent` call is blocking. Use one only when context isolation, high-volume output
82
+ isolation, or independent review is worth waiting for; otherwise the main agent should do the work.
83
+ - Do not delegate ordinary critical-path work merely to wait for it. Use detached `subagent_spawn`
84
+ only when parallel work, bounded context/output, independent review, a distinct model/tool profile,
85
+ or workspace isolation provides concrete value. Continue useful non-overlapping work when available;
86
+ otherwise call `subagent_wait` and synthesize the result before finishing.
69
87
  - Prefer **2–4 parallel read-only subagents** when a broad task naturally splits into independent
70
88
  branches that can each return a concise summary.
71
89
  - Exceed 4 tasks only when the branches are clearly distinct and worth the extra cost, while staying
@@ -128,6 +146,8 @@ Run one read-only reconnaissance agent:
128
146
  }
129
147
  ```
130
148
 
149
+ For genuinely random values, specify the range, duplicate policy, and a system randomness source instead of relying on model sampling, for example: `Use Python secrets to return 10 integers from 0 through 999; duplicates are allowed.`
150
+
131
151
  Run multiple agents in parallel with a shared thinking level and one per-task override:
132
152
 
133
153
  ```json
@@ -178,6 +198,92 @@ Run a chain where each step receives the previous output:
178
198
  }
179
199
  ```
180
200
 
201
+ ## 🔁 Stateful agents
202
+
203
+ Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects one bounded `pi-subagent-completion` message per settled turn. The message uses `deliverAs: "steer"` with `triggerTurn: false`, so an active root turn can consume it naturally.
204
+
205
+ Detached work has a root-coordination invariant: the main agent should continue useful non-overlapping work when available, or call `subagent_wait` when coordination is the only useful next action. If it yields while current delegated work is still live or awaiting synthesis, the extension schedules one idle-gated recovery continuation for that orchestration revision. A completion, close, interruption, new root turn, session replacement, or shutdown revises or clears that ephemeral state. Recovery is bounded and does not internally wait forever or persist across sessions.
206
+
207
+ A single detached agent is justified only by a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation. Simple work that the main agent can perform directly should not be delegated.
208
+
209
+ When `subagent_wait` starts while a turn is running, that wait consumes the turn's completion: the terminal output is returned by the tool and is not also injected as a duplicate asynchronous completion. Multiple active waiters may all receive the terminal result. A timed-out or aborted wait does not consume it, so later settlement still delivers the normal asynchronous completion. If a turn has already settled and queued its completion before a wait starts, the queued message cannot be retracted safely.
210
+
211
+ The default `subprocess` transport preserves compatibility: each turn starts a fresh isolated `pi --mode json -p --no-session` child and receives sanitized, bounded history. Set `transport` to `in-process` to retain one public Pi SDK `AgentSession` per stateful `agentId`, avoiding repeated process startup while preserving native child history in memory.
212
+
213
+ Configure the runtime in `~/.pi/agent/pi-subagents-config.json`, then reload Pi:
214
+
215
+ ```json
216
+ {
217
+ "stateful": {
218
+ "transport": "in-process",
219
+ "maxAgents": 16,
220
+ "maxActiveTurns": 4,
221
+ "maxDepth": 3,
222
+ "maxChildrenPerAgent": 8,
223
+ "maxMailboxMessages": 100,
224
+ "maxMailboxMessageBytes": 16384,
225
+ "idleTtlMs": 3600000,
226
+ "retentionDays": 30,
227
+ "maxStoredAgents": 50
228
+ }
229
+ }
230
+ ```
231
+
232
+ Set `"enabled": false` to remove all stateful lifecycle tools. Otherwise, the extension registers:
233
+
234
+ | Tool | Purpose |
235
+ | --- | --- |
236
+ | `subagent_spawn` | Start detached work, return an opaque `agentId` immediately, and deliver completion asynchronously. |
237
+ | `subagent_send` | Send follow-up work to a reusable agent; shared-workspace write conflicts are guarded unless explicitly overridden. |
238
+ | `subagent_message` | Queue a bounded mailbox message without starting a turn; sender IDs must be `root` or an agent in the same tree. |
239
+ | `subagent_messages` | Read and optionally acknowledge unread mailbox messages. |
240
+ | `subagent_wait` | Wait when coordination is the useful next action; timeout does not terminate the agent, and parent abort/user steering cancels only the wait. Already-terminal agents return immediately. |
241
+ | `subagent_list` | List retained agents and lifecycle states. |
242
+ | `subagent_interrupt` | Abort the current turn while retaining its identity and history. |
243
+ | `subagent_close` | Abort if necessary, close the agent, and remove it from retained persistence. |
244
+
245
+ Use `/subagents:agents list` to inspect the indented agent tree, lifecycle state, unread count, and available actions. Use `/subagents:agents clear` to close and delete all retained agents for the session. Active turns are FIFO-limited by `maxActiveTurns`; excess retained work remains in `starting` state until a slot is available. `maxAgents` separately bounds running, queued, and idle records. `parentId` creates a bounded child relationship; subtree interrupt and close operate child-first.
246
+
247
+ `subagent_spawn.context` accepts:
248
+
249
+ - `"none"` (default) — no parent conversation.
250
+ - `"all"` — bounded user/assistant text from the active branch.
251
+ - `"summary"` — a bounded earlier-context checkpoint plus recent messages verbatim.
252
+ - A positive number — the most recent N user turns and related assistant text.
253
+
254
+ Use `contextEntryIds` to select exact session entries. Supplying IDs without `context` implies `context: "all"`; an explicit `context: "none"` still disables parent context. Stable source IDs are retained so repeated follow-ups do not need to duplicate parent context.
255
+
256
+ Reasoning, tool results, custom transport messages, and non-text parts are excluded. Text inside `<private>...</private>` and lines containing `[subagent-private]` are omitted before context, mailbox content, or history is persisted.
257
+
258
+ Stateful execution uses a transport boundary:
259
+
260
+ - `subprocess` is the default compatibility and rollback path.
261
+ - `in-process` uses only public Pi SDK APIs: `createAgentSession()`, `SessionManager.inMemory()`, `DefaultResourceLoader`, and normal session lifecycle methods. It isolates conversation/tool selection, not memory or crashes; child failures share the parent Node.js process.
262
+ - Child resource loading sets `noExtensions: true`, preventing recursive `pi-subagents` loading and duplicate extension side effects while retaining normal context/skill resources and the selected agent prompt.
263
+ - Agent model, thinking level, and built-in tool allow-list overrides are applied when the child is created. Parent model/thinking changes are snapshotted for subsequently created children; an existing child keeps its own session configuration.
264
+ - Extension/custom tool names are rejected in-process with an actionable recommendation to use `subprocess`; permissions are never silently widened.
265
+ - Timeout, parent abort, close, expiry, and session shutdown abort/dispose owned child sessions. A child that does not settle after abort grace is discarded rather than reused.
266
+ - In-process startup failures do not silently retry through subprocesses, preventing duplicate side effects.
267
+
268
+ No private Pi imports, runtime casts, or `ExtensionAPI` monkey-patching are used. Approval policy, sandbox profile, provider-header hooks, extension state, global scheduling, and parent/child transcript switching are not inherited or provided by the in-process transport.
269
+
270
+ Write-capable agents share the workspace by default. Concurrent write-capable starts in the same cwd are rejected unless `allowConcurrentWrites` is explicitly set. Classification is intentionally conservative: an agent with `bash`, `write`, or `edit` is write-capable even when its task prompt says “read only,” because prompt wording is not a filesystem sandbox. For independent one-shot work, use batch parallel mode. Otherwise wait for or close the active agent, explicitly accept safe overlap with `allowConcurrentWrites`, or use an isolated worktree when repository isolation is actually needed.
271
+
272
+ Set `workspaceMode: "worktree"` to opt into a disposable detached Git worktree; this requires a clean repository and the worktree is removed on close or session shutdown. Isolated worktree agents are intentionally not restored after shutdown.
273
+
274
+ ## 📜 Compatibility and failure contract
275
+
276
+ Existing `subagent` requests remain unchanged:
277
+
278
+ | Mode | Ordering | Failure behavior |
279
+ | --- | --- | --- |
280
+ | Single | One result. | A failed/aborted/timed-out worker is marked as a tool error while preserving bounded details. |
281
+ | Chain | Input order. | Stops at the first failed step; completed steps remain in details. |
282
+ | Parallel | Input order, with at most four active children. | Collects all task results; partial worker failure is reported in summaries but does not discard successful results. |
283
+ | Parallel + aggregator | Source input order, then aggregator. | The aggregator runs with both successful outputs and failure descriptions; aggregator failure marks the tool result as an error. |
284
+
285
+ Timeout precedence remains: task/step/aggregator → call → agent setting → `PI_SUBAGENT_TIMEOUT_MS` → 600000 ms. Thinking precedence remains: task/step/aggregator → call → agent setting → child default. Project-agent resolution and confirmation behavior is unchanged.
286
+
181
287
  ## 🤖 Built-in agents
182
288
 
183
289
  Built-in agents are available without setup and can be overridden by user or project agents with the same name.
@@ -241,7 +347,15 @@ Set `thinkingLevel` to pass Pi's `--thinking <level>` to a subprocess. Supported
241
347
 
242
348
  Thinking-level precedence is: task/chain step/aggregator `thinkingLevel` → top-level `thinkingLevel` → agent default from config or frontmatter → Pi subprocess default. Omit `thinkingLevel` to preserve existing behavior. Pi still owns model capability clamping, so unsupported thinking levels are handled by the spawned Pi process.
243
349
 
244
- On timeout, the extension sends `SIGTERM`, escalates to `SIGKILL` after a short grace period, and returns any partial messages or stderr collected so far.
350
+ On timeout, the extension sends process-group `SIGTERM`, escalates to `SIGKILL` after a five-second grace period if the process has not actually closed, and returns partial bounded messages or stderr collected so far. Parent abort uses the same cleanup path and preserves a structured result.
351
+
352
+ The child event protocol limits each JSON line to 256 KiB. Captured output uses these defaults:
353
+
354
+ - final output and fan-in/chain context: 50 KiB;
355
+ - stderr: 16 KiB;
356
+ - captured messages: 200.
357
+
358
+ Truncated text includes a `truncated by pi-subagents` marker and details expose `truncated: true`. `PI_SUBAGENT_MAX_DEPTH` controls nested delegation depth and defaults to 1; child processes receive `PI_SUBAGENT_DEPTH` automatically.
245
359
 
246
360
  ## 📡 Runtime status
247
361
 
@@ -249,7 +363,17 @@ While the `subagent` tool is running, `pi-subagents` publishes compact activity
249
363
 
250
364
  ## 🔒 Safety notes
251
365
 
252
- Subagents are separate Pi processes and may use the tools allowed by their agent definition. Treat project-local agent prompts like executable project configuration: only enable them in trusted repositories.
366
+ Subagents have separate processes and context windows, but they are **not security sandboxes**. They run as the same OS user, share the host filesystem and network access, and may conflict if they edit the same files. Tool allow-lists reduce available Pi tools but do not reduce operating-system permissions.
367
+
368
+ The runner explicitly reports policy continuity in result details:
369
+
370
+ - inherited: process environment;
371
+ - overridden when selected: cwd, model, thinking level, and tool list;
372
+ - unsupported guarantees: parent approval policy, sandbox profile, and provider headers.
373
+
374
+ Treat project-local agent prompts like executable project configuration: only enable them in trusted repositories. Stateful project agents require Pi's project trust; interactive use also keeps confirmation enabled by default.
375
+
376
+ Stateful records are stored as versioned mode-0600 JSON under `~/.pi/agent/pi-subagents-state/` (or the configured Pi agent directory). Records contain sanitized logical history, never process IDs or credentials. Corrupt or unsupported state is quarantined, restored agents are always inert `idle` records, and no prior side effect is automatically resumed. Retention and count limits are configurable. Downgrading is safe: older extension versions ignore this separate state directory; use `/subagents:agents clear` before downgrade if the histories should be removed.
253
377
 
254
378
  ## 🗂️ Package layout
255
379
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "@earendil-works/pi-tui": "*"
39
39
  },
40
40
  "devDependencies": {
41
- "@biomejs/biome": "2.5.2",
42
- "@earendil-works/pi-agent-core": "0.80.3",
41
+ "@biomejs/biome": "2.5.3",
42
+ "@earendil-works/pi-agent-core": "0.80.6",
43
43
  "@earendil-works/pi-ai": "0.80.3",
44
44
  "@earendil-works/pi-coding-agent": "0.80.3",
45
45
  "@earendil-works/pi-tui": "0.80.3",
package/src/agents.ts CHANGED
@@ -38,8 +38,25 @@ export interface SubagentAgentConfig {
38
38
  timeoutMs?: number | null;
39
39
  }
40
40
 
41
+ export type SubagentTransportKind = "subprocess" | "in-process";
42
+
43
+ export interface SubagentRuntimeSettings {
44
+ enabled?: boolean;
45
+ transport?: SubagentTransportKind;
46
+ maxAgents?: number;
47
+ maxActiveTurns?: number;
48
+ maxDepth?: number;
49
+ maxChildrenPerAgent?: number;
50
+ maxMailboxMessages?: number;
51
+ maxMailboxMessageBytes?: number;
52
+ idleTtlMs?: number;
53
+ retentionDays?: number;
54
+ maxStoredAgents?: number;
55
+ }
56
+
41
57
  export interface SubagentSettings {
42
58
  agents?: Record<string, SubagentAgentConfig>;
59
+ stateful?: SubagentRuntimeSettings;
43
60
  }
44
61
 
45
62
  const BUILT_IN_AGENTS: AgentConfig[] = [
package/src/context.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ DEFAULT_MAX_CONTEXT_BYTES,
4
+ truncateUtf8,
5
+ truncateUtf8Tail,
6
+ } from "./limits.js";
7
+
8
+ export type ContextMode = "none" | "all" | "summary" | number;
9
+
10
+ export interface ContextSnapshot {
11
+ text: string;
12
+ turns: number;
13
+ truncated: boolean;
14
+ sourceIds: string[];
15
+ }
16
+
17
+ function textParts(content: unknown): string {
18
+ if (typeof content === "string") return content;
19
+ if (!Array.isArray(content)) return "";
20
+ return content
21
+ .filter((part): part is { type: "text"; text: string } =>
22
+ Boolean(
23
+ part &&
24
+ typeof part === "object" &&
25
+ (part as { type?: unknown }).type === "text" &&
26
+ typeof (part as { text?: unknown }).text === "string",
27
+ ),
28
+ )
29
+ .map((part) => part.text)
30
+ .join("\n");
31
+ }
32
+
33
+ export function redactPrivateText(text: string): string {
34
+ const tagPattern = /<\/?private>/gi;
35
+ let redacted = "";
36
+ let cursor = 0;
37
+ let depth = 0;
38
+ for (const match of text.matchAll(tagPattern)) {
39
+ const tag = match[0].toLowerCase();
40
+ const index = match.index ?? cursor;
41
+ if (depth === 0) redacted += text.slice(cursor, index);
42
+ if (tag === "<private>") {
43
+ if (depth === 0) redacted += "[private content omitted]";
44
+ depth++;
45
+ } else if (depth > 0) {
46
+ depth--;
47
+ } else {
48
+ redacted += match[0];
49
+ }
50
+ cursor = index + match[0].length;
51
+ }
52
+ if (depth === 0) redacted += text.slice(cursor);
53
+ return redacted
54
+ .split("\n")
55
+ .filter((line) => !line.includes("[subagent-private]"))
56
+ .join("\n");
57
+ }
58
+
59
+ export function buildContextSnapshot(
60
+ entries: readonly unknown[],
61
+ mode: ContextMode,
62
+ maxBytes = DEFAULT_MAX_CONTEXT_BYTES,
63
+ selectedSourceIds?: readonly string[],
64
+ ): ContextSnapshot {
65
+ if (mode === "none") return { text: "", turns: 0, truncated: false, sourceIds: [] };
66
+ const messages: Array<{ role: "user" | "assistant"; text: string; sourceId: string }> = [];
67
+ for (const entry of entries) {
68
+ if (!entry || typeof entry !== "object") continue;
69
+ const candidate = entry as {
70
+ id?: string;
71
+ type?: string;
72
+ role?: string;
73
+ content?: unknown;
74
+ message?: { role?: string; content?: unknown };
75
+ };
76
+ const message: { role?: string; content?: unknown } | undefined =
77
+ candidate.type === "message" ? candidate.message : candidate;
78
+ if (message?.role !== "user" && message?.role !== "assistant") continue;
79
+ const text = redactPrivateText(textParts(message.content));
80
+ if (text.trim()) {
81
+ const sourceId =
82
+ candidate.id ??
83
+ createHash("sha256").update(`${message.role}\0${text}`).digest("hex").slice(0, 16);
84
+ messages.push({ role: message.role, text, sourceId });
85
+ }
86
+ }
87
+ const seenSourceIds = new Set<string>();
88
+ const uniqueMessages = messages.filter((message) => {
89
+ if (seenSourceIds.has(message.sourceId)) return false;
90
+ seenSourceIds.add(message.sourceId);
91
+ return true;
92
+ });
93
+ const selectedSet = selectedSourceIds ? new Set(selectedSourceIds) : undefined;
94
+ const eligible = selectedSet
95
+ ? uniqueMessages.filter((message) => selectedSet.has(message.sourceId))
96
+ : uniqueMessages;
97
+ const turnLimit =
98
+ typeof mode === "number" ? Math.max(1, Math.floor(mode)) : Number.POSITIVE_INFINITY;
99
+ let userTurns = 0;
100
+ let start = eligible.length;
101
+ for (let index = eligible.length - 1; index >= 0; index--) {
102
+ if (eligible[index].role === "user") userTurns++;
103
+ if (userTurns > turnLimit) break;
104
+ start = index;
105
+ }
106
+ const selected = eligible.slice(start);
107
+ const raw =
108
+ mode === "summary" && selected.length > 4
109
+ ? [
110
+ `## Earlier context checkpoint\n${truncateUtf8(
111
+ selected
112
+ .slice(0, -4)
113
+ .map((message) => `${message.role}: ${message.text}`)
114
+ .join("\n"),
115
+ Math.floor(maxBytes / 3),
116
+ ).text}`,
117
+ ...selected
118
+ .slice(-4)
119
+ .map((message) => `## ${message.role}\n${message.text}`),
120
+ ].join("\n\n")
121
+ : selected.map((message) => `## ${message.role}\n${message.text}`).join("\n\n");
122
+ const bounded = mode === "summary" ? truncateUtf8Tail(raw, maxBytes) : truncateUtf8(raw, maxBytes);
123
+ return {
124
+ text: bounded.text,
125
+ turns: selected.filter((message) => message.role === "user").length,
126
+ truncated: bounded.truncated,
127
+ sourceIds: selected.map((message) => message.sourceId),
128
+ };
129
+ }
package/src/execution.ts CHANGED
@@ -15,12 +15,13 @@ import {
15
15
  type SingleResult,
16
16
  type SubagentDetails,
17
17
  } from "./runner.js";
18
+ import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
18
19
  import type { SubagentParams } from "./params.js";
19
20
  import { readSubagentSettings, resolveSubagentThinkingLevel } from "./settings.js";
20
21
 
21
22
  const MAX_PARALLEL_TASKS = 8;
22
23
  const MAX_CONCURRENCY = 4;
23
- const DEFAULT_TIMEOUT_MS = parsePositiveInteger(process.env.PI_SUBAGENT_TIMEOUT_MS) ?? 10 * 60 * 1000;
24
+ export const FALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
24
25
  const STATUS_KEY = "subagents";
25
26
  const activeStatuses = new Map<string, string>();
26
27
 
@@ -30,6 +31,18 @@ export function parsePositiveInteger(value: string | undefined): number | undefi
30
31
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
31
32
  }
32
33
 
34
+ export function resolveDefaultSubagentTimeoutMs(): number {
35
+ return parsePositiveInteger(process.env.PI_SUBAGENT_TIMEOUT_MS) ?? FALLBACK_TIMEOUT_MS;
36
+ }
37
+
38
+ export function assertSubagentDepthAllowed(): void {
39
+ const depth = Number.parseInt(process.env.PI_SUBAGENT_DEPTH ?? "0", 10) || 0;
40
+ const maxDepth = parsePositiveInteger(process.env.PI_SUBAGENT_MAX_DEPTH) ?? 1;
41
+ if (depth >= maxDepth) {
42
+ throw new Error(`Subagent recursion depth limit reached (${maxDepth})`);
43
+ }
44
+ }
45
+
33
46
  interface StatusContext {
34
47
  ui: { setStatus: (key: string, value: string | undefined) => void };
35
48
  }
@@ -90,6 +103,7 @@ export async function executeSubagent(
90
103
  onUpdate: AgentToolUpdateCallback<SubagentDetails> | undefined,
91
104
  ctx: ExtensionContext,
92
105
  ): Promise<AgentToolResult<SubagentDetails> & { isError?: boolean }> {
106
+ assertSubagentDepthAllowed();
93
107
  const agentScope: AgentScope = params.agentScope ?? "user";
94
108
  const config = readSubagentSettings();
95
109
  const discovery = discoverAgents(ctx.cwd, agentScope, config);
@@ -99,7 +113,7 @@ export async function executeSubagent(
99
113
  localTimeoutMs ??
100
114
  params.timeoutMs ??
101
115
  agents.find((agent) => agent.name === agentName)?.timeoutMs ??
102
- DEFAULT_TIMEOUT_MS;
116
+ resolveDefaultSubagentTimeoutMs();
103
117
  const resolveThinkingLevel = (agentName: string, localThinkingLevel?: SubagentThinkingLevel) =>
104
118
  resolveSubagentThinkingLevel(agents, agentName, params.thinkingLevel, localThinkingLevel);
105
119
 
@@ -135,7 +149,7 @@ export async function executeSubagent(
135
149
  };
136
150
  }
137
151
 
138
- if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
152
+ if (agentScope === "project" || agentScope === "both") {
139
153
  const requestedAgentNames = new Set<string>();
140
154
  if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
141
155
  if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
@@ -147,17 +161,23 @@ export async function executeSubagent(
147
161
  .filter((a): a is AgentConfig => a?.source === "project");
148
162
 
149
163
  if (projectAgentsRequested.length > 0) {
150
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
151
- const dir = discovery.projectAgentsDir ?? "(unknown)";
152
- const ok = await ctx.ui.confirm(
153
- "Run project-local agents?",
154
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
155
- );
156
- if (!ok)
157
- return {
158
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
159
- details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
160
- };
164
+ if (!ctx.isProjectTrusted()) {
165
+ throw new Error("Project-local subagent definitions require a trusted project");
166
+ }
167
+ if (confirmProjectAgents && ctx.hasUI) {
168
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
169
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
170
+ const ok = await ctx.ui.confirm(
171
+ "Run project-local agents?",
172
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
173
+ );
174
+ if (!ok) {
175
+ return {
176
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
177
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
178
+ };
179
+ }
180
+ }
161
181
  }
162
182
  }
163
183
 
@@ -170,7 +190,10 @@ export async function executeSubagent(
170
190
  for (let i = 0; i < params.chain.length; i++) {
171
191
  const step = params.chain[i];
172
192
  status.update(chainStatus(i + 1, params.chain.length, step.agent));
173
- const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
193
+ const taskWithContext = truncateUtf8(
194
+ step.task.replace(/\{previous\}/g, previousOutput),
195
+ DEFAULT_MAX_CONTEXT_BYTES,
196
+ ).text;
174
197
 
175
198
  // Create update callback that includes all previous results
176
199
  const chainUpdate: OnUpdateCallback | undefined = onUpdate
@@ -208,7 +231,7 @@ export async function executeSubagent(
208
231
  const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
209
232
  return {
210
233
  content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
211
- details: makeDetails("chain")(results),
234
+ details: { ...makeDetails("chain")(results), isError: true },
212
235
  isError: true,
213
236
  };
214
237
  }
@@ -299,16 +322,33 @@ export async function executeSubagent(
299
322
  runningCount -= 1;
300
323
  emitParallelUpdate();
301
324
  return result;
325
+ }, signal, (task, index) => {
326
+ const skipped: SingleResult = {
327
+ ...allResults[index],
328
+ task: task.task,
329
+ exitCode: 130,
330
+ stopReason: "aborted",
331
+ aborted: true,
332
+ errorMessage: "Subagent was not started because the parent call was aborted",
333
+ };
334
+ allResults[index] = skipped;
335
+ doneCount += 1;
336
+ runningCount -= 1;
337
+ emitParallelUpdate();
338
+ return skipped;
302
339
  });
303
340
 
304
341
  let aggregatorResult: SingleResult | undefined;
305
- if (params.aggregator) {
342
+ if (params.aggregator && !signal?.aborted) {
306
343
  const aggregator = params.aggregator;
307
344
  status.update(fanInStatus(aggregator.agent));
308
345
  const fanInContext = buildFanInContext(results);
309
- const aggregatorTask = aggregator.task.includes("{previous}")
310
- ? aggregator.task.replace(/\{previous\}/g, fanInContext)
311
- : `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`;
346
+ const aggregatorTask = truncateUtf8(
347
+ aggregator.task.includes("{previous}")
348
+ ? aggregator.task.replace(/\{previous\}/g, fanInContext)
349
+ : `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`,
350
+ DEFAULT_MAX_CONTEXT_BYTES,
351
+ ).text;
312
352
  aggregatorResult = await runSingleAgent(
313
353
  ctx.cwd,
314
354
  agents,
@@ -351,7 +391,14 @@ export async function executeSubagent(
351
391
  : `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
352
392
  },
353
393
  ],
354
- details: makeDetails("parallel")(results, aggregatorResult),
394
+ details: {
395
+ ...makeDetails("parallel")(results, aggregatorResult),
396
+ isError: aggregatorResult
397
+ ? aggregatorResult.exitCode !== 0 ||
398
+ aggregatorResult.stopReason === "error" ||
399
+ aggregatorResult.stopReason === "aborted"
400
+ : false,
401
+ },
355
402
  isError: aggregatorResult
356
403
  ? aggregatorResult.exitCode !== 0 ||
357
404
  aggregatorResult.stopReason === "error" ||
@@ -385,7 +432,7 @@ export async function executeSubagent(
385
432
  const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
386
433
  return {
387
434
  content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
388
- details: makeDetails("single")([result]),
435
+ details: { ...makeDetails("single")([result]), isError: true },
389
436
  isError: true,
390
437
  };
391
438
  }