pi-subagents 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.0] - 2026-02-23
6
+
7
+ ### Added
8
+ - **Async parallel chain support**: Chains with `{ parallel: [...] }` steps now work in async mode. Previously they were rejected with "Async mode doesn't support chains with parallel steps." The async runner now spawns concurrent pi processes for parallel step groups with configurable `concurrency` and `failFast` options. Inspired by PR #31 from @marcfargas.
9
+ - **Comprehensive test suite**: 85 integration tests and 12 E2E tests covering all execution modes (single, parallel, chain, async), error handling, template resolution, and tool validation. Uses `@marcfargas/pi-test-harness` for subprocess mocking and in-process session testing. Thanks @marcfargas for PR #32.
10
+ - GitHub Actions CI workflow running tests on both Ubuntu and Windows with Node.js 24.
11
+
12
+ ### Changed
13
+ - **BREAKING:** `share` parameter now defaults to `false`. Previously, sessions were silently uploaded to GitHub Gists without user consent. Users who want session sharing must now explicitly pass `share: true`. Added documentation explaining what the feature does and its privacy implications.
14
+
15
+ ### Fixed
16
+ - `mapConcurrent` with `limit=0` returned array of undefined values instead of processing items sequentially. Now clamps limit to at least 1.
17
+ - ANSI background color bleed in truncated text. The `truncLine` function now properly tracks and re-applies all active ANSI styles (bold, colors, etc.) before the ellipsis, preventing style leakage. Also uses `Intl.Segmenter` for correct Unicode/emoji handling. Thanks @monotykamary for identifying the issue.
18
+ - `detectSubagentError` no longer produces false positives when the agent recovers from tool errors. Previously, any error in the last tool result would override exitCode 0→1, even if the agent had already produced complete output. Now only errors AFTER the agent's final text response are flagged. Thanks @marcfargas for the fix and comprehensive test coverage.
19
+ - Parallel mode (`tasks: [...]`) now returns aggregated output from all tasks instead of just a success count. Previously only returned "3/3 succeeded" with actual task outputs lost.
20
+ - Session sharing fallback no longer fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. The fallback now resolves the main entry point and walks up to find the package root instead of trying to resolve `package.json` directly.
21
+ - Skills from globally-installed npm packages (via `pi install npm:...`) are now discoverable by subagents. Previously only scanned local `.pi/npm/node_modules/` paths, missing the global npm root where pi actually installs packages.
22
+ - **Windows compatibility**: Fixed `ENAMETOOLONG` errors when tasks exceed command-line length limits by writing long tasks to temp files using pi's `@file` syntax. Thanks @marcfargas.
23
+ - **Windows compatibility**: Suppressed flashing console windows when spawning async runner processes (`windowsHide: true`).
24
+ - **Windows compatibility**: Fixed pi CLI resolution in async runner by passing `piPackageRoot` through to `getPiSpawnCommand`.
25
+ - **Cross-platform paths**: Replaced `startsWith("/")` checks with `path.isAbsolute()` for correct Windows absolute path detection. Replaced template string path concatenation with `path.join()` for consistent path separators.
26
+ - **Resilience**: Added error handling and auto-restart for the results directory watcher. Previously, if the directory was deleted or became inaccessible, the watcher would die silently.
27
+ - **Resilience**: Added `ensureAccessibleDir` helper that verifies directory accessibility after creation and attempts recovery if the directory has broken ACLs (can happen on Windows with Azure AD/Entra ID after wake-from-sleep).
28
+
5
29
  ## [0.9.2] - 2026-02-19
6
30
 
7
31
  ### Fixed
package/README.md CHANGED
@@ -270,10 +270,10 @@ Chains can be created from the Agents Manager template picker ("Blank Chain"), o
270
270
  | Mode | Async Support | Notes |
271
271
  |------|---------------|-------|
272
272
  | Single | Yes | `{ agent, task }` - agents with `output` write to temp dir |
273
- | Chain | Yes* | `{ chain: [{agent, task}...] }` with `{task}`, `{previous}`, `{chain_dir}` variables |
273
+ | Chain | Yes | `{ chain: [{agent, task}...] }` with `{task}`, `{previous}`, `{chain_dir}` variables |
274
274
  | Parallel | Sync only | `{ tasks: [{agent, task}...] }` - auto-downgrades if async requested |
