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 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 | temp | Directory to store session logs |
541
+ | `sessionDir` | string | - | Override session log directory (takes precedence over `defaultSessionDir` and parent-session-derived path) |
542
542
 
543
543
  **ChainItem** can be either a sequential step or a parallel step:
544
544
 
@@ -604,11 +604,31 @@ Templates support three variables:
604
604
 
605
605
  This aggregated output becomes `{previous}` for the next step.
606
606
 
607
- ## Chain Directory
607
+ ## Extension Configuration
608
+
609
+ `pi-subagents` reads optional JSON config from `~/.pi/agent/extensions/subagent/config.json`.
610
+
611
+ ### `defaultSessionDir`
608
612
 
613
+ `defaultSessionDir` sets the fallback directory used for session logs. Eg:
614
+
615
+ ```json
616
+ {
617
+ "defaultSessionDir": "~/.pi/agent/sessions/subagent/"
618
+ }
619
+ ```
620
+
621
+ Session root resolution follows this precedence:
622
+ 1. `params.sessionDir` from the `subagent` tool call
623
+ 2. `config.defaultSessionDir`
624
+ 3. Derived from parent session (stored alongside parent session file)
625
+
626
+ Sessions are always enabled — every subagent run gets a session directory for tracking.
627
+
628
+ ## Chain Directory
609
629
  Each chain run creates `<tmpdir>/pi-chain-runs/{runId}/` containing:
610
630
  - `context.md` - Scout/context-builder output
611
- - `plan.md` - Planner output
631
+ - `plan.md` - Planner output
612
632
  - `progress.md` - Worker/reviewer shared progress
613
633
  - `parallel-{stepIndex}/` - Subdirectories for parallel step outputs
614
634
  - `0-{agent}/output.md` - First parallel task output
@@ -629,7 +649,7 @@ Files per task:
629
649
 
630
650
  ## Session Logs
631
651
 
632
- Session files (JSONL) are stored under a per-run session dir (temp by default). The session file path is shown in output. Set `sessionDir` to keep session logs outside `<tmpdir>`.
652
+ Session files (JSONL) are stored under a per-run session directory. Directory selection follows the same precedence as session root resolution: explicit `sessionDir` > `config.defaultSessionDir` > parent-session-derived path. The session file path is shown in output.
633
653
 
634
654
  ## Session Sharing
635
655
 
package/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
- currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
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
- const sessionEnabled = shareEnabled || Boolean(params.sessionDir);
285
- const sessionRoot = sessionEnabled
286
- ? params.sessionDir
287
- ? path.resolve(params.sessionDir)
288
- : fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"))
289
- : undefined;
290
- if (sessionRoot) {
291
- try {
292
- fs.mkdirSync(sessionRoot, { recursive: true });
293
- } catch {}
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
- sessionRoot ? path.join(sessionRoot, `run-${idx ?? 0}`) : undefined;
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 sessionFile = ctx.sessionManager.getSessionFile() ?? null;
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 sessionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
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(ctx.sessionManager.getSessionFile() ?? null),
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 sessionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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",
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
 
@@ -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
@@ -217,6 +217,7 @@ export interface RunSyncOptions {
217
217
 
218
218
  export interface ExtensionConfig {
219
219
  asyncByDefault?: boolean;
220
+ defaultSessionDir?: string;
220
221
  }
221
222
 
222
223
  // ============================================================================
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
  }