pi-subagents 0.57.0 → 0.58.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.
- package/CHANGELOG.md +39 -0
- package/docs/agents.md +13 -6
- package/docs/extension-api.md +39 -0
- package/docs/models.md +3 -3
- package/docs/tool-reference.md +3 -2
- package/docs/workflows.md +24 -0
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +2 -0
- package/skills/pi-subagents/references/execution-controls.md +1 -1
- package/skills/pi-subagents/references/management-authoring-rpc.md +1 -0
- package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
- package/src/agents/agent-management.ts +23 -0
- package/src/agents/agent-serializer.ts +5 -0
- package/src/agents/agents.ts +38 -3
- package/src/agents/runtime-agent-events.ts +70 -0
- package/src/agents/runtime-agent-registry.ts +10 -2
- package/src/api/agents.ts +10 -5
- package/src/api/preflight.ts +26 -1
- package/src/extension/index.ts +15 -4
- package/src/extension/rpc.ts +35 -14
- package/src/extension/schemas.ts +2 -2
- package/src/extension/tool-description.ts +8 -4
- package/src/integrations/herdr-status.ts +5 -0
- package/src/runs/background/async-execution.ts +29 -12
- package/src/runs/background/async-resume.ts +6 -2
- package/src/runs/background/async-status.ts +2 -0
- package/src/runs/background/chain-append.ts +2 -0
- package/src/runs/background/notify.ts +26 -3
- package/src/runs/background/result-delivery-ownership.ts +45 -0
- package/src/runs/background/result-watcher.ts +27 -10
- package/src/runs/background/subagent-runner.ts +71 -21
- package/src/runs/foreground/execution.ts +20 -1
- package/src/runs/foreground/subagent-executor.ts +57 -26
- package/src/runs/foreground/workflow-detach-reconcile.ts +128 -17
- package/src/runs/shared/completion-guard.ts +4 -3
- package/src/runs/shared/dynamic-fanout.ts +3 -3
- package/src/runs/shared/fast-mode-extension.ts +5 -5
- package/src/runs/shared/launch-cwd.ts +16 -0
- package/src/runs/shared/long-running-guard.ts +2 -1
- package/src/runs/shared/mcp-config-sources.ts +386 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +155 -42
- package/src/runs/shared/model-exclusions.ts +12 -0
- package/src/runs/shared/model-fallback.ts +16 -4
- package/src/runs/shared/parallel-utils.ts +8 -1
- package/src/runs/shared/pi-args.ts +61 -6
- package/src/runs/shared/single-output.ts +17 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +85 -9
- package/src/shared/formatters.ts +6 -0
- package/src/shared/launch-contract.ts +4 -0
- package/src/shared/types.ts +42 -1
- package/src/shared/utils.ts +6 -29
- package/src/slash/subagents-admin.ts +3 -0
- package/src/tui/render.ts +14 -6
- package/src/workflows/scripted-workflow.ts +52 -5
- package/src/workflows/workflow-receipt.ts +39 -4
|
@@ -49,9 +49,9 @@ const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
|
|
|
49
49
|
const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "fast", "toolBudget", "acceptance", "agentContract", "gateOn"]);
|
|
50
50
|
const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
|
|
51
51
|
...DYNAMIC_PARALLEL_KEYS,
|
|
52
|
-
"outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled",
|
|
53
|
-
"structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "capabilityCeiling", "completionGuard", "systemPrompt",
|
|
54
|
-
"systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId", "launchResolvedExtensions",
|
|
52
|
+
"outputName", "structured", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled",
|
|
53
|
+
"structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "capabilityCeiling", "completionGuard", "systemPrompt",
|
|
54
|
+
"systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId", "launchResolvedExtensions", "requestedCwd",
|
|
55
55
|
]);
|
|
56
56
|
const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
|
|
57
57
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { BeforeProviderRequestEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
function
|
|
4
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
|
5
|
-
return { ...payload, service_tier: "priority" };
|
|
3
|
+
export function rewriteFastModeProviderRequest(event: BeforeProviderRequestEvent): unknown {
|
|
4
|
+
if (!event.payload || typeof event.payload !== "object" || Array.isArray(event.payload)) return event.payload;
|
|
5
|
+
return { ...event.payload, service_tier: "priority" };
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export default function registerSubagentFastModeExtension(pi: ExtensionAPI): void {
|
|
9
|
-
pi.on("before_provider_request",
|
|
9
|
+
pi.on("before_provider_request", rewriteFastModeProviderRequest);
|
|
10
10
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function preflightLaunchCwd(requestedCwd: string, effectiveCwd: string): string | undefined {
|
|
4
|
+
const resolution = requestedCwd === effectiveCwd ? "" : `\n(resolved from ${JSON.stringify(requestedCwd)})`;
|
|
5
|
+
try {
|
|
6
|
+
if (!fs.statSync(effectiveCwd).isDirectory()) {
|
|
7
|
+
return `Subagent launch aborted: cwd is not a directory: ${effectiveCwd}${resolution}`;
|
|
8
|
+
}
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
11
|
+
return `Subagent launch aborted: cwd does not exist: ${effectiveCwd}${resolution}`;
|
|
12
|
+
}
|
|
13
|
+
return `Subagent launch aborted: cwd could not be accessed: ${effectiveCwd}${resolution}\n${error instanceof Error ? error.message : String(error)}`;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
@@ -141,8 +141,9 @@ export function isMutatingBashCommand(command: string): boolean {
|
|
|
141
141
|
|| MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(command));
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
export function isMutatingTool(toolName: string | undefined, args: Record<string, unknown> | undefined): boolean {
|
|
144
|
+
export function isMutatingTool(toolName: string | undefined, args: Record<string, unknown> | undefined, mutationTools?: readonly string[]): boolean {
|
|
145
145
|
if (!toolName) return false;
|
|
146
|
+
if (mutationTools?.includes(toolName)) return true;
|
|
146
147
|
if (toolName === "edit" || toolName === "write") return true;
|
|
147
148
|
if (toolName === "cursor") {
|
|
148
149
|
const activityTitle = typeof args?.activityTitle === "string" ? args.activityTitle : "";
|
|
@@ -0,0 +1,386 @@
|
|
|
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 loadPackageMcpServers(cwd: string): Record<string, McpServerDefinition> {
|
|
43
|
+
const servers: Record<string, McpServerDefinition> = {};
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
|
|
46
|
+
for (const packageRoot of getConfiguredPackageRoots(cwd)) {
|
|
47
|
+
const manifest = readJson(path.join(packageRoot, "package.json"));
|
|
48
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) continue;
|
|
49
|
+
const packageName = (manifest as { name?: unknown }).name;
|
|
50
|
+
if (typeof packageName !== "string" || !packageName) continue;
|
|
51
|
+
const mcpValue = (manifest as { pi?: { mcp?: unknown } }).pi?.mcp;
|
|
52
|
+
const configPaths = typeof mcpValue === "string"
|
|
53
|
+
? [mcpValue]
|
|
54
|
+
: Array.isArray(mcpValue) && mcpValue.every((value): value is string => typeof value === "string")
|
|
55
|
+
? mcpValue
|
|
56
|
+
: [];
|
|
57
|
+
const packagePrefix = formatName(packageName, "package");
|
|
58
|
+
|
|
59
|
+
for (const configPath of configPaths) {
|
|
60
|
+
const resolvedPath = resolvePackageConfigPath(packageRoot, configPath);
|
|
61
|
+
if (!resolvedPath) continue;
|
|
62
|
+
const config = readJson(resolvedPath);
|
|
63
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) continue;
|
|
64
|
+
const rawServers = (config as { mcpServers?: unknown }).mcpServers;
|
|
65
|
+
if (!rawServers || typeof rawServers !== "object" || Array.isArray(rawServers)) continue;
|
|
66
|
+
for (const [serverName, definition] of Object.entries(rawServers)) {
|
|
67
|
+
if (!definition || typeof definition !== "object" || Array.isArray(definition)) continue;
|
|
68
|
+
const normalizedName = `${packagePrefix}__${formatName(serverName, "server")}`;
|
|
69
|
+
if (seen.has(normalizedName)) continue;
|
|
70
|
+
seen.add(normalizedName);
|
|
71
|
+
servers[normalizedName] = definition as McpServerDefinition;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return servers;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function loadAgentPluginMcpServers(paths: unknown, cwd: string): Record<string, McpServerDefinition> {
|
|
80
|
+
const servers: Record<string, McpServerDefinition> = {};
|
|
81
|
+
if (!Array.isArray(paths)) return servers;
|
|
82
|
+
|
|
83
|
+
for (const configuredPath of paths) {
|
|
84
|
+
if (typeof configuredPath !== "string") continue;
|
|
85
|
+
const pluginRoot = resolvePluginPath(configuredPath, cwd);
|
|
86
|
+
const manifest = readJson(path.join(pluginRoot, "plugin.json"));
|
|
87
|
+
if (!isValidPluginManifest(manifest)) continue;
|
|
88
|
+
const config = readJson(path.join(pluginRoot, "mcp.json"));
|
|
89
|
+
if (!isValidPluginConfig(config)) continue;
|
|
90
|
+
const rawServers = (config as { mcpServers: Record<string, unknown> }).mcpServers;
|
|
91
|
+
|
|
92
|
+
for (const [serverName, rawDefinition] of Object.entries(rawServers)) {
|
|
93
|
+
const definition = translatePluginServer(manifest.name, pluginRoot, serverName, rawDefinition);
|
|
94
|
+
if (!definition) continue;
|
|
95
|
+
const normalizedName = `${formatName(manifest.name, "plugin")}__${formatName(serverName, "server")}`;
|
|
96
|
+
if (Object.hasOwn(servers, normalizedName)) continue;
|
|
97
|
+
servers[normalizedName] = definition;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return servers;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getConfiguredPackageRoots(cwd: string): string[] {
|
|
105
|
+
const roots: string[] = [];
|
|
106
|
+
const projectConfigDir = findProjectConfigDir(cwd);
|
|
107
|
+
const sources = [
|
|
108
|
+
{ path: path.join(projectConfigDir, "settings.json"), baseDir: projectConfigDir },
|
|
109
|
+
{ path: path.join(getAgentDir(), "settings.json"), baseDir: getAgentDir() },
|
|
110
|
+
];
|
|
111
|
+
for (const source of sources) {
|
|
112
|
+
const settings = readJson(source.path);
|
|
113
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) continue;
|
|
114
|
+
const packages = (settings as { packages?: unknown }).packages;
|
|
115
|
+
if (!Array.isArray(packages)) continue;
|
|
116
|
+
for (const entry of packages) {
|
|
117
|
+
const packageSource = typeof entry === "string"
|
|
118
|
+
? entry
|
|
119
|
+
: entry && typeof entry === "object" && !Array.isArray(entry) && typeof (entry as { source?: unknown }).source === "string"
|
|
120
|
+
? (entry as { source: string }).source
|
|
121
|
+
: undefined;
|
|
122
|
+
if (!packageSource) continue;
|
|
123
|
+
const root = resolvePackageRoot(packageSource, source.baseDir);
|
|
124
|
+
if (root && !roots.includes(root)) roots.push(root);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return roots;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function findProjectConfigDir(cwd: string): string {
|
|
131
|
+
const projectRoot = findConfiguredProjectRoot(path.resolve(cwd));
|
|
132
|
+
return projectRoot ? getProjectConfigDir(projectRoot) : path.join(path.resolve(cwd), getConfigDirName());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolvePackageRoot(source: string, baseDir: string): string | undefined {
|
|
136
|
+
const trimmed = source.trim();
|
|
137
|
+
if (!trimmed) return undefined;
|
|
138
|
+
if (trimmed.startsWith("npm:")) {
|
|
139
|
+
const packageName = parseNpmPackageName(trimmed);
|
|
140
|
+
return packageName ? resolveContainedPath(path.join(baseDir, PACKAGE_CONFIG_ROOT, "node_modules"), packageName) ?? undefined : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (trimmed.startsWith("git:") || /^(?:https?:\/\/|ssh:\/\/|git@[^:]+:)/.test(trimmed)) {
|
|
144
|
+
const parsed = parseGitPackagePath(trimmed.startsWith("git:") ? trimmed : `git:${trimmed}`);
|
|
145
|
+
return parsed ? path.join(baseDir, PACKAGE_GIT_ROOT, parsed.host, parsed.repoPath) : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const localPath = trimmed.startsWith("file:") ? trimmed.slice(5) : trimmed;
|
|
149
|
+
if (localPath === "~") return os.homedir();
|
|
150
|
+
if (localPath.startsWith("~/")) return path.join(os.homedir(), localPath.slice(2));
|
|
151
|
+
return path.isAbsolute(localPath) ? path.resolve(localPath) : path.resolve(baseDir, localPath);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseNpmPackageName(source: string): string | undefined {
|
|
155
|
+
const spec = source.slice(4).trim();
|
|
156
|
+
if (!spec) return undefined;
|
|
157
|
+
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
|
|
158
|
+
const packageName = match?.[1] ?? spec;
|
|
159
|
+
return resolveContainedPath("/", packageName) ? packageName : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseGitPackagePath(source: string): { host: string; repoPath: string } | undefined {
|
|
163
|
+
const spec = source.slice(4).trim();
|
|
164
|
+
if (!spec) return undefined;
|
|
165
|
+
|
|
166
|
+
let host = "";
|
|
167
|
+
let repoPath = "";
|
|
168
|
+
const scpLike = spec.match(/^git@([^:]+):(.+)$/);
|
|
169
|
+
if (scpLike) {
|
|
170
|
+
host = scpLike[1] ?? "";
|
|
171
|
+
repoPath = scpLike[2] ?? "";
|
|
172
|
+
} else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec)) {
|
|
173
|
+
try {
|
|
174
|
+
const url = new URL(spec);
|
|
175
|
+
host = url.hostname;
|
|
176
|
+
repoPath = url.pathname.replace(/^\/+/, "");
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
const slashIndex = spec.indexOf("/");
|
|
182
|
+
if (slashIndex < 0) return undefined;
|
|
183
|
+
host = spec.slice(0, slashIndex);
|
|
184
|
+
repoPath = spec.slice(slashIndex + 1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const normalizedPath = stripGitRef(repoPath).replace(/\.git$/, "").replace(/^\/+/, "");
|
|
188
|
+
if (!host || !isSafePackagePath(host) || !isSafePackagePath(normalizedPath) || normalizedPath.split(/[\\/]/).length < 2) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
return { host, repoPath: normalizedPath };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stripGitRef(repoPath: string): string {
|
|
195
|
+
const atIndex = repoPath.indexOf("@");
|
|
196
|
+
const hashIndex = repoPath.indexOf("#");
|
|
197
|
+
const refIndex = [atIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
|
|
198
|
+
return refIndex === undefined ? repoPath : repoPath.slice(0, refIndex);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isSafePackagePath(value: string): boolean {
|
|
202
|
+
return value.length > 0
|
|
203
|
+
&& !path.isAbsolute(value)
|
|
204
|
+
&& value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolvePackageConfigPath(packageRoot: string, configuredPath: string): string | undefined {
|
|
208
|
+
const lexicalPath = resolveContainedPath(packageRoot, configuredPath);
|
|
209
|
+
if (!lexicalPath || !fs.existsSync(lexicalPath)) return undefined;
|
|
210
|
+
try {
|
|
211
|
+
if (!fs.statSync(lexicalPath).isFile()) return undefined;
|
|
212
|
+
const packageRealPath = fs.realpathSync(packageRoot);
|
|
213
|
+
const configRealPath = fs.realpathSync(lexicalPath);
|
|
214
|
+
return resolveContainedPath(packageRealPath, configRealPath) ?? undefined;
|
|
215
|
+
} catch {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resolveContainedPath(root: string, value: string): string | undefined {
|
|
221
|
+
const resolved = path.resolve(root, value);
|
|
222
|
+
const relative = path.relative(root, resolved);
|
|
223
|
+
return relative === "" || (!relative.startsWith("..") && !relative.startsWith(path.sep) && !path.isAbsolute(relative))
|
|
224
|
+
? resolved
|
|
225
|
+
: undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function resolvePluginPath(configuredPath: string, cwd: string): string {
|
|
229
|
+
if (configuredPath === "~") return path.resolve(process.env.HOME ?? "", ".");
|
|
230
|
+
if (configuredPath.startsWith("~/")) return path.resolve(process.env.HOME ?? "", configuredPath.slice(2));
|
|
231
|
+
return path.isAbsolute(configuredPath) ? path.resolve(configuredPath) : path.resolve(cwd, configuredPath);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isValidPluginManifest(value: unknown): value is { name: string } {
|
|
235
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
236
|
+
const manifest = value as { $schema?: unknown; name?: unknown };
|
|
237
|
+
return manifest.$schema === PLUGIN_SCHEMA
|
|
238
|
+
&& typeof manifest.name === "string"
|
|
239
|
+
&& manifest.name.length >= 1
|
|
240
|
+
&& manifest.name.length <= 64
|
|
241
|
+
&& PLUGIN_NAME_PATTERN.test(manifest.name);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isValidPluginConfig(value: unknown): value is { mcpServers: Record<string, unknown> } {
|
|
245
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
246
|
+
const config = value as Record<string, unknown>;
|
|
247
|
+
return config.$schema === PLUGIN_MCP_SCHEMA
|
|
248
|
+
&& Object.keys(config).every((key) => key === "$schema" || key === "mcpServers")
|
|
249
|
+
&& !!config.mcpServers
|
|
250
|
+
&& typeof config.mcpServers === "object"
|
|
251
|
+
&& !Array.isArray(config.mcpServers);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function translatePluginServer(
|
|
255
|
+
pluginName: string,
|
|
256
|
+
pluginRoot: string,
|
|
257
|
+
serverName: string,
|
|
258
|
+
value: unknown,
|
|
259
|
+
): McpServerDefinition | undefined {
|
|
260
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
261
|
+
const raw = value as Record<string, unknown>;
|
|
262
|
+
if (raw.type === "stdio") return translatePluginStdioServer(pluginName, pluginRoot, serverName, raw);
|
|
263
|
+
if (raw.type === "streamable-http" || raw.type === "sse") return translatePluginHttpServer(raw);
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function translatePluginStdioServer(
|
|
268
|
+
pluginName: string,
|
|
269
|
+
pluginRoot: string,
|
|
270
|
+
serverName: string,
|
|
271
|
+
raw: Record<string, unknown>,
|
|
272
|
+
): McpServerDefinition | undefined {
|
|
273
|
+
if ([...Object.keys(raw)].some((key) => !PLUGIN_STDIO_FIELDS.has(key))) return undefined;
|
|
274
|
+
if (typeof raw.command !== "string" || !raw.command) return undefined;
|
|
275
|
+
if (!isBareCommand(raw.command) && !raw.command.startsWith("./")) return undefined;
|
|
276
|
+
const args = stringArray(raw.args);
|
|
277
|
+
if (raw.args !== undefined && !args) return undefined;
|
|
278
|
+
const env = stringRecord(raw.env);
|
|
279
|
+
if (raw.env !== undefined && !env) return undefined;
|
|
280
|
+
if (env && (Object.hasOwn(env, "PLUGIN_ROOT") || Object.hasOwn(env, "PLUGIN_DATA"))) return undefined;
|
|
281
|
+
|
|
282
|
+
const pluginDataDir = path.join(getAgentDir(), "agent-plugin-data", pluginName);
|
|
283
|
+
const command = raw.command.startsWith("./") ? resolveContainedPath(pluginRoot, raw.command) : raw.command;
|
|
284
|
+
if (!command) return undefined;
|
|
285
|
+
const cwd = resolvePluginCwd(raw.cwd, pluginRoot, pluginDataDir);
|
|
286
|
+
if (!cwd) return undefined;
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
command,
|
|
290
|
+
args: (args ?? []).map((value) => expandPluginPlaceholders(value, pluginRoot, pluginDataDir)),
|
|
291
|
+
env: {
|
|
292
|
+
...Object.fromEntries(Object.entries(env ?? {}).map(([key, value]) => [key, expandPluginPlaceholders(value, pluginRoot, pluginDataDir)])),
|
|
293
|
+
PLUGIN_ROOT: pluginRoot,
|
|
294
|
+
PLUGIN_DATA: pluginDataDir,
|
|
295
|
+
},
|
|
296
|
+
cwd,
|
|
297
|
+
pluginDataDir,
|
|
298
|
+
literalEnv: true,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function translatePluginHttpServer(raw: Record<string, unknown>): McpServerDefinition | undefined {
|
|
303
|
+
if ([...Object.keys(raw)].some((key) => !PLUGIN_HTTP_FIELDS.has(key))) return undefined;
|
|
304
|
+
if (typeof raw.url !== "string" || !isValidPluginUrl(raw.url)) return undefined;
|
|
305
|
+
const headers = stringRecord(raw.headers);
|
|
306
|
+
if (raw.headers !== undefined && !headers) return undefined;
|
|
307
|
+
if (headers) {
|
|
308
|
+
const normalized = new Set<string>();
|
|
309
|
+
for (const key of Object.keys(headers)) {
|
|
310
|
+
const lower = key.toLowerCase();
|
|
311
|
+
if (normalized.has(lower)) return undefined;
|
|
312
|
+
normalized.add(lower);
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
new Headers(headers);
|
|
316
|
+
} catch {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { url: raw.url, ...(headers ? { headers } : {}), httpTransport: raw.type as string };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function resolvePluginCwd(value: unknown, pluginRoot: string, pluginDataDir: string): string | undefined {
|
|
324
|
+
if (value === undefined) return pluginRoot;
|
|
325
|
+
if (typeof value !== "string") return undefined;
|
|
326
|
+
if (value.startsWith("./")) return resolveContainedPath(pluginRoot, value);
|
|
327
|
+
if (value === "${PLUGIN_ROOT}" || value.startsWith("${PLUGIN_ROOT}/")) return resolveContainedPath(pluginRoot, value.replace("${PLUGIN_ROOT}", "."));
|
|
328
|
+
if (value === "${PLUGIN_DATA}" || value.startsWith("${PLUGIN_DATA}/")) return resolveContainedPath(pluginDataDir, value.replace("${PLUGIN_DATA}", "."));
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function expandPluginPlaceholders(value: string, pluginRoot: string, pluginDataDir: string): string {
|
|
333
|
+
return value.replaceAll("${PLUGIN_ROOT}", pluginRoot).replaceAll("${PLUGIN_DATA}", pluginDataDir);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function isBareCommand(command: string): boolean {
|
|
337
|
+
return !command.includes("/")
|
|
338
|
+
&& !command.includes("\\")
|
|
339
|
+
&& !command.includes("${PLUGIN_ROOT}")
|
|
340
|
+
&& !command.includes("${PLUGIN_DATA}");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isValidPluginUrl(value: string): boolean {
|
|
344
|
+
if (value.includes("${") || value.includes("$env:") || value.includes("{env:")) return false;
|
|
345
|
+
let url: URL;
|
|
346
|
+
try {
|
|
347
|
+
url = new URL(value);
|
|
348
|
+
} catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
352
|
+
if (url.username || url.password || url.hash) return false;
|
|
353
|
+
if (url.protocol === "https:") return true;
|
|
354
|
+
const host = url.hostname.toLowerCase();
|
|
355
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
359
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function stringRecord(value: unknown): Record<string, string> | undefined {
|
|
363
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
364
|
+
const entries = Object.entries(value);
|
|
365
|
+
if (entries.some(([, entry]) => typeof entry !== "string")) return undefined;
|
|
366
|
+
return Object.fromEntries(entries) as Record<string, string>;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function formatName(value: string, fallback: string): string {
|
|
370
|
+
return value.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^[_-]+|[_-]+$/g, "") || fallback;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function readJson(filePath: string): unknown | undefined {
|
|
374
|
+
let content: string;
|
|
375
|
+
try {
|
|
376
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
379
|
+
throw new Error(`Failed to read JSON file '${filePath}': ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
return JSON.parse(content);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
throw new Error(`Invalid JSON in '${filePath}': ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
385
|
+
}
|
|
386
|
+
}
|