pi-subagents 0.58.0 → 0.60.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 (116) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/docs/agents.md +5 -3
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +1 -1
  5. package/docs/models.md +24 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +93 -5
  8. package/docs/workflows.md +94 -3
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +87 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  15. package/skills/pi-subagents/references/execution-controls.md +52 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +159 -42
  21. package/src/agents/agent-serializer.ts +4 -2
  22. package/src/agents/agents.ts +67 -26
  23. package/src/agents/runtime-agent-registry.ts +9 -16
  24. package/src/api/background-work.ts +5 -1
  25. package/src/api/delegation.ts +0 -7
  26. package/src/api/preflight.ts +6 -9
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +5 -3
  29. package/src/extension/index.ts +51 -23
  30. package/src/extension/public-execution.ts +16 -2
  31. package/src/extension/schemas.ts +36 -10
  32. package/src/extension/tool-description.ts +24 -5
  33. package/src/intercom/result-intercom.ts +2 -0
  34. package/src/profiles/profiles.ts +5 -6
  35. package/src/runs/background/active-async-capacity.ts +2 -2
  36. package/src/runs/background/async-execution.ts +52 -37
  37. package/src/runs/background/async-job-tracker.ts +17 -12
  38. package/src/runs/background/async-resume.ts +23 -25
  39. package/src/runs/background/async-status-snapshot.ts +23 -261
  40. package/src/runs/background/async-status.ts +110 -8
  41. package/src/runs/background/chain-append.ts +6 -3
  42. package/src/runs/background/chain-root-attachment.ts +60 -8
  43. package/src/runs/background/control-channel.ts +3 -2
  44. package/src/runs/background/fleet-view.ts +21 -11
  45. package/src/runs/background/notify.ts +158 -6
  46. package/src/runs/background/result-files.ts +2 -1
  47. package/src/runs/background/result-watcher.ts +2 -0
  48. package/src/runs/background/resume-guidance.ts +1 -1
  49. package/src/runs/background/retained-children.ts +1 -1
  50. package/src/runs/background/run-status.ts +30 -9
  51. package/src/runs/background/scheduled-runs.ts +86 -7
  52. package/src/runs/background/stale-run-reconciler.ts +10 -4
  53. package/src/runs/background/steering.ts +4 -14
  54. package/src/runs/background/subagent-runner.ts +351 -358
  55. package/src/runs/background/subagent-wait.ts +68 -12
  56. package/src/runs/background/terminal-run-index.ts +1 -1
  57. package/src/runs/background/wait-completions.ts +25 -1
  58. package/src/runs/background/wait-config.ts +23 -9
  59. package/src/runs/background/wait-tool.ts +9 -2
  60. package/src/runs/foreground/async-steering-action.ts +2 -2
  61. package/src/runs/foreground/execution.ts +125 -122
  62. package/src/runs/foreground/foreground-control.ts +3 -0
  63. package/src/runs/foreground/foreground-history.ts +1 -0
  64. package/src/runs/foreground/subagent-executor.ts +576 -240
  65. package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
  66. package/src/runs/shared/abort-recovery.ts +119 -0
  67. package/src/runs/shared/async-status-projection.ts +597 -0
  68. package/src/runs/shared/background-process-options.ts +9 -0
  69. package/src/runs/shared/child-identity.ts +19 -4
  70. package/src/runs/shared/child-launch-plan.ts +151 -0
  71. package/src/runs/shared/completion-evidence.ts +89 -0
  72. package/src/runs/shared/completion-guard.ts +1 -1
  73. package/src/runs/shared/dynamic-fanout.ts +2 -2
  74. package/src/runs/shared/host-step-status.ts +230 -0
  75. package/src/runs/shared/lane-metadata.ts +105 -0
  76. package/src/runs/shared/mcp-config-sources.ts +42 -6
  77. package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
  78. package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
  79. package/src/runs/shared/model-fallback.ts +9 -2
  80. package/src/runs/shared/mutation-evidence.ts +52 -3
  81. package/src/runs/shared/nested-events.ts +6 -2
  82. package/src/runs/shared/nested-render.ts +7 -3
  83. package/src/runs/shared/parallel-handoff.ts +419 -7
  84. package/src/runs/shared/parallel-utils.ts +7 -0
  85. package/src/runs/shared/pi-args.ts +67 -3
  86. package/src/runs/shared/single-output.ts +72 -22
  87. package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
  88. package/src/runs/shared/workflow-graph.ts +15 -0
  89. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  90. package/src/runs/shared/worktree.ts +18 -0
  91. package/src/shared/child-session-name.ts +46 -0
  92. package/src/shared/extension-context.ts +24 -0
  93. package/src/shared/formatters.ts +5 -2
  94. package/src/shared/launch-contract.ts +1 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +232 -30
  97. package/src/shared/utils.ts +35 -55
  98. package/src/slash/delegation-adapters.ts +1 -8
  99. package/src/slash/delegation-request.ts +0 -4
  100. package/src/slash/slash-bridge.ts +1 -2
  101. package/src/slash/slash-commands.ts +369 -90
  102. package/src/slash/slash-live-state.ts +22 -11
  103. package/src/tui/fleet-status.ts +134 -75
  104. package/src/tui/fleet.ts +11 -5
  105. package/src/tui/render-helpers.ts +31 -0
  106. package/src/tui/render.ts +895 -121
  107. package/src/watchdog/change-signature.ts +40 -1
  108. package/src/watchdog/turn-delta.ts +1 -1
  109. package/src/workflows/chat-progress.ts +6 -3
  110. package/src/workflows/host-command.ts +235 -0
  111. package/src/workflows/scripted-workflow.ts +503 -38
  112. package/src/workflows/workflow-child-summary.ts +9 -5
  113. package/src/workflows/workflow-preflight.ts +270 -0
  114. package/src/workflows/workflow-receipt.ts +43 -4
  115. package/src/workflows/workflow-settlement.ts +246 -0
  116. package/src/runs/shared/turn-budget.ts +0 -98
