pi-subagents 0.28.0 → 0.29.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 (36) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +18 -61
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +4 -35
  5. package/src/agents/agent-management.ts +10 -20
  6. package/src/agents/agent-selection.ts +2 -0
  7. package/src/agents/agent-serializer.ts +0 -10
  8. package/src/agents/agents.ts +304 -47
  9. package/src/agents/chain-serializer.ts +4 -9
  10. package/src/extension/doctor.ts +4 -3
  11. package/src/extension/fanout-child.ts +0 -2
  12. package/src/extension/index.ts +3 -8
  13. package/src/extension/schemas.ts +32 -22
  14. package/src/intercom/intercom-bridge.ts +11 -1
  15. package/src/intercom/result-intercom.ts +0 -5
  16. package/src/runs/background/async-execution.ts +20 -11
  17. package/src/runs/background/run-status.ts +1 -7
  18. package/src/runs/background/subagent-runner.ts +81 -211
  19. package/src/runs/foreground/chain-execution.ts +62 -58
  20. package/src/runs/foreground/execution.ts +38 -343
  21. package/src/runs/foreground/subagent-executor.ts +28 -99
  22. package/src/runs/shared/acceptance.ts +605 -22
  23. package/src/runs/shared/completion-guard.ts +3 -26
  24. package/src/runs/shared/model-fallback.ts +38 -0
  25. package/src/runs/shared/parallel-utils.ts +6 -10
  26. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  27. package/src/runs/shared/workflow-graph.ts +2 -6
  28. package/src/shared/atomic-json.ts +68 -11
  29. package/src/shared/settings.ts +1 -0
  30. package/src/shared/types.ts +10 -48
  31. package/src/shared/utils.ts +2 -8
  32. package/src/tui/render.ts +14 -29
  33. package/src/runs/shared/acceptance-contract.ts +0 -318
  34. package/src/runs/shared/acceptance-evaluation.ts +0 -221
  35. package/src/runs/shared/acceptance-finalization.ts +0 -173
  36. 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,6 @@ export interface BuiltinAgentOverrideBase {
46
47
  skills?: string[];
47
48
  tools?: string[];
48
49
  mcpDirectTools?: string[];
49
- maxExecutionTimeMs?: number;
50
- maxTokens?: number;
51
50
  completionGuard?: boolean;
52
51
  }
53
52
 
@@ -63,8 +62,6 @@ interface BuiltinAgentOverrideConfig {
63
62
  systemPrompt?: string;
64
63
  skills?: string[] | false;
65
64
  tools?: string[] | false;
66
- maxExecutionTimeMs?: number | false;
67
- maxTokens?: number | false;
68
65
  completionGuard?: boolean;
69
66
  }
70
67
 
