pi-subagents 0.11.1 → 0.11.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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.3] - 2026-03-17
6
+
7
+ ### Changed
8
+ - Decomposed `index.ts` (1,450 → ~350 lines) into focused modules: `subagent-executor.ts`, `async-job-tracker.ts`, `result-watcher.ts`, `slash-commands.ts`. Shared mutable state centralized in `SubagentState` interface. Three identical session handlers collapsed into one.
9
+ - Extracted shared pi CLI arg-builder (`pi-args.ts`) from duplicated logic in `execution.ts` and `subagent-runner.ts`.
10
+ - Consolidated `mapConcurrent` (canonical in `parallel-utils.ts`, re-exported from `utils.ts`), `aggregateParallelOutputs` (canonical in `parallel-utils.ts` with optional header formatter, re-exported from `settings.ts`), and `parseFrontmatter` (extracted to `frontmatter.ts`).
11
+
12
+ ## [0.11.2] - 2026-03-11
13
+
14
+ ### Fixed
15
+ - `--no-skills` was missing from the async runner (`subagent-runner.ts`). PR #41 added skill scoping to the sync path but the async runner spawns pi through its own code path, so background subagents with explicit skills still got the full `<available_skills>` catalog injected.
16
+ - `defaultSessionDir` and `sessionDir` with `~` paths (e.g. `"~/.pi/agent/sessions/subagent/"`) were not expanded — `path.resolve("~/...")` treats `~` as a literal directory name. Added tilde expansion matching the existing pattern in `skills.ts`.
17
+ - Multiple subagent calls within a session would collide when `defaultSessionDir` was configured, since it wasn't appending a unique `runId`. Both `defaultSessionDir` and parent-session-derived paths now get `runId` appended.
18
+
19
+ ### Removed
20
+ - Removed exported `resolveSessionRoot()` function and `SessionRootInput` interface. These were introduced by PR #46 but never called in production — the inline resolution logic diverged (always-on sessions, `runId` appended) making the function's contract misleading. Associated tests and dead code from PR #47 scaffolding also removed from `path-handling.test.ts`.
21
+
5
22
  ## [0.11.1] - 2026-03-08
6
23
 
7
24
  ### Changed
package/README.md CHANGED
@@ -538,7 +538,7 @@ Notes:
538
538
  | `artifacts` | boolean | true | Write debug artifacts |
539
539
  | `includeProgress` | boolean | false | Include full progress in result |
