pi-subagents 0.57.0 → 0.59.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 (115) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/docs/agents.md +18 -9
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +40 -1
  5. package/docs/models.md +27 -4
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +92 -6
  8. package/docs/workflows.md +115 -0
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +1 -1
  12. package/skills/pi-subagents/SKILL.md +3 -1
  13. package/skills/pi-subagents/references/execution-controls.md +6 -2
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  15. package/skills/pi-subagents/references/prompting-and-roles.md +26 -5
  16. package/src/agents/agent-management.ts +28 -17
  17. package/src/agents/agent-serializer.ts +9 -2
  18. package/src/agents/agents.ts +103 -27
  19. package/src/agents/runtime-agent-events.ts +70 -0
  20. package/src/agents/runtime-agent-registry.ts +19 -18
  21. package/src/api/agents.ts +10 -5
  22. package/src/api/background-work.ts +5 -1
  23. package/src/api/delegation.ts +0 -7
  24. package/src/api/preflight.ts +32 -10
  25. package/src/extension/fanout-child.ts +5 -3
  26. package/src/extension/index.ts +66 -27
  27. package/src/extension/public-execution.ts +15 -2
  28. package/src/extension/rpc.ts +35 -14
  29. package/src/extension/schemas.ts +37 -12
  30. package/src/extension/tool-description.ts +25 -6
  31. package/src/integrations/herdr-status.ts +5 -0
  32. package/src/intercom/result-intercom.ts +2 -0
  33. package/src/profiles/profiles.ts +5 -6
  34. package/src/runs/background/active-async-capacity.ts +2 -2
  35. package/src/runs/background/async-execution.ts +78 -46
  36. package/src/runs/background/async-job-tracker.ts +14 -12
  37. package/src/runs/background/async-resume.ts +29 -27
  38. package/src/runs/background/async-status-snapshot.ts +23 -261
  39. package/src/runs/background/async-status.ts +68 -7
  40. package/src/runs/background/chain-append.ts +8 -3
  41. package/src/runs/background/chain-root-attachment.ts +60 -8
  42. package/src/runs/background/fleet-view.ts +21 -11
  43. package/src/runs/background/notify.ts +184 -9
  44. package/src/runs/background/result-delivery-ownership.ts +45 -0
  45. package/src/runs/background/result-files.ts +2 -1
  46. package/src/runs/background/result-watcher.ts +29 -10
  47. package/src/runs/background/resume-guidance.ts +1 -1
  48. package/src/runs/background/retained-children.ts +1 -1
  49. package/src/runs/background/run-status.ts +18 -8
  50. package/src/runs/background/scheduled-runs.ts +86 -7
  51. package/src/runs/background/stale-run-reconciler.ts +10 -4
  52. package/src/runs/background/steering.ts +4 -14
  53. package/src/runs/background/subagent-runner.ts +389 -350
  54. package/src/runs/background/subagent-wait.ts +58 -10
  55. package/src/runs/background/terminal-run-index.ts +1 -1
  56. package/src/runs/background/wait-completions.ts +22 -1
  57. package/src/runs/background/wait-config.ts +23 -9
  58. package/src/runs/background/wait-tool.ts +9 -2
  59. package/src/runs/foreground/async-steering-action.ts +2 -2
  60. package/src/runs/foreground/execution.ts +134 -121
  61. package/src/runs/foreground/foreground-control.ts +3 -0
  62. package/src/runs/foreground/foreground-history.ts +1 -0
  63. package/src/runs/foreground/subagent-executor.ts +527 -257
  64. package/src/runs/foreground/workflow-detach-reconcile.ts +130 -120
  65. package/src/runs/shared/abort-recovery.ts +119 -0
  66. package/src/runs/shared/async-status-projection.ts +463 -0
  67. package/src/runs/shared/child-identity.ts +19 -4
  68. package/src/runs/shared/child-launch-plan.ts +151 -0
  69. package/src/runs/shared/completion-evidence.ts +89 -0
  70. package/src/runs/shared/completion-guard.ts +5 -4
  71. package/src/runs/shared/dynamic-fanout.ts +3 -3
  72. package/src/runs/shared/fast-mode-extension.ts +5 -5
  73. package/src/runs/shared/host-step-status.ts +230 -0
  74. package/src/runs/shared/lane-metadata.ts +105 -0
  75. package/src/runs/shared/launch-cwd.ts +16 -0
  76. package/src/runs/shared/long-running-guard.ts +2 -1
  77. package/src/runs/shared/mcp-config-sources.ts +422 -0
  78. package/src/runs/shared/mcp-direct-tool-allowlist.ts +221 -158
  79. package/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
  80. package/src/runs/shared/model-exclusions.ts +12 -0
  81. package/src/runs/shared/model-fallback.ts +25 -6
  82. package/src/runs/shared/nested-events.ts +6 -2
  83. package/src/runs/shared/nested-render.ts +7 -3
  84. package/src/runs/shared/parallel-handoff.ts +419 -7
  85. package/src/runs/shared/parallel-utils.ts +15 -1
  86. package/src/runs/shared/pi-args.ts +81 -8
  87. package/src/runs/shared/single-output.ts +44 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +98 -13
  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 +11 -2
  94. package/src/shared/launch-contract.ts +5 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +240 -30
  97. package/src/shared/utils.ts +41 -84
  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/slash/subagents-admin.ts +3 -0
  104. package/src/tui/fleet-status.ts +125 -74
  105. package/src/tui/fleet.ts +11 -5
  106. package/src/tui/render.ts +330 -33
  107. package/src/watchdog/turn-delta.ts +1 -1
  108. package/src/workflows/chat-progress.ts +6 -3
  109. package/src/workflows/host-command.ts +230 -0
  110. package/src/workflows/scripted-workflow.ts +496 -35
  111. package/src/workflows/workflow-child-summary.ts +9 -5
  112. package/src/workflows/workflow-preflight.ts +270 -0
  113. package/src/workflows/workflow-receipt.ts +79 -5
  114. package/src/workflows/workflow-settlement.ts +246 -0
  115. package/src/runs/shared/turn-budget.ts +0 -98
