oira666_pi-subagent 0.2.0 → 0.2.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/README.md CHANGED
@@ -22,26 +22,13 @@ pi remove npm:oira666_pi-subagent
22
22
 
23
23
  ## How It Works
24
24
 
25
- The extension supports two execution modes, selectable via `PI_SUBAGENTS_MODE`.
26
-
27
- ### Execution mode: `subprocess` (default)
28
-
29
25
  Each subagent runs as a **separate `pi` process** — fully isolated memory, its own model/tool loop.
30
26
  Processes are spawned via the operating system and communicate through JSON-line stdout.
31
27
 
32
28
  - Full OS-level isolation — a crashed subagent cannot affect the parent
33
29
  - True parallel execution across all CPU cores
34
- - Each subprocess boots a fresh Node.js runtime (adds ~200–500 ms per subagent)
35
- - Requires the `pi` binary on `PATH`
36
-
37
- ### Execution mode: `sdk`
38
-
39
- Each subagent runs as an **in-process `AgentSession`** created via the pi SDK — no new process is spawned.
40
-
41
- - No spawn overhead — sessions start in milliseconds
42
- - No temp files for system prompts
43
- - Concurrency through the Node.js event loop (fine for I/O-bound LLM work)
44
- - All sessions share the same memory and event loop
30
+ - Each subprocess boots a fresh Node.js runtime
31
+ - Uses the same Pi CLI entrypoint as the parent process when available
45
32
 
46
33
  Each subagent receives only the task string. The main agent in turn receives
47
34
  only the **final text output** from subagents (no tool calls, no reasoning).
@@ -110,31 +97,6 @@ Available tools: `read`, `bash`, `edit`, `write`.
110
97
 
111
98
  The Markdown body becomes the agent's system prompt (appended to Pi's default, not replacing it).
112
99
 
113
- ## Execution Mode
114
-
115
- | Env Var | Default | Values | Description |
116
- | --------------------- | ------------ | -------------------- | -------------------------------------- |
117
- | `PI_SUBAGENTS_MODE` | `subprocess` | `subprocess` / `sdk` | How subagent sessions are created |
118
-
119
- ```bash
120
- # Run subagents as in-process SDK sessions (faster startup, no spawn overhead)
121
- PI_SUBAGENTS_MODE=sdk pi
122
-
123
- # Run subagents as isolated subprocess pi instances (default, full isolation)
124
- PI_SUBAGENTS_MODE=subprocess pi
125
- ```
126
-
127
- ### Comparison
128
-
129
- | | `subprocess` | `sdk` |
130
- |---|---|---|
131
- | Session isolation | Full OS-level | Shared memory/event loop |
132
- | Startup overhead | ~200–500 ms per agent | ~10–50 ms per agent |
133
- | Parallelism | True OS parallelism | Event-loop concurrency |
134
- | Depth tracking | Env vars in child process | Closure parameters |
135
- | `pi` binary required | Yes | No |
136
- | Crash isolation | Yes — subprocess crash is contained | No — exception bubbles up |
137
-
138
100
  ## Delegation Guards
139
101
 
140
102
  Depth and cycle guards prevent runaway recursive delegation.
@@ -165,11 +127,7 @@ pi --no-subagent-prevent-cycles # allow cycles (not recommended)
165
127
 
166
128
  ## CLI Argument Proxying
167
129
 
168
- > **Note:** CLI argument proxying only applies to `subprocess` mode. In `sdk` mode, subagents
169
- > inherit provider and model configuration directly from the calling session's model registry
170
- > and API keys from environment variables, so no forwarding is needed.
171
-
172
- In `subprocess` mode, all flags passed to the parent `pi` process are forwarded to subagent child
130
+ Flags passed to the parent `pi` process are forwarded to subagent child
173
131
  processes, so they inherit the same provider, API key, model, and other runtime settings. Flags the
174
132
  extension manages itself are blocked from being forwarded.
175
133
 
@@ -466,7 +424,7 @@ for await (const line of jsonLines) {
466
424
 
467
425
  Note: if you also track the main agent's own usage from `message_end` events, make sure **not** to
468
426
  double-count the subagent costs there — the main agent's own token usage (from its own `message_end`
469
- events) does not include subagent work (whether subprocess or SDK-mode).
427
+ events) does not include subagent work.
470
428
 
471
429
  ---
472
430
 