275
275
 
276
- *Chain defaults to sync with TUI clarification. Use `clarify: false` to enable async (sequential-only chains; parallel-in-chain requires sync mode).
276
+ Chain defaults to sync with TUI clarification. Use `clarify: false` to enable async. Chains with parallel steps (`{ parallel: [...] }`) are fully supported in async mode — parallel tasks run concurrently with configurable `concurrency` and `failFast` options.
277
277
 
278
278
  **Clarify TUI for single/parallel:**
279
279
 
@@ -423,6 +423,16 @@ Skills are specialized instructions loaded from SKILL.md files and injected into
423
423
  { agent: "worker", task: "Refactor module C" }
424
424
  ], concurrency: 2, failFast: true } // limit concurrency, stop on first failure
425
425
  ]}
426
+
427
+ // Async chain with parallel step (runs in background)
428
+ { chain: [
429
+ { agent: "scout", task: "Gather context" },
430
+ { parallel: [
431
+ { agent: "worker", task: "Implement feature A based on {previous}" },
432
+ { agent: "worker", task: "Implement feature B based on {previous}" }
433
+ ]},
434
+ { agent: "reviewer", task: "Review all changes from {previous}" }
435
+ ], clarify: false, async: true }
426
436
  ```
427
437
 
428
438
  **subagent_status tool:**
@@ -514,7 +524,7 @@ Notes:
514
524
  | `maxOutput` | `{bytes?, lines?}` | 200KB, 5000 lines | Truncation limits for final output |
515
525
  | `artifacts` | boolean | true | Write debug artifacts |
516
526
  | `includeProgress` | boolean | false | Include full progress in result |
517
- | `share` | boolean | true | Create shareable session log |
527
+ | `share` | boolean | false | Upload session to GitHub Gist (see [Session Sharing](#session-sharing)) |
518
528
  | `sessionDir` | string | temp | Directory to store session logs |
519
529
 
520
530
  **ChainItem** can be either a sequential step or a parallel step:
@@ -608,6 +618,25 @@ Files per task:
608
618
 
609
619
  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>`.
610
620
 
621
+ ## Session Sharing
622
+
623
+ When `share: true` is passed, the extension will:
624
+
625
+ 1. Export the full session (all tool calls, file contents, outputs) to an HTML file
626
+ 2. Upload it to a GitHub Gist using your `gh` CLI credentials
627
+ 3. Return a shareable URL (`https://shittycodingagent.ai/session/?<gistId>`)
628
+
629
+ **This is disabled by default.** Session data may contain sensitive information like source code, file paths, environment variables, or credentials that appear in tool outputs.
630
+
631
+ To enable sharing for a specific run:
632
+ ```typescript
633
+ { agent: "scout", task: "...", share: true }
634
+ ```
635
+
636
+ Requirements:
637
+ - GitHub CLI (`gh`) must be installed and authenticated (`gh auth login`)
638
+ - Gists are created as "secret" (unlisted but accessible to anyone with the URL)
639
+
611
640
  ## Live progress (sync mode)
612
641
 
613
642
  During sync execution, the collapsed view shows real-time progress for single, chain, and parallel modes.
@@ -686,10 +715,16 @@ Async events:
686
715
  ├── artifacts.ts # Artifact management
687
716
  ├── formatters.ts # Output formatting utilities
688
717
  ├── schemas.ts # TypeBox parameter schemas
689
- ├── utils.ts # Shared utility functions
718
+ ├── utils.ts # Shared utility functions (mapConcurrent, readStatus, etc.)
690
719
  ├── types.ts # Shared types and constants
691
720
  ├── subagent-runner.ts # Async runner (detached process)
721
+ ├── parallel-utils.ts # Parallel execution utilities for async runner
722
+ ├── pi-spawn.ts # Cross-platform pi CLI spawning
723
+ ├── single-output.ts # Solo agent output file handling
692
724
  ├── notify.ts # Async completion notifications