@@ -0,0 +1,422 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { findConfiguredProjectRoot } from "../../agents/agents.ts";
5
+ import { getAgentDir, getConfigDirName, getProjectConfigDir } from "../../shared/utils.ts";
6
+
7
+ const PACKAGE_CONFIG_ROOT = "npm";
8
+ const PACKAGE_GIT_ROOT = "git";
9
+ const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
10
+ const PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
11
+ const PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
12
+ const PLUGIN_STDIO_FIELDS = new Set(["type", "command", "args", "env", "cwd"]);
13
+ const PLUGIN_HTTP_FIELDS = new Set(["type", "url", "headers"]);
14
+
15
+ export interface McpServerDefinition {
16
+ command?: string;
17
+ args?: string[];
18
+ socket?: string;
19
+ env?: Record<string, string>;
20
+ cwd?: string;
21
+ url?: string;
22
+ headers?: Record<string, string>;
23
+ requestHeadersCommand?: {
24
+ command: string;
25
+ args?: string[];
26
+ env?: Record<string, string>;
27
+ timeoutMs?: number;
28
+ };
29
+ auth?: "oauth" | "bearer" | false;
30
+ bearerToken?: string;
31
+ bearerTokenEnv?: string;
32
+ exposeResources?: boolean;
33
+ includeTools?: string[];
34
+ excludeTools?: string[];
35
+ protocolVersion?: string;
36
+ directTools?: boolean | string[];
37
+ httpTransport?: string;
38
+ pluginDataDir?: string;
39
+ literalEnv?: boolean;
40
+ }
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
+
80
+ export function loadPackageMcpServers(cwd: string): Record<string, McpServerDefinition> {
81
+ const servers: Record<string, McpServerDefinition> = {};
82
+ const seen = new Set<string>();
83
+
84
+ for (const packageRoot of getConfiguredPackageRoots(cwd)) {
85
+ const manifest = readJson(path.join(packageRoot, "package.json"));
86
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) continue;
87
+ const packageName = (manifest as { name?: unknown }).name;
88
+ if (typeof packageName !== "string" || !packageName) continue;
89
+ const mcpValue = (manifest as { pi?: { mcp?: unknown } }).pi?.mcp;
90
+ const configPaths = typeof mcpValue === "string"
91
+ ? [mcpValue]
92
+ : Array.isArray(mcpValue) && mcpValue.every((value): value is string => typeof value === "string")
93
+ ? mcpValue
94
+ : [];
95
+ const packagePrefix = formatName(packageName, "package");
96
+
97
+ for (const configPath of configPaths) {
98
+ const resolvedPath = resolvePackageConfigPath(packageRoot, configPath);
99
+ if (!resolvedPath) continue;
100
+ const config = readJson(resolvedPath);
101
+ if (!config || typeof config !== "object" || Array.isArray(config)) continue;
102
+ const rawServers = (config as { mcpServers?: unknown }).mcpServers;
103
+ if (!rawServers || typeof rawServers !== "object" || Array.isArray(rawServers)) continue;
104
+ for (const [serverName, definition] of Object.entries(rawServers)) {
105
+ if (!isMcpServerDefinition(definition)) continue;
106
+ const normalizedName = `${packagePrefix}__${formatName(serverName, "server")}`;
107
+ if (seen.has(normalizedName)) continue;
108
+ seen.add(normalizedName);
109
+ servers[normalizedName] = definition;
110
+ }
111
+ }
112
+ }
113
+
114
+ return servers;
115
+ }
116
+
117
+ export function loadAgentPluginMcpServers(paths: unknown, cwd: string): Record<string, McpServerDefinition> {
118
+ const servers: Record<string, McpServerDefinition> = {};
119
+ if (!Array.isArray(paths)) return servers;
120
+
121
+ for (const configuredPath of paths) {
122
+ if (typeof configuredPath !== "string") continue;
123
+ const pluginRoot = resolvePluginPath(configuredPath, cwd);
124
+ const manifest = readJson(path.join(pluginRoot, "plugin.json"));
125
+ if (!isValidPluginManifest(manifest)) continue;
126
+ const config = readJson(path.join(pluginRoot, "mcp.json"));
127
+ if (!isValidPluginConfig(config)) continue;
128
+ const rawServers = (config as { mcpServers: Record<string, unknown> }).mcpServers;
129
+
130
+ for (const [serverName, rawDefinition] of Object.entries(rawServers)) {
131
+ const definition = translatePluginServer(manifest.name, pluginRoot, rawDefinition);
132
+ if (!definition) continue;
133
+ const normalizedName = `${formatName(manifest.name, "plugin")}__${formatName(serverName, "server")}`;
134
+ if (Object.hasOwn(servers, normalizedName)) continue;
135
+ servers[normalizedName] = definition;
136
+ }
137
+ }
138
+
139
+ return servers;
140
+ }
141
+
142
+ function getConfiguredPackageRoots(cwd: string): string[] {
143
+ const roots: string[] = [];
144
+ const projectConfigDir = findProjectConfigDir(cwd);
145
+ const sources = [
146
+ { path: path.join(projectConfigDir, "settings.json"), baseDir: projectConfigDir },
147
+ { path: path.join(getAgentDir(), "settings.json"), baseDir: getAgentDir() },
148
+ ];
149
+ for (const source of sources) {
150
+ const settings = readJson(source.path);
151
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) continue;
152
+ const packages = (settings as { packages?: unknown }).packages;
153
+ if (!Array.isArray(packages)) continue;
154
+ for (const entry of packages) {
155
+ const packageSource = typeof entry === "string"
156
+ ? entry
157
+ : entry && typeof entry === "object" && !Array.isArray(entry) && typeof (entry as { source?: unknown }).source === "string"
158
+ ? (entry as { source: string }).source
159
+ : undefined;
160
+ if (!packageSource) continue;
161
+ const root = resolvePackageRoot(packageSource, source.baseDir);
162
+ if (root && !roots.includes(root)) roots.push(root);
163
+ }
164
+ }
165
+ return roots;
166
+ }
167
+
168
+ function findProjectConfigDir(cwd: string): string {
169
+ const projectRoot = findConfiguredProjectRoot(path.resolve(cwd));
170
+ return projectRoot ? getProjectConfigDir(projectRoot) : path.join(path.resolve(cwd), getConfigDirName());
171
+ }
172
+
173
+ function resolvePackageRoot(source: string, baseDir: string): string | undefined {
174
+ const trimmed = source.trim();
175
+ if (!trimmed) return undefined;
176
+ if (trimmed.startsWith("npm:")) {
177
+ const packageName = parseNpmPackageName(trimmed);
178
+ return packageName ? resolveContainedPath(path.join(baseDir, PACKAGE_CONFIG_ROOT, "node_modules"), packageName) ?? undefined : undefined;
179
+ }
180
+
181
+ if (trimmed.startsWith("git:") || /^(?:https?:\/\/|ssh:\/\/|git@[^:]+:)/.test(trimmed)) {
182
+ const parsed = parseGitPackagePath(trimmed.startsWith("git:") ? trimmed : `git:${trimmed}`);
183
+ return parsed ? path.join(baseDir, PACKAGE_GIT_ROOT, parsed.host, parsed.repoPath) : undefined;
184
+ }
185
+
186
+ const localPath = trimmed.startsWith("file:") ? trimmed.slice(5) : trimmed;
187
+ if (localPath === "~") return os.homedir();
188
+ if (localPath.startsWith("~/")) return path.join(os.homedir(), localPath.slice(2));
189
+ return path.isAbsolute(localPath) ? path.resolve(localPath) : path.resolve(baseDir, localPath);
190
+ }
191
+
192
+ function parseNpmPackageName(source: string): string | undefined {
193
+ const spec = source.slice(4).trim();
194
+ if (!spec) return undefined;
195
+ const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
196
+ const packageName = match?.[1] ?? spec;
197
+ return resolveContainedPath("/", packageName) ? packageName : undefined;
198
+ }
199
+
200
+ function parseGitPackagePath(source: string): { host: string; repoPath: string } | undefined {
201
+ const spec = source.slice(4).trim();
202
+ if (!spec) return undefined;
203
+
204
+ let host = "";
205
+ let repoPath = "";
206
+ const scpLike = spec.match(/^git@([^:]+):(.+)$/);
207
+ if (scpLike) {
208
+ host = scpLike[1] ?? "";
209
+ repoPath = scpLike[2] ?? "";
210
+ } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec)) {
211
+ try {
212
+ const url = new URL(spec);
213
+ host = url.hostname;
214
+ repoPath = url.pathname.replace(/^\/+/, "");
215
+ } catch {
216
+ return undefined;
217
+ }
218
+ } else {
219
+ const slashIndex = spec.indexOf("/");
220
+ if (slashIndex < 0) return undefined;
221
+ host = spec.slice(0, slashIndex);
222
+ repoPath = spec.slice(slashIndex + 1);
223
+ }
224
+
225
+ const normalizedPath = stripGitRef(repoPath).replace(/\.git$/, "").replace(/^\/+/, "");
226
+ if (!host || !isSafePackagePath(host) || !isSafePackagePath(normalizedPath) || normalizedPath.split(/[\\/]/).length < 2) {
227
+ return undefined;
228
+ }
229
+ return { host, repoPath: normalizedPath };
230
+ }
231
+
232
+ function stripGitRef(repoPath: string): string {
233
+ const atIndex = repoPath.indexOf("@");
234
+ const hashIndex = repoPath.indexOf("#");
235
+ const refIndex = [atIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
236
+ return refIndex === undefined ? repoPath : repoPath.slice(0, refIndex);
237
+ }
238
+
239
+ function isSafePackagePath(value: string): boolean {
240
+ return value.length > 0
241
+ && !path.isAbsolute(value)
242
+ && value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
243
+ }
244
+
245
+ function resolvePackageConfigPath(packageRoot: string, configuredPath: string): string | undefined {
246
+ const lexicalPath = resolveContainedPath(packageRoot, configuredPath);
247
+ if (!lexicalPath || !fs.existsSync(lexicalPath)) return undefined;
248
+ try {
249
+ if (!fs.statSync(lexicalPath).isFile()) return undefined;
250
+ const packageRealPath = fs.realpathSync(packageRoot);
251
+ const configRealPath = fs.realpathSync(lexicalPath);
252
+ return resolveContainedPath(packageRealPath, configRealPath) ?? undefined;
253
+ } catch {
254
+ return undefined;
255
+ }
256
+ }
257
+
258
+ function resolveContainedPath(root: string, value: string): string | undefined {
259
+ const resolved = path.resolve(root, value);
260
+ const relative = path.relative(root, resolved);
261
+ return relative === "" || (!relative.startsWith("..") && !relative.startsWith(path.sep) && !path.isAbsolute(relative))
262
+ ? resolved
263
+ : undefined;
264
+ }
265
+
266
+ function resolvePluginPath(configuredPath: string, cwd: string): string {
267
+ if (configuredPath === "~") return path.resolve(process.env.HOME ?? "", ".");
268
+ if (configuredPath.startsWith("~/")) return path.resolve(process.env.HOME ?? "", configuredPath.slice(2));
269
+ return path.isAbsolute(configuredPath) ? path.resolve(configuredPath) : path.resolve(cwd, configuredPath);
270
+ }
271
+
272
+ function isValidPluginManifest(value: unknown): value is { name: string } {
273
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
274
+ const manifest = value as { $schema?: unknown; name?: unknown };
275
+ return manifest.$schema === PLUGIN_SCHEMA
276
+ && typeof manifest.name === "string"
277
+ && manifest.name.length >= 1
278
+ && manifest.name.length <= 64
279
+ && PLUGIN_NAME_PATTERN.test(manifest.name);
280
+ }
281
+
282
+ function isValidPluginConfig(value: unknown): value is { mcpServers: Record<string, unknown> } {
283
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
284
+ const config = value as Record<string, unknown>;
285
+ return config.$schema === PLUGIN_MCP_SCHEMA
286
+ && Object.keys(config).every((key) => key === "$schema" || key === "mcpServers")
287
+ && !!config.mcpServers
288
+ && typeof config.mcpServers === "object"
289
+ && !Array.isArray(config.mcpServers);
290
+ }
291
+
292
+ function translatePluginServer(
293
+ pluginName: string,
294
+ pluginRoot: string,
295
+ value: unknown,
296
+ ): McpServerDefinition | undefined {
297
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
298
+ const raw = value as Record<string, unknown>;
299
+ if (raw.type === "stdio") return translatePluginStdioServer(pluginName, pluginRoot, raw);
300
+ if (raw.type === "streamable-http" || raw.type === "sse") return translatePluginHttpServer(raw);
301
+ return undefined;
302
+ }
303
+
304
+ function translatePluginStdioServer(
305
+ pluginName: string,
306
+ pluginRoot: string,
307
+ raw: Record<string, unknown>,
308
+ ): McpServerDefinition | undefined {
309
+ if ([...Object.keys(raw)].some((key) => !PLUGIN_STDIO_FIELDS.has(key))) return undefined;
310
+ if (typeof raw.command !== "string" || !raw.command) return undefined;
311
+ if (!isBareCommand(raw.command) && !raw.command.startsWith("./")) return undefined;
312
+ const args = stringArray(raw.args);
313
+ if (raw.args !== undefined && !args) return undefined;
314
+ const env = stringRecord(raw.env);
315
+ if (raw.env !== undefined && !env) return undefined;
316
+ if (env && (Object.hasOwn(env, "PLUGIN_ROOT") || Object.hasOwn(env, "PLUGIN_DATA"))) return undefined;
317
+
318
+ const pluginDataDir = path.join(getAgentDir(), "agent-plugin-data", pluginName);
319
+ const command = raw.command.startsWith("./") ? resolveContainedPath(pluginRoot, raw.command) : raw.command;
320
+ if (!command) return undefined;
321
+ const cwd = resolvePluginCwd(raw.cwd, pluginRoot, pluginDataDir);
322
+ if (!cwd) return undefined;
323
+
324
+ return {
325
+ command,
326
+ args: (args ?? []).map((value) => expandPluginPlaceholders(value, pluginRoot, pluginDataDir)),
327
+ env: {
328
+ ...Object.fromEntries(Object.entries(env ?? {}).map(([key, value]) => [key, expandPluginPlaceholders(value, pluginRoot, pluginDataDir)])),
329
+ PLUGIN_ROOT: pluginRoot,
330
+ PLUGIN_DATA: pluginDataDir,
331
+ },
332
+ cwd,
333
+ pluginDataDir,
334
+ literalEnv: true,
335
+ };
336
+ }
337
+
338
+ function translatePluginHttpServer(raw: Record<string, unknown>): McpServerDefinition | undefined {
339
+ if ([...Object.keys(raw)].some((key) => !PLUGIN_HTTP_FIELDS.has(key))) return undefined;
340
+ if (typeof raw.url !== "string" || !isValidPluginUrl(raw.url)) return undefined;
341
+ const headers = stringRecord(raw.headers);
342
+ if (raw.headers !== undefined && !headers) return undefined;
343
+ if (headers) {
344
+ const normalized = new Set<string>();
345
+ for (const key of Object.keys(headers)) {
346
+ const lower = key.toLowerCase();
347
+ if (normalized.has(lower)) return undefined;
348
+ normalized.add(lower);
349
+ }
350
+ try {
351
+ new Headers(headers);
352
+ } catch {
353
+ return undefined;
354
+ }
355
+ }
356
+ return { url: raw.url, ...(headers ? { headers } : {}), httpTransport: raw.type as string };
357
+ }
358
+
359
+ function resolvePluginCwd(value: unknown, pluginRoot: string, pluginDataDir: string): string | undefined {
360
+ if (value === undefined) return pluginRoot;
361
+ if (typeof value !== "string") return undefined;
362
+ if (value.startsWith("./")) return resolveContainedPath(pluginRoot, value);
363
+ if (value === "${PLUGIN_ROOT}" || value.startsWith("${PLUGIN_ROOT}/")) return resolveContainedPath(pluginRoot, value.replace("${PLUGIN_ROOT}", "."));
364
+ if (value === "${PLUGIN_DATA}" || value.startsWith("${PLUGIN_DATA}/")) return resolveContainedPath(pluginDataDir, value.replace("${PLUGIN_DATA}", "."));
365
+ return undefined;
366
+ }
367
+
368
+ function expandPluginPlaceholders(value: string, pluginRoot: string, pluginDataDir: string): string {
369
+ return value.replaceAll("${PLUGIN_ROOT}", pluginRoot).replaceAll("${PLUGIN_DATA}", pluginDataDir);
370
+ }
371
+
372
+ function isBareCommand(command: string): boolean {
373
+ return !command.includes("/")
374
+ && !command.includes("\\")
375
+ && !command.includes("${PLUGIN_ROOT}")
376
+ && !command.includes("${PLUGIN_DATA}");
377
+ }
378
+
379
+ function isValidPluginUrl(value: string): boolean {
380
+ if (value.includes("${") || value.includes("$env:") || value.includes("{env:")) return false;
381
+ let url: URL;
382
+ try {
383
+ url = new URL(value);
384
+ } catch {
385
+ return false;
386
+ }
387
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
388
+ if (url.username || url.password || url.hash) return false;
389
+ if (url.protocol === "https:") return true;
390
+ const host = url.hostname.toLowerCase();
391
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(host);
392
+ }
393
+
394
+ function stringArray(value: unknown): string[] | undefined {
395
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : undefined;
396
+ }
397
+
398
+ function stringRecord(value: unknown): Record<string, string> | undefined {
399
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
400
+ const entries = Object.entries(value);
401
+ if (entries.some(([, entry]) => typeof entry !== "string")) return undefined;
402
+ return Object.fromEntries(entries) as Record<string, string>;
403
+ }
404
+
405
+ function formatName(value: string, fallback: string): string {
406
+ return value.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^[_-]+|[_-]+$/g, "") || fallback;
407
+ }
408
+
409
+ function readJson(filePath: string): unknown | undefined {
410
+ let content: string;
411
+ try {
412
+ content = fs.readFileSync(filePath, "utf-8");
413
+ } catch (error) {
414
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
415
+ throw new Error(`Failed to read JSON file '${filePath}': ${error instanceof Error ? error.message : String(error)}`, { cause: error });
416
+ }
417
+ try {
418
+ return JSON.parse(content);
419
+ } catch (error) {
420
+ throw new Error(`Invalid JSON in '${filePath}': ${error instanceof Error ? error.message : String(error)}`, { cause: error });
421
+ }
422
+ }