pi-subagents 0.11.2 → 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,13 @@
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
+
5
12
  ## [0.11.2] - 2026-03-11
6
13
 
7
14
  ### Fixed
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,85 +55,34 @@ 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);
115
65
 
116
- // When explicit skills are specified (via options or agent config), disable
117
- // pi's own skill discovery so the spawned process doesn't inject the full
118
- // <available_skills> catalog. This mirrors how extensions are scoped above.
119
- if (skillNames.length > 0) {
120
- args.push("--no-skills");
121
- }
122
-
123
66
  let systemPrompt = agent.systemPrompt?.trim() || "";
124
67
  if (resolvedSkills.length > 0) {
125
68
  const skillInjection = buildSkillInjection(resolvedSkills);
126
69
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${skillInjection}` : skillInjection;
127
70
  }
128
71
 
129
- let tmpDir: string | null = null;
130
- if (systemPrompt) {
131
- const tmp = writePrompt(agent.name, systemPrompt);
132
- tmpDir = tmp.dir;
133
- args.push("--append-system-prompt", tmp.path);
134
- }
135
-
136
- // When the task is too long for a CLI argument (Windows ENAMETOOLONG),
137
- // write it to a temp file and use pi's @file syntax instead.
138
- const TASK_ARG_LIMIT = 8000;
139
- if (task.length > TASK_ARG_LIMIT) {
140
- if (!tmpDir) {
141
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
142
- }
143
- const taskFilePath = path.join(tmpDir, "task.md");
144
- fs.writeFileSync(taskFilePath, `Task: ${task}`, { mode: 0o600 });
145
- args.push(`@${taskFilePath}`);
146
- } else {
147
- args.push(`Task: ${task}`);
148
- }
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
+ });
149
86
 
150
87
  const result: SingleResult = {
151
88
  agent: agentName,
@@ -187,13 +124,7 @@ export async function runSync(
187
124
  }
188
125
  }
189
126
 
190
- const spawnEnv = { ...process.env, ...getSubagentDepthEnv() };
191
- const mcpDirect = agent.mcpDirectTools;
192
- if (mcpDirect?.length) {
193
- spawnEnv.MCP_DIRECT_TOOLS = mcpDirect.join(",");
194
- } else {
195
- spawnEnv.MCP_DIRECT_TOOLS = "__none__";
196
- }
127
+ const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv() };
197
128
 
198
129
  let closeJsonlWriter: (() => Promise<void>) | undefined;
199
130
  const exitCode = await new Promise<number>((resolve) => {
@@ -379,7 +310,7 @@ export async function runSync(
379
310
  } catch {}
380
311
  }
381
312
 
382
- if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
313
+ cleanupTempDir(tempDir);
383
314
  result.exitCode = exitCode;
384
315
 
385
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
+ }