package/index.ts CHANGED
@@ -1,14 +1,7 @@
1
1
  /**
2
2
  * Pi Subagent Extension
3
3
  *
4
- * Delegates tasks to specialized subagents running as in-process AgentSessions
5
- * (via the pi SDK) rather than spawning separate `pi` processes.
6
- *
7
- * Environment variables (PI_SUBAGENT_DEPTH, PI_SUBAGENT_MAX_DEPTH,
8
- * PI_SUBAGENT_STACK, PI_SUBAGENT_PREVENT_CYCLES, PI_SUBAGENT_MAX_PARALLEL_TASKS,
9
- * PI_SUBAGENT_MAX_CONCURRENCY, PI_SUBAGENT_CONFIRM_PROJECT_AGENTS) are read
10
- * here at root startup exactly as before. Depth/stack propagate to nested
11
- * sessions via closure in runner-sdk.ts instead of via subprocess env vars.
4
+ * Delegates tasks to specialized subagents running as isolated `pi` processes.
12
5
  *
13
6
  * The tool always accepts a `tasks` array:
14
7
  * - One task: treated as a single-agent delegation.
@@ -20,8 +13,11 @@ import { Type } from "@sinclair/typebox";
20
13
  import { type AgentConfig, discoverAgents } from "./agents.js";
21
14
  import { renderCall, renderResult } from "./render.js";
22
15
  import { runAgentSubprocess, executeParallelSubprocess } from "./runner.js";
23
- import { runAgentSameProcess, executeParallelSameProcess } from "./runner-sdk.js";
24
- import { parseNonNegativeInt, subagentContext } from "./shared.js";
16
+ import {
17
+ DEFAULT_MAX_PARALLEL_TASKS,
18
+ SUBAGENT_MAX_PARALLEL_TASKS_ENV,
19
+ parseNonNegativeInt,
20
+ } from "./shared.js";
25
21
 
26
22
  import {
27
23
  type DelegationMode,
@@ -45,7 +41,6 @@ const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
45
41
  const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
46
42
  const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
47
43
  const SUBAGENT_CONFIRM_PROJECT_AGENTS_ENV = "PI_SUBAGENT_CONFIRM_PROJECT_AGENTS";
48
- const SUBAGENTS_MODE_ENV = "PI_SUBAGENTS_MODE";
49
44
 
50
45
  type ProjectAgentConfirmationSetting = "ask" | "never" | "session";
51
46
  type ProjectAgentApproval = "once" | "session" | "no";
@@ -186,20 +181,6 @@ function getPreventCyclesFlagFromArgv(
186
181
  }
187
182
 
188
183
  function resolveDelegationDepthConfig(pi: ExtensionAPI): DelegationDepthConfig {
189
- // When loaded inside an in-process child session (SDK mode), the correct
190
- // depth/stack/limits are provided via AsyncLocalStorage rather than
191
- // process.env (which still holds the root process values).
192
- const inProcessCtx = subagentContext.getStore();
193
- if (inProcessCtx) {
194
- return {
195
- currentDepth: inProcessCtx.depth,
196
- maxDepth: inProcessCtx.maxDepth,
197
- canDelegate: inProcessCtx.depth < inProcessCtx.maxDepth,
198
- ancestorAgentStack: inProcessCtx.stack,
199
- preventCycles: inProcessCtx.preventCycles,
200
- };
201
- }
202
-
203
184
  const depthRaw = process.env[SUBAGENT_DEPTH_ENV];
204
185
  const parsedDepth = parseNonNegativeInt(depthRaw);
205
186
  if (depthRaw !== undefined && parsedDepth === null) {
@@ -358,38 +339,6 @@ function getProjectAgentSessionKey(projectAgentsDir: string | null): string {
358
339
  return projectAgentsDir ?? "(unknown-project-agents-dir)";
359
340
  }
360
341
 
361
- // ---------------------------------------------------------------------------
362
- // Subagent mode helpers (exported for testing and external consumers)
363
- // ---------------------------------------------------------------------------
364
-
365
- /** The two supported subagent execution modes. */
366
- export type SubagentMode = "subprocess" | "sdk";
367
-
368
- /** Default subagent execution mode. */
369
- export const DEFAULT_SUBAGENTS_MODE: SubagentMode = "subprocess";
370
-
371
- /**
372
- * Parse a raw value into a SubagentMode.
373
- * Returns null for invalid/unrecognized values, the default mode for undefined.
374
- */
375
- export function parseSubagentMode(raw: unknown): SubagentMode | null {
376
- if (raw === undefined) return DEFAULT_SUBAGENTS_MODE;
377
- if (typeof raw !== "string") return null;
378
- const normalized = raw.trim().toLowerCase();
379
- if (normalized === "subprocess") return "subprocess";
380
- if (normalized === "sdk") return "sdk";
381
- return null;
382
- }
383
-
384
- /**
385
- * Read the subagent mode from PI_SUBAGENTS_MODE env var.
386
- * Falls back to DEFAULT_SUBAGENTS_MODE if the env var is absent or invalid.
387
- */
388
- export function getSubagentMode(): SubagentMode {
389
- const parsed = parseSubagentMode(process.env["PI_SUBAGENTS_MODE"]);
390
- return parsed ?? DEFAULT_SUBAGENTS_MODE;
391
- }
392
-
393
342
  // ---------------------------------------------------------------------------
394
343
  // Extension entry point
395
344
  // ---------------------------------------------------------------------------
