pi-subagents 0.50.0 → 0.51.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 (92) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/agents/gpt-pro.md +17 -0
  3. package/async-retention-discovery-worker.mjs +180 -0
  4. package/docs/agents.md +35 -2
  5. package/docs/configuration.md +30 -12
  6. package/docs/extension-api.md +42 -1
  7. package/docs/observability.md +4 -4
  8. package/docs/tool-reference.md +38 -39
  9. package/docs/workflows.md +169 -3
  10. package/package.json +4 -2
  11. package/skills/pi-subagents/SKILL.md +5 -4
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +6 -4
  13. package/skills/pi-subagents/references/execution-controls.md +18 -15
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  15. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  16. package/src/agents/agent-management.ts +100 -345
  17. package/src/agents/agents.ts +125 -25
  18. package/src/api/external-job-provider.ts +185 -0
  19. package/src/api/preflight.ts +31 -6
  20. package/src/api/shared-types.ts +2 -0
  21. package/src/extension/config.ts +3 -3
  22. package/src/extension/doctor.ts +3 -6
  23. package/src/extension/fanout-child.ts +2 -2
  24. package/src/extension/index.ts +165 -88
  25. package/src/extension/public-execution.ts +28 -1
  26. package/src/extension/schemas.ts +12 -35
  27. package/src/extension/tool-description.ts +35 -24
  28. package/src/inspectors/herdr/actions.ts +2 -2
  29. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  30. package/src/inspectors/herdr/project-panes.ts +2 -2
  31. package/src/intercom/native-supervisor-channel.ts +30 -9
  32. package/src/missions/lifecycle.ts +6 -1
  33. package/src/missions/store.ts +4 -9
  34. package/src/profiles/profiles.ts +3 -1
  35. package/src/runs/background/active-run-index.ts +31 -8
  36. package/src/runs/background/async-execution.ts +49 -31
  37. package/src/runs/background/async-job-tracker.ts +20 -4
  38. package/src/runs/background/async-resume.ts +28 -13
  39. package/src/runs/background/async-retention.ts +888 -0
  40. package/src/runs/background/async-status.ts +39 -53
  41. package/src/runs/background/chain-append.ts +3 -33
  42. package/src/runs/background/control-channel.ts +14 -68
  43. package/src/runs/background/index-segment.ts +59 -0
  44. package/src/runs/background/notify.ts +3 -1
  45. package/src/runs/background/result-files.ts +158 -90
  46. package/src/runs/background/result-watcher.ts +72 -20
  47. package/src/runs/background/retained-children.ts +13 -3
  48. package/src/runs/background/run-id-query.ts +7 -0
  49. package/src/runs/background/run-id-resolver.ts +11 -9
  50. package/src/runs/background/run-status.ts +27 -18
  51. package/src/runs/background/scheduled-runs.ts +22 -4
  52. package/src/runs/background/stale-run-reconciler.ts +6 -3
  53. package/src/runs/background/steering.ts +11 -1
  54. package/src/runs/background/subagent-runner.ts +250 -138
  55. package/src/runs/background/subagent-wait.ts +7 -7
  56. package/src/runs/background/terminal-run-index.ts +129 -0
  57. package/src/runs/background/wait-completions.ts +21 -4
  58. package/src/runs/background/wait-subscriptions.ts +80 -1
  59. package/src/runs/foreground/async-steering-action.ts +21 -14
  60. package/src/runs/foreground/execution.ts +2 -0
  61. package/src/runs/foreground/subagent-executor.ts +460 -1536
  62. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  63. package/src/runs/shared/chain-outputs.ts +1 -3
  64. package/src/runs/shared/external-job-bridge.ts +450 -0
  65. package/src/runs/shared/external-job-runner.ts +286 -0
  66. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  67. package/src/runs/shared/model-fallback.ts +22 -5
  68. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  69. package/src/runs/shared/parallel-handoff.ts +46 -4
  70. package/src/runs/shared/parallel-utils.ts +4 -15
  71. package/src/runs/shared/permissions.ts +5 -1
  72. package/src/runs/shared/pi-args.ts +8 -1
  73. package/src/runs/shared/subagent-control.ts +26 -4
  74. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  75. package/src/runs/shared/workflow-graph.ts +1 -23
  76. package/src/runs/shared/worktree.ts +12 -1
  77. package/src/shared/atomic-json.ts +22 -2
  78. package/src/shared/capacity-resilient-json.ts +102 -0
  79. package/src/shared/completion-owner.ts +14 -0
  80. package/src/shared/file-system-retry.ts +49 -1
  81. package/src/shared/fork-context.ts +42 -0
  82. package/src/shared/prompt-resources.ts +0 -40
  83. package/src/shared/settings.ts +3 -27
  84. package/src/shared/types.ts +60 -26
  85. package/src/shared/utils.ts +8 -0
  86. package/src/shared/watch-strategy.ts +10 -0
  87. package/src/slash/slash-commands.ts +11 -3
  88. package/src/tui/fleet.ts +63 -15
  89. package/src/workflows/chat-progress.ts +1 -1
  90. package/src/workflows/scripted-workflow.ts +207 -65
  91. package/src/runs/foreground/chain-clarify.ts +0 -1354
  92. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -38,6 +38,7 @@ export interface AgentMemoryConfig {
38
38
  export const BUILTIN_AGENT_NAMES = [
39
39
  "advisor",
40
40
  "delegate",
41
+ "gpt-pro",
41
42
  "oracle",
42
43
  "researcher",
43
44
  "reviewer",
@@ -137,6 +138,7 @@ export interface AgentConfig {
137
138
  systemPrompt: string;
138
139
  source: AgentSource;
139
140
  filePath: string;
141
+ discoveryPriority?: number;
140
142
  skills?: string[];
141
143
  skillPath?: string[];
142
144
  extensions?: string[];
@@ -212,8 +214,45 @@ export interface ChainDiscoveryDiagnostic {
212
214
  error: string;
213
215
  }
214
216
 
217
+ export interface AgentDiscoveryDiagnostic extends ChainDiscoveryDiagnostic {
218
+ name?: string;
219
+ runtimeName?: string;
220
+ packageSpecified?: boolean;
221
+ discoveryPriority?: number;
222
+ }
223
+
224
+ const AGENT_SOURCE_PRIORITY: Record<AgentSource, number> = {
225
+ builtin: 0,
226
+ package: 1,
227
+ user: 2,
228
+ project: 3,
229
+ };
230
+
231
+ function agentDefinitionPriority(definition: Pick<AgentConfig | AgentDiscoveryDiagnostic, "source" | "discoveryPriority">): number {
232
+ return AGENT_SOURCE_PRIORITY[definition.source] * 1_000_000
233
+ + (definition.discoveryPriority ?? 0);
234
+ }
235
+
236
+ export function findBlockingAgentDiagnostic(name: string, agent: AgentConfig | readonly AgentConfig[] | undefined, diagnostics: AgentDiscoveryDiagnostic[] | undefined): AgentDiscoveryDiagnostic | undefined {
237
+ const normalizedName = name.trim();
238
+ const agents = Array.isArray(agent) ? agent : agent ? [agent] : [];
239
+ let match: AgentDiscoveryDiagnostic | undefined;
240
+ for (const diagnostic of diagnostics ?? []) {
241
+ if ((diagnostic.runtimeName === normalizedName
242
+ || (diagnostic.name === normalizedName && (!diagnostic.packageSpecified
243
+ || diagnostic.runtimeName === undefined
244
+ || agents.some((agent) => agent.name === diagnostic.runtimeName && agent.localName === diagnostic.name))))
245
+ && (!match || agentDefinitionPriority(diagnostic) > agentDefinitionPriority(match))) {
246
+ match = diagnostic;
247
+ }
248
+ }
249
+ const highestPriority = Math.max(...agents.map(agentDefinitionPriority), -Infinity);
250
+ return !agents.length || (match && agentDefinitionPriority(match) > highestPriority) ? match : undefined;
251
+ }
252
+
215
253
  interface AgentDiscoveryResult {
216
254
  agents: AgentConfig[];
255
+ agentDiagnostics?: AgentDiscoveryDiagnostic[];
217
256
  projectAgentsDir: string | null;
218
257
  modelScope?: ModelScopeConfig;
219
258
  }
@@ -1448,6 +1487,14 @@ function isLegacyAgentSkillPath(rootDir: string, filePath: string): boolean {
1448
1487
  return parts.some((part, index) => part === ".agents" && parts[index + 1] === "skills");
1449
1488
  }
1450
1489
 
1490
+ function isJsonSerializable(value: unknown): boolean {
1491
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
1492
+ if (typeof value === "number") return Number.isFinite(value);
1493
+ if (Array.isArray(value)) return value.every(isJsonSerializable);
1494
+ if (value && typeof value === "object") return Object.values(value).every(isJsonSerializable);
1495
+ return false;
1496
+ }
1497
+
1451
1498
  function parseAgentRunnerFrontmatter(raw: string | undefined, agentName: string): AgentRunnerConfig | undefined {
1452
1499
  if (raw === undefined || !raw.trim()) return undefined;
1453
1500
  let parsed: unknown;
@@ -1464,8 +1511,24 @@ function parseAgentRunnerFrontmatter(raw: string | undefined, agentName: string)
1464
1511
  if (Object.keys(runner).some((key) => key !== "type")) throw new Error(`Agent '${agentName}' has invalid Pi runner frontmatter; only 'type' is supported.`);
1465
1512
  return { type: "pi" };
1466
1513
  }
1514
+ if (runner.type === "external-job") {
1515
+ if (typeof runner.provider !== "string" || !runner.provider.trim() || runner.provider.trim() !== runner.provider) {
1516
+ throw new Error(`Agent '${agentName}' external-job runner requires a non-empty trimmed provider string.`);
1517
+ }
1518
+ if (runner.options !== undefined && (!runner.options || typeof runner.options !== "object" || Array.isArray(runner.options) || !isJsonSerializable(runner.options))) {
1519
+ throw new Error(`Agent '${agentName}' external-job runner options must be a JSON-serializable object.`);
1520
+ }
1521
+ const supported = new Set(["type", "provider", "options"]);
1522
+ const unknown = Object.keys(runner).filter((key) => !supported.has(key));
1523
+ if (unknown.length > 0) throw new Error(`Agent '${agentName}' external-job runner has unsupported fields: ${unknown.join(", ")}.`);
1524
+ return {
1525
+ type: "external-job",
1526
+ provider: runner.provider,
1527
+ ...(runner.options ? { options: runner.options as Record<string, unknown> } : {}),
1528
+ };
1529
+ }
1467
1530
  if (runner.type !== "external-cli") {
1468
- throw new Error(`Agent '${agentName}' has invalid runner.type; expected 'pi' or 'external-cli'.`);
1531
+ throw new Error(`Agent '${agentName}' has invalid runner.type; expected 'pi', 'external-cli', or 'external-job'.`);
1469
1532
  }
1470
1533
  if (typeof runner.command !== "string" || !runner.command.trim()) {
1471
1534
  throw new Error(`Agent '${agentName}' external-cli runner requires a non-empty command string.`);
@@ -1489,11 +1552,11 @@ function parseAgentRunnerFrontmatter(raw: string | undefined, agentName: string)
1489
1552
  }
1490
1553
 
1491
1554
  function validateExternalRunnerProfile(frontmatter: Record<string, string>, agentName: string, runner: AgentRunnerConfig | undefined): void {
1492
- if (runner?.type !== "external-cli") return;
1555
+ if (runner?.type !== "external-cli" && runner?.type !== "external-job") return;
1493
1556
  const unsupported = ["tools", "model", "fallbackModels", "thinking", "extensions", "subagentOnlyExtensions", "maxSubagentDepth", "completionGuard", "skills", "skill", "skillPath", "toolBudget", "permission", "permissions"]
1494
1557
  .filter((field) => frontmatter[field] !== undefined);
1495
1558
  if (unsupported.length > 0) {
1496
- throw new Error(`Agent '${agentName}' uses runner.type='external-cli' and declares unsupported Pi-only fields: ${unsupported.join(", ")}.`);
1559
+ throw new Error(`Agent '${agentName}' uses runner.type='${runner.type}' and declares unsupported Pi-only fields: ${unsupported.join(", ")}.`);
1497
1560
  }
1498
1561
  }
1499
1562
 
@@ -1510,8 +1573,9 @@ function parseAgentAcceptanceFrontmatter(raw: string | undefined, agentName: str
1510
1573
  return parsed as AcceptanceInput;
1511
1574
  }
1512
1575
 
1513
- function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
1576
+ function loadAgentsFromDir(dir: string, source: AgentSource, discoveryPriority?: number): { agents: AgentConfig[]; diagnostics: AgentDiscoveryDiagnostic[] } {
1514
1577
  const agents: AgentConfig[] = [];
1578
+ const diagnostics: AgentDiscoveryDiagnostic[] = [];
1515
1579
 
1516
1580
  for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) {
1517
1581
  if (isLegacyAgentSkillPath(dir, filePath)) {
@@ -1525,6 +1589,10 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
1525
1589
  continue;
1526
1590
  }
1527
1591
 
1592
+ let name: string | undefined;
1593
+ let runtimeName: string | undefined;
1594
+ let packageSpecified = false;
1595
+ try {
1528
1596
  const { frontmatter, body } = parseFrontmatter(content);
1529
1597
 
1530
1598
  if (!frontmatter.name || !frontmatter.description) {
@@ -1532,10 +1600,12 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
1532
1600
  }
1533
1601
 
1534
1602
  const localName = frontmatter.name;
1603
+ name = localName;
1535
1604
  const parsedPackage = parsePackageName(frontmatter.package, `Agent '${localName}' package`);
1536
- if (parsedPackage.error) continue;
1605
+ packageSpecified = parsedPackage.packageName !== undefined || parsedPackage.error !== undefined;
1606
+ if (parsedPackage.error) throw new Error(parsedPackage.error);
1537
1607
  const packageName = parsedPackage.packageName;
1538
- const runtimeName = buildRuntimeName(localName, packageName);
1608
+ runtimeName = buildRuntimeName(localName, packageName);
1539
1609
 
1540
1610
  const runner = parseAgentRunnerFrontmatter(frontmatter.runner, localName);
1541
1611
  validateExternalRunnerProfile(frontmatter, localName, runner);
@@ -1664,6 +1734,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
1664
1734
  systemPrompt: body,
1665
1735
  source,
1666
1736
  filePath,
1737
+ ...(discoveryPriority !== undefined ? { discoveryPriority } : {}),
1667
1738
  ...(skills?.length ? { skills } : {}),
1668
1739
  ...(skillPath?.length ? { skillPath } : {}),
1669
1740
  ...(extensions !== undefined ? { extensions } : {}),
@@ -1681,9 +1752,12 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
1681
1752
  };
1682
1753
  agentFrontmatterFields.set(agent, new Set(Object.keys(frontmatter)));
1683
1754
  agents.push(agent);
1755
+ } catch (error) {
1756
+ diagnostics.push({ source, filePath, ...(name ? { name } : {}), ...(runtimeName && runtimeName !== name ? { runtimeName } : {}), ...(packageSpecified ? { packageSpecified: true } : {}), ...(discoveryPriority !== undefined ? { discoveryPriority } : {}), error: error instanceof Error ? error.message : String(error) });
1757
+ }
1684
1758
  }
1685
1759
 
1686
- return agents;
1760
+ return { agents, diagnostics };
1687
1761
  }
1688
1762
 
1689
1763
  function loadChainsFromDir(dir: string, source: AgentSource): { chains: ChainConfig[]; diagnostics: ChainDiscoveryDiagnostic[] } {
@@ -1781,34 +1855,42 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
1781
1855
  includeProject: scope !== "user",
1782
1856
  });
