oira666_pi-subagent 0.2.1 → 0.2.3

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,12 +13,10 @@ 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
16
  import {
25
17
  DEFAULT_MAX_PARALLEL_TASKS,
26
18
  SUBAGENT_MAX_PARALLEL_TASKS_ENV,
27
19
  parseNonNegativeInt,
28
- subagentContext,
29
20
  } from "./shared.js";
30
21
 
31
22
  import {
@@ -50,7 +41,6 @@ const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
50
41
  const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
51
42
  const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
52
43
  const SUBAGENT_CONFIRM_PROJECT_AGENTS_ENV = "PI_SUBAGENT_CONFIRM_PROJECT_AGENTS";
53
- const SUBAGENTS_MODE_ENV = "PI_SUBAGENTS_MODE";
54
44
 
55
45
  type ProjectAgentConfirmationSetting = "ask" | "never" | "session";
56
46
  type ProjectAgentApproval = "once" | "session" | "no";
@@ -191,20 +181,6 @@ function getPreventCyclesFlagFromArgv(
191
181
  }
192
182
 
193
183
  function resolveDelegationDepthConfig(pi: ExtensionAPI): DelegationDepthConfig {
194
- // When loaded inside an in-process child session (SDK mode), the correct
195
- // depth/stack/limits are provided via AsyncLocalStorage rather than
196
- // process.env (which still holds the root process values).
197
- const inProcessCtx = subagentContext.getStore();
198
- if (inProcessCtx) {
199
- return {
200
- currentDepth: inProcessCtx.depth,
201
- maxDepth: inProcessCtx.maxDepth,
202
- canDelegate: inProcessCtx.depth < inProcessCtx.maxDepth,
203
- ancestorAgentStack: inProcessCtx.stack,
204
- preventCycles: inProcessCtx.preventCycles,
205
- };
206
- }
207
-
208
184
  const depthRaw = process.env[SUBAGENT_DEPTH_ENV];
209
185
  const parsedDepth = parseNonNegativeInt(depthRaw);
210
186
  if (depthRaw !== undefined && parsedDepth === null) {
@@ -363,38 +339,6 @@ function getProjectAgentSessionKey(projectAgentsDir: string | null): string {
363
339
  return projectAgentsDir ?? "(unknown-project-agents-dir)";
364
340
  }
365
341
 
366
- // ---------------------------------------------------------------------------
367
- // Subagent mode helpers (exported for testing and external consumers)
368
- // ---------------------------------------------------------------------------
369
-
370
- /** The two supported subagent execution modes. */
371
- export type SubagentMode = "subprocess" | "sdk";
372
-
373
- /** Default subagent execution mode. */
374
- export const DEFAULT_SUBAGENTS_MODE: SubagentMode = "subprocess";
375
-
376
- /**
377
- * Parse a raw value into a SubagentMode.
378
- * Returns null for invalid/unrecognized values, the default mode for undefined.
379
- */
380
- export function parseSubagentMode(raw: unknown): SubagentMode | null {
381
- if (raw === undefined) return DEFAULT_SUBAGENTS_MODE;
382
- if (typeof raw !== "string") return null;
383
- const normalized = raw.trim().toLowerCase();
384
- if (normalized === "subprocess") return "subprocess";
385
- if (normalized === "sdk") return "sdk";
386
- return null;
387
- }
388
-
389
- /**
390
- * Read the subagent mode from PI_SUBAGENTS_MODE env var.
391
- * Falls back to DEFAULT_SUBAGENTS_MODE if the env var is absent or invalid.
392
- */
393
- export function getSubagentMode(): SubagentMode {
394
- const parsed = parseSubagentMode(process.env["PI_SUBAGENTS_MODE"]);
395
- return parsed ?? DEFAULT_SUBAGENTS_MODE;
396
- }
397
-
398
342
  // ---------------------------------------------------------------------------
399
343
  // Extension entry point
400
344
  // ---------------------------------------------------------------------------
@@ -413,7 +357,6 @@ export default function (pi: ExtensionAPI) {
413
357
  const depthConfig = resolveDelegationDepthConfig(pi);
414
358
  const { currentDepth, maxDepth, canDelegate, ancestorAgentStack, preventCycles } =
415
359
  depthConfig;
416
- const subagentMode = getSubagentMode();
417
360
  const maxParallelTasks =
418
361
  parseNonNegativeInt(process.env[SUBAGENT_MAX_PARALLEL_TASKS_ENV]) ??
419
362
  DEFAULT_MAX_PARALLEL_TASKS;
@@ -496,9 +439,7 @@ calls one after another. Do NOT put dependent tasks in the same array.
496
439
  name: "subagent",
497
440
  label: "Subagent",
498
441
  description: [
499
- `Delegate work to specialized subagents running as ${
500
- subagentMode === "sdk" ? "in-process SDK sessions" : "isolated pi processes"
501
- }.`,
442
+ "Delegate work to specialized subagents running as isolated pi processes.",
502
443
  "",
503
444
  "Pass a `tasks` array. Every task in the same call runs IN PARALLEL.",
504
445
  " - 1 task -> single delegation",
@@ -628,8 +569,6 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
628
569
  signal,
629
570
  onUpdate,
630
571
  makeDetails,
631
- ctx.modelRegistry,
632
- ctx.model,
633
572
  );
634
573
  }
635
574
 
@@ -640,8 +579,6 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
640
579
  signal,
641
580
  onUpdate,
642
581
  makeDetails,
643
- ctx.modelRegistry,
644
- ctx.model,
645
582
  );
646
583
  } catch (err) {
647
584
  const msg = err instanceof Error ? err.message : String(err);
@@ -674,41 +611,21 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
674
611
  signal: AbortSignal | undefined,
675
612
  onUpdate: ((partial: any) => void) | undefined,
676
613
  makeDetails: ReturnType<typeof makeDetailsFactory>,
677
- modelRegistry: any,
678
- parentModel: any,
679
614
  ) {
680
- const result =
681
- subagentMode === "sdk"
682
- ? await runAgentSameProcess({
683
- cwd: defaultCwd,
684
- agents,
685
- agentName,
686
- task,
687
- taskCwd: cwd,
688
- parentDepth: currentDepth,
689
- parentAgentStack: ancestorAgentStack,
690
- maxDepth,
691
- preventCycles,
692
- modelRegistry,
693
- parentModel,
694
- signal,
695
- onUpdate,
696
- makeDetails: makeDetails("single"),
697
- })
698
- : await runAgentSubprocess({
699
- cwd: defaultCwd,
700
- agents,
701
- agentName,
702
- task,
703
- taskCwd: cwd,
704
- parentDepth: currentDepth,
705
- parentAgentStack: ancestorAgentStack,
706
- maxDepth,
707
- preventCycles,
708
- signal,
709
- onUpdate,
710
- makeDetails: makeDetails("single"),
711
- });
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
+ });
712
629
 