@@ -408,7 +357,9 @@ export default function (pi: ExtensionAPI) {
408
357
  const depthConfig = resolveDelegationDepthConfig(pi);
409
358
  const { currentDepth, maxDepth, canDelegate, ancestorAgentStack, preventCycles } =
410
359
  depthConfig;
411
- const subagentMode = getSubagentMode();
360
+ const maxParallelTasks =
361
+ parseNonNegativeInt(process.env[SUBAGENT_MAX_PARALLEL_TASKS_ENV]) ??
362
+ DEFAULT_MAX_PARALLEL_TASKS;
412
363
 
413
364
  let discoveredAgents: AgentConfig[] = [];
414
365
  const approvedProjectAgentDirsForSession = new Set<string>();
@@ -456,26 +407,25 @@ ${agentList}
456
407
 
457
408
  Each subagent runs in an **isolated process**.
458
409
 
459
- The tool always accepts a \`tasks\` array:
460
- - one item = single-agent delegation
461
- - multiple items = parallel delegation
410
+ Pass a \`tasks\` array. **Every task in the same call runs in parallel.**
411
+ - 1 task -> single delegation
412
+ - N tasks -> all N run concurrently in one call
462
413
 
463
- **Single-task delegation**:
414
+ For **sequential** work (task B needs task A's output), make separate tool
415
+ calls one after another. Do NOT put dependent tasks in the same array.
416
+
417
+ **Single (1 agent)**:
464
418
  \`\`\`json
465
419
  { "tasks": [{ "agent": "agent-name", "task": "Detailed task..." }] }
466
420
  \`\`\`
467
421
 
468
- **Multi-task delegation**:
422
+ **Parallel (N agents at once)**:
469
423
  \`\`\`json
470
- { "tasks": [{ "agent": "agent-name", "task": "..." }, { "agent": "other-agent", "task": "..." }] }
424
+ { "tasks": [{ "agent": "agent-a", "task": "..." }, { "agent": "agent-b", "task": "..." }] }
471
425
  \`\`\`
472
426
 
473
- ### Runtime delegation guards
474
-
475
427
  - Max depth: current depth ${currentDepth}, max depth ${maxDepth}
476
- - Cycle prevention: ${preventCycles ? "enabled" : "disabled"}
477
- - Current delegation stack: ${ancestorAgentStack.length > 0 ? ancestorAgentStack.join(" -> ") : "(root)"}
478
- - Execution mode: ${subagentMode === "sdk" ? "in-process SDK" : "subprocess"}
428
+ - Max subagents per tool call: ${maxParallelTasks}
479
429
  `,
480
430
  };