540
540
  | `share` | boolean | false | Upload session to GitHub Gist (see [Session Sharing](#session-sharing)) |
541
- | `sessionDir` | string | temp | Directory to store session logs |
541
+ | `sessionDir` | string | - | Override session log directory (takes precedence over `defaultSessionDir` and parent-session-derived path) |
542
542
 
543
543
  **ChainItem** can be either a sequential step or a parallel step:
544
544
 
@@ -604,11 +604,31 @@ Templates support three variables:
604
604
 
605
605
  This aggregated output becomes `{previous}` for the next step.
606
606
 
607
- ## Chain Directory
607
+ ## Extension Configuration
608
+
609
+ `pi-subagents` reads optional JSON config from `~/.pi/agent/extensions/subagent/config.json`.
610
+
611
+ ### `defaultSessionDir`
608
612
 
613
+ `defaultSessionDir` sets the fallback directory used for session logs. Eg:
614
+
615
+ ```json
616
+ {
617
+ "defaultSessionDir": "~/.pi/agent/sessions/subagent/"
618
+ }
619
+ ```
620
+
621
+ Session root resolution follows this precedence:
622
+ 1. `params.sessionDir` from the `subagent` tool call
623
+ 2. `config.defaultSessionDir`
624
+ 3. Derived from parent session (stored alongside parent session file)
625
+
626
+ Sessions are always enabled — every subagent run gets a session directory for tracking.
627
+
628
+ ## Chain Directory
609
629
  Each chain run creates `<tmpdir>/pi-chain-runs/{runId}/` containing:
610
630
  - `context.md` - Scout/context-builder output
611
- - `plan.md` - Planner output
631
+ - `plan.md` - Planner output
612
632
  - `progress.md` - Worker/reviewer shared progress
613
633
  - `parallel-{stepIndex}/` - Subdirectories for parallel step outputs
614
634
  - `0-{agent}/output.md` - First parallel task output
@@ -629,7 +649,7 @@ Files per task:
629
649
 
630
650
  ## Session Logs
631
651
 
632
- Session files (JSONL) are stored under a per-run session dir (temp by default). The session file path is shown in output. Set `sessionDir` to keep session logs outside `<tmpdir>`.
652
+ Session files (JSONL) are stored under a per-run session directory. Directory selection follows the same precedence as session root resolution: explicit `sessionDir` > `config.defaultSessionDir` > parent-session-derived path. The session file path is shown in output.
633
653
 
634
654
  ## Session Sharing
635
655
 
package/agents.ts CHANGED
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { KNOWN_FIELDS } from "./agent-serializer.js";
10
10
  import { parseChain } from "./chain-serializer.js";
11
11
  import { mergeAgentsForScope } from "./agent-selection.js";
12
+ import { parseFrontmatter } from "./frontmatter.js";
12
13
 
13
14
  export type AgentScope = "user" | "project" | "both";
14
15
 
@@ -58,36 +59,6 @@ export interface AgentDiscoveryResult {
58
59
  projectAgentsDir: string | null;
59
60
  }
60
61
 
61
- function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
62
- const frontmatter: Record<string, string> = {};
63
- const normalized = content.replace(/\r\n/g, "\n");
64
-
65
- if (!normalized.startsWith("---")) {
66
- return { frontmatter, body: normalized };
67
- }
68
-
69
- const endIndex = normalized.indexOf("\n---", 3);
70
- if (endIndex === -1) {
71
- return { frontmatter, body: normalized };
72
- }
73
-
74
- const frontmatterBlock = normalized.slice(4, endIndex);
75
- const body = normalized.slice(endIndex + 4).trim();
76
-
77
- for (const line of frontmatterBlock.split("\n")) {
78
- const match = line.match(/^([\w-]+):\s*(.*)$/);
79
- if (match) {
80
- let value = match[2].trim();
81
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
82
- value = value.slice(1, -1);
83
- }
84
- frontmatter[match[1]] = value;
85
- }
86
- }
87
-
88
- return { frontmatter, body };
89
- }
90
-
91
62
  function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
92
63
  const agents: AgentConfig[] = [];
93
64
 
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  import { createRequire } from "node:module";
11
11
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
12
  import type { AgentConfig } from "./agents.js";
