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 +24 -0
- package/README.md +41 -4
- package/async-execution.ts +64 -30
- package/chain-execution.ts +1 -1
- package/execution.ts +16 -1
- package/index.ts +90 -13
- package/package.json +11 -2
- package/parallel-utils.ts +93 -0
- package/render.ts +78 -8
- package/schemas.ts +1 -1
- package/settings.ts +16 -14
- package/skills.ts +25 -1
- package/subagent-runner.ts +360 -176
- package/utils.ts +23 -7
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
|
|
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
|
-
|
|
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 |
|
|
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)
|
package/async-execution.ts
CHANGED
|
@@ -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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
205
|
-
task:
|
|
206
|
-
|
|
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
|
}
|
package/chain-execution.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
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;
|