481
431
  } catch (err) {
@@ -489,16 +439,17 @@ The tool always accepts a \`tasks\` array:
489
439
  name: "subagent",
490
440
  label: "Subagent",
491
441
  description: [
492
- `Delegate work to specialized subagents running as ${
493
- subagentMode === "sdk" ? "in-process SDK sessions" : "isolated pi processes"
494
- }.`,
442
+ "Delegate work to specialized subagents running as isolated pi processes.",
495
443
  "",
496
- "The tool always accepts a `tasks` array:",
497
- " - one task: single-agent delegation",
498
- " - multiple tasks: parallel delegation",
444
+ "Pass a `tasks` array. Every task in the same call runs IN PARALLEL.",
445
+ " - 1 task -> single delegation",
446
+ " - N tasks -> all N run concurrently in one call",
499
447
  "",
500
- 'Example single: { tasks: [{ agent: "writer", task: "Rewrite README.md" }] }',
501
- 'Example parallel: { tasks: [{ agent: "writer", task: "..." }, { agent: "tester", task: "..." }] }',
448
+ "For sequential work (task B depends on task A's output), make separate",
449
+ "tool calls one after another. Do NOT put dependent tasks in the same array.",
450
+ "",
451
+ 'Single: { tasks: [{ agent: "writer", task: "Rewrite README.md" }] }',
452
+ 'Parallel: { tasks: [{ agent: "writer", task: "..." }, { agent: "tester", task: "..." }] }',
502
453
  ].join("\n"),
503
454
  parameters: SubagentParams,
504
455
 
@@ -618,8 +569,6 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
618
569
  signal,
619
570
  onUpdate,
620
571
  makeDetails,
621
- ctx.modelRegistry,
622
- ctx.model,
623
572
  );
624
573
  }
625
574
 
@@ -630,8 +579,6 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
630
579
  signal,
631
580
  onUpdate,
632
581
  makeDetails,
633
- ctx.modelRegistry,
634
- ctx.model,
635
582
  );
636
583
  } catch (err) {
637
584
  const msg = err instanceof Error ? err.message : String(err);
@@ -664,41 +611,21 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
664
611
  signal: AbortSignal | undefined,
665
612
  onUpdate: ((partial: any) => void) | undefined,
666
613
  makeDetails: ReturnType<typeof makeDetailsFactory>,
667
- modelRegistry: any,
668
- parentModel: any,
669
614
  ) {
670
- const result =
671
- subagentMode === "sdk"
672
- ? await runAgentSameProcess({
673
- cwd: defaultCwd,
674
- agents,
675
- agentName,
676
- task,
677
- taskCwd: cwd,
678
- parentDepth: currentDepth,
679
- parentAgentStack: ancestorAgentStack,
680
- maxDepth,
681
- preventCycles,
682
- modelRegistry,
683
- parentModel,
684
- signal,
685
- onUpdate,
686
- makeDetails: makeDetails("single"),
687
- })
688
- : await runAgentSubprocess({
689
- cwd: defaultCwd,
690
- agents,
691
- agentName,
692
- task,
693
- taskCwd: cwd,
694
- parentDepth: currentDepth,
695
- parentAgentStack: ancestorAgentStack,
696
- maxDepth,
697
- preventCycles,
698
- signal,
699
- onUpdate,
700
- makeDetails: makeDetails("single"),
701
- });
615
+ const result = await runAgentSubprocess({
616
+ cwd: defaultCwd,
617
+ agents,
618
+ agentName,
619
+ task,
620
+ taskCwd: cwd,
621
+ parentDepth: currentDepth,
622
+ parentAgentStack: ancestorAgentStack,
623
+ maxDepth,
624
+ preventCycles,
625
+ signal,
626
+ onUpdate,
627
+ makeDetails: makeDetails("single"),
628
+ });
702
629
 
703
630
  if (isResultError(result)) {
704
631
  const errorMsg =
@@ -735,25 +662,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
735
662
  signal: AbortSignal | undefined,
736
663
  onUpdate: ((partial: any) => void) | undefined,
737
664
  makeDetails: ReturnType<typeof makeDetailsFactory>,
738
- modelRegistry: any,
739
- parentModel: any,
740
665
  ) {
741
- if (subagentMode === "sdk") {
742
- return executeParallelSameProcess(
743
- tasks,
744
- agents,
745
- defaultCwd,
746
- currentDepth,
747
- maxDepth,
748
- ancestorAgentStack,
749
- preventCycles,
750
- modelRegistry,
751
- parentModel,
752
- signal,
753
- onUpdate,
754
- makeDetails("parallel"),
755
- );
756
- }
757
666
  return executeParallelSubprocess(
758
667
  tasks,
759
668
  agents,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -9,7 +9,6 @@
9
9
  "agents.ts",
10
10
  "runner.ts",
11
11
  "shared.ts",
12
- "runner-sdk.ts",
13
12
  "render.ts",
14
13
  "types.ts",
15
14
  "agents/*.md",
package/runner.ts CHANGED
@@ -81,6 +81,21 @@ function cleanupTempDir(dir: string | null): void {
81
81
  }
82
82
  }
83
83
 
84
+ function getCurrentPiCliScript(): string | null {
85
+ const script = process.argv[1];
86
+ if (!script) return null;
87
+
88
+ // When this extension is loaded by pi, process.argv[1] is the pi CLI JS
89
+ // entrypoint. Reusing it with process.execPath avoids relying on PATH while
90
+ // still running the exact same pi installation as the parent process.
91
+ const normalized = script.replace(/\\/g, "/");
92
+ if (!normalized.includes("/pi-coding-agent/") || !normalized.endsWith("/dist/cli.js")) {
93
+ return null;
94
+ }
95
+
96
+ return script;
97
+ }
98
+
84
99
  function resolveExtensionArg(value: string): string {
85
100
  if (!value) return value;
86
101
  if (value.startsWith("npm:") || value.startsWith("git:")) return value;
@@ -514,8 +529,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
514
529
  // but shell:true splits arguments on whitespace — breaking task strings.
515
530
  // Fix: reuse the running node binary + the pi CLI script path directly,
516
531
  // so the child is spawned without a shell and args are passed safely.
517
- const spawnCmd = process.platform === "win32" ? process.execPath : "pi";
518
- const spawnArgs = process.platform === "win32" ? [process.argv[1], ...piArgs] : piArgs;
532
+ const currentPiCli = getCurrentPiCliScript();
533
+ const spawnCmd = currentPiCli ? process.execPath : "pi";
534
+ const spawnArgs = currentPiCli ? [currentPiCli, ...piArgs] : piArgs;
519
535
  const proc = spawn(spawnCmd, spawnArgs, {
520
536
  cwd: taskCwd ?? cwd,
521
537
  shell: false,
@@ -705,8 +721,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
705
721
 
706
722
 
707
723
  // ---------------------------------------------------------------------------
708
- // Parallel execution (subprocess flavour)
709
- // Symmetric to executeParallelSameProcess in runner-sdk.ts but uses runAgentSubprocess().
724
+ // Parallel execution (subprocess runner).
710
725
  // ---------------------------------------------------------------------------
711
726
 
712
727
 
package/shared.ts CHANGED
@@ -1,37 +1,14 @@
1
1
  /**
2
- * Shared constants and utilities used by both runner.ts and runner-sdk.ts.
2
+ * Shared constants and utilities used by the subprocess runner.
3
3
  *
4
- * Single source of truth for parallel-execution defaults and the small helpers
5
- * that both runners need. Change a value here and it propagates everywhere.
4
+ * Single source of truth for parallel-execution defaults and helpers.
6
5
  */
7
6
 
8
- import { AsyncLocalStorage } from "node:async_hooks";
9
-
10
- // ---------------------------------------------------------------------------
11
- // In-process child session context
12
- // ---------------------------------------------------------------------------
13
-
14
- /**
15
- * Depth and delegation config for an in-process child AgentSession.
16
- *
17
- * Stored via AsyncLocalStorage so each nested session sees its own correct
18
- * values without mutating process.env. index.ts reads this first; if absent
19
- * it falls back to env vars (subprocess mode).
20
- */
21
- export interface SubagentSessionContext {
22
- depth: number;
23
- maxDepth: number;
24
- stack: string[];
25
- preventCycles: boolean;
26
- }
27
-
28
- export const subagentContext = new AsyncLocalStorage<SubagentSessionContext>();
29
-
30
7
  // ---------------------------------------------------------------------------
31
8
  // Parallel execution limits
32
9
  // ---------------------------------------------------------------------------
33
10
 
34
- export const DEFAULT_MAX_PARALLEL_TASKS = 16;
11
+ export const DEFAULT_MAX_PARALLEL_TASKS = 30;
35
12
  export const DEFAULT_MAX_CONCURRENCY = 8;
36
13
  export const PARALLEL_HEARTBEAT_MS = 1000;
37
14
  export const SUBAGENT_MAX_PARALLEL_TASKS_ENV = "PI_SUBAGENT_MAX_PARALLEL_TASKS";
package/runner-sdk.ts DELETED
@@ -1,481 +0,0 @@
1
- /**
2
- * In-process subagent runner using the pi SDK.
3
- *
4
- * Creates an isolated AgentSession per subagent call instead of spawning a
5
- * separate `pi` process. All environment variables (PI_SUBAGENT_DEPTH,
6
- * PI_SUBAGENT_MAX_DEPTH, PI_SUBAGENT_STACK, PI_SUBAGENT_PREVENT_CYCLES,
7
- * PI_SUBAGENT_MAX_PARALLEL_TASKS, PI_SUBAGENT_MAX_CONCURRENCY, …) are still
8
- * read at the root level exactly as before — they're just propagated to nested
9
- * sessions via closure parameters rather than via subprocess env inheritance.
10
- *
11
- */
12
-
13
- import {
14
- createAgentSession,
15
- DefaultResourceLoader,
16
- SessionManager,
17
- createCodingTools,
18
- createReadTool,
19
- createBashTool,
20
- createEditTool,
21
- createWriteTool,
22
- createGrepTool,
23
- createFindTool,
24
- createLsTool,
25
- type ToolDefinition,
26
- } from "@mariozechner/pi-coding-agent";
27
- import type { AgentConfig } from "./agents.js";
28
- import {
29
- type LiveLogEntry,
30
- type SingleResult,
31
- type SubagentDetails,
32
- MAX_LIVE_LOG_ENTRIES,
33
- emptyUsage,
34
- extractToolCalls,
35
- getFinalOutput,
36
- getNestedSubagentErrorSummary,
37
- isResultError,
38
- } from "./types.js";
39
- import { renderCall, renderResult } from "./render.js";
40
- import {
41
- DEFAULT_MAX_PARALLEL_TASKS,
42
- DEFAULT_MAX_CONCURRENCY,
43
- PARALLEL_HEARTBEAT_MS,
44
- SUBAGENT_MAX_PARALLEL_TASKS_ENV,
45
- SUBAGENT_MAX_CONCURRENCY_ENV,
46
- parseNonNegativeInt,
47
- mapConcurrent,
48
- subagentContext,
49
- type SubagentSessionContext,
50
- } from "./shared.js";
51
-
52
- // ---------------------------------------------------------------------------
53
- // Helpers
54
- // ---------------------------------------------------------------------------
55
-
56
-
57
- /**
58
- * Build the tool list for a child agent.
59
- * When the agent config restricts tools, only those are included.
60
- * Otherwise the full default coding tools are used.
61
- */
62
- function buildAgentTools(agent: AgentConfig, cwd: string) {
63
- if (!agent.tools || agent.tools.length === 0) {
64
- return createCodingTools(cwd);
65
- }
66
- const factories: Record<string, () => unknown> = {
67
- read: () => createReadTool(cwd),
68
- bash: () => createBashTool(cwd),
69
- edit: () => createEditTool(cwd),
70
- write: () => createWriteTool(cwd),
71
- grep: () => createGrepTool(cwd),
72
- find: () => createFindTool(cwd),
73
- ls: () => createLsTool(cwd),
74
- };
75
- const tools = agent.tools
76
- .map((name) => factories[name.toLowerCase()]?.())
77
- .filter(Boolean) as ReturnType<typeof createCodingTools>;
78
- return tools.length > 0 ? tools : createCodingTools(cwd);
79
- }
80
-
81
- /**
82
- * Search available models by id.
83
- * Falls back to undefined when the model name can't be resolved.
84
- */
85
- function resolveModelByName(modelName: string, modelRegistry: any): any {
86
- try {
87
- const available: any[] = modelRegistry.getAvailable();
88
- return available.find((m) => m.id === modelName) ?? undefined;
89
- } catch {
90
- return undefined;
91
- }
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // Public API: RunAgentSameProcessOptions / runAgentSameProcess
96
- // ---------------------------------------------------------------------------
97
-
98
- export interface RunAgentSameProcessOptions {
99
- cwd: string;
100
- agents: AgentConfig[];
101
- agentName: string;
102
- task: string;
103
- taskCwd?: string;
104
- parentDepth: number;
105
- parentAgentStack: string[];
106
- maxDepth: number;
107
- preventCycles: boolean;
108
- /** Pass ctx.modelRegistry from the calling tool's ExtensionContext */
109
- modelRegistry: any;
110
- /** Pass ctx.model from the calling tool's ExtensionContext */
111
- parentModel: any;
112
- signal?: AbortSignal;
113
- onUpdate?: (partial: any) => void;
114
- makeDetails: (results: SingleResult[]) => SubagentDetails;
115
- }
116
-
117
- /**
118
- * Run a single subagent in-process using createAgentSession().
119
- *
120
- * Replaces runner.ts::runAgentSubprocess — no subprocess is spawned.
121
- *
122
- * Environment variables (PI_SUBAGENT_DEPTH, PI_SUBAGENT_MAX_DEPTH,
123
- * PI_SUBAGENT_STACK, PI_SUBAGENT_PREVENT_CYCLES, …) are read at the root
124
- * level in index.ts exactly as before. Here depth/stack propagate via
125
- * the opts parameters and are captured in closure by child extension
126
- * factories, so nested levels always see the correct values.
127
- */
128
- export async function runAgentSameProcess(
129
- opts: RunAgentSameProcessOptions,
130
- ): Promise<SingleResult> {
131
- const {
132
- cwd,
133
- agents,
134
- agentName,
135
- task,
136
- taskCwd,
137
- parentDepth,
138
- parentAgentStack,
139
- maxDepth,
140
- preventCycles,
141
- modelRegistry,
142
- parentModel,
143
- signal,
144
- onUpdate,
145
- makeDetails,
146
- } = opts;
147
-
148
- const agent = agents.find((a) => a.name === agentName);
149
- if (!agent) {
150
- const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
151
- return {
152
- agent: agentName,
153
- agentSource: "unknown",
154
- task,
155
- exitCode: 1,
156
- messages: [],
157
- stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
158
- usage: emptyUsage(),
159
- toolCalls: {},
160
- completedTurns: 0,
161
- turnInProgress: false,
162
- liveLog: [],
163
- };
164
- }
165
-
166
- const childDepth = Math.max(0, Math.floor(parentDepth)) + 1;
167
- const childStack = [...parentAgentStack, agentName];
168
- const effectiveCwd = taskCwd ?? cwd;
169
-
170
- const result: SingleResult = {
171
- agent: agentName,
172
- agentSource: agent.source,
173
- task,
174
- exitCode: -1,
175
- messages: [],
176
- stderr: "",
177
- usage: emptyUsage(),
178
- toolCalls: {},
179
- model: agent.model,
180
- completedTurns: 0,
181
- turnInProgress: false,
182
- liveLog: [],
183
- };
184
-
185
- const emitUpdate = () =>
186
- onUpdate?.({
187
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
188
- details: makeDetails([result]),
189
- });
190
-
191
- emitUpdate();
192
-
193
- const childCtx: SubagentSessionContext = {
194
- depth: childDepth,
195
- maxDepth,
196
- stack: childStack,
197
- preventCycles,
198
- };
199
-
200
- try {
201
- // Run the child session inside an AsyncLocalStorage context so that all
202
- // extensions loaded within it — including the subagent extension itself —
203
- // see the correct depth/stack/limits for this nesting level rather than
204
- // the stale root values that live in process.env.
205
- const loader = await subagentContext.run(childCtx, async () => {
206
- const l = new DefaultResourceLoader({
207
- cwd: effectiveCwd,
208
- // Append agent system prompt on top of the base pi prompt —
209
- // matches the --append-system-prompt behaviour of the subprocess runner
210
- ...(agent.systemPrompt.trim() ? { appendSystemPrompt: agent.systemPrompt } : {}),
211
- });
212
- await l.reload();
213
- return l;
214
- });
215
-
216
- // Resolve model: agent config overrides parent fallback
217
- const model = agent.model
218
- ? (resolveModelByName(agent.model, modelRegistry) ?? parentModel)
219
- : parentModel;
220
-
221
- // Thinking level from agent config (same string values as --thinking flag)
222
- const thinkingLevel = agent.thinking as any | undefined;
223
-
224
- const sessionManager = SessionManager.inMemory();
225
-
226
- const { session } = await subagentContext.run(childCtx, () =>
227
- createAgentSession({
228
- cwd: effectiveCwd,
229
- sessionManager,
230
- resourceLoader: loader,
231
- tools: buildAgentTools(agent, effectiveCwd),
232
- modelRegistry,
233
- ...(model ? { model } : {}),
234
- ...(thinkingLevel ? { thinkingLevel } : {}),
235
- })
236
- );
237
-
238
- // Forward abort signal to the child session
239
- const onAbort = () => session.abort();
240
- signal?.addEventListener("abort", onAbort, { once: true });
241
-
242
- const pushLiveLog = (entry: LiveLogEntry) => {
243
- result.liveLog.push(entry);
244
- if (result.liveLog.length > MAX_LIVE_LOG_ENTRIES) result.liveLog.shift();
245
- };
246
-
247
- const unsub = session.subscribe((event: any) => {
248
- if (event.type === "message_end" && event.message) {
249
- result.messages.push(event.message);
250
- if (event.message.role === "assistant") {
251
- const u = event.message.usage;
252
- if (u) {
253
- result.usage.input += u.input ?? 0;
254
- result.usage.output += u.output ?? 0;
255
- result.usage.cacheRead += u.cacheRead ?? 0;
256
- result.usage.cacheWrite += u.cacheWrite ?? 0;
257
- result.usage.cost += u.cost?.total ?? 0;
258
- result.usage.contextTokens = u.totalTokens ?? 0;
259
- result.usage.turns++;
260
- }
261
- if (event.message.stopReason) result.stopReason = event.message.stopReason;
262
- if (event.message.errorMessage)
263
- result.errorMessage = event.message.errorMessage;
264
- if (!result.model && event.message.model) result.model = event.message.model;
265
- }
266
- emitUpdate();
267
-
268
- } else if (event.type === "turn_start") {
269
- result.turnInProgress = true;
270
- pushLiveLog({ kind: "turn_start" });
271
- emitUpdate();
272
-
273
- } else if (event.type === "turn_end") {
274
- result.completedTurns++;
275
- result.turnInProgress = false;
276
- const u = (event.message as any)?.usage;
277
- pushLiveLog({
278
- kind: "turn_end",
279
- turn: result.completedTurns,
280
- inputTokens: u?.input ?? 0,
281
- outputTokens: u?.output ?? 0,
282
- });
283
- emitUpdate();
284
-
285
- } else if (event.type === "tool_execution_start") {
286
- result.liveToolExecutions ??= {};
287
- result.liveToolExecutions[event.toolCallId] = {
288
- toolName: event.toolName,
289
- args: event.args,
290
- };
291
- pushLiveLog({ kind: "tool_start", toolName: event.toolName, args: event.args });
292
- emitUpdate();
293
-
294
- } else if (event.type === "tool_execution_end") {
295
- if (result.liveToolExecutions) {
296
- delete result.liveToolExecutions[event.toolCallId];
297
- }
298
- pushLiveLog({ kind: "tool_end", toolName: event.toolName });
299
- emitUpdate();
300
- }
301
- });
302
-
303
- try {
304
- await session.prompt(`Task: ${task}`);
305
- result.exitCode = 0;
306
- result.toolCalls = extractToolCalls(result.messages);
307
-
308
- if (result.exitCode === 0) {
309
- const nestedError = getNestedSubagentErrorSummary(result.messages);
310
- if (nestedError) {
311
- result.exitCode = 1;
312
- result.stopReason = "error";
313
- result.errorMessage = nestedError;
314
- if (!result.stderr.trim()) result.stderr = nestedError;
315
- }
316
- }
317
- } catch (err: any) {
318
- if (signal?.aborted) {
319
- result.exitCode = 130;
320
- result.stopReason = "aborted";
321
- result.errorMessage = "Subagent was aborted.";
322
- if (!result.stderr.trim()) result.stderr = "Subagent was aborted.";
323
- } else {
324
- result.exitCode = 1;
325
- result.stopReason = "error";
326
- result.errorMessage = err?.message ?? String(err);
327
- result.stderr = result.errorMessage ?? "";
328
- }
329
- } finally {
330
- signal?.removeEventListener("abort", onAbort);
331
- unsub();
332
- session.dispose();
333
- }
334
-
335
- return result;
336
- } catch (err: any) {
337
- // Outer safety net: catches anything thrown by loader.reload(),
338
- // createAgentSession(), or other setup code outside the inner try/catch.
339
- const msg = err instanceof Error ? err.message : String(err);
340
- result.exitCode = result.exitCode === -1 ? 1 : result.exitCode;
341
- result.stopReason = result.stopReason ?? "error";
342
- result.errorMessage = result.errorMessage ?? msg;
343
- if (!result.stderr.trim()) result.stderr = msg;
344
- return result;
345
- }
346
- }
347
-
348
- // ---------------------------------------------------------------------------
349
- // Parallel execution (shared by root index.ts and child extension factories)
350
- // ---------------------------------------------------------------------------
351
-
352
- export async function executeParallelSameProcess(
353
- tasks: Array<{ agent: string; task: string; cwd?: string }>,
354
- agents: AgentConfig[],
355
- defaultCwd: string,
356
- depth: number,
357
- maxDepth: number,
358
- stack: string[],
359
- preventCycles: boolean,
360
- modelRegistry: any,
361
- parentModel: any,
362
- signal: AbortSignal | undefined,
363
- onUpdate: ((partial: any) => void) | undefined,
364
- makeDetails: (results: SingleResult[]) => SubagentDetails,
365
- ) {
366
- // Limits still read from env vars — same as before
367
- const maxParallelTasksRaw = process.env[SUBAGENT_MAX_PARALLEL_TASKS_ENV];
368
- const maxParallelTasksParsed = parseNonNegativeInt(maxParallelTasksRaw);
369
- if (maxParallelTasksRaw !== undefined && maxParallelTasksParsed === null) {
370
- console.warn(
371
- `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_PARALLEL_TASKS_ENV}="${maxParallelTasksRaw}". Expected a non-negative integer.`,
372
- );
373
- }
374
- const maxParallelTasks = maxParallelTasksParsed ?? DEFAULT_MAX_PARALLEL_TASKS;
375
-
376
- const maxConcurrencyRaw = process.env[SUBAGENT_MAX_CONCURRENCY_ENV];
377
- const maxConcurrencyParsed = parseNonNegativeInt(maxConcurrencyRaw);
378
- if (maxConcurrencyRaw !== undefined && maxConcurrencyParsed === null) {
379
- console.warn(
380
- `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_CONCURRENCY_ENV}="${maxConcurrencyRaw}". Expected a non-negative integer.`,
381
- );
382
- }
383
- const maxConcurrency = maxConcurrencyParsed ?? DEFAULT_MAX_CONCURRENCY;
384
-
385
- if (tasks.length > maxParallelTasks) {
386
- return {
387
- content: [
388
- {
389
- type: "text" as const,
390
- text: `Too many parallel tasks (${tasks.length}). Max is ${maxParallelTasks}.`,
391
- },
392
- ],
393
- details: makeDetails([]),
394
- };
395
- }
396
-
397
- const allResults: SingleResult[] = tasks.map((t) => ({
398
- agent: t.agent,
399
- agentSource: "unknown" as const,
400
- task: t.task,
401
- exitCode: -1,
402
- messages: [],
403
- stderr: "",
404
- usage: emptyUsage(),
405
- toolCalls: {},
406
- completedTurns: 0,
407
- turnInProgress: false,
408
- liveLog: [],
409
- }));
410
-
411
- const emitProgress = () => {
412
- if (!onUpdate) return;
413
- const running = allResults.filter((r) => r.exitCode === -1).length;
414
- const done = allResults.filter((r) => r.exitCode !== -1).length;
415
- onUpdate({
416
- content: [
417
- {
418
- type: "text",
419
- text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
420
- },
421
- ],
422
- details: makeDetails([...allResults]),
423
- });
424
- };
425
-
426
- let heartbeat: NodeJS.Timeout | undefined;
427
- if (onUpdate) {
428
- emitProgress();
429
- heartbeat = setInterval(() => {
430
- if (allResults.some((r) => r.exitCode === -1)) emitProgress();
431
- }, PARALLEL_HEARTBEAT_MS);
432
- }
433
-
434
- let results: SingleResult[];
435
- try {
436
- results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
437
- const result = await runAgentSameProcess({
438
- cwd: defaultCwd,
439
- agents,
440
- agentName: t.agent,
441
- task: t.task,
442
- taskCwd: t.cwd,
443
- parentDepth: depth,
444
- parentAgentStack: stack,
445
- maxDepth,
446
- preventCycles,
447
- modelRegistry,
448
- parentModel,
449
- signal,
450
- onUpdate: (partial: any) => {
451
- if (partial.details?.results[0]) {
452
- allResults[index] = partial.details.results[0];
453
- emitProgress();
454
- }
455
- },
456
- makeDetails,
457
- });
458
- allResults[index] = result;
459
- emitProgress();
460
- return result;
461
- });
462
- } finally {
463
- if (heartbeat) clearInterval(heartbeat);
464
- }
465
-
466
- const successCount = results.filter((r) => r.exitCode === 0).length;
467
- const summaries = results.map((r) => {
468
- const output = getFinalOutput(r.messages);
469
- return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
470
- });
471
-
472
- return {
473
- content: [
474
- {
475
- type: "text" as const,
476
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
477
- },
478
- ],
479
- details: makeDetails(results),
480
- };
481
- }