pi-subagents 0.11.1 → 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,16 @@
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
+
5
15
  ## [0.11.1] - 2026-03-08
6
16
 
7
17
  ### Changed
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
@@ -83,6 +83,10 @@ function loadConfig(): ExtensionConfig {
83
83
  return {};
84
84
  }
85
85
 
86
+ function expandTilde(p: string): string {
87
+ return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
88
+ }
89
+
86
90
  /**
87
91
  * Create a directory and verify it is actually accessible.
88
92
  * On Windows with Azure AD/Entra ID, directories created shortly after
@@ -298,12 +302,17 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
298
302
  const agents = discoverAgents(ctx.cwd, scope).agents;
299
303
  const runId = randomUUID().slice(0, 8);
300
304
  const shareEnabled = params.share === true;
301
- // Session root: explicit param > derived from parent session > temp fallback
302
- // Sessions are always enabled now - stored alongside parent session for tracking
305
+ // Session root precedence: explicit param > config default > parent session derived
306
+ // Sessions are always enabled - stored alongside parent session for tracking
303
307
  // Include runId to ensure uniqueness across multiple subagent calls
304
308
  const sessionRoot = params.sessionDir
305
- ? path.resolve(params.sessionDir)
306
- : path.join(getSubagentSessionRoot(parentSessionFile), runId);
309
+ ? path.resolve(expandTilde(params.sessionDir))
310
+ : path.join(
311
+ config.defaultSessionDir
312
+ ? path.resolve(expandTilde(config.defaultSessionDir))
313
+ : getSubagentSessionRoot(parentSessionFile),
314
+ runId,
315
+ );
307
316
  try {
308
317
  fs.mkdirSync(sessionRoot, { recursive: true });
309
318
  } catch {}
@@ -320,7 +329,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
320
329
  // - Chains default to TUI (clarify: true), so async requires explicit clarify: false
321
330
  // - Single defaults to no TUI, so async is allowed unless clarify: true is passed
322
331
  const effectiveAsync = requestedAsync && !hasTasks && (
323
- hasChain
332
+ hasChain
324
333
  ? params.clarify === false // chains: only async if TUI explicitly disabled
325
334
  : params.clarify !== true // single: async unless TUI explicitly enabled
326
335
  );
@@ -539,7 +548,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
539
548
  let tasks = params.tasks.map(t => t.task);
540
549
  const modelOverrides: (string | undefined)[] = params.tasks.map(t => (t as { model?: string }).model);
541
550
  // Initialize skill overrides from task-level skill params (may be overridden by TUI)
542
- const skillOverrides: (string[] | false | undefined)[] = params.tasks.map(t =>
551
+ const skillOverrides: (string[] | false | undefined)[] = params.tasks.map(t =>
543
552
  normalizeSkillInput((t as { skill?: string | string[] | boolean }).skill)
544
553
  );
545
554
 
@@ -553,7 +562,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
553
562
  }));
554
563
 
555
564
  // Resolve behaviors with task-level skill overrides for TUI display
556
- const behaviors = agentConfigs.map((c, i) =>
565
+ const behaviors = agentConfigs.map((c, i) =>
557
566
  resolveStepBehavior(c, { skills: skillOverrides[i] })
558
567
  );
559
568
  const availableSkills = discoverAvailableSkills(ctx.cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.11.1",
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
  }