pi-subagents 0.28.0 → 0.30.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +26 -62
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +29 -35
  5. package/src/agents/agent-management.ts +29 -22
  6. package/src/agents/agent-selection.ts +2 -0
  7. package/src/agents/agent-serializer.ts +5 -10
  8. package/src/agents/agents.ts +339 -47
  9. package/src/agents/chain-serializer.ts +4 -9
  10. package/src/agents/proactive-skills.ts +191 -0
  11. package/src/extension/doctor.ts +4 -3
  12. package/src/extension/fanout-child.ts +1 -3
  13. package/src/extension/index.ts +6 -9
  14. package/src/extension/schemas.ts +63 -26
  15. package/src/intercom/intercom-bridge.ts +11 -1
  16. package/src/intercom/result-intercom.ts +0 -5
  17. package/src/runs/background/async-execution.ts +186 -74
  18. package/src/runs/background/async-resume.ts +53 -5
  19. package/src/runs/background/async-status.ts +4 -1
  20. package/src/runs/background/chain-append.ts +282 -0
  21. package/src/runs/background/chain-root-attachment.ts +161 -0
  22. package/src/runs/background/run-status.ts +2 -7
  23. package/src/runs/background/subagent-runner.ts +160 -219
  24. package/src/runs/foreground/chain-execution.ts +62 -58
  25. package/src/runs/foreground/execution.ts +39 -343
  26. package/src/runs/foreground/subagent-executor.ts +316 -111
  27. package/src/runs/shared/acceptance.ts +605 -22
  28. package/src/runs/shared/chain-outputs.ts +23 -8
  29. package/src/runs/shared/completion-guard.ts +3 -26
  30. package/src/runs/shared/dynamic-fanout.ts +1 -1
  31. package/src/runs/shared/model-fallback.ts +38 -0
  32. package/src/runs/shared/parallel-utils.ts +13 -10
  33. package/src/runs/shared/pi-args.ts +3 -2
  34. package/src/runs/shared/subagent-control.ts +8 -11
  35. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  36. package/src/runs/shared/workflow-graph.ts +2 -6
  37. package/src/shared/atomic-json.ts +68 -11
  38. package/src/shared/settings.ts +1 -0
  39. package/src/shared/types.ts +20 -49
  40. package/src/shared/utils.ts +2 -8
  41. package/src/slash/slash-bridge.ts +3 -1
  42. package/src/slash/slash-commands.ts +1 -1
  43. package/src/tui/render.ts +14 -29
  44. package/src/runs/shared/acceptance-contract.ts +0 -318
  45. package/src/runs/shared/acceptance-evaluation.ts +0 -221
  46. package/src/runs/shared/acceptance-finalization.ts +0 -173
  47. package/src/runs/shared/acceptance-reports.ts +0 -127
@@ -2,6 +2,7 @@
2
2
  * Agent discovery and configuration
3
3
  */
4
4
 
5
+ import { execSync } from "node:child_process";
5
6
  import * as fs from "node:fs";
6
7
  import * as os from "node:os";
7
8
  import * as path from "node:path";
@@ -17,7 +18,7 @@ export { buildRuntimeName, frontmatterNameForConfig, parsePackageName } from "./
17
18
 
18
19
  export type AgentScope = "user" | "project" | "both";
19
20
 
20
- export type AgentSource = "builtin" | "user" | "project";
21
+ export type AgentSource = "builtin" | "package" | "user" | "project";
21
22
  type SystemPromptMode = "append" | "replace";
22
23
  export type AgentDefaultContext = "fresh" | "fork";
23
24
 
@@ -46,8 +47,7 @@ export interface BuiltinAgentOverrideBase {
46
47
  skills?: string[];
47
48
  tools?: string[];
48
49
  mcpDirectTools?: string[];
49
- maxExecutionTimeMs?: number;
50
- maxTokens?: number;
50
+ subagentOnlyExtensions?: string[];
51
51
  completionGuard?: boolean;
52
52
  }
53
53
 
@@ -63,8 +63,7 @@ interface BuiltinAgentOverrideConfig {
63
63
  systemPrompt?: string;
64
64
  skills?: string[] | false;
65
65
  tools?: string[] | false;
66
- maxExecutionTimeMs?: number | false;
67
- maxTokens?: number | false;
66
+ subagentOnlyExtensions?: string[] | false;
68
67
  completionGuard?: boolean;
69
68
  }
70
69
 