725
+ ├── completion-dedupe.ts # Completion deduplication for notifications
726
+ ├── file-coalescer.ts # Debounced file write coalescing
727
+ ├── jsonl-writer.ts # JSONL event stream writer
693
728
  ├── agent-manager.ts # Overlay orchestrator, screen routing, CRUD
694
729
  ├── agent-manager-list.ts # List screen (search, multi-select, progressive footer)
695
730
  ├── agent-manager-detail.ts # Detail screen (resolved prompt, runs, fields)
@@ -698,6 +733,8 @@ Async events:
698
733
  ├── agent-manager-chain-detail.ts # Chain detail screen (flow visualization)
699
734
  ├── agent-management.ts # Management action handlers (list, get, create, update, delete)
700
735
  ├── agent-serializer.ts # Serialize agents to markdown frontmatter
736
+ ├── agent-scope.ts # Agent scope resolution utilities
737
+ ├── agent-selection.ts # Agent selection state management
701
738
  ├── agent-templates.ts # Agent/chain creation templates
702
739
  ├── render-helpers.ts # Shared pad/row/header/footer helpers
703
740
  ├── run-history.ts # Per-agent run recording (JSONL)
@@ -12,7 +12,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
12
  import type { AgentConfig } from "./agents.js";
13
13
  import { applyThinkingSuffix } from "./execution.js";
14
14
  import { injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.js";
15
- import { isParallelStep, resolveStepBehavior, type ChainStep, type SequentialStep, type StepOverrides } from "./settings.js";
15
+ import { isParallelStep, resolveStepBehavior, type ChainStep, type ParallelStep, type SequentialStep, type StepOverrides } from "./settings.js";
16
+ import type { RunnerStep } from "./parallel-utils.js";
16
17
  import { resolvePiPackageRoot } from "./pi-spawn.js";
17
18
  import { buildSkillInjection, normalizeSkillInput, resolveSkills } from "./skills.js";
18
19
  import {
@@ -105,6 +106,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string): number | undefin
105
106
  cwd,
106
107
  detached: true,
107
108
  stdio: "ignore",
109
+ windowsHide: true,
108
110
  });
109
111
  proc.unref();
110
112
  return proc.pid;