713
630
  if (isResultError(result)) {
714
631
  const errorMsg =
@@ -745,25 +662,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
745
662
  signal: AbortSignal | undefined,
746
663
  onUpdate: ((partial: any) => void) | undefined,
747
664
  makeDetails: ReturnType<typeof makeDetailsFactory>,
748
- modelRegistry: any,
749
- parentModel: any,
750
665
  ) {
751
- if (subagentMode === "sdk") {
752
- return executeParallelSameProcess(
753
- tasks,
754
- agents,
755
- defaultCwd,
756
- currentDepth,
757
- maxDepth,
758
- ancestorAgentStack,
759
- preventCycles,
760
- modelRegistry,
761
- parentModel,
762
- signal,
763
- onUpdate,
764
- makeDetails("parallel"),
765
- );
766
- }
767
666
  return executeParallelSubprocess(
768
667
  tasks,
769
668
  agents,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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,40 @@ function cleanupTempDir(dir: string | null): void {
81
81
  }
82
82
  }
83
83
 
84
+ function resolveWorkingDirectory(input: string, fallbackBase: string): string {
85
+ if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
86
+ if (path.isAbsolute(input)) return input;
87
+ return path.resolve(fallbackBase, input);
88
+ }
89
+
90
+ function getInvalidWorkingDirectoryMessage(input: string, resolved: string): string | null {
91
+ try {
92
+ const stat = fs.statSync(resolved);
93
+ if (!stat.isDirectory()) {
94
+ return `Invalid cwd "${input}" resolved to "${resolved}", but it is not a directory.`;
95
+ }
96
+ return null;
97
+ } catch (err) {
98
+ const detail = err instanceof Error ? err.message : String(err);
99
+ return `Invalid cwd "${input}" resolved to "${resolved}": ${detail}`;
100
+ }
101
+ }
102
+
103
+ function getCurrentPiCliScript(): string | null {
104
+ const script = process.argv[1];
105
+ if (!script) return null;
106
+
107
+ // When this extension is loaded by pi, process.argv[1] is the pi CLI JS
108
+ // entrypoint. Reusing it with process.execPath avoids relying on PATH while
109
+ // still running the exact same pi installation as the parent process.
110
+ const normalized = script.replace(/\\/g, "/");
111
+ if (!normalized.includes("/pi-coding-agent/") || !normalized.endsWith("/dist/cli.js")) {
112
+ return null;
113
+ }
114
+
115
+ return script;
116
+ }
117
+
84
118
  function resolveExtensionArg(value: string): string {
85
119
  if (!value) return value;
86
120
  if (value.startsWith("npm:") || value.startsWith("git:")) return value;
@@ -489,6 +523,18 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
489
523
 
490
524
  emitUpdate();
491
525
 
526
+ const requestedCwd = taskCwd ?? cwd;
527
+ const resolvedCwd = resolveWorkingDirectory(requestedCwd, cwd);
528
+ const invalidCwdMessage = getInvalidWorkingDirectoryMessage(requestedCwd, resolvedCwd);
529
+ if (invalidCwdMessage) {
530
+ result.exitCode = 1;
531
+ result.stopReason = "error";
532
+ result.errorMessage = invalidCwdMessage;
533
+ result.stderr = invalidCwdMessage;
534
+ emitUpdate();
535
+ return result;
536
+ }
537
+
492
538
  // Write system prompt to temp file if needed
493
539
  let promptTmpDir: string | null = null;
494
540
  let promptTmpPath: string | null = null;
@@ -514,10 +560,11 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
514
560
  // but shell:true splits arguments on whitespace — breaking task strings.
515
561
  // Fix: reuse the running node binary + the pi CLI script path directly,
516
562
  // 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;
563
+ const currentPiCli = getCurrentPiCliScript();
564
+ const spawnCmd = currentPiCli ? process.execPath : "pi";
565
+ const spawnArgs = currentPiCli ? [currentPiCli, ...piArgs] : piArgs;
519
566
  const proc = spawn(spawnCmd, spawnArgs, {
520
- cwd: taskCwd ?? cwd,
567
+ cwd: resolvedCwd,
521
568
  shell: false,
522
569
  stdio: ["ignore", "pipe", "pipe"],
523
570
  env: {
@@ -651,9 +698,10 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
651
698
  });
652
699
 
653
700
  proc.on("error", (err) => {
654
- result.stderr += `Spawn error: ${err.message}`;
701
+ const message = `Failed to spawn pi process in cwd "${resolvedCwd}": ${err.message}`;
702
+ result.stderr += result.stderr ? `\n${message}` : message;
655
703
  result.stopReason = "error";
656
- result.errorMessage = `Failed to spawn pi process: ${err.message}`;
704
+ result.errorMessage = message;
657
705
  doResolve(1);
658
706
  });
659
707
 
@@ -705,8 +753,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
705
753
 
706
754
 
707
755
  // ---------------------------------------------------------------------------
708
- // Parallel execution (subprocess flavour)
709
- // Symmetric to executeParallelSameProcess in runner-sdk.ts but uses runAgentSubprocess().
756
+ // Parallel execution (subprocess runner).
710
757
  // ---------------------------------------------------------------------------
711
758
 
712
759
 
@@ -825,7 +872,8 @@ export async function executeParallelSubprocess(
825
872
  const successCount = results.filter((r) => r.exitCode === 0).length;
826
873
  const summaries = results.map((r) => {
827
874
  const output = getFinalOutput(r.messages);
828
- return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
875
+ const error = r.errorMessage || r.stderr;
876
+ return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || error || "(no output)"}`;
829
877
  });
830
878
 
831
879
  return {
package/shared.ts CHANGED
@@ -1,32 +1,9 @@
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
  // ---------------------------------------------------------------------------
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
- }