@@ -93,13 +92,12 @@ export interface AgentConfig {
93
92
  filePath: string;
94
93
  skills?: string[];
95
94
  extensions?: string[];
95
+ subagentOnlyExtensions?: string[];
96
96
  output?: string;
97
97
  defaultReads?: string[];
98
98
  defaultProgress?: boolean;
99
99
  interactive?: boolean;
100
100
  maxSubagentDepth?: number;
101
- maxExecutionTimeMs?: number;
102
- maxTokens?: number;
103
101
  completionGuard?: boolean;
104
102
  disabled?: boolean;
105
103
  extraFields?: Record<string, string>;
@@ -147,7 +145,7 @@ export interface ChainConfig {
147
145
  }
148
146
 
149
147
  export interface ChainDiscoveryDiagnostic {
150
- source: "user" | "project";
148
+ source: AgentSource;
151
149
  filePath: string;
152
150
  error: string;
153
151
  }
@@ -161,6 +159,259 @@ function getUserChainDir(): string {
161
159
  return path.join(getAgentDir(), "chains");
162
160
  }
163
161
 
162
+ interface PackageSubagentPaths {
163
+ agents: string[];
164
+ chains: string[];
165
+ }
166
+
167
+ let cachedGlobalNpmRoot: string | null = null;
168
+
169
+ function readJsonFileBestEffort(filePath: string): unknown {
170
+ try {
171
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
172
+ } catch {
173
+ // Installed package scans are opportunistic; bad third-party manifests
174
+ // should not break local agent discovery.
175
+ return null;
176
+ }
177
+ }
178
+
179
+ function readOptionalJsonFile(filePath: string): unknown {
180
+ try {
181
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
182
+ } catch (error) {
183
+ const code = typeof error === "object" && error !== null && "code" in error
184
+ ? (error as { code?: unknown }).code
185
+ : undefined;
186
+ if (code === "ENOENT") return null;
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ function isSafePackagePath(value: string): boolean {
192
+ return value.length > 0
193
+ && !path.isAbsolute(value)
194
+ && value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
195
+ }
196
+
197
+ function parseNpmPackageName(source: string): string | undefined {
198
+ const spec = source.slice(4).trim();
199
+ if (!spec) return undefined;
200
+ const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
201
+ const packageName = match?.[1] ?? spec;
202
+ return isSafePackagePath(packageName) ? packageName : undefined;
203
+ }
204
+
205
+ function stripGitRef(repoPath: string): string {
206
+ const atIndex = repoPath.indexOf("@");
207
+ const hashIndex = repoPath.indexOf("#");
208
+ const refIndex = [atIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
209
+ return refIndex === undefined ? repoPath : repoPath.slice(0, refIndex);
210
+ }
211
+
212
+ function parseGitPackagePath(source: string): { host: string; repoPath: string } | undefined {
213
+ const spec = source.slice(4).trim();
214
+ if (!spec) return undefined;
215
+
216
+ let host = "";
217
+ let repoPath = "";
218
+ const scpLike = spec.match(/^git@([^:]+):(.+)$/);
219
+ if (scpLike) {
220
+ host = scpLike[1] ?? "";
221
+ repoPath = scpLike[2] ?? "";
222
+ } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec)) {
223
+ try {
224
+ const url = new URL(spec);
225
+ host = url.hostname;
226
+ repoPath = url.pathname.replace(/^\/+/, "");
227
+ } catch {
228
+ return undefined;
229
+ }
230
+ } else {
231
+ const slashIndex = spec.indexOf("/");
232
+ if (slashIndex < 0) return undefined;
233
+ host = spec.slice(0, slashIndex);
234
+ repoPath = spec.slice(slashIndex + 1);
235
+ }
236
+
237
+ const normalizedPath = stripGitRef(repoPath).replace(/\.git$/, "").replace(/^\/+/, "");
238
+ if (!host || !isSafePackagePath(host) || !isSafePackagePath(normalizedPath) || normalizedPath.split(/[\\/]/).length < 2) {
239
+ return undefined;
240
+ }
241
+ return { host, repoPath: normalizedPath };
242
+ }
243
+
244
+ function resolveSettingsPackageRoot(source: string, baseDir: string): string | undefined {
245
+ const trimmed = source.trim();
246
+ if (!trimmed) return undefined;
247
+ if (trimmed.startsWith("git:")) {
248
+ const parsed = parseGitPackagePath(trimmed);
249
+ return parsed ? path.join(baseDir, "git", parsed.host, parsed.repoPath) : undefined;
250
+ }
251
+ if (trimmed.startsWith("npm:")) {
252
+ const packageName = parseNpmPackageName(trimmed);
253
+ return packageName ? path.join(baseDir, "npm", "node_modules", packageName) : undefined;
254
+ }
255
+ const normalized = trimmed.startsWith("file:") ? trimmed.slice(5) : trimmed;
256
+ if (normalized === "~") return os.homedir();
257
+ if (normalized.startsWith("~/")) return path.join(os.homedir(), normalized.slice(2));
258
+ if (path.isAbsolute(normalized)) return normalized;
259
+ if (normalized === "." || normalized === ".." || normalized.startsWith("./") || normalized.startsWith("../")) {
260
+ return path.resolve(baseDir, normalized);
261
+ }
262
+ return undefined;
263
+ }
264
+
265
+ function getGlobalNpmRoot(): string | null {
266
+ if (cachedGlobalNpmRoot !== null) return cachedGlobalNpmRoot;
267
+ try {
268
+ cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim());
269
+ return cachedGlobalNpmRoot;
270
+ } catch {
271
+ cachedGlobalNpmRoot = "";
272
+ return null;
273
+ }
274
+ }
275
+
276
+ function stringArray(value: unknown): string[] {
277
+ if (!Array.isArray(value)) return [];
278
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
279
+ }
280
+
281
+ function extractSubagentPathsFromPackageRoot(packageRoot: string): PackageSubagentPaths {
282
+ const packageJsonPath = path.join(packageRoot, "package.json");
283
+ const pkg = readJsonFileBestEffort(packageJsonPath);
284
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) return { agents: [], chains: [] };
285
+
286
+ const roots: Record<string, unknown>[] = [];
287
+ const piSubagents = (pkg as { "pi-subagents"?: unknown })["pi-subagents"];
288
+ if (piSubagents && typeof piSubagents === "object" && !Array.isArray(piSubagents)) {
289
+ roots.push(piSubagents as Record<string, unknown>);
290
+ }
291
+
292
+ const pi = (pkg as { pi?: unknown }).pi;
293
+ if (pi && typeof pi === "object" && !Array.isArray(pi)) {
294
+ const subagents = (pi as { subagents?: unknown }).subagents;
295
+ if (subagents && typeof subagents === "object" && !Array.isArray(subagents)) {
296
+ roots.push(subagents as Record<string, unknown>);
297
+ }
298
+ }
299
+
300
+ const agents: string[] = [];
301
+ const chains: string[] = [];
302
+ for (const root of roots) {
303
+ for (const entry of stringArray(root.agents)) agents.push(path.resolve(packageRoot, entry));
304
+ for (const entry of stringArray(root.chains)) chains.push(path.resolve(packageRoot, entry));
305
+ }
306
+ return { agents, chains };
307
+ }
308
+
309
+ function collectPackageRootsFromNodeModules(nodeModulesDir: string): string[] {
310
+ const roots: string[] = [];
311
+ if (!fs.existsSync(nodeModulesDir)) return roots;
312
+
313
+ let entries: fs.Dirent[];
314
+ try {
315
+ entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true });
316
+ } catch {
317
+ return roots;
318
+ }
319
+
320
+ for (const entry of entries) {
321
+ if (entry.name.startsWith(".")) continue;
322
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
323
+
324
+ if (entry.name.startsWith("@")) {
325
+ const scopeDir = path.join(nodeModulesDir, entry.name);
326
+ let scopeEntries: fs.Dirent[];
327
+ try {
328
+ scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
329
+ } catch {
330
+ continue;
331
+ }
332
+ for (const scopeEntry of scopeEntries) {
333
+ if (scopeEntry.name.startsWith(".")) continue;
334
+ if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) continue;
335
+ roots.push(path.join(scopeDir, scopeEntry.name));
336
+ }
337
+ continue;
338
+ }
339
+
340
+ roots.push(path.join(nodeModulesDir, entry.name));
341
+ }
342
+ return roots;
343
+ }
344
+
345
+ function collectSettingsPackageRoots(settingsFile: string, baseDir: string): string[] {
346
+ const settings = readOptionalJsonFile(settingsFile);
347
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return [];
348
+ const packages = (settings as { packages?: unknown }).packages;
349
+ if (!Array.isArray(packages)) return [];
350
+
351
+ const roots: string[] = [];
352
+ for (const entry of packages) {
353
+ const packageSource = typeof entry === "string"
354
+ ? entry
355
+ : typeof entry === "object" && entry !== null && typeof (entry as { source?: unknown }).source === "string"
356
+ ? (entry as { source: string }).source
357
+ : undefined;
358
+ if (!packageSource) continue;
359
+ const packageRoot = resolveSettingsPackageRoot(packageSource, baseDir);
360
+ if (packageRoot) roots.push(packageRoot);
361
+ }
362
+ return roots;
363
+ }
364
+
365
+ function collectPackageSubagentPaths(cwd: string, options: { includeUser: boolean; includeProject: boolean } = { includeUser: true, includeProject: true }): PackageSubagentPaths {
366
+ const agentDir = getAgentDir();
367
+ const projectRoot = findNearestProjectRoot(cwd) ?? cwd;
368
+ const packageRoots = [
369
+ projectRoot,
370
+ ];
371
+
372
+ if (options.includeProject) {
373
+ packageRoots.push(
374
+ ...collectPackageRootsFromNodeModules(path.join(projectRoot, ".pi", "npm", "node_modules")),
375
+ ...collectSettingsPackageRoots(path.join(projectRoot, ".pi", "settings.json"), path.join(projectRoot, ".pi")),
376
+ );
377
+ }
378
+
379
+ if (options.includeUser) {
380
+ packageRoots.push(
381
+ ...collectPackageRootsFromNodeModules(path.join(agentDir, "npm", "node_modules")),
382
+ ...collectSettingsPackageRoots(path.join(agentDir, "settings.json"), agentDir),
383
+ );
384
+ }
385
+
386
+ if (options.includeUser) {
387
+ const globalRoot = getGlobalNpmRoot();
388
+ if (globalRoot) packageRoots.push(...collectPackageRootsFromNodeModules(globalRoot));
389
+ }
390
+
391
+ const seenRoots = new Set<string>();
392
+ const seenAgents = new Set<string>();
393
+ const seenChains = new Set<string>();
394
+ const agents: string[] = [];
395
+ const chains: string[] = [];
396
+ for (const packageRoot of packageRoots) {
397
+ const resolvedRoot = path.resolve(packageRoot);
398
+ if (seenRoots.has(resolvedRoot)) continue;
399
+ seenRoots.add(resolvedRoot);
400
+ const paths = extractSubagentPathsFromPackageRoot(resolvedRoot);
401
+ for (const agentDir of paths.agents) {
402
+ if (seenAgents.has(agentDir)) continue;
403
+ seenAgents.add(agentDir);
404
+ agents.push(agentDir);
405
+ }
406
+ for (const chainDir of paths.chains) {
407
+ if (seenChains.has(chainDir)) continue;
408
+ seenChains.add(chainDir);
409
+ chains.push(chainDir);
410
+ }
411
+ }
412
+ return { agents, chains };
413
+ }
414
+
164
415
  function splitToolList(rawTools: string[] | undefined): { tools?: string[]; mcpDirectTools?: string[] } {
165
416
  const mcpDirectTools: string[] = [];
166
417
  const tools: string[] = [];
@@ -209,8 +460,7 @@ function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase {
209
460
  skills: agent.skills ? [...agent.skills] : undefined,
210
461
  tools: agent.tools ? [...agent.tools] : undefined,
211
462
  mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined,
212
- maxExecutionTimeMs: agent.maxExecutionTimeMs,
213
- maxTokens: agent.maxTokens,
463
+ subagentOnlyExtensions: agent.subagentOnlyExtensions ? [...agent.subagentOnlyExtensions] : undefined,
214
464
  completionGuard: agent.completionGuard,
215
465
  };
216
466
  }