@@ -119,28 +121,20 @@ export function executeAsyncChain(
119
121
  ): AsyncExecutionResult {
120
122
  const { chain, agents, ctx, cwd, maxOutput, artifactsDir, artifactConfig, shareEnabled, sessionRoot } = params;
121
123
  const chainSkills = params.chainSkills ?? [];
122
-
123
- // Async mode doesn't support parallel steps (v1 limitation)
124
- const hasParallelInChain = chain.some(isParallelStep);
125
- if (hasParallelInChain) {
126
- return {
127
- content: [{ type: "text", text: "Async mode doesn't support chains with parallel steps. Use clarify: true (sync mode) for parallel-in-chain." }],
128
- isError: true,
129
- details: { mode: "chain" as const, results: [] },
130
- };
131
- }
132
-
133
- // At this point, all steps are sequential
134
- const seqSteps = chain as SequentialStep[];
135
124
 
136
125
  // Validate all agents exist before building steps
137
- for (const s of seqSteps) {
138
- if (!agents.find((x) => x.name === s.agent)) {
139
- return {
140
- content: [{ type: "text", text: `Unknown agent: ${s.agent}` }],
141
- isError: true,
142
- details: { mode: "chain" as const, results: [] },
143
- };
126
+ for (const s of chain) {
127
+ const stepAgents = isParallelStep(s)
128
+ ? s.parallel.map((t) => t.agent)
129
+ : [(s as SequentialStep).agent];
130
+ for (const agentName of stepAgents) {
131
+ if (!agents.find((x) => x.name === agentName)) {
132
+ return {
133
+ content: [{ type: "text", text: `Unknown agent: ${agentName}` }],
134
+ isError: true,
135
+ details: { mode: "chain" as const, results: [] },
136
+ };
137
+ }
144
138
  }
145
139
  }
146
140
 
@@ -149,7 +143,8 @@ export function executeAsyncChain(
149
143
  fs.mkdirSync(asyncDir, { recursive: true });
150
144
  } catch {}
151
145
 
152
- const steps = seqSteps.map((s) => {
146
+ /** Build a resolved runner step from a SequentialStep */
147
+ const buildSeqStep = (s: SequentialStep) => {
153
148
  const a = agents.find((x) => x.name === s.agent)!;
154
149
  const stepSkillInput = normalizeSkillInput(s.skill);
155
150
  const stepOverrides: StepOverrides = { skills: stepSkillInput };
@@ -162,9 +157,15 @@ export function executeAsyncChain(
162
157
  const injection = buildSkillInjection(resolvedSkills);
163
158
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection;
164
159
  }
160
+
161
+ // Resolve output path and inject instruction into task
162
+ // Use step's cwd if specified, otherwise fall back to chain-level cwd
163
+ const outputPath = resolveSingleOutputPath(s.output, ctx.cwd, s.cwd ?? cwd);
164
+ const task = injectSingleOutputInstruction(s.task ?? "{previous}", outputPath);
165
+
165
166
  return {
166
167
  agent: s.agent,
167
- task: s.task ?? "{previous}",
168
+ task,
168
169
  cwd: s.cwd,
169
170
  model: applyThinkingSuffix(s.model ?? a.model, a.thinking),
170
171
  tools: a.tools,
@@ -172,7 +173,28 @@ export function executeAsyncChain(
172
173
  mcpDirectTools: a.mcpDirectTools,
173
174
  systemPrompt,
174
175
  skills: resolvedSkills.map((r) => r.name),
176
+ outputPath,
175
177
  };
178
+ };
179
+
180
+ // Build runner steps — sequential steps become flat objects,
181
+ // parallel steps become { parallel: [...], concurrency?, failFast? }
182
+ const steps: RunnerStep[] = chain.map((s) => {
183
+ if (isParallelStep(s)) {
184
+ return {
185
+ parallel: s.parallel.map((t) => buildSeqStep({
186
+ agent: t.agent,
187
+ task: t.task,
188
+ cwd: t.cwd,
189
+ skill: t.skill,
190
+ model: t.model,
191
+ output: t.output,
192
+ })),
193
+ concurrency: s.concurrency,
194
+ failFast: s.failFast,
195
+ };
196
+ }
197
+ return buildSeqStep(s as SequentialStep);
176
198
  });
177
199
 
178
200
  const runnerCwd = cwd ?? ctx.cwd;
@@ -197,22 +219,34 @@ export function executeAsyncChain(
197
219
  );
198
220
 
199
221
  if (pid) {
200
- const firstAgent = chain[0] as SequentialStep;
222
+ const firstStep = chain[0];
223
+ const firstAgents = isParallelStep(firstStep)
224
+ ? firstStep.parallel.map((t) => t.agent)
225
+ : [(firstStep as SequentialStep).agent];
201
226
  ctx.pi.events.emit("subagent:started", {
202
227
  id,
203
228
  pid,
204
- agent: firstAgent.agent,
205
- task: firstAgent.task?.slice(0, 50),
206
- chain: chain.map((s) => (s as SequentialStep).agent),
229
+ agent: firstAgents[0],
230
+ task: isParallelStep(firstStep)
231
+ ? firstStep.parallel[0]?.task?.slice(0, 50)
232
+ : (firstStep as SequentialStep).task?.slice(0, 50),
233
+ chain: chain.map((s) =>
234
+ isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : (s as SequentialStep).agent,
235
+ ),
207
236
  cwd: runnerCwd,
208
237
  asyncDir,
209
238
  });
210
239
  }
211
240
 
241
+ // Build chain description with parallel groups shown as [agent1+agent2]
242
+ const chainDesc = chain
243
+ .map((s) =>
244
+ isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : (s as SequentialStep).agent,
245
+ )
246
+ .join(" -> ");
247
+
212
248
  return {
213
- content: [
214
- { type: "text", text: `Async chain: ${chain.map((s) => (s as SequentialStep).agent).join(" -> ")} [${id}]` },
215
- ],
249
+ content: [{ type: "text", text: `Async chain: ${chainDesc} [${id}]` }],
216
250
  details: { mode: "chain", results: [], asyncId: id, asyncDir },
217
251
  };
218
252
  }
@@ -493,7 +493,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
493
493
  // Validate expected output file was created
494
494
  if (behavior.output && r.exitCode === 0) {
495
495
  try {
496
- const expectedPath = behavior.output.startsWith("/")
496
+ const expectedPath = path.isAbsolute(behavior.output)
497
497
  ? behavior.output
498
498
  : path.join(chainDir, behavior.output);
499
499
  if (!fs.existsSync(expectedPath)) {
package/execution.ts CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { spawn } from "node:child_process";
6
6
  import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
7
9
  import type { Message } from "@mariozechner/pi-ai";
8
10
  import type { AgentConfig } from "./agents.js";
9
11
  import {
@@ -123,7 +125,20 @@ export async function runSync(
123
125
  tmpDir = tmp.dir;
124
126
  args.push("--append-system-prompt", tmp.path);
125
127
  }
126
- args.push(`Task: ${task}`);
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
+ }
127
142
 
128
143
  const result: SingleResult = {
129
144
  agent: agentName,
package/index.ts CHANGED
@@ -67,9 +67,31 @@ function loadConfig(): ExtensionConfig {
67
67
  return {};
68
68
  }
69
69
 
70
+ /**
71
+ * Create a directory and verify it is actually accessible.
72
+ * On Windows with Azure AD/Entra ID, directories created shortly after
73
+ * wake-from-sleep can end up with broken NTFS ACLs (null DACL) when the
74
+ * cloud SID cannot be resolved without network connectivity. This leaves
75
+ * the directory completely inaccessible to the creating user.
76
+ */
77
+ function ensureAccessibleDir(dirPath: string): void {
78
+ fs.mkdirSync(dirPath, { recursive: true });
79
+ try {
80
+ fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
81
+ } catch {
82
+ // Directory exists but is inaccessible — remove and recreate
83
+ try {
84
+ fs.rmSync(dirPath, { recursive: true, force: true });
85
+ } catch {}
86
+ fs.mkdirSync(dirPath, { recursive: true });
87
+ // Verify recovery succeeded
88
+ fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
89
+ }
90
+ }
91
+
70
92
  export default function registerSubagentExtension(pi: ExtensionAPI): void {
71
- fs.mkdirSync(RESULTS_DIR, { recursive: true });
72
- fs.mkdirSync(ASYNC_DIR, { recursive: true });
93
+ ensureAccessibleDir(RESULTS_DIR);
94
+ ensureAccessibleDir(ASYNC_DIR);
73
95
 
74
96
  // Cleanup old chain directories on startup (after 24h)
75
97
  cleanupOldChainDirs();
@@ -152,13 +174,42 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
152
174
  };
153
175
 
154
176
  const resultFileCoalescer = createFileCoalescer(handleResult, 50);
155
- const watcher = fs.watch(RESULTS_DIR, (ev, file) => {
156
- if (ev !== "rename" || !file) return;
157
- const fileName = file.toString();
158
- if (!fileName.endsWith(".json")) return;
159
- resultFileCoalescer.schedule(fileName);
160
- });
161
- watcher.unref?.();
177
+ let watcher: fs.FSWatcher | null = null;
178
+ let watcherRestartTimer: ReturnType<typeof setTimeout> | null = null;
179
+
180
+ function startResultWatcher(): void {
181
+ watcherRestartTimer = null;
182
+ try {
183
+ watcher = fs.watch(RESULTS_DIR, (ev, file) => {
184
+ if (ev !== "rename" || !file) return;
185
+ const fileName = file.toString();
186
+ if (!fileName.endsWith(".json")) return;
187
+ resultFileCoalescer.schedule(fileName);
188
+ });
189
+ watcher.on("error", () => {
190
+ // Watcher died (directory deleted, ACL change, etc.) — restart after delay
191
+ watcher = null;
192
+ watcherRestartTimer = setTimeout(() => {
193
+ try {
194
+ fs.mkdirSync(RESULTS_DIR, { recursive: true });
195
+ startResultWatcher();
196
+ } catch {}
197
+ }, 3000);
198
+ });
199
+ watcher.unref?.();
200
+ } catch {
201
+ // fs.watch can throw if directory is inaccessible — retry after delay
202
+ watcher = null;
203
+ watcherRestartTimer = setTimeout(() => {
204
+ try {
205
+ fs.mkdirSync(RESULTS_DIR, { recursive: true });
206
+ startResultWatcher();
207
+ } catch {}
208
+ }, 3000);
209
+ }
210
+ }
211
+
212
+ startResultWatcher();
162
213
  fs.readdirSync(RESULTS_DIR)
163
214
  .filter((f) => f.endsWith(".json"))
164
215
  .forEach((file) => resultFileCoalescer.schedule(file, 0));
@@ -229,7 +280,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
229
280
  currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
230
281
  const agents = discoverAgents(ctx.cwd, scope).agents;
231
282
  const runId = randomUUID().slice(0, 8);
232
- const shareEnabled = params.share !== false;
283
+ const shareEnabled = params.share === true;
233
284
  const sessionEnabled = shareEnabled || Boolean(params.sessionDir);
234
285
  const sessionRoot = sessionEnabled
235
286
  ? params.sessionDir
@@ -548,8 +599,32 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
548
599
 
549
600
  const ok = results.filter((r) => r.exitCode === 0).length;
550
601
  const downgradeNote = parallelDowngraded ? " (async not supported for parallel)" : "";
602
+
603
+ // Aggregate outputs from all parallel tasks
604
+ const aggregatedOutput = results
605
+ .map((r, i) => {
606
+ const header = `=== Task ${i + 1}: ${r.agent} ===`;
607
+ const output = r.truncation?.text || getFinalOutput(r.messages);
608
+ const hasOutput = Boolean(output?.trim());
609
+ const status = r.exitCode !== 0
610
+ ? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
611
+ : r.error
612
+ ? `⚠️ WARNING: ${r.error}`
613
+ : !hasOutput
614
+ ? "⚠️ EMPTY OUTPUT"
615
+ : "";
616
+ const body = status
617
+ ? (hasOutput ? `${status}\n${output}` : status)
618
+ : output;
619
+ return `${header}\n${body}`;
620
+ })
621
+ .join("\n\n");
622
+
623
+ const summary = `${ok}/${results.length} succeeded${downgradeNote}`;
624
+ const fullContent = `${summary}\n\n${aggregatedOutput}`;
625
+
551
626
  return {
552
- content: [{ type: "text", text: `${ok}/${results.length} succeeded${downgradeNote}` }],
627
+ content: [{ type: "text", text: fullContent }],
553
628
  details: {
554
629
  mode: "parallel",
555
630
  results,
@@ -851,7 +926,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
851
926
  const sessionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
852
927
  return {
853
928
  runId,
854
- shareEnabled: true,
929
+ shareEnabled: false,
855
930
  sessionDirForIndex: (idx?: number) => path.join(sessionRoot, `run-${idx ?? 0}`),
856
931
  artifactsDir: getArtifactsDir(ctx.sessionManager.getSessionFile() ?? null),
857
932
  artifactConfig: { ...DEFAULT_ARTIFACT_CONFIG } as ArtifactConfig,
@@ -1192,7 +1267,9 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
1192
1267
  }
1193
1268
  });
1194
1269
  pi.on("session_shutdown", () => {
1195
- watcher.close();
1270
+ watcher?.close();
1271
+ if (watcherRestartTimer) clearTimeout(watcherRestartTimer);
1272
+ watcherRestartTimer = null;
1196
1273
  if (poller) clearInterval(poller);
1197
1274
  poller = null;
1198
1275
  // Clear all pending cleanup timers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -34,12 +34,21 @@
34
34
  "CHANGELOG.md"
35
35
  ],
36
36
  "scripts": {
37
- "test": "node --experimental-strip-types --test *.test.ts"
37
+ "test": "node --experimental-strip-types --test *.test.ts",
38
+ "test:integration": "node --experimental-transform-types --import ./test/register-loader.mjs --test test/*.test.ts",
39
+ "test:e2e": "node --experimental-transform-types --import ./test/register-loader.mjs --test test/e2e-*.test.ts",
40
+ "test:all": "node --experimental-transform-types --import ./test/register-loader.mjs --test *.test.ts test/*.test.ts"
38
41
  },
39
42
  "pi": {
40
43
  "extensions": [
41
44
  "./index.ts",
42
45
  "./notify.ts"
43
46
  ]
47
+ },
48
+ "devDependencies": {
49
+ "@marcfargas/pi-test-harness": "^0.5.0",
50
+ "@mariozechner/pi-agent-core": "^0.54.0",
51
+ "@mariozechner/pi-ai": "^0.54.0",
52
+ "@mariozechner/pi-coding-agent": "^0.54.0"
44
53
  }
45
54
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Parallel execution utilities for the async runner.
3
+ * Kept minimal and self-contained so the standalone runner can use them
4
+ * without pulling in the full extension dependency tree.
5
+ */
6
+
7
+ /** A single agent step in the runner config */
8
+ export interface RunnerSubagentStep {
9
+ agent: string;
10
+ task: string;
11
+ cwd?: string;
12
+ model?: string;
13
+ tools?: string[];
14
+ extensions?: string[];
15
+ mcpDirectTools?: string[];
16
+ systemPrompt?: string | null;
17
+ skills?: string[];
18
+ outputPath?: string;
19
+ }
20
+
21
+ /** Parallel step group — multiple agents running concurrently */
22
+ export interface ParallelStepGroup {
23
+ parallel: RunnerSubagentStep[];
24
+ concurrency?: number;
25
+ failFast?: boolean;
26
+ }
27
+
28
+ export type RunnerStep = RunnerSubagentStep | ParallelStepGroup;
29
+
30
+ export function isParallelGroup(step: RunnerStep): step is ParallelStepGroup {
31
+ return "parallel" in step && Array.isArray((step as ParallelStepGroup).parallel);
32
+ }
33
+
34
+ /** Flatten runner steps into individual SubagentSteps for status tracking */
35
+ export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
36
+ const flat: RunnerSubagentStep[] = [];
37
+ for (const step of steps) {
38
+ if (isParallelGroup(step)) {
39
+ for (const task of step.parallel) flat.push(task);
40
+ } else {
41
+ flat.push(step);
42
+ }
43
+ }
44
+ return flat;
45
+ }
46
+
47
+ /** Run async tasks with bounded concurrency, preserving result order */
48
+ export async function mapConcurrent<T, R>(
49
+ items: T[],
50
+ limit: number,
51
+ fn: (item: T, i: number) => Promise<R>,
52
+ ): Promise<R[]> {
53
+ // Clamp to at least 1; NaN/undefined/0/negative all become 1
54
+ const safeLimit = Math.max(1, Math.floor(limit) || 1);
55
+ const results: R[] = new Array(items.length);
56
+ let next = 0;
57
+
58
+ async function worker(): Promise<void> {
59
+ while (next < items.length) {
60
+ const i = next++;
61
+ results[i] = await fn(items[i], i);
62
+ }
63
+ }
64
+
65
+ await Promise.all(
66
+ Array.from({ length: Math.min(safeLimit, items.length) }, () => worker()),
67
+ );
68
+ return results;
69
+ }
70
+
71
+ /** Aggregate outputs from parallel tasks into a single string for {previous} */
72
+ export function aggregateParallelOutputs(
73
+ results: Array<{ agent: string; output: string; exitCode: number | null; error?: string }>,
74
+ ): string {
75
+ return results
76
+ .map((r, i) => {
77
+ const header = `=== Parallel Task ${i + 1} (${r.agent}) ===`;
78
+ const hasOutput = Boolean(r.output?.trim());
79
+ const status =
80
+ r.exitCode === -1
81
+ ? "⏭️ SKIPPED"
82
+ : r.exitCode !== 0
83
+ ? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
84
+ : !hasOutput
85
+ ? "⚠️ EMPTY OUTPUT"
86
+ : "";
87
+ const body = status ? (hasOutput ? `${status}\n${r.output}` : status) : r.output;
88
+ return `${header}\n${body}`;
89
+ })
90
+ .join("\n\n");
91
+ }
92
+
93
+ export const MAX_PARALLEL_CONCURRENCY = 4;