13
- import { applyThinkingSuffix } from "./execution.js";
13
+ import { applyThinkingSuffix } from "./pi-args.js";
14
14
  import { injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.js";
15
15
  import { isParallelStep, resolveStepBehavior, type ChainStep, type ParallelStep, type SequentialStep, type StepOverrides } from "./settings.js";
16
16
  import type { RunnerStep } from "./parallel-utils.js";
@@ -0,0 +1,123 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import * as path from "node:path";
3
+ import { renderWidget } from "./render.js";
4
+ import {
5
+ type SubagentState,
6
+ POLL_INTERVAL_MS,
7
+ } from "./types.js";
8
+ import { readStatus } from "./utils.js";
9
+
10
+ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string): {
11
+ ensurePoller: () => void;
12
+ handleStarted: (data: unknown) => void;
13
+ handleComplete: (data: unknown) => void;
14
+ resetJobs: (ctx?: ExtensionContext) => void;
15
+ } {
16
+ const ensurePoller = () => {
17
+ if (state.poller) return;
18
+ state.poller = setInterval(() => {
19
+ if (!state.lastUiContext || !state.lastUiContext.hasUI) return;
20
+ if (state.asyncJobs.size === 0) {
21
+ renderWidget(state.lastUiContext, []);
22
+ if (state.poller) {
23
+ clearInterval(state.poller);
24
+ state.poller = null;
25
+ }
26
+ return;
27
+ }
28
+
29
+ for (const job of state.asyncJobs.values()) {
30
+ if (job.status === "complete" || job.status === "failed") {
31
+ continue;
32
+ }
33
+ const status = readStatus(job.asyncDir);
34
+ if (status) {
35
+ job.status = status.state;
36
+ job.mode = status.mode;
37
+ job.currentStep = status.currentStep ?? job.currentStep;
38
+ job.stepsTotal = status.steps?.length ?? job.stepsTotal;
39
+ job.startedAt = status.startedAt ?? job.startedAt;
40
+ job.updatedAt = status.lastUpdate ?? Date.now();
41
+ if (status.steps?.length) {
42
+ job.agents = status.steps.map((step) => step.agent);
43
+ }
44
+ job.sessionDir = status.sessionDir ?? job.sessionDir;
45
+ job.outputFile = status.outputFile ?? job.outputFile;
46
+ job.totalTokens = status.totalTokens ?? job.totalTokens;
47
+ job.sessionFile = status.sessionFile ?? job.sessionFile;
48
+ } else {
49
+ job.status = job.status === "queued" ? "running" : job.status;
50
+ job.updatedAt = Date.now();
51
+ }
52
+ }
53
+
54
+ renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
55
+ }, POLL_INTERVAL_MS);
56
+ state.poller.unref?.();
57
+ };
58
+
59
+ const handleStarted = (data: unknown) => {
60
+ const info = data as {
61
+ id?: string;
62
+ asyncDir?: string;
63
+ agent?: string;
64
+ chain?: string[];
65
+ };
66
+ if (!info.id) return;
67
+ const now = Date.now();
68
+ const asyncDir = info.asyncDir ?? path.join(asyncDirRoot, info.id);
69
+ const agents = info.chain && info.chain.length > 0 ? info.chain : info.agent ? [info.agent] : undefined;
70
+ state.asyncJobs.set(info.id, {
71
+ asyncId: info.id,
72
+ asyncDir,
73
+ status: "queued",
74
+ mode: info.chain ? "chain" : "single",
75
+ agents,
76
+ stepsTotal: agents?.length,
77
+ startedAt: now,
78
+ updatedAt: now,
79
+ });
80
+ if (state.lastUiContext) {
81
+ renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
82
+ ensurePoller();
83
+ }
84
+ };
85
+
86
+ const handleComplete = (data: unknown) => {
87
+ const result = data as { id?: string; success?: boolean; asyncDir?: string };
88
+ const asyncId = result.id;
89
+ if (!asyncId) return;
90
+ const job = state.asyncJobs.get(asyncId);
91
+ if (job) {
92
+ job.status = result.success ? "complete" : "failed";
93
+ job.updatedAt = Date.now();
94
+ if (result.asyncDir) job.asyncDir = result.asyncDir;
95
+ }
96
+ if (state.lastUiContext) {
97
+ renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
98
+ }
99
+ const timer = setTimeout(() => {
100
+ state.cleanupTimers.delete(asyncId);
101
+ state.asyncJobs.delete(asyncId);
102
+ if (state.lastUiContext) {
103
+ renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
104
+ }
105
+ }, 10000);
106
+ state.cleanupTimers.set(asyncId, timer);
107
+ };
108
+
109
+ const resetJobs = (ctx?: ExtensionContext) => {
110
+ for (const timer of state.cleanupTimers.values()) {
111
+ clearTimeout(timer);
112
+ }
113
+ state.cleanupTimers.clear();
114
+ state.asyncJobs.clear();
115
+ state.resultFileCoalescer.clear();
116
+ if (ctx?.hasUI) {
117
+ state.lastUiContext = ctx;
118
+ renderWidget(ctx, []);
119
+ }
120
+ };
121
+
122
+ return { ensurePoller, handleStarted, handleComplete, resetJobs };
123
+ }
@@ -1,34 +1,5 @@
1
1
  import type { ChainConfig, ChainStepConfig } from "./agents.js";