@@ -98,8 +95,6 @@ export interface AgentConfig {
98
95
  defaultProgress?: boolean;
99
96
  interactive?: boolean;
100
97
  maxSubagentDepth?: number;
101
- maxExecutionTimeMs?: number;
102
- maxTokens?: number;
103
98
  completionGuard?: boolean;
104
99
  disabled?: boolean;
105
100
  extraFields?: Record<string, string>;
@@ -147,7 +142,7 @@ export interface ChainConfig {
147
142
  }
148
143
 
149
144
  export interface ChainDiscoveryDiagnostic {
150
- source: "user" | "project";
145
+ source: AgentSource;
151
146
  filePath: string;
152
147
  error: string;
153
148
  }
@@ -161,6 +156,259 @@ function getUserChainDir(): string {
161
156
  return path.join(getAgentDir(), "chains");
162
157
  }
163
158
 
159
+ interface PackageSubagentPaths {
160
+ agents: string[];
161
+ chains: string[];
162
+ }
163
+
164
+ let cachedGlobalNpmRoot: string | null = null;
165
+
166
+ function readJsonFileBestEffort(filePath: string): unknown {
167
+ try {
168
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
169
+ } catch {
170
+ // Installed package scans are opportunistic; bad third-party manifests
171
+ // should not break local agent discovery.
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function readOptionalJsonFile(filePath: string): unknown {
177
+ try {
178
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
179
+ } catch (error) {
180
+ const code = typeof error === "object" && error !== null && "code" in error
181
+ ? (error as { code?: unknown }).code
182
+ : undefined;
183
+ if (code === "ENOENT") return null;
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ function isSafePackagePath(value: string): boolean {
189
+ return value.length > 0
190
+ && !path.isAbsolute(value)
191
+ && value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
192
+ }
193
+
194
+ function parseNpmPackageName(source: string): string | undefined {
195
+ const spec = source.slice(4).trim();
196
+ if (!spec) return undefined;
197
+ const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
198
+ const packageName = match?.[1] ?? spec;
199
+ return isSafePackagePath(packageName) ? packageName : undefined;
200
+ }
201
+
202
+ function stripGitRef(repoPath: string): string {
203
+ const atIndex = repoPath.indexOf("@");
204
+ const hashIndex = repoPath.indexOf("#");
205
+ const refIndex = [atIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
206
+ return refIndex === undefined ? repoPath : repoPath.slice(0, refIndex);
207
+ }
208
+
209
+ function parseGitPackagePath(source: string): { host: string; repoPath: string } | undefined {
210
+ const spec = source.slice(4).trim();
211
+ if (!spec) return undefined;
212
+
213
+ let host = "";
214
+ let repoPath = "";
215
+ const scpLike = spec.match(/^git@([^:]+):(.+)$/);
216
+ if (scpLike) {
217
+ host = scpLike[1] ?? "";
218
+ repoPath = scpLike[2] ?? "";
219
+ } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec)) {
220
+ try {
221
+ const url = new URL(spec);
222
+ host = url.hostname;
223
+ repoPath = url.pathname.replace(/^\/+/, "");
224
+ } catch {
225
+ return undefined;
226
+ }
227
+ } else {
228
+ const slashIndex = spec.indexOf("/");
229
+ if (slashIndex < 0) return undefined;
230
+ host = spec.slice(0, slashIndex);
231
+ repoPath = spec.slice(slashIndex + 1);
232
+ }
233
+
234
+ const normalizedPath = stripGitRef(repoPath).replace(/\.git$/, "").replace(/^\/+/, "");
235
+ if (!host || !isSafePackagePath(host) || !isSafePackagePath(normalizedPath) || normalizedPath.split(/[\\/]/).length < 2) {
236
+ return undefined;
237
+ }
238
+ return { host, repoPath: normalizedPath };
239
+ }
240
+
241
+ function resolveSettingsPackageRoot(source: string, baseDir: string): string | undefined {
242
+ const trimmed = source.trim();
243
+ if (!trimmed) return undefined;
244
+ if (trimmed.startsWith("git:")) {
245
+ const parsed = parseGitPackagePath(trimmed);
246
+ return parsed ? path.join(baseDir, "git", parsed.host, parsed.repoPath) : undefined;
247
+ }
248
+ if (trimmed.startsWith("npm:")) {
249
+ const packageName = parseNpmPackageName(trimmed);
250
+ return packageName ? path.join(baseDir, "npm", "node_modules", packageName) : undefined;
251
+ }
252
+ const normalized = trimmed.startsWith("file:") ? trimmed.slice(5) : trimmed;
253
+ if (normalized === "~") return os.homedir();
254
+ if (normalized.startsWith("~/")) return path.join(os.homedir(), normalized.slice(2));
255
+ if (path.isAbsolute(normalized)) return normalized;
256
+ if (normalized === "." || normalized === ".." || normalized.startsWith("./") || normalized.startsWith("../")) {
257
+ return path.resolve(baseDir, normalized);
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ function getGlobalNpmRoot(): string | null {
263
+ if (cachedGlobalNpmRoot !== null) return cachedGlobalNpmRoot;
264
+ try {
265
+ cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim());
266
+ return cachedGlobalNpmRoot;
267
+ } catch {
268
+ cachedGlobalNpmRoot = "";
269
+ return null;
270
+ }
271
+ }
272
+
273
+ function stringArray(value: unknown): string[] {
274
+ if (!Array.isArray(value)) return [];
275
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
276
+ }
277
+
278
+ function extractSubagentPathsFromPackageRoot(packageRoot: string): PackageSubagentPaths {
279
+ const packageJsonPath = path.join(packageRoot, "package.json");
280
+ const pkg = readJsonFileBestEffort(packageJsonPath);
281
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) return { agents: [], chains: [] };
282
+
283
+ const roots: Record<string, unknown>[] = [];
284
+ const piSubagents = (pkg as { "pi-subagents"?: unknown })["pi-subagents"];
285
+ if (piSubagents && typeof piSubagents === "object" && !Array.isArray(piSubagents)) {
286
+ roots.push(piSubagents as Record<string, unknown>);
287
+ }
288
+
289
+ const pi = (pkg as { pi?: unknown }).pi;
290
+ if (pi && typeof pi === "object" && !Array.isArray(pi)) {
291
+ const subagents = (pi as { subagents?: unknown }).subagents;
292
+ if (subagents && typeof subagents === "object" && !Array.isArray(subagents)) {
293
+ roots.push(subagents as Record<string, unknown>);
294
+ }
295
+ }
296
+
297
+ const agents: string[] = [];
298
+ const chains: string[] = [];
299
+ for (const root of roots) {
300
+ for (const entry of stringArray(root.agents)) agents.push(path.resolve(packageRoot, entry));
301
+ for (const entry of stringArray(root.chains)) chains.push(path.resolve(packageRoot, entry));
302
+ }
303
+ return { agents, chains };
304
+ }
305
+
306
+ function collectPackageRootsFromNodeModules(nodeModulesDir: string): string[] {
307
+ const roots: string[] = [];
308
+ if (!fs.existsSync(nodeModulesDir)) return roots;
309
+
310
+ let entries: fs.Dirent[];
311
+ try {
312
+ entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true });
313
+ } catch {
314
+ return roots;
315
+ }
316
+
317
+ for (const entry of entries) {
318
+ if (entry.name.startsWith(".")) continue;
319
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
320
+
321
+ if (entry.name.startsWith("@")) {
322
+ const scopeDir = path.join(nodeModulesDir, entry.name);
323
+ let scopeEntries: fs.Dirent[];
324
+ try {
325
+ scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
326
+ } catch {
327
+ continue;
328
+ }
329
+ for (const scopeEntry of scopeEntries) {
330
+ if (scopeEntry.name.startsWith(".")) continue;
331
+ if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) continue;
332
+ roots.push(path.join(scopeDir, scopeEntry.name));
333
+ }
334
+ continue;
335
+ }
336
+
337
+ roots.push(path.join(nodeModulesDir, entry.name));
338
+ }
339
+ return roots;
340
+ }
341
+
342
+ function collectSettingsPackageRoots(settingsFile: string, baseDir: string): string[] {
343
+ const settings = readOptionalJsonFile(settingsFile);
344
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return [];
345
+ const packages = (settings as { packages?: unknown }).packages;
346
+ if (!Array.isArray(packages)) return [];
347
+
348
+ const roots: string[] = [];
349
+ for (const entry of packages) {
350
+ const packageSource = typeof entry === "string"
351
+ ? entry
352
+ : typeof entry === "object" && entry !== null && typeof (entry as { source?: unknown }).source === "string"
353
+ ? (entry as { source: string }).source
354
+ : undefined;
355
+ if (!packageSource) continue;
356
+ const packageRoot = resolveSettingsPackageRoot(packageSource, baseDir);
357
+ if (packageRoot) roots.push(packageRoot);
358
+ }
359
+ return roots;
360
+ }
361
+
362
+ function collectPackageSubagentPaths(cwd: string, options: { includeUser: boolean; includeProject: boolean } = { includeUser: true, includeProject: true }): PackageSubagentPaths {
363
+ const agentDir = getAgentDir();
364
+ const projectRoot = findNearestProjectRoot(cwd) ?? cwd;
365
+ const packageRoots = [
366
+ projectRoot,
367
+ ];
368
+
369
+ if (options.includeProject) {
370
+ packageRoots.push(
371
+ ...collectPackageRootsFromNodeModules(path.join(projectRoot, ".pi", "npm", "node_modules")),
372
+ ...collectSettingsPackageRoots(path.join(projectRoot, ".pi", "settings.json"), path.join(projectRoot, ".pi")),
373
+ );
374
+ }
375
+
376
+ if (options.includeUser) {
377
+ packageRoots.push(
378
+ ...collectPackageRootsFromNodeModules(path.join(agentDir, "npm", "node_modules")),
379
+ ...collectSettingsPackageRoots(path.join(agentDir, "settings.json"), agentDir),
380
+ );
381
+ }
382
+
383
+ if (options.includeUser) {
384
+ const globalRoot = getGlobalNpmRoot();
385
+ if (globalRoot) packageRoots.push(...collectPackageRootsFromNodeModules(globalRoot));
386
+ }
387
+
388
+ const seenRoots = new Set<string>();
389
+ const seenAgents = new Set<string>();
390
+ const seenChains = new Set<string>();
391
+ const agents: string[] = [];
392
+ const chains: string[] = [];
393
+ for (const packageRoot of packageRoots) {
394
+ const resolvedRoot = path.resolve(packageRoot);
395
+ if (seenRoots.has(resolvedRoot)) continue;
396
+ seenRoots.add(resolvedRoot);
397
+ const paths = extractSubagentPathsFromPackageRoot(resolvedRoot);
398
+ for (const agentDir of paths.agents) {
399
+ if (seenAgents.has(agentDir)) continue;
400
+ seenAgents.add(agentDir);
401
+ agents.push(agentDir);
402
+ }
403
+ for (const chainDir of paths.chains) {
404
+ if (seenChains.has(chainDir)) continue;
405
+ seenChains.add(chainDir);
406
+ chains.push(chainDir);
407
+ }
408
+ }
409
+ return { agents, chains };
410
+ }
411
+
164
412
  function splitToolList(rawTools: string[] | undefined): { tools?: string[]; mcpDirectTools?: string[] } {
165
413
  const mcpDirectTools: string[] = [];
166
414
  const tools: string[] = [];
@@ -209,8 +457,6 @@ function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase {
209
457
  skills: agent.skills ? [...agent.skills] : undefined,
210
458
  tools: agent.tools ? [...agent.tools] : undefined,
211
459
  mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined,
212
- maxExecutionTimeMs: agent.maxExecutionTimeMs,
213
- maxTokens: agent.maxTokens,
214
460
  completionGuard: agent.completionGuard,
215
461
  };
216
462
  }
@@ -230,8 +476,6 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO
230
476
  ...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}),