@@ -39,6 +39,44 @@ export interface McpServerDefinition {
39
39
  literalEnv?: boolean;
40
40
  }
41
41
 
42
+ export function isMcpServerDefinition(value: unknown): value is McpServerDefinition {
43
+ if (!isRecord(value)) return false;
44
+ for (const field of ["command", "socket", "cwd", "url", "bearerToken", "bearerTokenEnv", "protocolVersion", "httpTransport", "pluginDataDir"] as const) {
45
+ if (value[field] !== undefined && typeof value[field] !== "string") return false;
46
+ }
47
+ for (const field of ["args", "includeTools", "excludeTools"] as const) {
48
+ if (value[field] !== undefined && !isStringArray(value[field])) return false;
49
+ }
50
+ for (const field of ["env", "headers"] as const) {
51
+ if (value[field] !== undefined && !isStringRecord(value[field])) return false;
52
+ }
53
+ for (const field of ["exposeResources", "literalEnv"] as const) {
54
+ if (value[field] !== undefined && typeof value[field] !== "boolean") return false;
55
+ }
56
+ if (value.requestHeadersCommand !== undefined && !isRequestHeadersCommand(value.requestHeadersCommand)) return false;
57
+ if (value.auth !== undefined && value.auth !== "oauth" && value.auth !== "bearer" && value.auth !== false) return false;
58
+ return value.directTools === undefined || typeof value.directTools === "boolean" || isStringArray(value.directTools);
59
+ }
60
+
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return value !== null && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+
65
+ function isStringArray(value: unknown): value is string[] {
66
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
67
+ }
68
+
69
+ function isStringRecord(value: unknown): value is Record<string, string> {
70
+ return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
71
+ }
72
+
73
+ function isRequestHeadersCommand(value: unknown): value is NonNullable<McpServerDefinition["requestHeadersCommand"]> {
74
+ if (!isRecord(value) || typeof value.command !== "string") return false;
75
+ if (value.args !== undefined && !isStringArray(value.args)) return false;
76
+ if (value.env !== undefined && !isStringRecord(value.env)) return false;
77
+ return value.timeoutMs === undefined || (typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs));
78
+ }
79
+
42
80
  export function loadPackageMcpServers(cwd: string): Record<string, McpServerDefinition> {
43
81
  const servers: Record<string, McpServerDefinition> = {};
44
82
  const seen = new Set<string>();
@@ -64,11 +102,11 @@ export function loadPackageMcpServers(cwd: string): Record<string, McpServerDefi
64
102
  const rawServers = (config as { mcpServers?: unknown }).mcpServers;
65
103
  if (!rawServers || typeof rawServers !== "object" || Array.isArray(rawServers)) continue;
66
104
  for (const [serverName, definition] of Object.entries(rawServers)) {
67
- if (!definition || typeof definition !== "object" || Array.isArray(definition)) continue;
105
+ if (!isMcpServerDefinition(definition)) continue;
68
106
  const normalizedName = `${packagePrefix}__${formatName(serverName, "server")}`;
69
107
  if (seen.has(normalizedName)) continue;
70
108
  seen.add(normalizedName);
71
- servers[normalizedName] = definition as McpServerDefinition;
109
+ servers[normalizedName] = definition;
72
110
  }
73
111
  }
74
112
  }
@@ -90,7 +128,7 @@ export function loadAgentPluginMcpServers(paths: unknown, cwd: string): Record<s
90
128
  const rawServers = (config as { mcpServers: Record<string, unknown> }).mcpServers;
91
129
 