@@ -230,8 +480,7 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO
230
480
  ...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}),
231
481
  ...(override.skills !== undefined ? { skills: override.skills === false ? false : [...override.skills] } : {}),
232
482
  ...(override.tools !== undefined ? { tools: override.tools === false ? false : [...override.tools] } : {}),
233
- ...(override.maxExecutionTimeMs !== undefined ? { maxExecutionTimeMs: override.maxExecutionTimeMs } : {}),
234
- ...(override.maxTokens !== undefined ? { maxTokens: override.maxTokens } : {}),
483
+ ...(override.subagentOnlyExtensions !== undefined ? { subagentOnlyExtensions: override.subagentOnlyExtensions === false ? false : [...override.subagentOnlyExtensions] } : {}),
235
484
  ...(override.completionGuard !== undefined ? { completionGuard: override.completionGuard } : {}),
236
485
  };
237
486
  }
@@ -369,22 +618,6 @@ function parseBuiltinOverrideEntry(
369
618
  }
370
619
  }
371
620
 
372
- if ("maxExecutionTimeMs" in input) {
373
- if (input.maxExecutionTimeMs === false || (typeof input.maxExecutionTimeMs === "number" && Number.isInteger(input.maxExecutionTimeMs) && input.maxExecutionTimeMs >= 1)) {
374
- override.maxExecutionTimeMs = input.maxExecutionTimeMs;
375
- } else {
376
- throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'maxExecutionTimeMs'; expected an integer >= 1 or false.`);
377
- }
378
- }
379
-
380
- if ("maxTokens" in input) {
381
- if (input.maxTokens === false || (typeof input.maxTokens === "number" && Number.isInteger(input.maxTokens) && input.maxTokens >= 1)) {
382
- override.maxTokens = input.maxTokens;
383
- } else {
384
- throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'maxTokens'; expected an integer >= 1 or false.`);
385
- }
386
- }
387
-
388
621
  if ("completionGuard" in input) {
389
622
  if (typeof input.completionGuard === "boolean") {
390
623
  override.completionGuard = input.completionGuard;
@@ -407,6 +640,9 @@ function parseBuiltinOverrideEntry(
407
640
  const tools = parseOverrideStringArrayOrFalse(input.tools, { filePath, name, field: "tools" });
408
641
  if (tools !== undefined) override.tools = tools;
409
642
 
643
+ const subagentOnlyExtensions = parseOverrideStringArrayOrFalse(input.subagentOnlyExtensions, { filePath, name, field: "subagentOnlyExtensions" });
644
+ if (subagentOnlyExtensions !== undefined) override.subagentOnlyExtensions = subagentOnlyExtensions;
645
+
410
646
  return Object.keys(override).length > 0 ? override : undefined;
411
647
  }
412
648
 
@@ -465,8 +701,9 @@ function applyBuiltinOverride(
465
701
  next.tools = tools;
466
702
  next.mcpDirectTools = mcpDirectTools;
467
703
  }
468
- if (override.maxExecutionTimeMs !== undefined) next.maxExecutionTimeMs = override.maxExecutionTimeMs === false ? undefined : override.maxExecutionTimeMs;
469
- if (override.maxTokens !== undefined) next.maxTokens = override.maxTokens === false ? undefined : override.maxTokens;
704
+ if (override.subagentOnlyExtensions !== undefined) {
705
+ next.subagentOnlyExtensions = override.subagentOnlyExtensions === false ? undefined : [...override.subagentOnlyExtensions];
706
+ }
470
707
  if (override.completionGuard !== undefined) next.completionGuard = override.completionGuard;
471
708
 
472
709
  return next;
@@ -507,7 +744,7 @@ function applyBuiltinOverrides(
507
744
 
508
745
  export function buildBuiltinOverrideConfig(
509
746
  base: BuiltinAgentOverrideBase,
510
- draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "maxExecutionTimeMs" | "maxTokens" | "completionGuard">,
747
+ draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "subagentOnlyExtensions" | "completionGuard">,
511
748
  ): BuiltinAgentOverrideConfig | undefined {
512
749
  const override: BuiltinAgentOverrideConfig = {};
513
750
 
@@ -525,8 +762,9 @@ export function buildBuiltinOverrideConfig(
525
762
  const baseTools = joinToolList(base);
526
763
  const draftTools = joinToolList(draft);
527
764
  if (!arraysEqual(draftTools, baseTools)) override.tools = draftTools ? [...draftTools] : false;
528
- if (draft.maxExecutionTimeMs !== base.maxExecutionTimeMs) override.maxExecutionTimeMs = draft.maxExecutionTimeMs ?? false;
529
- if (draft.maxTokens !== base.maxTokens) override.maxTokens = draft.maxTokens ?? false;
765
+ if (!arraysEqual(draft.subagentOnlyExtensions, base.subagentOnlyExtensions)) {
766
+ override.subagentOnlyExtensions = draft.subagentOnlyExtensions ? [...draft.subagentOnlyExtensions] : false;
767
+ }
530
768
  if ((draft.completionGuard !== false) !== (base.completionGuard !== false)) {
531
769
  override.completionGuard = draft.completionGuard !== false;
532
770
  }
@@ -606,10 +844,23 @@ function listFilesRecursive(dir: string, predicate: (fileName: string) => boolea
606
844
  return files;
607
845
  }
608
846
 
847
+ function isLegacyAgentSkillPath(rootDir: string, filePath: string): boolean {
848
+ const relative = path.relative(rootDir, filePath);
849
+ const parts = relative.split(path.sep).map((part) => part.toLowerCase());
850
+ if (path.basename(rootDir).toLowerCase() === ".agents") {
851
+ parts.unshift(".agents");
852
+ }
853
+ return parts.some((part, index) => part === ".agents" && parts[index + 1] === "skills");
854
+ }
855
+
609
856
  function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
610
857
  const agents: AgentConfig[] = [];
611
858
 
612
859
  for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) {
860
+ if (isLegacyAgentSkillPath(dir, filePath)) {
861
+ continue;
862
+ }
863
+
613
864
  let content: string;
614
865
  try {
615
866
  content = fs.readFileSync(filePath, "utf-8");
@@ -688,6 +939,13 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
688
939
  .map((e) => e.trim())
689
940
  .filter(Boolean);
690
941
  }
942
+ let subagentOnlyExtensions: string[] | undefined;
943
+ if (frontmatter.subagentOnlyExtensions !== undefined) {
944
+ subagentOnlyExtensions = frontmatter.subagentOnlyExtensions
945
+ .split(",")
946
+ .map((e) => e.trim())
947
+ .filter(Boolean);
948
+ }
691
949
 
692
950
  const extraFields: Record<string, string> = {};
693
951
  for (const [key, value] of Object.entries(frontmatter)) {
@@ -695,8 +953,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
695
953
  }
696
954
 
697
955
  const parsedMaxSubagentDepth = Number(frontmatter.maxSubagentDepth);
698
- const parsedMaxExecutionTimeMs = Number(frontmatter.maxExecutionTimeMs);
699
- const parsedMaxTokens = Number(frontmatter.maxTokens);
700
956
  const completionGuard = frontmatter.completionGuard === "false"
701
957
  ? false
702
958
  : frontmatter.completionGuard === "true"
@@ -722,6 +978,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
722
978
  filePath,
723
979
  skills: skills && skills.length > 0 ? skills : undefined,
724
980
  extensions,
981
+ subagentOnlyExtensions,
725
982
  output: frontmatter.output,
726
983
  defaultReads: defaultReads && defaultReads.length > 0 ? defaultReads : undefined,
727
984
  defaultProgress: frontmatter.defaultProgress === "true",
@@ -730,14 +987,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
730
987
  Number.isInteger(parsedMaxSubagentDepth) && parsedMaxSubagentDepth >= 0
731
988
  ? parsedMaxSubagentDepth
732
989
  : undefined,
733
- maxExecutionTimeMs:
734
- Number.isInteger(parsedMaxExecutionTimeMs) && parsedMaxExecutionTimeMs >= 1
735
- ? parsedMaxExecutionTimeMs
736
- : undefined,
737
- maxTokens:
738
- Number.isInteger(parsedMaxTokens) && parsedMaxTokens >= 1
739
- ? parsedMaxTokens
740
- : undefined,
741
990
  completionGuard,
742
991
  extraFields: Object.keys(extraFields).length > 0 ? extraFields : undefined,
743
992
  });
@@ -746,7 +995,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
746
995
  return agents;
747
996
  }
748
997
 
749
- function loadChainsFromDir(dir: string, source: "user" | "project"): { chains: ChainConfig[]; diagnostics: ChainDiscoveryDiagnostic[] } {
998
+ function loadChainsFromDir(dir: string, source: AgentSource): { chains: ChainConfig[]; diagnostics: ChainDiscoveryDiagnostic[] } {
750
999
  const chains = new Map<string, ChainConfig>();
751
1000
  const diagnostics: ChainDiscoveryDiagnostic[] = [];
752
1001
 
@@ -808,6 +1057,22 @@ function resolveNearestProjectChainDirs(cwd: string): { readDirs: string[]; pref
808
1057
  }
809
1058
  const BUILTIN_AGENTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
810
1059
 
1060
+ export const EXTRA_AGENT_DIRS_ENV = "PI_SUBAGENT_EXTRA_AGENT_DIRS";
1061
+
1062
+ // Additional read-only directories to scan for agent definitions, supplied by the
1063
+ // launcher via PI_SUBAGENT_EXTRA_AGENT_DIRS (PATH-style, split on os/path delimiter).
1064
+ // Lets a hermetic wrapper (e.g. a Nix-store install) expose bundled agents without
1065
+ // copying or symlinking them into the writable agent dir. Loaded as "user" source,
1066
+ // at lower precedence than agents the user placed in their own agent dir.
1067
+ function extraUserAgentDirs(): string[] {
1068
+ const raw = process.env[EXTRA_AGENT_DIRS_ENV];
1069
+ if (!raw) return [];
1070
+ return raw
1071
+ .split(path.delimiter)
1072
+ .map((dir) => dir.trim())
1073
+ .filter((dir) => dir.length > 0);
1074
+ }
1075
+
811
1076
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
812
1077
  const userDirOld = path.join(getAgentDir(), "agents");
813
1078
  const userDirNew = path.join(os.homedir(), ".agents");
@@ -816,6 +1081,10 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
816
1081
  const projectSettingsPath = getProjectAgentSettingsPath(cwd);
817
1082
  const userSettings = scope === "project" ? EMPTY_SUBAGENT_SETTINGS : readSubagentSettings(userSettingsPath);
818
1083
  const projectSettings = scope === "user" ? EMPTY_SUBAGENT_SETTINGS : readSubagentSettings(projectSettingsPath);
1084
+ const packageSubagentPaths = collectPackageSubagentPaths(cwd, {
1085
+ includeUser: scope !== "project",
1086
+ includeProject: scope !== "user",
1087
+ });
819
1088
 
820
1089
  const builtinAgents = applyBuiltinOverrides(
821
1090
  loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"),
@@ -825,12 +1094,14 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
825
1094
  projectSettingsPath,
826
1095
  );
827
1096
 
1097
+ const userAgentsExtra = scope === "project" ? [] : extraUserAgentDirs().flatMap((dir) => loadAgentsFromDir(dir, "user"));
828
1098
  const userAgentsOld = scope === "project" ? [] : loadAgentsFromDir(userDirOld, "user");
829
1099
  const userAgentsNew = scope === "project" ? [] : loadAgentsFromDir(userDirNew, "user");
830
- const userAgents = [...userAgentsOld, ...userAgentsNew];
1100
+ const userAgents = [...userAgentsExtra, ...userAgentsOld, ...userAgentsNew];
831
1101
 
832
1102
  const projectAgents = scope === "user" ? [] : projectAgentDirs.flatMap((dir) => loadAgentsFromDir(dir, "project"));
833
- const agents = mergeAgentsForScope(scope, userAgents, projectAgents, builtinAgents)
1103
+ const packageAgents = packageSubagentPaths.agents.flatMap((dir) => loadAgentsFromDir(dir, "package"));
1104
+ const agents = mergeAgentsForScope(scope, userAgents, projectAgents, builtinAgents, packageAgents)
834
1105
  .filter((agent) => agent.disabled !== true);
835
1106
 
836
1107
  return { agents, projectAgentsDir };
@@ -838,6 +1109,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
838
1109
 
839
1110
  export function discoverAgentsAll(cwd: string): {
840
1111
  builtin: AgentConfig[];
1112
+ package: AgentConfig[];
841
1113
  user: AgentConfig[];
842
1114
  project: AgentConfig[];
843
1115
  chains: ChainConfig[];
@@ -858,6 +1130,7 @@ export function discoverAgentsAll(cwd: string): {
858
1130
  const projectSettingsPath = getProjectAgentSettingsPath(cwd);
859
1131
  const userSettings = readSubagentSettings(userSettingsPath);
860
1132
  const projectSettings = readSubagentSettings(projectSettingsPath);
1133
+ const packageSubagentPaths = collectPackageSubagentPaths(cwd);
861
1134
 
862
1135
  const builtin = applyBuiltinOverrides(
863
1136
  loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"),
@@ -867,9 +1140,17 @@ export function discoverAgentsAll(cwd: string): {
867
1140
  projectSettingsPath,
868
1141
  );
869
1142
  const user = [
1143
+ ...extraUserAgentDirs().flatMap((dir) => loadAgentsFromDir(dir, "user")),
870
1144
  ...loadAgentsFromDir(userDirOld, "user"),
871
1145
  ...loadAgentsFromDir(userDirNew, "user"),
872
1146
  ];
1147
+ const packageMap = new Map<string, AgentConfig>();
1148
+ for (const dir of packageSubagentPaths.agents) {
1149
+ for (const agent of loadAgentsFromDir(dir, "package")) {
1150
+ if (!packageMap.has(agent.name)) packageMap.set(agent.name, agent);
1151
+ }
1152
+ }
1153
+ const packageAgents = Array.from(packageMap.values());
873
1154
  const projectMap = new Map<string, AgentConfig>();
874
1155
  for (const dir of projectDirs) {
875
1156
  for (const agent of loadAgentsFromDir(dir, "project")) {
@@ -879,6 +1160,15 @@ export function discoverAgentsAll(cwd: string): {
879
1160
  const project = Array.from(projectMap.values());
880
1161
 
881
1162
  const chainMap = new Map<string, ChainConfig>();
1163
+ const packageChainDiagnostics: ChainDiscoveryDiagnostic[] = [];
1164
+ const packageChainMap = new Map<string, ChainConfig>();
1165
+ for (const dir of packageSubagentPaths.chains) {
1166
+ const loaded = loadChainsFromDir(dir, "package");
1167
+ packageChainDiagnostics.push(...loaded.diagnostics);
1168
+ for (const chain of loaded.chains) {
1169
+ if (!packageChainMap.has(chain.name)) packageChainMap.set(chain.name, chain);
1170
+ }
1171
+ }
882
1172
  const projectChainDiagnostics: ChainDiscoveryDiagnostic[] = [];
883
1173
  for (const dir of projectChainDirs) {
884
1174
  const loaded = loadChainsFromDir(dir, "project");
@@ -889,15 +1179,17 @@ export function discoverAgentsAll(cwd: string): {
889
1179
  }
890
1180
  const userChains = loadChainsFromDir(userChainDir, "user");
891
1181
  const chains = [
1182
+ ...Array.from(packageChainMap.values()),
892
1183
  ...userChains.chains,
893
1184
  ...Array.from(chainMap.values()),
894
1185
  ];
895
1186
  const chainDiagnostics = [
1187
+ ...packageChainDiagnostics,
896
1188
  ...userChains.diagnostics,
897
1189
  ...projectChainDiagnostics,
898
1190
  ];
899
1191
 
900
1192
  const userDir = process.env.PI_CODING_AGENT_DIR ? userDirOld : fs.existsSync(userDirNew) ? userDirNew : userDirOld;
901
1193
 
902
- return { builtin, user, project, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
1194
+ return { builtin, package: packageAgents, user, project, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
903
1195
  }
@@ -4,6 +4,7 @@ import { parseFrontmatter } from "./frontmatter.ts";
4
4
  import { ChainOutputValidationError, validateChainOutputBindings } from "../runs/shared/chain-outputs.ts";
5
5
  import { validateAcceptanceInput } from "../runs/shared/acceptance.ts";
6
6
  import type { ChainStep } from "../shared/settings.ts";
7
+ import type { AgentSource } from "./agents.ts";
7
8
 
8
9
  function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
9
10
  const lines = sectionBody.split("\n");
@@ -83,7 +84,7 @@ function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
83
84
  return step;
84
85
  }
85
86
 
86
- export function parseChain(content: string, source: "user" | "project", filePath: string): ChainConfig {
87
+ export function parseChain(content: string, source: AgentSource, filePath: string): ChainConfig {
87
88
  const { frontmatter, body } = parseFrontmatter(content);
88
89
  if (!frontmatter.name || !frontmatter.description) {
89
90
  throw new Error("Chain frontmatter must include name and description");
@@ -124,7 +125,7 @@ export function parseChain(content: string, source: "user" | "project", filePath
124
125
  };
125
126
  }
126
127
 
127
- export function parseJsonChain(content: string, source: "user" | "project", filePath: string): ChainConfig {
128
+ export function parseJsonChain(content: string, source: AgentSource, filePath: string): ChainConfig {
128
129
  let parsed: unknown;
129
130
  try {
130
131
  parsed = JSON.parse(content);
@@ -151,17 +152,11 @@ export function parseJsonChain(content: string, source: "user" | "project", file
151
152
  throw new Error(`JSON chain '${filePath}' step ${i + 1} must be an object.`);
152
153
  }
153
154
  const stepRecord = step as Record<string, unknown>;
154
- const parallel = stepRecord.parallel;
155
- if (Array.isArray(parallel) && Object.hasOwn(stepRecord, "acceptance")) {
156
- throw new Error(`Invalid JSON chain '${filePath}': step ${i + 1} acceptance is not supported on static parallel groups; set acceptance on each parallel task.`);
157
- }
158
- if (parallel && typeof parallel === "object" && !Array.isArray(parallel) && Object.hasOwn(stepRecord, "acceptance")) {
159
- throw new Error(`Invalid JSON chain '${filePath}': step ${i + 1} acceptance is not supported on dynamic fanout groups; set acceptance on the dynamic template.`);
160
- }
161
155
  const acceptanceErrors = validateAcceptanceInput(stepRecord.acceptance, `step ${i + 1} acceptance`);
162
156
  if (acceptanceErrors.length > 0) {
163
157
  throw new Error(`Invalid JSON chain '${filePath}': ${acceptanceErrors.join(" ")}`);
164
158
  }
159
+ const parallel = stepRecord.parallel;
165
160
  if (Array.isArray(parallel)) {
166
161
  for (let taskIndex = 0; taskIndex < parallel.length; taskIndex++) {
167
162
  const task = parallel[taskIndex];