2
-
3
- function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
4
- const frontmatter: Record<string, string> = {};
5
- const normalized = content.replace(/\r\n/g, "\n");
6
-
7
- if (!normalized.startsWith("---")) {
8
- return { frontmatter, body: normalized };
9
- }
10
-
11
- const endIndex = normalized.indexOf("\n---", 3);
12
- if (endIndex === -1) {
13
- return { frontmatter, body: normalized };
14
- }
15
-
16
- const frontmatterBlock = normalized.slice(4, endIndex);
17
- const body = normalized.slice(endIndex + 4).trim();
18
-
19
- for (const line of frontmatterBlock.split("\n")) {
20
- const match = line.match(/^([\w-]+):\s*(.*)$/);
21
- if (match) {
22
- let value = match[2].trim();
23
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
24
- value = value.slice(1, -1);
25
- }
26
- frontmatter[match[1]] = value;
27
- }
28
- }
29
-
30
- return { frontmatter, body };
31
- }
2
+ import { parseFrontmatter } from "./frontmatter.js";
32
3
 
33
4
  function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
34
5
  const lines = sectionBody.split("\n");
package/execution.ts CHANGED
@@ -3,9 +3,6 @@
3
3
  */
4
4
 
5
5
  import { spawn } from "node:child_process";
6
- import * as fs from "node:fs";
7
- import * as os from "node:os";
8
- import * as path from "node:path";
9
6
  import type { Message } from "@mariozechner/pi-ai";
10
7
  import type { AgentConfig } from "./agents.js";
11
8
  import {
@@ -24,7 +21,6 @@ import {
24
21
  getSubagentDepthEnv,
25
22
  } from "./types.js";
26
23
  import {
27
- writePrompt,
28
24
  getFinalOutput,
29
25
  findLatestSessionFile,
30
26
  detectSubagentError,
@@ -34,15 +30,7 @@ import {
34
30
  import { buildSkillInjection, resolveSkills } from "./skills.js";
35
31
  import { getPiSpawnCommand } from "./pi-spawn.js";
36
32
  import { createJsonlWriter } from "./jsonl-writer.js";
37
-
38
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
39
-
40
- export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined): string | undefined {
41
- if (!model || !thinking || thinking === "off") return model;
42
- const colonIdx = model.lastIndexOf(":");
43
- if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1))) return model;
44
- return `${model}:${thinking}`;
45
- }
33
+ import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "./pi-args.js";
46
34
 