1783
1857
 
1858
+ const builtinLoaded = loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin");
1784
1859
  const builtinAgents = applyBuiltinOverrides(
1785
- applySubagentDefaults(loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"), defaultModel, defaultThinking, defaultExtensions),
1860
+ applySubagentDefaults(builtinLoaded.agents, defaultModel, defaultThinking, defaultExtensions),
1786
1861
  userSettings,
1787
1862
  projectSettings,
1788
1863
  userSettingsPath,
1789
1864
  projectSettingsPath,
1790
1865
  );
1791
1866
 
1792
- const userAgentsExtra = scope === "project" ? [] : extraUserAgentDirs().flatMap((dir) => loadAgentsFromDir(dir, "user"));
1793
- const userAgentsOld = scope === "project" ? [] : loadAgentsFromDir(userDirOld, "user");
1794
- const userAgentsNew = scope === "project" ? [] : loadAgentsFromDir(userDirNew, "user");
1867
+ const userLoaded = scope === "project" ? [] : [...extraUserAgentDirs(), userDirOld, userDirNew]
1868
+ .map((dir, discoveryPriority) => loadAgentsFromDir(dir, "user", discoveryPriority));
1795
1869
  const userAgents = applyCustomAgentOverrides(
1796
- applySubagentDefaults([...userAgentsExtra, ...userAgentsOld, ...userAgentsNew], defaultModel, defaultThinking, defaultExtensions),
1870
+ applySubagentDefaults(userLoaded.flatMap((loaded) => loaded.agents), defaultModel, defaultThinking, defaultExtensions),
1797
1871
  userSettings,
1798
1872
  projectSettings,
1799
1873
  userSettingsPath,
1800
1874
  projectSettingsPath,
1801
1875
  );
1802
1876
 
1877
+ const projectLoaded = scope === "user" ? [] : projectAgentDirs.map((dir) => loadAgentsFromDir(dir, "project", dir === projectAgentsDir ? 1 : 0));
1803
1878
  const projectAgents = applyCustomAgentOverrides(
1804
- applySubagentDefaults(scope === "user" ? [] : projectAgentDirs.flatMap((dir) => loadAgentsFromDir(dir, "project")), defaultModel, defaultThinking, defaultExtensions),
1879
+ applySubagentDefaults(projectLoaded.flatMap((loaded) => loaded.agents), defaultModel, defaultThinking, defaultExtensions),
1805
1880
  userSettings,
1806
1881
  projectSettings,
1807
1882
  userSettingsPath,
1808
1883
  projectSettingsPath,
1809
1884
  );
1885
+ const packageLoaded = packageSubagentPaths.agents.map((dir, index) => loadAgentsFromDir(dir, "package", packageSubagentPaths.agents.length - index));
1886
+ const packageMap = new Map<string, AgentConfig>();
1887
+ for (const loaded of packageLoaded) {
1888
+ for (const agent of loaded.agents) {
1889
+ if (!packageMap.has(agent.name)) packageMap.set(agent.name, agent);
1890
+ }
1891
+ }
1810
1892
  const packageAgents = applyCustomAgentOverrides(
1811
- applySubagentDefaults(packageSubagentPaths.agents.flatMap((dir) => loadAgentsFromDir(dir, "package")), defaultModel, defaultThinking, defaultExtensions),
1893
+ applySubagentDefaults(Array.from(packageMap.values()), defaultModel, defaultThinking, defaultExtensions),
1812
1894
  userSettings,
1813
1895
  projectSettings,
1814
1896
  userSettingsPath,
@@ -1817,7 +1899,13 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
1817
1899
  const agents = mergeAgentsForScope(scope, userAgents, projectAgents, builtinAgents, packageAgents)
1818
1900
  .filter((agent) => agent.disabled !== true);
1819
1901
 
1820
- return { agents, projectAgentsDir, ...(modelScope !== undefined ? { modelScope } : {}) };
1902
+ const agentDiagnostics = [
1903
+ ...builtinLoaded.diagnostics,
1904
+ ...userLoaded.flatMap((loaded) => loaded.diagnostics),
1905
+ ...projectLoaded.flatMap((loaded) => loaded.diagnostics),
1906
+ ...packageLoaded.flatMap((loaded) => loaded.diagnostics),
1907
+ ];
1908
+ return { agents, agentDiagnostics, projectAgentsDir, ...(modelScope !== undefined ? { modelScope } : {}) };
1821
1909
  }
1822
1910
 
1823
1911
  export function discoverAgentsAll(cwd: string): {
@@ -1825,6 +1913,7 @@ export function discoverAgentsAll(cwd: string): {
1825
1913
  package: AgentConfig[];
1826
1914
  user: AgentConfig[];
1827
1915
  project: AgentConfig[];
1916
+ agentDiagnostics?: AgentDiscoveryDiagnostic[];
1828
1917
  chains: ChainConfig[];
1829
1918
  chainDiagnostics: ChainDiscoveryDiagnostic[];
1830
1919
  userDir: string;
@@ -1848,27 +1937,29 @@ export function discoverAgentsAll(cwd: string): {
1848
1937
  const defaultExtensions = resolveSubagentDefaultExtensions(userSettings, projectSettings, projectSettingsPath);
1849
1938
  const packageSubagentPaths = collectPackageSubagentPaths(cwd);
1850
1939
 
1940
+ const builtinLoaded = loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin");
1851
1941
  const builtin = applyBuiltinOverrides(
1852
- applySubagentDefaults(loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"), defaultModel, defaultThinking, defaultExtensions),
1942
+ applySubagentDefaults(builtinLoaded.agents, defaultModel, defaultThinking, defaultExtensions),
1853
1943
  userSettings,
1854
1944
  projectSettings,
1855
1945
  userSettingsPath,
1856
1946
  projectSettingsPath,
1857
1947
  );
1948
+ const userLoaded = [...extraUserAgentDirs(), userDirOld, userDirNew]
1949
+ .map((dir, discoveryPriority) => loadAgentsFromDir(dir, "user", discoveryPriority));
1858
1950
  const user = applyCustomAgentOverrides(
1859
- applySubagentDefaults([
1860
- ...extraUserAgentDirs().flatMap((dir) => loadAgentsFromDir(dir, "user")),
1861
- ...loadAgentsFromDir(userDirOld, "user"),
1862
- ...loadAgentsFromDir(userDirNew, "user"),
1863
- ], defaultModel, defaultThinking, defaultExtensions),
1951
+ applySubagentDefaults(userLoaded.flatMap((loaded) => loaded.agents), defaultModel, defaultThinking, defaultExtensions),
1864
1952
  userSettings,
1865
1953
  projectSettings,
1866
1954
  userSettingsPath,
1867
1955
  projectSettingsPath,
1868
1956
  );
1869
1957
  const packageMap = new Map<string, AgentConfig>();
1870
- for (const dir of packageSubagentPaths.agents) {
1871
- for (const agent of loadAgentsFromDir(dir, "package")) {
1958
+ const packageAgentDiagnostics: AgentDiscoveryDiagnostic[] = [];
1959
+ for (const [index, dir] of packageSubagentPaths.agents.entries()) {
1960
+ const loaded = loadAgentsFromDir(dir, "package", packageSubagentPaths.agents.length - index);
1961
+ packageAgentDiagnostics.push(...loaded.diagnostics);
1962
+ for (const agent of loaded.agents) {
1872
1963
  if (!packageMap.has(agent.name)) packageMap.set(agent.name, agent);
1873
1964
  }
1874
1965
  }
@@ -1880,8 +1971,11 @@ export function discoverAgentsAll(cwd: string): {
1880
1971
  projectSettingsPath,
1881
1972
  );
1882
1973
  const projectMap = new Map<string, AgentConfig>();
1974
+ const projectAgentDiagnostics: AgentDiscoveryDiagnostic[] = [];
1883
1975
  for (const dir of projectDirs) {
1884
- for (const agent of loadAgentsFromDir(dir, "project")) {
1976
+ const loaded = loadAgentsFromDir(dir, "project", dir === projectDir ? 1 : 0);
1977
+ projectAgentDiagnostics.push(...loaded.diagnostics);
1978
+ for (const agent of loaded.agents) {
1885
1979
  projectMap.set(agent.name, agent);
1886
1980
  }
1887
1981
  }
@@ -1922,8 +2016,14 @@ export function discoverAgentsAll(cwd: string): {
1922
2016
  ...userChains.diagnostics,
1923
2017
  ...projectChainDiagnostics,
1924
2018
  ];
2019
+ const agentDiagnostics = [
2020
+ ...builtinLoaded.diagnostics,
2021
+ ...userLoaded.flatMap((loaded) => loaded.diagnostics),
2022
+ ...packageAgentDiagnostics,
2023
+ ...projectAgentDiagnostics,
2024
+ ];
1925
2025
 
1926
2026
  const userDir = process.env.PI_CODING_AGENT_DIR ? userDirOld : fs.existsSync(userDirNew) ? userDirNew : userDirOld;
1927
2027
 
1928
- return { builtin, package: packageAgents, user, project, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
2028
+ return { builtin, package: packageAgents, user, project, agentDiagnostics, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
1929
2029
  }
@@ -0,0 +1,185 @@
1
+ export const EXTERNAL_JOB_PROVIDER_PROTOCOL_VERSION = 1;
2
+ export const EXTERNAL_JOB_PROVIDER_REGISTRY_KEY = "pi-subagents.external-job-providers.v1";
3
+
4
+ const MAX_PROVIDER_NAME_LENGTH = 128;
5
+ const MAX_PROVIDERS = 100;
6
+ const MAX_JOB_ID_LENGTH = 256;
7
+ const MAX_FAILURE_CODE_LENGTH = 128;
8
+ const MAX_FAILURE_MESSAGE_LENGTH = 4_096;
9
+ const MAX_URL_LENGTH = 4_096;
10
+
11
+ export type ExternalJobState = "queued" | "running" | "completed" | "failed" | "stopped" | "blocked";
12
+ export type ExternalJobOperation = "start" | "status" | "result" | "reattach";
13
+
14
+ export type ExternalJobOptions = Record<string, unknown>;
15
+
16
+ export interface ExternalJobStartInput {
17
+ prompt: string;
18
+ promptDigest: string;
19
+ cwd: string;
20
+ runId: string;
21
+ stepIndex: number;
22
+ agent: string;
23
+ options: ExternalJobOptions;
24
+ sessionId?: string;
25
+ }
26
+
27
+ export interface ExternalJobHandle {
28
+ providerJobId: string;
29
+ state: ExternalJobState;
30
+ handleUrl?: string;
31
+ conversationUrl?: string;
32
+ failureCode?: string;
33
+ failureMessage?: string;
34
+ blockingJobId?: string;
35
+ }
36
+
37
+ export interface ExternalJobResult extends ExternalJobHandle {
38
+ output?: string;
39
+ artifactPath?: string;
40
+ }
41
+
42
+ export interface ExternalJobProvider {
43
+ name: string;
44
+ start(input: ExternalJobStartInput): Promise<ExternalJobHandle> | ExternalJobHandle;
45
+ status(providerJobId: string): Promise<ExternalJobHandle> | ExternalJobHandle;
46
+ result(providerJobId: string): Promise<ExternalJobResult> | ExternalJobResult;
47
+ reattach(providerJobId: string): Promise<ExternalJobHandle> | ExternalJobHandle;
48
+ }
49
+
50
+ export class ExternalJobProviderError extends Error {
51
+ readonly code: string;
52
+ readonly blockingJobId?: string;
53
+
54
+ constructor(message: string, options: { code: string; blockingJobId?: string; cause?: unknown }) {
55
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
56
+ this.name = "ExternalJobProviderError";
57
+ this.code = options.code;
58
+ this.blockingJobId = options.blockingJobId;
59
+ }
60
+ }
61
+
62
+ interface ExternalJobProviderRegistry {
63
+ version: typeof EXTERNAL_JOB_PROVIDER_PROTOCOL_VERSION;
64
+ providers: Map<string, ExternalJobProvider>;
65
+ }
66
+
67
+ function registry(): ExternalJobProviderRegistry {
68
+ const key = Symbol.for(EXTERNAL_JOB_PROVIDER_REGISTRY_KEY);
69
+ const globalObject = globalThis as Record<PropertyKey, unknown>;
70
+ const existing = globalObject[key];
71
+ if (existing === undefined) {
72
+ const created: ExternalJobProviderRegistry = {
73
+ version: EXTERNAL_JOB_PROVIDER_PROTOCOL_VERSION,
74
+ providers: new Map(),
75
+ };
76
+ globalObject[key] = created;
77
+ return created;
78
+ }
79
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
80
+ throw new Error(`Malformed external-job provider registry at Symbol.for("${EXTERNAL_JOB_PROVIDER_REGISTRY_KEY}").`);
81
+ }
82
+ const candidate = existing as Partial<ExternalJobProviderRegistry>;
83
+ if (candidate.version !== EXTERNAL_JOB_PROVIDER_PROTOCOL_VERSION || !(candidate.providers instanceof Map)) {
84
+ throw new Error(`Unsupported external-job provider registry at Symbol.for("${EXTERNAL_JOB_PROVIDER_REGISTRY_KEY}").`);
85
+ }
86
+ return candidate as ExternalJobProviderRegistry;
87
+ }
88
+
89
+ function validateString(value: unknown, field: string, maxLength: number): string {
90
+ if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
91
+ throw new Error(`${field} must be a non-empty string without leading or trailing whitespace.`);
92
+ }
93
+ if (value.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters.`);
94
+ if (value.includes("\0")) throw new Error(`${field} must not contain NUL characters.`);
95
+ return value;
96
+ }
97
+
98
+ function validateOptionalString(value: unknown, field: string, maxLength: number): string | undefined {
99
+ if (value === undefined) return undefined;
100
+ return validateString(value, field, maxLength);
101
+ }
102
+
103
+ function validateState(value: unknown, field: string): ExternalJobState {
104
+ if (value === "queued" || value === "running" || value === "completed" || value === "failed" || value === "stopped" || value === "blocked") return value;
105
+ throw new Error(`${field} must be queued, running, completed, failed, stopped, or blocked.`);
106
+ }
107
+
108
+ function validateHandle(provider: string, value: unknown, field: string, extraFields: readonly string[] = []): ExternalJobHandle {
109
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${field} from external-job provider '${provider}' must be an object.`);
110
+ const handle = value as Record<string, unknown>;
111
+ const supported = new Set(["providerJobId", "state", "handleUrl", "conversationUrl", "failureCode", "failureMessage", "blockingJobId", ...extraFields]);
112
+ const unknown = Object.keys(handle).filter((key) => !supported.has(key));
113
+ if (unknown.length > 0) throw new Error(`${field} from external-job provider '${provider}' has unknown fields: ${unknown.join(", ")}.`);
114
+ const handleUrl = validateOptionalString(handle.handleUrl, `${field}.handleUrl`, MAX_URL_LENGTH);
115
+ const conversationUrl = validateOptionalString(handle.conversationUrl, `${field}.conversationUrl`, MAX_URL_LENGTH);
116
+ const failureCode = validateOptionalString(handle.failureCode, `${field}.failureCode`, MAX_FAILURE_CODE_LENGTH);
117
+ const failureMessage = validateOptionalString(handle.failureMessage, `${field}.failureMessage`, MAX_FAILURE_MESSAGE_LENGTH);
118
+ const blockingJobId = validateOptionalString(handle.blockingJobId, `${field}.blockingJobId`, MAX_JOB_ID_LENGTH);
119
+ return {
120
+ providerJobId: validateString(handle.providerJobId, `${field}.providerJobId`, MAX_JOB_ID_LENGTH),
121
+ state: validateState(handle.state, `${field}.state`),
122
+ ...(handleUrl ? { handleUrl } : {}),
123
+ ...(conversationUrl ? { conversationUrl } : {}),
124
+ ...(failureCode ? { failureCode } : {}),
125
+ ...(failureMessage ? { failureMessage } : {}),
126
+ ...(blockingJobId ? { blockingJobId } : {}),
127
+ };
128
+ }
129
+
130
+ export function validateExternalJobHandle(provider: string, value: unknown, field = "External-job handle"): ExternalJobHandle {
131
+ return validateHandle(provider, value, field);
132
+ }
133
+
134
+ export function validateExternalJobResult(provider: string, value: unknown, field = "External-job result"): ExternalJobResult {
135
+ const result = validateHandle(provider, value, field, ["output", "artifactPath"]) as ExternalJobResult;
136
+ const record = value as Record<string, unknown>;
137
+ const output = validateOptionalString(record.output, `${field}.output`, 1024 * 1024);
138
+ const artifactPath = validateOptionalString(record.artifactPath, `${field}.artifactPath`, MAX_URL_LENGTH);
139
+ return {
140
+ ...result,
141
+ ...(output !== undefined ? { output } : {}),
142
+ ...(artifactPath !== undefined ? { artifactPath } : {}),
143
+ };
144
+ }
145
+
146
+ function validateProvider(value: unknown): ExternalJobProvider {
147
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("External-job provider must be an object.");
148
+ const provider = value as Record<string, unknown>;
149
+ const unknownFields = Object.keys(provider).filter((key) => !["name", "start", "status", "result", "reattach"].includes(key));
150
+ if (unknownFields.length > 0) throw new Error(`External-job provider has unknown fields: ${unknownFields.join(", ")}.`);
151
+ const name = validateString(provider.name, "External-job provider name", MAX_PROVIDER_NAME_LENGTH);
152
+ for (const op of ["start", "status", "result", "reattach"] as const) {
153
+ if (typeof provider[op] !== "function") throw new Error(`External-job provider '${name}' must expose ${op}().`);
154
+ }
155
+ return value as ExternalJobProvider;
156
+ }
157
+
158
+ export function registerExternalJobProvider(provider: ExternalJobProvider): () => void {
159
+ const validated = validateProvider(provider);
160
+ const current = registry();
161
+ if (!current.providers.has(validated.name) && current.providers.size >= MAX_PROVIDERS) {
162
+ throw new Error(`External-job provider registry supports at most ${MAX_PROVIDERS} providers.`);
163
+ }
164
+ current.providers.set(validated.name, validated);
165
+ return () => {
166
+ if (current.providers.get(validated.name) === validated) current.providers.delete(validated.name);
167
+ };
168
+ }
169
+
170
+ export function listExternalJobProviders(): readonly ExternalJobProvider[] {
171
+ const current = registry();
172
+ if (current.providers.size > MAX_PROVIDERS) throw new Error(`External-job provider registry contains more than ${MAX_PROVIDERS} providers.`);
173
+ const providers: ExternalJobProvider[] = [];
174
+ for (const [key, value] of current.providers) {
175
+ const provider = validateProvider(value);
176
+ if (key !== provider.name) throw new Error(`External-job provider registry key '${key}' does not match provider name '${provider.name}'.`);
177
+ providers.push(provider);
178
+ }
179
+ return providers;
180
+ }
181
+
182
+ export function getExternalJobProvider(name: string): ExternalJobProvider | undefined {
183
+ const safeName = validateString(name, "External-job provider name", MAX_PROVIDER_NAME_LENGTH);
184
+ return listExternalJobProviders().find((provider) => provider.name === safeName);
185
+ }
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { discoverAgents, discoverAgentsAll, resolveAgentName, type AgentConfig, type AgentScope, type AgentSource } from "../agents/agents.ts";
5
+ import { discoverAgents, discoverAgentsAll, findBlockingAgentDiagnostic, resolveAgentName, type AgentConfig, type AgentScope, type AgentSource } from "../agents/agents.ts";
6
6
  import { resolveExecutionAgentScope } from "../agents/agent-scope.ts";
7
7
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../agents/skills.ts";
8
8
  import { buildAgentMemoryInjection } from "../agents/agent-memory.ts";
@@ -17,6 +17,8 @@ import { appendTurnBudgetSystemPrompt } from "../runs/shared/turn-budget.ts";
17
17
  import type { ResolvedTurnBudget } from "../shared/types.ts";
18
18
  import type { ResolvedMcpDirectToolSelection } from "../runs/shared/mcp-direct-tool-allowlist.ts";
19
19
  import { resolveStepBehavior } from "../shared/settings.ts";
20
+ import { canPreferForkFromSnapshot, resolveSubagentLaunchContext } from "../shared/fork-context.ts";
21
+ import { loadConfig } from "../extension/config.ts";
20
22
  import { agentDefinitionDigest, AGENT_DEFINITION_PROJECTION_VERSION, launchBindingDigest } from "../shared/launch-contract.ts";
21
23
  import { DIRS, TEMP_ROOT_DIR } from "../shared/types.ts";
22
24
  import { processTerminalCandidatePath, processTerminalPath } from "../runs/background/process-terminal.ts";
@@ -60,6 +62,8 @@ export interface SubagentLaunchContractInput {
60
62
  artifacts?: boolean;
61
63
  artifactDir?: ArtifactDirPreference;
62
64
  parentSessionFile?: string | null;
65
+ /** Current parent leaf required before an implicit `defaultContext: fork` stays `fork`. */
66
+ parentLeafId?: string | null;
63
67
  sessionRoot?: string;
64
68
  sessionDir?: string;
65
69
  runId?: string;
@@ -190,6 +194,18 @@ function normalizeAvailableModels(models: SubagentLaunchContractInput["available
190
194
  return (models ?? []).map((model) => ({ ...model, fullId: model.fullId ?? `${model.provider}/${model.id}` }));
191
195
  }
192
196
 
197
+ function resolveLaunchContractContext(input: SubagentLaunchContractInput, agent: AgentConfig): "fresh" | "fork" {
198
+ return resolveSubagentLaunchContext({
199
+ explicitContext: input.context,
200
+ agentDefaultContext: agent.defaultContext,
201
+ defaultSubagentContext: loadConfig().defaultSubagentContext,
202
+ canUseImplicitFork: canPreferForkFromSnapshot({
203
+ parentSessionFile: input.parentSessionFile,
204
+ leafId: input.parentLeafId,
205
+ }),
206
+ });
207
+ }
208
+
193
209
  function candidateList(inputAgent: string, selected: AgentConfig | undefined, cwd: string): SubagentLaunchContractAgentCandidate[] {
194
210
  const all = discoverAgentsAll(cwd);
195
211
  return [...all.builtin, ...all.package, ...all.user, ...all.project]
@@ -222,12 +238,17 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
222
238
  if (input.artifactDir !== undefined && input.artifactDir !== "project" && input.artifactDir !== "session" && input.artifactDir !== "temp") {
223
239
  return { ok: false, code: "invalid_artifact_dir", message: `Unsupported artifactDir '${String(input.artifactDir)}'; expected 'project', 'session', or 'temp'.`, diagnostics };
224
240
  }
225
- if (input.context === "fork") {
226
- diagnostics.push({ code: "host_required", severity: "host-required", message: "Exact fork session branching and fork-thinking downgrade checks require Pi host session and model-registry snapshots." });
227
- }
228
241
  const scope = resolveExecutionAgentScope(input.agentScope);
229
242
  const discovered = discoverAgents(effectiveCwd, scope);
230
243
  const resolvedAgent = resolveAgentName(input.agent, discovered.agents);
244
+ const ambiguousCandidates = resolvedAgent.error
245
+ ? discovered.agents.filter((agent) => resolveAgentName(input.agent, [agent]).agent)
246
+ : resolvedAgent.agent;
247
+ const invalidAgent = findBlockingAgentDiagnostic(input.agent, ambiguousCandidates, discovered.agentDiagnostics);
248
+ if (invalidAgent) {
249
+ const message = `Agent '${input.agent}' has invalid configuration: ${invalidAgent.error}`;
250
+ return { ok: false, code: "missing_agent", message, diagnostics: [{ code: "missing_agent", severity: "error", message }] };
251
+ }
231
252
  if (resolvedAgent.error) {
232
253
  return { ok: false, code: "ambiguous_agent", message: resolvedAgent.error, diagnostics };
233
254
  }
@@ -235,6 +256,10 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
235
256
  return { ok: false, code: "missing_agent", message: `Unknown agent: ${input.agent}`, diagnostics };
236
257
  }
237
258
  const agent = resolvedAgent.agent;
259
+ const context = resolveLaunchContractContext(input, agent);
260
+ if (context === "fork") {
261
+ diagnostics.push({ code: "host_required", severity: "host-required", message: "Exact fork session branching and fork-thinking downgrade checks require Pi host session and model-registry snapshots." });
262
+ }
238
263
  const effectiveCapabilityCeiling = intersectSubagentCapabilityCeilings(input.capabilityCeiling, input.inheritedCapabilityCeiling);
239
264
  const restrictionMessage = capabilityCeilingAgentRestrictionMessage(agent.name, effectiveCapabilityCeiling);
240
265
  if (restrictionMessage) return { ok: false, code: "restricted_agent", message: restrictionMessage, diagnostics };
@@ -260,7 +285,7 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
260
285
  }
261
286
  if (resolvedSkills.missing.length > 0) diagnostics.push({ code: "missing_skill", severity: "error", message: `Missing skills: ${resolvedSkills.missing.join(", ")}` });
262
287
 
263
- const externalRunner = agent.runner?.type === "external-cli";
288
+ const externalRunner = agent.runner?.type === "external-cli" || agent.runner?.type === "external-job";
264
289
  const availableModels = normalizeAvailableModels(input.availableModels);
265
290
  const preferredProvider = input.preferredProvider ?? input.parentModel?.provider;
266
291
  const primaryModel = externalRunner
@@ -335,7 +360,7 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
335
360
  definitionDigest,
336
361
  shadowedCandidates,
337
362
  },
338
- context: input.context ?? agent.defaultContext ?? "fresh",
363
+ context,
339
364
  ...(model ? { model } : {}),
340
365
  modelCandidates,
341
366
  ...(resolveEffectiveThinking(model, effectiveThinkingConfig) ? { thinking: resolveEffectiveThinking(model, effectiveThinkingConfig) } : {}),
@@ -9,6 +9,8 @@ export {
9
9
  type ControlEvent,
10
10
  type Details,
11
11
  type ExecutionProjection,
12
+ type ExternalJobRunnerStatus,
13
+ type ExternalJobStatus,
12
14
  type JsonSchemaObject,
13
15
  type OutputMode,
14
16
  type ReviewProjection,
@@ -94,6 +94,9 @@ function validateMainWindowRendererConfig(value: unknown): void {
94
94
  }
95
95
 
96
96
  function validateConfig(config: Record<string, unknown>): void {
97
+ if (config.defaultSubagentContext !== undefined && config.defaultSubagentContext !== "fresh" && config.defaultSubagentContext !== "fork") {
98
+ throw new Error('config.defaultSubagentContext must be "fresh" or "fork"');
99
+ }
97
100
  if (config.foregroundDetachShortcut !== undefined
98
101
  && (typeof config.foregroundDetachShortcut !== "string" || !isValidKeyId(config.foregroundDetachShortcut))) {
99
102
  throw new Error("config.foregroundDetachShortcut must be a valid keybinding string such as \"ctrl+b\"");
@@ -101,9 +104,6 @@ function validateConfig(config: Record<string, unknown>): void {
101
104
  if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
102
105
  throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
103
106
  }
104
- if (config.legacyChainControls !== undefined && typeof config.legacyChainControls !== "boolean") {
105
- throw new Error("config.legacyChainControls must be a boolean");
106
- }
107
107
  if (config.maxActiveAsyncRunsPerSession !== undefined
108
108
  && (typeof config.maxActiveAsyncRunsPerSession !== "number"
109
109
  || !Number.isInteger(config.maxActiveAsyncRunsPerSession)
@@ -134,7 +134,7 @@ function formatSessionLines(input: DoctorReportInput): string[] {
134
134
 
135
135
  function formatDiscovery(input: DoctorReportInput, deps: DoctorDeps): string[] {
136
136
  return [
137
- lineFromCheck("agents/chains", () => {
137
+ lineFromCheck("agents", () => {
138
138
  const discovered = deps.discoverAgentsAll(input.cwd);
139
139
  const agentCounts = {
140
140
  builtin: discovered.builtin.length,
@@ -142,13 +142,10 @@ function formatDiscovery(input: DoctorReportInput, deps: DoctorDeps): string[] {
142
142
  user: discovered.user.length,
143
143
  project: discovered.project.length,
144
144
  };
145
- const chainCounts = discovered.chains.reduce<Record<AgentSource, number>>((counts, chain) => {
146
- counts[chain.source] += 1;
147
- return counts;
148
- }, { builtin: 0, package: 0, user: 0, project: 0 });
145
+ const diagnostics = discovered.agentDiagnostics ?? [];
149
146
  return [
150
147
  `- agents: total ${agentCounts.builtin + agentCounts.package + agentCounts.user + agentCounts.project} (${formatSourceCounts(agentCounts)})`,
151
- `- chains: total ${discovered.chains.length} (${formatSourceCounts(chainCounts)})`,
148
+ ...diagnostics.map((diagnostic) => `- invalid agent ${diagnostic.name ?? diagnostic.filePath} (${diagnostic.source}): ${diagnostic.error}`),
152
149
  ].join("\n");
153
150
  }),
154
151
  lineFromCheck("skills", () => {
@@ -171,13 +171,13 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
171
171
  allowMutatingManagementActions: false,
172
172
  });
173
173
 
174
- const params = createSubagentParamsSchema(config);
174
+ const params = createSubagentParamsSchema();
175
175
  const tool: ToolDefinition<typeof params, Details> = {
176
176
  name: "subagent",
177
177
  label: "Subagent",
178
178
  description: [
179
179
  "Delegate to subagents from child-safe fanout mode.",
180
- `Allowed management/control actions: list, get, status, interrupt, resume, steer${config.legacyChainControls === true ? ", append-step" : ""}, doctor.`,
180
+ "Allowed management/control actions: list, get, status, interrupt, resume, steer, doctor.",
181
181
  "Mutating management actions (create, update, delete, eject, disable, enable, reset, grant-spawn-budget) are blocked in this mode.",
182
182
  ].join("\n"),
183
183
  parameters: params,