231
477
  ...(override.skills !== undefined ? { skills: override.skills === false ? false : [...override.skills] } : {}),
232
478
  ...(override.tools !== undefined ? { tools: override.tools === false ? false : [...override.tools] } : {}),
233
- ...(override.maxExecutionTimeMs !== undefined ? { maxExecutionTimeMs: override.maxExecutionTimeMs } : {}),
234
- ...(override.maxTokens !== undefined ? { maxTokens: override.maxTokens } : {}),
235
479
  ...(override.completionGuard !== undefined ? { completionGuard: override.completionGuard } : {}),
236
480
  };
237
481
  }
@@ -369,22 +613,6 @@ function parseBuiltinOverrideEntry(
369
613
  }
370
614
  }
371
615
 
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
616
  if ("completionGuard" in input) {
389
617
  if (typeof input.completionGuard === "boolean") {
390
618
  override.completionGuard = input.completionGuard;
@@ -465,8 +693,6 @@ function applyBuiltinOverride(
465
693
  next.tools = tools;
466
694
  next.mcpDirectTools = mcpDirectTools;
467
695
  }
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;
470
696
  if (override.completionGuard !== undefined) next.completionGuard = override.completionGuard;
471
697
 
472
698
  return next;
@@ -507,7 +733,7 @@ function applyBuiltinOverrides(
507
733
 
508
734
  export function buildBuiltinOverrideConfig(
509
735
  base: BuiltinAgentOverrideBase,
510
- draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "maxExecutionTimeMs" | "maxTokens" | "completionGuard">,
736
+ draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "completionGuard">,
511
737
  ): BuiltinAgentOverrideConfig | undefined {
512
738
  const override: BuiltinAgentOverrideConfig = {};
513
739
 
@@ -525,8 +751,6 @@ export function buildBuiltinOverrideConfig(
525
751
  const baseTools = joinToolList(base);
526
752
  const draftTools = joinToolList(draft);
527
753
  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;
530
754
  if ((draft.completionGuard !== false) !== (base.completionGuard !== false)) {
531
755
  override.completionGuard = draft.completionGuard !== false;
532
756
  }
@@ -695,8 +919,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
695
919
  }
696
920
 
697
921
  const parsedMaxSubagentDepth = Number(frontmatter.maxSubagentDepth);
698
- const parsedMaxExecutionTimeMs = Number(frontmatter.maxExecutionTimeMs);
699
- const parsedMaxTokens = Number(frontmatter.maxTokens);
700
922
  const completionGuard = frontmatter.completionGuard === "false"
701
923
  ? false
702
924
  : frontmatter.completionGuard === "true"
@@ -730,14 +952,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
730
952
  Number.isInteger(parsedMaxSubagentDepth) && parsedMaxSubagentDepth >= 0
731
953
  ? parsedMaxSubagentDepth
732
954
  : 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
955
  completionGuard,
742
956
  extraFields: Object.keys(extraFields).length > 0 ? extraFields : undefined,
743
957
  });
@@ -746,7 +960,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
746
960
  return agents;
747
961
  }
748
962
 
749
- function loadChainsFromDir(dir: string, source: "user" | "project"): { chains: ChainConfig[]; diagnostics: ChainDiscoveryDiagnostic[] } {
963
+ function loadChainsFromDir(dir: string, source: AgentSource): { chains: ChainConfig[]; diagnostics: ChainDiscoveryDiagnostic[] } {
750
964
  const chains = new Map<string, ChainConfig>();
751
965
  const diagnostics: ChainDiscoveryDiagnostic[] = [];
752
966
 
@@ -808,6 +1022,22 @@ function resolveNearestProjectChainDirs(cwd: string): { readDirs: string[]; pref
808
1022
  }
809
1023
  const BUILTIN_AGENTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
810
1024
 
1025
+ export const EXTRA_AGENT_DIRS_ENV = "PI_SUBAGENT_EXTRA_AGENT_DIRS";
1026
+
1027
+ // Additional read-only directories to scan for agent definitions, supplied by the
1028
+ // launcher via PI_SUBAGENT_EXTRA_AGENT_DIRS (PATH-style, split on os/path delimiter).
1029
+ // Lets a hermetic wrapper (e.g. a Nix-store install) expose bundled agents without
1030
+ // copying or symlinking them into the writable agent dir. Loaded as "user" source,
1031
+ // at lower precedence than agents the user placed in their own agent dir.
1032
+ function extraUserAgentDirs(): string[] {
1033
+ const raw = process.env[EXTRA_AGENT_DIRS_ENV];
1034
+ if (!raw) return [];
1035
+ return raw
1036
+ .split(path.delimiter)
1037
+ .map((dir) => dir.trim())
1038
+ .filter((dir) => dir.length > 0);
1039
+ }
1040
+
811
1041
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
812
1042
  const userDirOld = path.join(getAgentDir(), "agents");
813
1043
  const userDirNew = path.join(os.homedir(), ".agents");
@@ -816,6 +1046,10 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
816
1046
  const projectSettingsPath = getProjectAgentSettingsPath(cwd);
817
1047
  const userSettings = scope === "project" ? EMPTY_SUBAGENT_SETTINGS : readSubagentSettings(userSettingsPath);
818
1048
  const projectSettings = scope === "user" ? EMPTY_SUBAGENT_SETTINGS : readSubagentSettings(projectSettingsPath);
1049
+ const packageSubagentPaths = collectPackageSubagentPaths(cwd, {
1050
+ includeUser: scope !== "project",
1051
+ includeProject: scope !== "user",
1052
+ });
819
1053
 
820
1054
  const builtinAgents = applyBuiltinOverrides(
821
1055
  loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"),
@@ -825,12 +1059,14 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
825
1059
  projectSettingsPath,
826
1060
  );
827
1061
 
1062
+ const userAgentsExtra = scope === "project" ? [] : extraUserAgentDirs().flatMap((dir) => loadAgentsFromDir(dir, "user"));
828
1063
  const userAgentsOld = scope === "project" ? [] : loadAgentsFromDir(userDirOld, "user");
829
1064
  const userAgentsNew = scope === "project" ? [] : loadAgentsFromDir(userDirNew, "user");
830
- const userAgents = [...userAgentsOld, ...userAgentsNew];
1065
+ const userAgents = [...userAgentsExtra, ...userAgentsOld, ...userAgentsNew];
831
1066
 
832
1067
  const projectAgents = scope === "user" ? [] : projectAgentDirs.flatMap((dir) => loadAgentsFromDir(dir, "project"));
833
- const agents = mergeAgentsForScope(scope, userAgents, projectAgents, builtinAgents)
1068
+ const packageAgents = packageSubagentPaths.agents.flatMap((dir) => loadAgentsFromDir(dir, "package"));
1069
+ const agents = mergeAgentsForScope(scope, userAgents, projectAgents, builtinAgents, packageAgents)
834
1070
  .filter((agent) => agent.disabled !== true);
835
1071
 
836
1072
  return { agents, projectAgentsDir };
@@ -838,6 +1074,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
838
1074
 
839
1075
  export function discoverAgentsAll(cwd: string): {
840
1076
  builtin: AgentConfig[];
1077
+ package: AgentConfig[];
841
1078
  user: AgentConfig[];
842
1079
  project: AgentConfig[];
843
1080
  chains: ChainConfig[];
@@ -858,6 +1095,7 @@ export function discoverAgentsAll(cwd: string): {
858
1095
  const projectSettingsPath = getProjectAgentSettingsPath(cwd);
859
1096
  const userSettings = readSubagentSettings(userSettingsPath);
860
1097
  const projectSettings = readSubagentSettings(projectSettingsPath);
1098
+ const packageSubagentPaths = collectPackageSubagentPaths(cwd);
861
1099
 
862
1100
  const builtin = applyBuiltinOverrides(
863
1101
  loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"),
@@ -867,9 +1105,17 @@ export function discoverAgentsAll(cwd: string): {
867
1105
  projectSettingsPath,
868
1106
  );
869
1107
  const user = [
1108
+ ...extraUserAgentDirs().flatMap((dir) => loadAgentsFromDir(dir, "user")),
870
1109
  ...loadAgentsFromDir(userDirOld, "user"),
871
1110
  ...loadAgentsFromDir(userDirNew, "user"),
872
1111
  ];
1112
+ const packageMap = new Map<string, AgentConfig>();
1113
+ for (const dir of packageSubagentPaths.agents) {
1114
+ for (const agent of loadAgentsFromDir(dir, "package")) {
1115
+ if (!packageMap.has(agent.name)) packageMap.set(agent.name, agent);
1116
+ }
1117
+ }
1118
+ const packageAgents = Array.from(packageMap.values());
873
1119
  const projectMap = new Map<string, AgentConfig>();
874
1120
  for (const dir of projectDirs) {
875
1121
  for (const agent of loadAgentsFromDir(dir, "project")) {
@@ -879,6 +1125,15 @@ export function discoverAgentsAll(cwd: string): {
879
1125
  const project = Array.from(projectMap.values());
880
1126
 
881
1127
  const chainMap = new Map<string, ChainConfig>();
1128
+ const packageChainDiagnostics: ChainDiscoveryDiagnostic[] = [];
1129
+ const packageChainMap = new Map<string, ChainConfig>();
1130
+ for (const dir of packageSubagentPaths.chains) {
1131
+ const loaded = loadChainsFromDir(dir, "package");
1132
+ packageChainDiagnostics.push(...loaded.diagnostics);
1133
+ for (const chain of loaded.chains) {
1134
+ if (!packageChainMap.has(chain.name)) packageChainMap.set(chain.name, chain);
1135
+ }
1136
+ }
882
1137
  const projectChainDiagnostics: ChainDiscoveryDiagnostic[] = [];
883
1138
  for (const dir of projectChainDirs) {
884
1139
  const loaded = loadChainsFromDir(dir, "project");
@@ -889,15 +1144,17 @@ export function discoverAgentsAll(cwd: string): {
889
1144
  }
890
1145
  const userChains = loadChainsFromDir(userChainDir, "user");
891
1146
  const chains = [
1147
+ ...Array.from(packageChainMap.values()),
892
1148
  ...userChains.chains,
893
1149
  ...Array.from(chainMap.values()),
894
1150
  ];
895
1151
  const chainDiagnostics = [
1152
+ ...packageChainDiagnostics,
896
1153
  ...userChains.diagnostics,
897
1154
  ...projectChainDiagnostics,
898
1155
  ];
899
1156
 
900
1157
  const userDir = process.env.PI_CODING_AGENT_DIR ? userDirOld : fs.existsSync(userDirNew) ? userDirNew : userDirOld;
901
1158
 
902
- return { builtin, user, project, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
1159
+ return { builtin, package: packageAgents, user, project, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
903
1160
  }
@@ -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];
@@ -81,7 +81,7 @@ function formatExistingDirectory(label: string, dirPath: string): string {
81
81
  }
82
82
 
83
83
  function formatSourceCounts(counts: Record<AgentSource, number>): string {
84
- return `builtin ${counts.builtin}, user ${counts.user}, project ${counts.project}`;
84
+ return `builtin ${counts.builtin}, package ${counts.package}, user ${counts.user}, project ${counts.project}`;
85
85
  }
86
86
 
87
87
  function formatSkillSourceCounts(skills: Array<{ source: SkillSource }>): string {
@@ -132,15 +132,16 @@ function formatDiscovery(input: DoctorReportInput, deps: DoctorDeps): string[] {
132
132
  const discovered = deps.discoverAgentsAll(input.cwd);
133
133
  const agentCounts = {
134
134
  builtin: discovered.builtin.length,
135
+ package: discovered.package?.length ?? 0,
135
136
  user: discovered.user.length,
136
137
  project: discovered.project.length,
137
138
  };
138
139
  const chainCounts = discovered.chains.reduce<Record<AgentSource, number>>((counts, chain) => {
139
140
  counts[chain.source] += 1;
140
141
  return counts;
141
- }, { builtin: 0, user: 0, project: 0 });
142
+ }, { builtin: 0, package: 0, user: 0, project: 0 });
142
143
  return [
143
- `- agents: total ${agentCounts.builtin + agentCounts.user + agentCounts.project} (${formatSourceCounts(agentCounts)})`,
144
+ `- agents: total ${agentCounts.builtin + agentCounts.package + agentCounts.user + agentCounts.project} (${formatSourceCounts(agentCounts)})`,
144
145
  `- chains: total ${discovered.chains.length} (${formatSourceCounts(chainCounts)})`,
145
146
  ].join("\n");
146
147
  }),
@@ -156,8 +156,6 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
156
156
  label: "Subagent",
157
157
  description: [
158
158
  "Delegate to subagents from child-safe fanout mode.",
159
- "For goal-style requests such as /goal, goal, active goal, or work until evidence says done, use explicit acceptance on the delegated run: criteria for the target, evidence/verify for proof, stopRules for constraints, and maxFinalizationTurns for the bounded loop.",
160
- "For implementation handoffs from a plan, PRD, spec, issue, or broad fix, put implementation instructions and plan paths in task, and put the definition of done, evidence, verification commands, constraints, and loop cap in acceptance.",
161
159
  "Allowed management/control actions: list, get, status, interrupt, resume, doctor.",
162
160
  "Agent config mutation actions create, update, and delete are blocked in this mode.",
163
161
  ].join("\n"),
@@ -33,8 +33,7 @@ import { registerSlashSubagentBridge } from "../slash/slash-bridge.ts";
33
33
  import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "../slash/slash-live-state.ts";
34
34
  import { inspectSubagentStatus } from "../runs/background/run-status.ts";
35
35
  import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/background/notify.ts";
36
- import { SUBAGENT_CHILD_ENV, SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
37
- import registerFanoutChildSubagentExtension from "./fanout-child.ts";
36
+ import { SUBAGENT_CHILD_ENV } from "../runs/shared/pi-args.ts";
38
37
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
39
38
  import { loadConfig } from "./config.ts";
40
39
  import {
@@ -209,7 +208,6 @@ class SubagentControlNoticeComponent implements Component {
209
208
 
210
209
  export default function registerSubagentExtension(pi: ExtensionAPI): void {
211
210
  if (process.env[SUBAGENT_CHILD_ENV] === "1") {
212
- if (process.env[SUBAGENT_FANOUT_CHILD_ENV] === "1") registerFanoutChildSubagentExtension(pi);
213
211
  return;
214
212
  }
215
213
  const globalStore = globalThis as Record<string, unknown>;
@@ -394,10 +392,7 @@ EXECUTION (use exactly ONE mode):
394
392
  • SINGLE: { agent, task? } - one task; omit task for self-contained agents
395
393
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
396
394
  • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
397
- • Foreground timeout: { timeoutMs } or { maxRuntimeMs } - wall-clock limit for foreground single, parallel, and chain runs. Timed-out children return timedOut:true with completed sibling/prior results preserved. Not for async/background runs.
398
395
  • Optional context: { context: "fresh" | "fork" } (default: if any requested agent has defaultContext: "fork", the whole invocation uses fork; otherwise "fresh"; inspect agent defaults via { action: "list" })
399
- • Goal-style requests: when the user says “/goal”, “goal”, “active goal”, “work until evidence says done”, or “verify against a goal”, model that as explicit acceptance. Use acceptance.criteria for the target, acceptance.evidence/verify for proof, acceptance.stopRules for constraints, and acceptance.maxFinalizationTurns for the bounded loop.
400
- • Plan/spec implementation handoffs: when delegating a plan, PRD, spec, issue, or broad fix to an editing-capable child, prefer structured acceptance instead of burying validation requirements in task prose. Put the implementation instructions and plan paths in task; put the definition of done, evidence, verification commands, constraints, and loop cap in acceptance.
401
396
 
402
397
  CHAIN TEMPLATE VARIABLES (use in task strings):
403
398
  • {task} - The original task/request from the user
@@ -409,8 +404,8 @@ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", t
409
404
  MANAGEMENT (use action field, omit agent/task/chain/tasks):
410
405
  • { action: "list" } - discover executable agents/chains
411
406
  • { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
412
- • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, maxExecutionTimeMs, maxTokens, ... } }
413
- • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", maxExecutionTimeMs, maxTokens, ... } } - merge
407
+ • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
408
+ • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
414
409
  • { action: "delete", agent: "code-analysis.custom-agent" }
415
410
  • Use chainName for chain operations; packaged chains also use dotted runtime names
416
411