47
35
  /**
48
36
  * Run a subagent synchronously (blocking until complete)
@@ -67,48 +55,10 @@ export async function runSync(
67
55
  };
68
56
  }
69
57
 
70
- const args = ["--mode", "json", "-p"];
71
58
  const shareEnabled = options.share === true;
72
59
  const sessionEnabled = Boolean(options.sessionDir) || shareEnabled;
73
- if (!sessionEnabled) {
74
- args.push("--no-session");
75
- }
76
- if (options.sessionDir) {
77
- try {
78
- fs.mkdirSync(options.sessionDir, { recursive: true });
79
- } catch {}
80
- args.push("--session-dir", options.sessionDir);
81
- }
82
60
  const effectiveModel = modelOverride ?? agent.model;
83
61
  const modelArg = applyThinkingSuffix(effectiveModel, agent.thinking);
84
- // Use --models (not --model) because pi CLI silently ignores --model
85
- // without a companion --provider flag. --models resolves the provider
86
- // automatically via resolveModelScope. See: #8
87
- if (modelArg) args.push("--models", modelArg);
88
- const toolExtensionPaths: string[] = [];
89
- if (agent.tools?.length) {
90
- const builtinTools: string[] = [];
91
- for (const tool of agent.tools) {
92
- if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
93
- toolExtensionPaths.push(tool);
94
- } else {
95
- builtinTools.push(tool);
96
- }
97
- }
98
- if (builtinTools.length > 0) {
99
- args.push("--tools", builtinTools.join(","));
100
- }
101
- }
102
- if (agent.extensions !== undefined) {
103
- args.push("--no-extensions");
104
- for (const extPath of agent.extensions) {
105
- args.push("--extension", extPath);
106
- }
107
- } else {
108
- for (const extPath of toolExtensionPaths) {
109
- args.push("--extension", extPath);
110
- }
111
- }
112
62
 
113
63
  const skillNames = options.skills ?? agent.skills ?? [];
114
64
  const { resolved: resolvedSkills, missing: missingSkills } = resolveSkills(skillNames, runtimeCwd);
@@ -119,26 +69,20 @@ export async function runSync(
119
69
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${skillInjection}` : skillInjection;
120
70
  }
121
71
 
122
- let tmpDir: string | null = null;
123
- if (systemPrompt) {
124
- const tmp = writePrompt(agent.name, systemPrompt);
125
- tmpDir = tmp.dir;
126
- args.push("--append-system-prompt", tmp.path);
127
- }
128
-
129
- // When the task is too long for a CLI argument (Windows ENAMETOOLONG),
130
- // write it to a temp file and use pi's @file syntax instead.
131
- const TASK_ARG_LIMIT = 8000;
132
- if (task.length > TASK_ARG_LIMIT) {
133
- if (!tmpDir) {
134
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
135
- }
136
- const taskFilePath = path.join(tmpDir, "task.md");
137
- fs.writeFileSync(taskFilePath, `Task: ${task}`, { mode: 0o600 });
138
- args.push(`@${taskFilePath}`);
139
- } else {
140
- args.push(`Task: ${task}`);
141
- }
72
+ const { args, env: sharedEnv, tempDir } = buildPiArgs({
73
+ baseArgs: ["--mode", "json", "-p"],
74
+ task,
75
+ sessionEnabled,
76
+ sessionDir: options.sessionDir,
77
+ model: effectiveModel,
78
+ thinking: agent.thinking,
79
+ tools: agent.tools,
80
+ extensions: agent.extensions,
81
+ skills: skillNames,
82
+ systemPrompt,
83
+ mcpDirectTools: agent.mcpDirectTools,
84
+ promptFileStem: agent.name,
85
+ });
142
86
 
143
87
  const result: SingleResult = {
144
88
  agent: agentName,
@@ -180,13 +124,7 @@ export async function runSync(
180
124
  }
181
125
  }
182
126
 
183
- const spawnEnv = { ...process.env, ...getSubagentDepthEnv() };
184
- const mcpDirect = agent.mcpDirectTools;
185
- if (mcpDirect?.length) {
186
- spawnEnv.MCP_DIRECT_TOOLS = mcpDirect.join(",");
187
- } else {
188
- spawnEnv.MCP_DIRECT_TOOLS = "__none__";
189
- }
127
+ const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv() };
190
128
 
191
129
  let closeJsonlWriter: (() => Promise<void>) | undefined;
192
130
  const exitCode = await new Promise<number>((resolve) => {
@@ -372,7 +310,7 @@ export async function runSync(
372
310
  } catch {}
373
311
  }
374
312
 
375
- if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
313
+ cleanupTempDir(tempDir);
376
314
  result.exitCode = exitCode;
377
315
 
378
316
  if (exitCode === 0 && !result.error) {
package/frontmatter.ts ADDED
@@ -0,0 +1,29 @@
1
+ export function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
2
+ const frontmatter: Record<string, string> = {};
3
+ const normalized = content.replace(/\r\n/g, "\n");
4
+
5
+ if (!normalized.startsWith("---")) {
6
+ return { frontmatter, body: normalized };
7
+ }
8
+
9
+ const endIndex = normalized.indexOf("\n---", 3);
10
+ if (endIndex === -1) {
11
+ return { frontmatter, body: normalized };
12
+ }
13
+
14
+ const frontmatterBlock = normalized.slice(4, endIndex);
15
+ const body = normalized.slice(endIndex + 4).trim();
16
+
17
+ for (const line of frontmatterBlock.split("\n")) {
18
+ const match = line.match(/^([\w-]+):\s*(.*)$/);
19
+ if (match) {
20
+ let value = match[2].trim();
21
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
22
+ value = value.slice(1, -1);
23
+ }
24
+ frontmatter[match[1]] = value;
25
+ }
26
+ }
27
+
28
+ return { frontmatter, body };
29
+ }