pi-subagents 0.11.0 → 0.11.2
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 +15 -0
- package/README.md +24 -4
- package/execution.ts +7 -0
- package/index.ts +50 -22
- package/package.json +1 -1
- package/parallel-utils.ts +9 -3
- package/skills.ts +15 -0
- package/subagent-runner.ts +4 -0
- package/types.ts +1 -0
- package/utils.ts +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.11.2] - 2026-03-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- `--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.
|
|
9
|
+
- `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`.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
12
|
+
### Removed
|
|
13
|
+
- 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`.
|
|
14
|
+
|
|
15
|
+
## [0.11.1] - 2026-03-08
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **Session persistence**: Subagent sessions are now stored alongside the parent session file instead of in `/tmp`. If the parent session is `~/.pi/agent/sessions/abc123.jsonl`, subagent sessions go to `~/.pi/agent/sessions/abc123/{runId}/run-{N}/`. This enables tracking subagent performance over time, analyzing token usage patterns, and debugging past delegations. Falls back to a unique temp directory when no parent session exists (API/headless mode).
|
|
19
|
+
|
|
5
20
|
## [0.11.0] - 2026-02-23
|
|
6
21
|
|
|
7
22
|
### Added
|
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 |
|
|
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
|
-
##
|
|
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
|
|
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/execution.ts
CHANGED
|
@@ -113,6 +113,13 @@ export async function runSync(
|
|
|
113
113
|
const skillNames = options.skills ?? agent.skills ?? [];
|
|
114
114
|
const { resolved: resolvedSkills, missing: missingSkills } = resolveSkills(skillNames, runtimeCwd);
|
|
115
115
|
|
|
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
|
+
|
|
116
123
|
let systemPrompt = agent.systemPrompt?.trim() || "";
|
|
117
124
|
if (resolvedSkills.length > 0) {
|
|
118
125
|
const skillInjection = buildSkillInjection(resolvedSkills);
|
package/index.ts
CHANGED
|
@@ -57,6 +57,22 @@ import { handleManagementAction } from "./agent-management.js";
|
|
|
57
57
|
|
|
58
58
|
// ExtensionConfig is now imported from ./types.js
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Derive subagent session base directory from parent session file.
|
|
62
|
+
* If parent session is ~/.pi/agent/sessions/abc123.jsonl,
|
|
63
|
+
* returns ~/.pi/agent/sessions/abc123/ as the base.
|
|
64
|
+
* Callers add runId to create the actual session root: abc123/{runId}/
|
|
65
|
+
* Falls back to a unique temp directory if no parent session.
|
|
66
|
+
*/
|
|
67
|
+
function getSubagentSessionRoot(parentSessionFile: string | null): string {
|
|
68
|
+
if (parentSessionFile) {
|
|
69
|
+
const baseName = path.basename(parentSessionFile, ".jsonl");
|
|
70
|
+
const sessionsDir = path.dirname(parentSessionFile);
|
|
71
|
+
return path.join(sessionsDir, baseName);
|
|
72
|
+
}
|
|
73
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
function loadConfig(): ExtensionConfig {
|
|
61
77
|
const configPath = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent", "config.json");
|
|
62
78
|
try {
|
|
@@ -67,6 +83,10 @@ function loadConfig(): ExtensionConfig {
|
|
|
67
83
|
return {};
|
|
68
84
|
}
|
|
69
85
|
|
|
86
|
+
function expandTilde(p: string): string {
|
|
87
|
+
return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
/**
|
|
71
91
|
* Create a directory and verify it is actually accessible.
|
|
72
92
|
* On Windows with Azure AD/Entra ID, directories created shortly after
|
|
@@ -277,23 +297,27 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
277
297
|
}
|
|
278
298
|
|
|
279
299
|
const scope: AgentScope = resolveExecutionAgentScope(params.agentScope);
|
|
280
|
-
|
|
300
|
+
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
301
|
+
currentSessionId = parentSessionFile ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
281
302
|
const agents = discoverAgents(ctx.cwd, scope).agents;
|
|
282
303
|
const runId = randomUUID().slice(0, 8);
|
|
283
304
|
const shareEnabled = params.share === true;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
:
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
305
|
+
// Session root precedence: explicit param > config default > parent session derived
|
|
306
|
+
// Sessions are always enabled - stored alongside parent session for tracking
|
|
307
|
+
// Include runId to ensure uniqueness across multiple subagent calls
|
|
308
|
+
const sessionRoot = params.sessionDir
|
|
309
|
+
? path.resolve(expandTilde(params.sessionDir))
|
|
310
|
+
: path.join(
|
|
311
|
+
config.defaultSessionDir
|
|
312
|
+
? path.resolve(expandTilde(config.defaultSessionDir))
|
|
313
|
+
: getSubagentSessionRoot(parentSessionFile),
|
|
314
|
+
runId,
|
|
315
|
+
);
|
|
316
|
+
try {
|
|
317
|
+
fs.mkdirSync(sessionRoot, { recursive: true });
|
|
318
|
+
} catch {}
|
|
295
319
|
const sessionDirForIndex = (idx?: number) =>
|
|
296
|
-
|
|
320
|
+
path.join(sessionRoot, `run-${idx ?? 0}`);
|
|
297
321
|
|
|
298
322
|
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
299
323
|
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
@@ -305,7 +329,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
305
329
|
// - Chains default to TUI (clarify: true), so async requires explicit clarify: false
|
|
306
330
|
// - Single defaults to no TUI, so async is allowed unless clarify: true is passed
|
|
307
331
|
const effectiveAsync = requestedAsync && !hasTasks && (
|
|
308
|
-
hasChain
|
|
332
|
+
hasChain
|
|
309
333
|
? params.clarify === false // chains: only async if TUI explicitly disabled
|
|
310
334
|
: params.clarify !== true // single: async unless TUI explicitly enabled
|
|
311
335
|
);
|
|
@@ -315,8 +339,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
315
339
|
enabled: params.artifacts !== false,
|
|
316
340
|
};
|
|
317
341
|
|
|
318
|
-
const
|
|
319
|
-
const artifactsDir = effectiveAsync ? tempArtifactsDir : getArtifactsDir(sessionFile);
|
|
342
|
+
const artifactsDir = effectiveAsync ? tempArtifactsDir : getArtifactsDir(parentSessionFile);
|
|
320
343
|
|
|
321
344
|
if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
322
345
|
return {
|
|
@@ -525,7 +548,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
525
548
|
let tasks = params.tasks.map(t => t.task);
|
|
526
549
|
const modelOverrides: (string | undefined)[] = params.tasks.map(t => (t as { model?: string }).model);
|
|
527
550
|
// Initialize skill overrides from task-level skill params (may be overridden by TUI)
|
|
528
|
-
const skillOverrides: (string[] | false | undefined)[] = params.tasks.map(t =>
|
|
551
|
+
const skillOverrides: (string[] | false | undefined)[] = params.tasks.map(t =>
|
|
529
552
|
normalizeSkillInput((t as { skill?: string | string[] | boolean }).skill)
|
|
530
553
|
);
|
|
531
554
|
|
|
@@ -539,7 +562,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
539
562
|
}));
|
|
540
563
|
|
|
541
564
|
// Resolve behaviors with task-level skill overrides for TUI display
|
|
542
|
-
const behaviors = agentConfigs.map((c, i) =>
|
|
565
|
+
const behaviors = agentConfigs.map((c, i) =>
|
|
543
566
|
resolveStepBehavior(c, { skills: skillOverrides[i] })
|
|
544
567
|
);
|
|
545
568
|
const availableSkills = discoverAvailableSkills(ctx.cwd);
|
|
@@ -1019,12 +1042,16 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
1019
1042
|
|
|
1020
1043
|
const setupDirectRun = (ctx: ExtensionContext) => {
|
|
1021
1044
|
const runId = randomUUID().slice(0, 8);
|
|
1022
|
-
const
|
|
1045
|
+
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
1046
|
+
const sessionRoot = path.join(getSubagentSessionRoot(parentSessionFile), runId);
|
|
1047
|
+
try {
|
|
1048
|
+
fs.mkdirSync(sessionRoot, { recursive: true });
|
|
1049
|
+
} catch {}
|
|
1023
1050
|
return {
|
|
1024
1051
|
runId,
|
|
1025
1052
|
shareEnabled: false,
|
|
1026
1053
|
sessionDirForIndex: (idx?: number) => path.join(sessionRoot, `run-${idx ?? 0}`),
|
|
1027
|
-
artifactsDir: getArtifactsDir(
|
|
1054
|
+
artifactsDir: getArtifactsDir(parentSessionFile),
|
|
1028
1055
|
artifactConfig: { ...DEFAULT_ARTIFACT_CONFIG } as ArtifactConfig,
|
|
1029
1056
|
};
|
|
1030
1057
|
};
|
|
@@ -1085,7 +1112,8 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
1085
1112
|
}
|
|
1086
1113
|
const id = randomUUID();
|
|
1087
1114
|
const asyncCtx = { pi, cwd: ctx.cwd, currentSessionId: ctx.sessionManager.getSessionId() ?? id };
|
|
1088
|
-
const
|
|
1115
|
+
const asyncSessionRoot = getSubagentSessionRoot(ctx.sessionManager.getSessionFile() ?? null);
|
|
1116
|
+
try { fs.mkdirSync(asyncSessionRoot, { recursive: true }); } catch {}
|
|
1089
1117
|
executeAsyncChain(id, {
|
|
1090
1118
|
chain: r.requestedAsync.chain,
|
|
1091
1119
|
agents,
|
|
@@ -1094,7 +1122,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
|
|
|
1094
1122
|
artifactsDir: exec.artifactsDir,
|
|
1095
1123
|
artifactConfig: exec.artifactConfig,
|
|
1096
1124
|
shareEnabled: false,
|
|
1097
|
-
sessionRoot,
|
|
1125
|
+
sessionRoot: asyncSessionRoot,
|
|
1098
1126
|
chainSkills: r.requestedAsync.chainSkills,
|
|
1099
1127
|
}).then((asyncResult) => {
|
|
1100
1128
|
pi.sendUserMessage(asyncResult.content[0]?.text || "(launched in background)");
|
package/package.json
CHANGED
package/parallel-utils.ts
CHANGED
|
@@ -44,18 +44,24 @@ export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
|
|
|
44
44
|
return flat;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/** Run async tasks with bounded concurrency, preserving result order
|
|
47
|
+
/** Run async tasks with bounded concurrency, preserving result order.
|
|
48
|
+
* `staggerMs` adds a small delay between each worker's start to avoid
|
|
49
|
+
* file-lock contention when multiple subagents read shared config. */
|
|
48
50
|
export async function mapConcurrent<T, R>(
|
|
49
51
|
items: T[],
|
|
50
52
|
limit: number,
|
|
51
53
|
fn: (item: T, i: number) => Promise<R>,
|
|
54
|
+
staggerMs = 150,
|
|
52
55
|
): Promise<R[]> {
|
|
53
56
|
// Clamp to at least 1; NaN/undefined/0/negative all become 1
|
|
54
57
|
const safeLimit = Math.max(1, Math.floor(limit) || 1);
|
|
55
58
|
const results: R[] = new Array(items.length);
|
|
56
59
|
let next = 0;
|
|
57
60
|
|
|
58
|
-
async function worker(): Promise<void> {
|
|
61
|
+
async function worker(workerIndex: number): Promise<void> {
|
|
62
|
+
if (staggerMs > 0 && workerIndex > 0) {
|
|
63
|
+
await new Promise((r) => setTimeout(r, workerIndex * staggerMs));
|
|
64
|
+
}
|
|
59
65
|
while (next < items.length) {
|
|
60
66
|
const i = next++;
|
|
61
67
|
results[i] = await fn(items[i], i);
|
|
@@ -63,7 +69,7 @@ export async function mapConcurrent<T, R>(
|
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
await Promise.all(
|
|
66
|
-
Array.from({ length: Math.min(safeLimit, items.length) }, () => worker()),
|
|
72
|
+
Array.from({ length: Math.min(safeLimit, items.length) }, (_, wi) => worker(wi)),
|
|
67
73
|
);
|
|
68
74
|
return results;
|
|
69
75
|
}
|
package/skills.ts
CHANGED
|
@@ -347,6 +347,21 @@ export function normalizeSkillInput(
|
|
|
347
347
|
if (Array.isArray(input)) {
|
|
348
348
|
return [...new Set(input.map((s) => s.trim()).filter((s) => s.length > 0))];
|
|
349
349
|
}
|
|
350
|
+
// Guard against JSON-encoded arrays arriving as strings (e.g. '["a","b"]').
|
|
351
|
+
// Models sometimes serialise the skill parameter as a JSON string instead of
|
|
352
|
+
// a native array, and naively splitting on "," would embed brackets/quotes
|
|
353
|
+
// into the skill names, causing resolution to silently fail.
|
|
354
|
+
const trimmed = input.trim();
|
|
355
|
+
if (trimmed.startsWith("[")) {
|
|
356
|
+
try {
|
|
357
|
+
const parsed = JSON.parse(trimmed);
|
|
358
|
+
if (Array.isArray(parsed)) {
|
|
359
|
+
return normalizeSkillInput(parsed);
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
// Not valid JSON – fall through to comma-split
|
|
363
|
+
}
|
|
364
|
+
}
|
|
350
365
|
return [...new Set(input.split(",").map((s) => s.trim()).filter((s) => s.length > 0))];
|
|
351
366
|
}
|
|
352
367
|
|
package/subagent-runner.ts
CHANGED
|
@@ -304,6 +304,10 @@ async function runSingleStep(
|
|
|
304
304
|
for (const extPath of toolExtensionPaths) args.push("--extension", extPath);
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
if (step.skills?.length) {
|
|
308
|
+
args.push("--no-skills");
|
|
309
|
+
}
|
|
310
|
+
|
|
307
311
|
let tmpDir: string | null = null;
|
|
308
312
|
if (step.systemPrompt) {
|
|
309
313
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
|
package/types.ts
CHANGED
package/utils.ts
CHANGED
|
@@ -332,20 +332,24 @@ export async function mapConcurrent<T, R>(
|
|
|
332
332
|
items: T[],
|
|
333
333
|
limit: number,
|
|
334
334
|
fn: (item: T, i: number) => Promise<R>,
|
|
335
|
+
staggerMs = 150,
|
|
335
336
|
): Promise<R[]> {
|
|
336
337
|
// Clamp to at least 1; NaN/undefined/0/negative all become 1
|
|
337
338
|
const safeLimit = Math.max(1, Math.floor(limit) || 1);
|
|
338
339
|
const results: R[] = new Array(items.length);
|
|
339
340
|
let next = 0;
|
|
340
341
|
|
|
341
|
-
async function worker(): Promise<void> {
|
|
342
|
+
async function worker(workerIndex: number): Promise<void> {
|
|
343
|
+
if (staggerMs > 0 && workerIndex > 0) {
|
|
344
|
+
await new Promise((r) => setTimeout(r, workerIndex * staggerMs));
|
|
345
|
+
}
|
|
342
346
|
while (next < items.length) {
|
|
343
347
|
const i = next++;
|
|
344
348
|
results[i] = await fn(items[i], i);
|
|
345
349
|
}
|
|
346
350
|
}
|
|
347
351
|
|
|
348
|
-
const workers = Array.from({ length: Math.min(safeLimit, items.length) }, () => worker());
|
|
352
|
+
const workers = Array.from({ length: Math.min(safeLimit, items.length) }, (_, wi) => worker(wi));
|
|
349
353
|
await Promise.all(workers);
|
|
350
354
|
return results;
|
|
351
355
|
}
|