92
130
  for (const [serverName, rawDefinition] of Object.entries(rawServers)) {
93
- const definition = translatePluginServer(manifest.name, pluginRoot, serverName, rawDefinition);
131
+ const definition = translatePluginServer(manifest.name, pluginRoot, rawDefinition);
94
132
  if (!definition) continue;
95
133
  const normalizedName = `${formatName(manifest.name, "plugin")}__${formatName(serverName, "server")}`;
96
134
  if (Object.hasOwn(servers, normalizedName)) continue;
@@ -254,12 +292,11 @@ function isValidPluginConfig(value: unknown): value is { mcpServers: Record<stri
254
292
  function translatePluginServer(
255
293
  pluginName: string,
256
294
  pluginRoot: string,
257
- serverName: string,
258
295
  value: unknown,
259
296
  ): McpServerDefinition | undefined {
260
297
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
261
298
  const raw = value as Record<string, unknown>;
262
- if (raw.type === "stdio") return translatePluginStdioServer(pluginName, pluginRoot, serverName, raw);
299
+ if (raw.type === "stdio") return translatePluginStdioServer(pluginName, pluginRoot, raw);
263
300
  if (raw.type === "streamable-http" || raw.type === "sse") return translatePluginHttpServer(raw);
264
301
  return undefined;
265
302
  }
@@ -267,7 +304,6 @@ function translatePluginServer(
267
304
  function translatePluginStdioServer(
268
305
  pluginName: string,
269
306
  pluginRoot: string,
270
- serverName: string,
271
307
  raw: Record<string, unknown>,
272
308
  ): McpServerDefinition | undefined {
273
309
  if ([...Object.keys(raw)].some((key) => !PLUGIN_STDIO_FIELDS.has(key))) return undefined;
@@ -4,11 +4,19 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { findConfiguredProjectRoot } from "../../agents/agents.ts";
6
6
  import { getAgentDir, getProjectConfigDir } from "../../shared/utils.ts";
7
- import { loadAgentPluginMcpServers, loadPackageMcpServers, type McpServerDefinition } from "./mcp-config-sources.ts";
7
+ import { isMcpServerDefinition, loadAgentPluginMcpServers, loadPackageMcpServers, type McpServerDefinition } from "./mcp-config-sources.ts";
8
+ import {
9
+ normalizeMcpDirectToolSelectors,
10
+ parseMcpDirectToolSelectors,
11
+ planMcpDirectToolGrant,
12
+ } from "./mcp-direct-tool-grant.ts";
13
+ import type { McpToolPrefix, ResolvedMcpDirectToolSelection } from "./mcp-direct-tool-grant.ts";
14
+
15
+ export { formatUnresolvedMcpDirectToolSelectors } from "./mcp-direct-tool-grant.ts";
16
+ export type { ResolvedMcpDirectToolSelection } from "./mcp-direct-tool-grant.ts";
8
17
 
9
18
  const CACHE_VERSION = 1;
10
19
  const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
11
- const BUILTIN_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
12
20
  const GENERIC_GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".config", "mcp", "mcp.json");
13
21
  const IMPORT_PATHS = {
14
22
  cursor: [path.join(os.homedir(), ".cursor", "mcp.json")],
@@ -23,7 +31,6 @@ const IMPORT_PATHS = {
23
31
  vscode: [".vscode/mcp.json"],
24
32
  } as const;
25
33
 
26
- type ToolPrefix = "server" | "none" | "short";
27
34
  type ImportKind = keyof typeof IMPORT_PATHS;
28
35
 
29
36
  type ServerEntry = McpServerDefinition;
@@ -32,7 +39,7 @@ export interface McpConfig {
32
39
  mcpServers: Record<string, ServerEntry>;
33
40
  imports?: ImportKind[];
34
41
  settings?: {
35
- toolPrefix?: ToolPrefix;
42
+ toolPrefix?: McpToolPrefix;
36
43
  directTools?: boolean;
37
44
  agentPluginPaths?: unknown;
38
45
  };
@@ -83,8 +90,6 @@ interface MetadataCache {
83
90
  servers: Record<string, ServerCacheEntry>;
84
91
  }
85
92
 
86
- export interface ResolvedMcpDirectToolSelection { name: string; selector: string }
87
-
88
93
  export interface McpDirectToolResolution {
89
94
  selections: ResolvedMcpDirectToolSelection[];
90
95
  unresolvedSelectors: string[];
@@ -93,21 +98,17 @@ export interface McpDirectToolResolution {
93
98
  runtimeServerNames?: string[];
94
99
  }
95
100
 
96
- function normalizeSelectors(selectors: string[] | undefined): string[] {
97
- return [...new Set((selectors ?? []).map((selector) => selector.replace(/\/+$/, "")).filter(Boolean))];
98
- }
99
-
100
101
  export function resolveMcpDirectToolResolution(
101
102
  mcpDirectTools: string[] | undefined,
102
103
  cwd = process.cwd(),
103
104
  runtimeSnapshotHost?: McpRuntimeSnapshotHost,
104
105
  configOverride?: McpConfig,
105
106
  ): McpDirectToolResolution {
106
- const selectors = normalizeSelectors(mcpDirectTools);
107
+ const selectors = normalizeMcpDirectToolSelectors(mcpDirectTools);
107
108
  if (selectors.length === 0) return { selections: [], unresolvedSelectors: [] };
108
109
 
109
110
  const config = configOverride ?? loadMcpConfig(cwd);
110
- const { servers: selectedServers, tools: selectedTools } = parseSelections(selectors);
111
+ const { servers: selectedServers, tools: selectedTools } = parseMcpDirectToolSelectors(selectors);
111
112
  const runtimeSelectionServers = new Set([...selectedServers, ...selectedTools.keys()]);
112
113
  const runtimeServers = resolveRuntimeMcpServers(config, runtimeSelectionServers, runtimeSnapshotHost);
113
114
  const resolvedConfig = Object.keys(runtimeServers).length > 0
@@ -116,15 +117,20 @@ export function resolveMcpDirectToolResolution(
116
117
  validateSelectedServerDefinitions(resolvedConfig, selectors);
117
118
  const cache = loadMetadataCache();
118
119
  if (!cache) return { selections: [], unresolvedSelectors: selectors };
119
- const selections = resolveDirectToolSelections(resolvedConfig, cache, getToolPrefix(resolvedConfig.settings?.toolPrefix), selectors);
120
- const unresolvedSelectors = selectors.filter((selector) =>
121
- selector.includes("/")
122
- ? !selections.some((selection) => selection.selector === selector)
123
- : !selections.some((selection) => selection.selector.startsWith(`${selector}/`)),
124
- );
120
+ const validMetadata: Record<string, ServerCacheEntry> = {};
121
+ for (const [serverName, definition] of Object.entries(resolvedConfig.mcpServers)) {
122
+ const serverCache = cache.servers[serverName];
123
+ if (isServerCacheValid(serverCache, definition)) validMetadata[serverName] = serverCache;
124
+ }
125
+ const grant = planMcpDirectToolGrant({
126
+ selectors,
127
+ servers: resolvedConfig.mcpServers,
128
+ metadata: validMetadata,
129
+ toolPrefix: resolvedConfig.settings?.toolPrefix,
130
+ });
125
131
  return {
126
- selections,
127
- unresolvedSelectors,
132
+ selections: grant.selections,
133
+ unresolvedSelectors: grant.unresolvedSelectors,
128
134
  ...(Object.keys(runtimeServers).length > 0
129
135
  ? { mcpConfig: resolvedConfig, runtimeServerNames: Object.keys(runtimeServers) }
130
136
  : {}),
@@ -139,10 +145,6 @@ export function resolveMcpDirectToolSelections(
139
145
  return resolveMcpDirectToolResolution(mcpDirectTools, cwd, runtimeSnapshotHost).selections;
140
146
  }
141
147
 
142
- export function formatUnresolvedMcpDirectToolSelectors(selectors: readonly string[]): string {
143
- return `Unresolved MCP direct-tool selectors: ${selectors.join(", ")}. Direct MCP tools require a matching configured server and fresh metadata cache; runtime-registered servers require a host/pi-mcp-adapter handoff before child launch.`;
144
- }
145
-
146
148
  function loadMetadataCache(): MetadataCache | null {
147
149
  const cachePath = path.join(getAgentDir(), "mcp-cache.json");
148
150
  let parsed: unknown;
@@ -152,12 +154,46 @@ function loadMetadataCache(): MetadataCache | null {
152
154
  return null;
153
155
  }
154
156
 
155
- if (!parsed || typeof parsed !== "object") return null;
156
- const raw = parsed as Record<string, unknown>;
157
- if (raw.version !== CACHE_VERSION || !raw.servers || typeof raw.servers !== "object" || Array.isArray(raw.servers)) {
158
- return null;
157
+ if (!isRecord(parsed) || parsed.version !== CACHE_VERSION || !isRecord(parsed.servers)) return null;
158
+ const servers: Record<string, ServerCacheEntry> = {};
159
+ for (const [name, value] of Object.entries(parsed.servers)) {
160
+ const entry = parseServerCacheEntry(value);
161
+ if (entry) servers[name] = entry;
159
162
  }
160
- return raw as unknown as MetadataCache;
163
+ return { version: CACHE_VERSION, servers };
164
+ }
165
+
166
+ function parseServerCacheEntry(value: unknown): ServerCacheEntry | undefined {
167
+ if (!isRecord(value)) return undefined;
168
+ if (value.configHash !== undefined && typeof value.configHash !== "string") return undefined;
169
+ if (value.cachedAt !== undefined && (typeof value.cachedAt !== "number" || !Number.isFinite(value.cachedAt))) return undefined;
170
+ if (value.tools !== undefined && !Array.isArray(value.tools)) return undefined;
171
+ if (value.resources !== undefined && !Array.isArray(value.resources)) return undefined;
172
+ const tools = value.tools?.map(parseCachedTool);
173
+ const resources = value.resources?.map(parseCachedResource);
174
+ if (tools && !tools.every((entry): entry is CachedTool => entry !== undefined)) return undefined;
175
+ if (resources && !resources.every((entry): entry is CachedResource => entry !== undefined)) return undefined;
176
+ return {
177
+ ...(value.configHash !== undefined ? { configHash: value.configHash } : {}),
178
+ ...(value.cachedAt !== undefined ? { cachedAt: value.cachedAt } : {}),
179
+ ...(tools ? { tools } : {}),
180
+ ...(resources ? { resources } : {}),
181
+ };
182
+ }
183
+
184
+ function parseCachedTool(value: unknown): CachedTool | undefined {
185
+ if (!isRecord(value) || (value.name !== undefined && typeof value.name !== "string")) return undefined;
186
+ return value.name === undefined ? {} : { name: value.name };
187
+ }
188
+
189
+ function parseCachedResource(value: unknown): CachedResource | undefined {
190
+ if (!isRecord(value)) return undefined;
191
+ if (value.uri !== undefined && typeof value.uri !== "string") return undefined;
192
+ if (value.name !== undefined && typeof value.name !== "string") return undefined;
193
+ return {
194
+ ...(value.uri !== undefined ? { uri: value.uri } : {}),
195
+ ...(value.name !== undefined ? { name: value.name } : {}),
196
+ };
161
197
  }
162
198
 
163
199
  function resolveRuntimeMcpServers(
@@ -196,9 +232,7 @@ function getRuntimeMcpServerSnapshot(
196
232
  snapshot.name !== name ||
197
233
  snapshot.runtime !== true ||
198
234
  snapshot.persisted !== false ||
199
- !snapshot.definition ||
200
- typeof snapshot.definition !== "object" ||
201
- Array.isArray(snapshot.definition)
235
+ !isMcpServerDefinition(snapshot.definition)
202
236
  ) {
203
237
  throw new Error(`Invalid MCP runtime snapshot for server "${name}"`);
204
238
  }
@@ -249,18 +283,33 @@ function readConfig(configPath: string): McpConfig | null {
249
283
  }
250
284
 
251
285
  function validateConfig(raw: unknown): McpConfig {
252
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { mcpServers: {} };
253
- const obj = raw as Record<string, unknown>;
286
+ if (!isRecord(raw)) return { mcpServers: {} };
287
+ const obj = raw;
254
288
  const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {};
255
289
  return {
256
- mcpServers: servers && typeof servers === "object" && !Array.isArray(servers) ? servers as Record<string, ServerEntry> : {},
290
+ mcpServers: parseServerEntries(servers),
257
291
  imports: Array.isArray(obj.imports) ? obj.imports.filter((value): value is ImportKind => isImportKind(value)) : undefined,
258
- settings: obj.settings && typeof obj.settings === "object" && !Array.isArray(obj.settings)
259
- ? obj.settings as McpConfig["settings"]
260
- : undefined,
292
+ settings: parseSettings(obj.settings),
261
293
  };
262
294
  }
263
295
 
296
+ function parseServerEntries(value: unknown): Record<string, ServerEntry> {
297
+ if (!isRecord(value)) return {};
298
+ return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, ServerEntry] => isMcpServerDefinition(entry[1])));
299
+ }
300
+
301
+ function parseSettings(value: unknown): McpConfig["settings"] | undefined {
302
+ if (!isRecord(value)) return undefined;
303
+ const toolPrefix = value.toolPrefix === "server" || value.toolPrefix === "short" || value.toolPrefix === "none" ? value.toolPrefix : undefined;
304
+ const directTools = typeof value.directTools === "boolean" ? value.directTools : undefined;
305
+ const settings: NonNullable<McpConfig["settings"]> = {
306
+ ...(toolPrefix ? { toolPrefix } : {}),
307
+ ...(directTools !== undefined ? { directTools } : {}),
308
+ ...(value.agentPluginPaths !== undefined ? { agentPluginPaths: value.agentPluginPaths } : {}),
309
+ };
310
+ return Object.keys(settings).length ? settings : undefined;
311
+ }
312
+
264
313
  function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
265
314
  const imports = [...(base.imports ?? []), ...(next.imports ?? [])];
266
315
  return {
@@ -304,79 +353,20 @@ function resolveImportPath(importKind: ImportKind, cwd: string): string | null {
304
353
  }
305
354
 
306
355
  function extractServers(config: unknown, kind: ImportKind): Record<string, ServerEntry> {
307
- if (!config || typeof config !== "object" || Array.isArray(config)) return {};
308
- const obj = config as Record<string, unknown>;
356
+ if (!isRecord(config)) return {};
357
+ const obj = config;
309
358
  const servers = kind === "cursor" || kind === "windsurf" || kind === "vscode"
310
359
  ? obj.mcpServers ?? obj["mcp-servers"]
311
360
  : obj.mcpServers;
312
- return servers && typeof servers === "object" && !Array.isArray(servers) ? servers as Record<string, ServerEntry> : {};
313
- }
314
-
315
- function resolveDirectToolSelections(config: McpConfig, cache: MetadataCache, prefix: ToolPrefix, envOverride: string[]): ResolvedMcpDirectToolSelection[] {
316
- const names: ResolvedMcpDirectToolSelection[] = [];
317
- const seenNames = new Set<string>();
318
- const { servers: selectedServers, tools: selectedTools } = parseSelections(envOverride);
319
-
320
- for (const [serverName, definition] of Object.entries(config.mcpServers)) {
321
- const serverCache = cache.servers[serverName];
322
- if (!isServerCacheValid(serverCache, definition)) continue;
323
-
324
- const toolFilter = selectedServers.has(serverName)
325
- ? true
326
- : selectedTools.get(serverName);
327
- if (!toolFilter) continue;
328
-
329
- for (const tool of Array.isArray(serverCache.tools) ? serverCache.tools : []) {
330
- if (typeof tool?.name !== "string" || !tool.name) continue;
331
- if (toolFilter !== true && !toolFilter.has(tool.name)) continue;
332
- if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue;
333
- const prefixedName = formatToolName(tool.name, serverName, prefix);
334
- if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue;
335
- seenNames.add(prefixedName);
336
- names.push({ name: prefixedName, selector: `${serverName}/${tool.name}` });
337
- }
338
-
339
- if (definition.exposeResources === false) continue;
340
- for (const resource of Array.isArray(serverCache.resources) ? serverCache.resources : []) {
341
- if (typeof resource?.name !== "string" || !resource.name || typeof resource.uri !== "string" || !resource.uri) continue;
342
- const baseName = `get_${resourceNameToToolName(resource.name)}`;
343
- if (toolFilter !== true && !toolFilter.has(baseName)) continue;
344
- if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue;
345
- const prefixedName = formatToolName(baseName, serverName, prefix);
346
- if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue;
347
- seenNames.add(prefixedName);
348
- names.push({ name: prefixedName, selector: `${serverName}/${baseName}` });
349
- }
350
- }
351
-
352
- return names;
361
+ return parseServerEntries(servers);
353
362
  }
354
363
 
355
364
  export function resolveMcpDirectToolNames(mcpDirectTools: string[] | undefined, cwd = process.cwd()): string[] {
356
365
  return resolveMcpDirectToolSelections(mcpDirectTools, cwd).map((selection) => selection.name);
357
366
  }
358
367
 
359
- function parseSelections(selections: string[]): { servers: Set<string>; tools: Map<string, Set<string>> } {
360
- const servers = new Set<string>();
361
- const tools = new Map<string, Set<string>>();
362
- for (const item of selections) {
363
- if (item.includes("/")) {
364
- const [server, tool] = item.split("/", 2);
365
- if (server && tool) {
366
- if (!tools.has(server)) tools.set(server, new Set());
367
- tools.get(server)!.add(tool);
368
- } else if (server) {
369
- servers.add(server);
370
- }
371
- } else if (item) {
372
- servers.add(item);
373
- }
374
- }
375
- return { servers, tools };
376
- }
377
-
378
368
  function validateSelectedServerDefinitions(config: McpConfig, selectors: string[]): void {
379
- const { servers, tools } = parseSelections(selectors);
369
+ const { servers, tools } = parseMcpDirectToolSelectors(selectors);
380
370
  for (const serverName of new Set([...servers, ...tools.keys()])) {
381
371
  const definition = config.mcpServers[serverName];
382
372
  if (definition) computeMcpServerHash(definition);
@@ -417,52 +407,12 @@ export function computeMcpServerHash(definition: ServerEntry): string {
417
407
  return createHash("sha256").update(stableStringify(identity)).digest("hex");
418
408
  }
419
409
 
420
- function getToolPrefix(value: unknown): ToolPrefix {
421
- return value === "none" || value === "short" || value === "server" ? value : "server";
422
- }
423
-
424
410
  function isImportKind(value: unknown): value is ImportKind {
425
411
  return typeof value === "string" && Object.hasOwn(IMPORT_PATHS, value);
426
412
  }
427
413
 
428
- function getServerPrefix(serverName: string, mode: ToolPrefix): string {
429
- if (mode === "none") return "";
430
- if (mode === "short") {
431
- const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
432
- return short || "mcp";
433
- }
434
- return serverName.replace(/-/g, "_");
435
- }
436
-
437
- function formatToolName(toolName: string, serverName: string, prefix: ToolPrefix): string {
438
- const serverPrefix = getServerPrefix(serverName, prefix);
439
- return serverPrefix ? `${serverPrefix}_${toolName}` : toolName;
440
- }
441
-
442
- function isToolExcluded(toolName: string, serverName: string, prefix: ToolPrefix, excludeTools: unknown): boolean {
443
- if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;
444
- const candidates = new Set([
445
- normalizeToolName(toolName),
446
- normalizeToolName(formatToolName(toolName, serverName, prefix)),
447
- normalizeToolName(formatToolName(toolName, serverName, "server")),
448
- normalizeToolName(formatToolName(toolName, serverName, "short")),
449
- ]);
450
- return excludeTools.some((excluded) => typeof excluded === "string" && candidates.has(normalizeToolName(excluded)));
451
- }
452
-
453
- function normalizeToolName(value: string): string {
454
- return value.replace(/-/g, "_");
455
- }
456
-
457
- function resourceNameToToolName(name: string): string {
458
- let result = name
459
- .replace(/[^a-zA-Z0-9]/g, "_")
460
- .replace(/_+/g, "_")
461
- .replace(/^_+/, "")
462
- .replace(/_+$/, "")
463
- .toLowerCase();
464
- if (!result || /^\d/.test(result)) result = `resource${result ? `_${result}` : ""}`;
465
- return result;
414
+ function isRecord(value: unknown): value is Record<string, unknown> {
415
+ return value !== null && typeof value === "object" && !Array.isArray(value);
466
416
  }
467
417
 
468
418
  function interpolateEnvRecord(values: Record<string, string> | undefined): Record<string, string> | undefined {
@@ -0,0 +1,194 @@
1
+ const BUILTIN_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
2
+
3
+ export type McpToolPrefix = "server" | "none" | "short";
4
+
5
+ export interface McpGrantServerFacts {
6
+ readonly exposeResources?: boolean;
7
+ readonly includeTools?: readonly unknown[];
8
+ readonly excludeTools?: readonly unknown[];
9
+ }
10
+
11
+ export interface McpGrantToolMetadata {
12
+ readonly name?: string;
13
+ }
14
+
15
+ export interface McpGrantResourceMetadata {
16
+ readonly name?: string;
17
+ readonly uri?: string;
18
+ }
19
+
20
+ export interface McpGrantServerMetadata {
21
+ readonly tools?: readonly McpGrantToolMetadata[];
22
+ readonly resources?: readonly McpGrantResourceMetadata[];
23
+ }
24
+
25
+ export interface McpDirectToolGrantInput {
26
+ readonly selectors?: readonly string[];
27
+ readonly servers: Readonly<Record<string, McpGrantServerFacts>>;
28
+ /** Cache entries already validated by the source-loading adapter. */
29
+ readonly metadata: Readonly<Record<string, McpGrantServerMetadata>>;
30
+ readonly toolPrefix?: unknown;
31
+ }
32
+
33
+ export interface ResolvedMcpDirectToolSelection {
34
+ name: string;
35
+ selector: string;
36
+ }
37
+
38
+ export interface McpDirectToolGrant {
39
+ selections: ResolvedMcpDirectToolSelection[];
40
+ unresolvedSelectors: string[];
41
+ }
42
+
43
+ export function normalizeMcpDirectToolSelectors(selectors: readonly string[] | undefined): string[] {
44
+ return [...new Set((selectors ?? []).map((selector) => selector.replace(/\/+$/, "")).filter(Boolean))];
45
+ }
46
+
47
+ export function parseMcpDirectToolSelectors(selectors: readonly string[]): {
48
+ servers: Set<string>;
49
+ tools: Map<string, Set<string>>;
50
+ } {
51
+ const servers = new Set<string>();
52
+ const tools = new Map<string, Set<string>>();
53
+ for (const item of selectors) {
54
+ if (item.includes("/")) {
55
+ const [server, tool] = item.split("/", 2);
56
+ if (server && tool) {
57
+ if (!tools.has(server)) tools.set(server, new Set());
58
+ tools.get(server)!.add(tool);
59
+ } else if (server) {
60
+ servers.add(server);
61
+ }
62
+ } else if (item) {
63
+ servers.add(item);
64
+ }
65
+ }
66
+ return { servers, tools };
67
+ }
68
+
69
+ export function planMcpDirectToolGrant(input: McpDirectToolGrantInput): McpDirectToolGrant {
70
+ const selectors = normalizeMcpDirectToolSelectors(input.selectors);
71
+ if (selectors.length === 0) return { selections: [], unresolvedSelectors: [] };
72
+
73
+ const { servers: selectedServers, tools: selectedTools } = parseMcpDirectToolSelectors(selectors);
74
+ const selections: ResolvedMcpDirectToolSelection[] = [];
75
+ const seenNames = new Set<string>();
76
+ const prefix = normalizeMcpToolPrefix(input.toolPrefix);
77
+
78
+ for (const [serverName, server] of Object.entries(input.servers)) {
79
+ const metadata = input.metadata[serverName];
80
+ if (!metadata) continue;
81
+
82
+ const toolFilter: true | ReadonlySet<string> | undefined = selectedServers.has(serverName)
83
+ ? true
84
+ : selectedTools.get(serverName);
85
+ if (!toolFilter) continue;
86
+
87
+ for (const tool of Array.isArray(metadata.tools) ? metadata.tools : []) {
88
+ if (typeof tool?.name !== "string" || !tool.name) continue;
89
+ if (toolFilter !== true && !toolFilter.has(tool.name)) continue;
90
+ if (!isToolAllowed(tool.name, serverName, prefix, server.includeTools, server.excludeTools)) continue;
91
+ const name = formatToolName(tool.name, serverName, prefix);
92
+ if (BUILTIN_TOOL_NAMES.has(name) || seenNames.has(name)) continue;
93
+ seenNames.add(name);
94
+ selections.push({ name, selector: `${serverName}/${tool.name}` });
95
+ }
96
+
97
+ if (server.exposeResources === false) continue;
98
+ for (const resource of Array.isArray(metadata.resources) ? metadata.resources : []) {
99
+ if (typeof resource?.name !== "string" || !resource.name || typeof resource.uri !== "string" || !resource.uri) continue;
100
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
101
+ if (toolFilter !== true && !toolFilter.has(baseName)) continue;
102
+ if (!isToolAllowed(baseName, serverName, prefix, server.includeTools, server.excludeTools)) continue;
103
+ const name = formatToolName(baseName, serverName, prefix);
104
+ if (BUILTIN_TOOL_NAMES.has(name) || seenNames.has(name)) continue;
105
+ seenNames.add(name);
106
+ selections.push({ name, selector: `${serverName}/${baseName}` });
107
+ }
108
+ }
109
+
110
+ const unresolvedSelectors = selectors.filter((selector) =>
111
+ selector.includes("/")
112
+ ? !selections.some((selection) => selection.selector === selector)
113
+ : !selections.some((selection) => selection.selector.startsWith(`${selector}/`)),
114
+ );
115
+ return { selections, unresolvedSelectors };
116
+ }
117
+
118
+ export function normalizeMcpToolPrefix(value: unknown): McpToolPrefix {
119
+ return value === "none" || value === "short" || value === "server" ? value : "server";
120
+ }
121
+
122
+ export function formatUnresolvedMcpDirectToolSelectors(selectors: readonly string[]): string {
123
+ return `Unresolved MCP direct-tool selectors: ${selectors.join(", ")}. Direct MCP tools require a matching configured server and fresh metadata cache; runtime-registered servers require a host/pi-mcp-adapter handoff before child launch.`;
124
+ }
125
+
126
+ function getServerPrefix(serverName: string, mode: McpToolPrefix): string {
127
+ if (mode === "none") return "";
128
+ if (mode === "short") return serverName.replace(/-?mcp$/i, "") || "mcp";
129
+ return serverName;
130
+ }
131
+
132
+ function formatToolName(toolName: string, serverName: string, prefix: McpToolPrefix): string {
133
+ const serverPrefix = getServerPrefix(serverName, prefix);
134
+ return serverPrefix ? `${serverPrefix}_${toolName}` : toolName;
135
+ }
136
+
137
+ function isToolAllowed(
138
+ toolName: string,
139
+ serverName: string,
140
+ prefix: McpToolPrefix,
141
+ includeTools: readonly unknown[] | undefined,
142
+ excludeTools: readonly unknown[] | undefined,
143
+ ): boolean {
144
+ const candidates = toolNameCandidates(toolName, serverName, prefix);
145
+ return (!Array.isArray(includeTools) || includeTools.length === 0 || matchesToolPatterns(candidates, includeTools))
146
+ && !matchesToolPatterns(candidates, excludeTools);
147
+ }
148
+
149
+ function toolNameCandidates(toolName: string, serverName: string, prefix: McpToolPrefix): Set<string> {
150
+ return new Set([
151
+ toolName,
152
+ `mcp_${toolName}`,
153
+ formatToolName(toolName, serverName, prefix),
154
+ formatToolName(toolName, serverName, "server"),
155
+ formatToolName(toolName, serverName, "short"),
156
+ formatToolName(toolName, serverName, "none"),
157
+ ]);
158
+ }
159
+
160
+ function matchesToolPatterns(candidates: ReadonlySet<string>, patterns: readonly unknown[] | undefined): boolean {
161
+ if (!Array.isArray(patterns) || patterns.length === 0) return false;
162
+ for (const pattern of patterns) {
163
+ if (typeof pattern !== "string") continue;
164
+ const normalizedPattern = normalizeToolName(pattern);
165
+ const matcher = normalizedPattern.includes("*") || normalizedPattern.includes("?")
166
+ ? globToRegExp(normalizedPattern)
167
+ : undefined;
168
+ for (const candidate of candidates) {
169
+ const normalizedCandidate = normalizeToolName(candidate);
170
+ if (matcher ? matcher.test(normalizedCandidate) : normalizedCandidate === normalizedPattern) return true;
171
+ }
172
+ }
173
+ return false;
174
+ }
175
+
176
+ function globToRegExp(pattern: string): RegExp {
177
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
178
+ return new RegExp(`^${escaped}$`);
179
+ }
180
+
181
+ function normalizeToolName(value: string): string {
182
+ return value.replace(/-/g, "_");
183
+ }
184
+
185
+ function resourceNameToToolName(name: string): string {
186
+ let result = name
187
+ .replace(/[^a-zA-Z0-9]/g, "_")
188
+ .replace(/_+/g, "_")
189
+ .replace(/^_+/, "")
190
+ .replace(/_+$/, "")
191
+ .toLowerCase();
192
+ if (!result || /^\d/.test(result)) result = `resource${result ? `_${result}` : ""}`;
193
+ return result;
194
+ }
@@ -24,7 +24,12 @@ export function formatSubagentModelVerificationError(expectedModel: string, obse
24
24
  const observedBase = splitThinkingSuffix(observedModel).baseModel;
25
25
  if (expectedBase === observedBase) return undefined;
26
26
  const expectedEntry = availableModels.find((entry) => entry.fullId === expectedBase);
27
- if (expectedEntry && expectedEntry.id === observedBase) return undefined;
27
+ if (expectedEntry) {
28
+ if (expectedEntry.id === observedBase) return undefined;
29
+ const expectedIdLeaf = expectedEntry.id.slice(expectedEntry.id.lastIndexOf("/") + 1);
30
+ const expectedFullIdLeaf = expectedEntry.fullId.slice(expectedEntry.fullId.lastIndexOf("/") + 1);
31
+ if (expectedIdLeaf === observedBase || expectedFullIdLeaf === observedBase) return undefined;
32
+ }
28
33
  return `model_verification_failed: child reported a different model than the launch candidate. Expected '${expectedModel}' but observed '${observedModel}'.`;
29
34
  }
30
35
 
@@ -446,9 +451,11 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
446
451
  /upstream/i,
447
452
  /timed? out/i,
448
453
  /timeout/i,
454
+ /\b500\b/,
449
455
  /\b502\b/,
450
456
  /\b503\b/,
451
457
  /\b504\b/,
458
+ /internal server error/i,
452
459
  /cold.?start/i,
453
460
  /empty response/i,
454
461
  /no output/i,
@@ -479,7 +486,7 @@ function messageError(message: unknown): string | undefined {
479
486
  export function isRetryableModelFailureAttempt(input: { error: string | undefined; messages?: readonly unknown[]; toolCount?: number }): boolean {
480
487
  if (!isRetryableModelFailure(input.error)) return false;
481
488
  if ((input.toolCount ?? 0) > 0) return false;
482
- if (input.error === "Subagent produced no output (possible model cold-start or empty response).") return true;
489
+ if (input.error === "Subagent produced no output (possible model cold-start or empty response)." || /^Subagent produced no output after terminal assistant stopReason "[^"]+"\.$/.test(input.error ?? "")) return true;
483
490
  if ((input.toolCount ?? 0) === 0 && (input.messages?.length ?? 0) === 0) return true;
484
491
  const error = input.error?.trim();
485
492
  return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));