@henryqw/pi-subagent 25.0.0 → 25.0.1

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/README.md CHANGED
@@ -181,7 +181,7 @@ Role Markdown lives in `~/.pi/agent/config/pi-subagent/` and requires frontmatte
181
181
 
182
182
  Roles describe responsibility and capabilities. They do not choose isolation; each request does. A same-named user Role overrides a built-in Role. The package ships `implementer`, `reviewer`, and `scout`.
183
183
 
184
- Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider, and only declared resources plus required internal policy adapters load. `builtin:<name>` entries load Pi built-in extensions (`builtin:codemode`, `builtin:tool-search`, `builtin:llama.cpp`) explicitly; `builtin:mcp` is rejected because it would connect every configured server and bypass `mcps`. The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only; their tools load in the child with direct exposure and are declared to the model as `mcp__<server>__<tool>` once the server connects. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. Main-only delegation and recovery tools plus `ask_question` are excluded from children.
184
+ Children run with `--no-extensions`, so a Role must list the extension that registers its route model's provider, and only declared resources plus required internal policy adapters load. `builtin:<name>` entries load Pi built-in extensions (`builtin:codemode`, `builtin:tool-search`, `builtin:llama.cpp`) explicitly; `builtin:mcp` is rejected because it would connect every configured server and bypass `mcps`. The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only; their tools load in the child with direct exposure and are declared to the model as `mcp__<server>__<tool>` once the server connects. Pi replaces `-` in server and tool names with `_` and appends a hash suffix when two tool names collide or a name exceeds 64 characters. Each selected entry is checked against Pi's documented `mcpServers` rules before launch, in Main and again in the child: a disabled, malformed, or unknown-transport entry, an invalid server name, two selected names that differ only in `-` and `_`, `auth.provider` over plain HTTP outside `localhost`, `127.0.0.1`, or `[::1]`, or a non-HTTPS `oauth.authServerMetadataUrl` outside those hosts fails the launch with the file path and field. The file is never modified; fix the entry or choose another server. Valid `auth`, `oauth`, and `description` fields are passed through so Pi's native provider auth and OAuth apply. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. Main-only delegation and recovery tools plus `ask_question` are excluded from children.
185
185
 
186
186
  ## API
187
187
 
package/dist/mcp-role.js CHANGED
@@ -22,17 +22,69 @@ export function parseRoleMcpAllowlist(value) {
22
22
  function isRecord(value) {
23
23
  return typeof value === "object" && value !== null && !Array.isArray(value);
24
24
  }
25
- const isStringRecord = (value) => isRecord(value) && Object.values(value).every((item) => typeof item === "string");
26
- /** Documented optional fields of an `mcpServers` entry and their shapes (see Pi's docs/mcp.md). */
27
- const FIELD_CHECKS = {
28
- args: [(value) => Array.isArray(value) && value.every((item) => typeof item === "string"), "an array of strings"],
25
+ const isString = (value) => typeof value === "string";
26
+ const isStringRecord = (value) => isRecord(value) && Object.values(value).every(isString);
27
+ const parseUrl = (value) => (typeof value === "string" && URL.canParse(value) ? new URL(value) : undefined);
28
+ // Pi's native MCP rules (core/mcp-servers.ts); `validateMcpServerConfig` is not exported, so they are mirrored here.
29
+ const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
30
+ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
31
+ const isHttpsOrLoopback = (url) => url !== undefined && (url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)));
32
+ /** Tool namespace `mcp__<server>` replaces `-` with `_`, so names differing only there collide. */
33
+ const mcpNamespace = (name) => name.replace(/-/g, "_");
34
+ /** Documented `mcpServers` fields (Pi's docs/mcp.md) and their shapes, by transport; dotted keys are nested. */
35
+ const COMMON_CHECKS = {
36
+ enabled: [(value) => typeof value === "boolean", "a boolean"],
37
+ timeout: [(value) => typeof value === "number" && Number.isFinite(value) && value > 0, "a positive number"],
38
+ description: [isString, "a string"],
39
+ };
40
+ const STDIO_CHECKS = {
41
+ args: [(value) => Array.isArray(value) && value.every(isString), "an array of strings"],
29
42
  env: [isStringRecord, "an object of strings"],
43
+ cwd: [isString, "a string"],
44
+ };
45
+ const HTTP_CHECKS = {
46
+ url: [(value) => /^https?:$/.test(parseUrl(value)?.protocol ?? ""), "an http or https URL"],
30
47
  headers: [isStringRecord, "an object of strings"],
31
- cwd: [(value) => typeof value === "string", "a string"],
32
- timeout: [(value) => typeof value === "number" && Number.isFinite(value) && value > 0, "a positive number"],
33
- enabled: [(value) => typeof value === "boolean", "a boolean"],
34
48
  oauth: [isRecord, "an object"],
49
+ "oauth.clientId": [isString, "a string"],
50
+ "oauth.clientSecret": [isString, "a string"],
51
+ "oauth.callbackPort": [(value) => Number.isInteger(value) && value >= 1 && value <= 65535, "a port number"],
52
+ "oauth.callbackUrl": [(value, server) => {
53
+ const url = parseUrl(value);
54
+ const port = server.oauth.callbackPort;
55
+ return url !== undefined && url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname) && url.search === "" && url.hash === ""
56
+ && (url.port === "" || port === undefined || Number(url.port) === port);
57
+ }, "an http URL on localhost, 127.0.0.1, or [::1] without query or fragment, on the \"oauth.callbackPort\" port"],
58
+ "oauth.scope": [isString, "a string"],
59
+ "oauth.clientName": [(value) => typeof value === "string" && value.trim() !== "", "a non-empty string"],
60
+ "oauth.authServerMetadataUrl": [(value) => isHttpsOrLoopback(parseUrl(value)), "an https URL, or http on localhost, 127.0.0.1, or [::1]"],
61
+ auth: [(value) => isRecord(value) && typeof value.provider === "string" && value.provider !== "", "an object with a non-empty \"provider\" string"],
35
62
  };
63
+ /** Validate one selected server against Pi's documented `mcpServers` rules; the native loader is bypassed by `loadConfig`. */
64
+ function validateRoleMcpServer(path, name, value) {
65
+ const fail = (message) => { throw new Error(`${path}: MCP server "${name}" ${message}`); };
66
+ if (!SERVER_NAME.test(name))
67
+ fail("has an invalid name (use letters, digits, \"_\" and \"-\").");
68
+ if (!isRecord(value))
69
+ fail("must be an object.");
70
+ if (value.enabled === false)
71
+ throw new Error(`${path}: Role MCP server "${name}" is disabled.`);
72
+ const stdio = typeof value.command === "string" && value.command.trim() !== "" && (value.type === undefined || value.type === "stdio");
73
+ const http = typeof value.url === "string" && value.url.trim() !== "" && (value.type === undefined || value.type === "http" || value.type === "streamable-http");
74
+ if (stdio === http)
75
+ fail("needs either a \"command\" (stdio) or a \"url\" (http or streamable-http).");
76
+ for (const [field, [valid, shape]] of Object.entries({ ...COMMON_CHECKS, ...(stdio ? STDIO_CHECKS : HTTP_CHECKS) })) {
77
+ const fieldValue = field.split(".").reduce((parent, key) => (isRecord(parent) ? parent[key] : undefined), value);
78
+ if (fieldValue !== undefined && !valid(fieldValue, value))
79
+ fail(`field "${field}" must be ${shape}.`);
80
+ }
81
+ // The native transport sends the provider's token to `url`, so Pi only allows it over https or loopback.
82
+ if (value.auth !== undefined && !isHttpsOrLoopback(parseUrl(value.url))) {
83
+ fail("field \"auth\" requires \"url\" to use https, or http on localhost, 127.0.0.1, or [::1].");
84
+ }
85
+ const { toolExposure: _toolExposure, ...config } = value;
86
+ return { ...config, exposure: "direct" };
87
+ }
36
88
  /**
37
89
  * Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
38
90
  * shape; the project `.pi/mcp.json` is ignored. A `--no-extensions` child has no codemode tool, so
@@ -56,23 +108,13 @@ export function loadRoleMcpConfig(agentDir, allowlist) {
56
108
  const missing = allowlist.filter((name) => !Object.hasOwn(configured, name));
57
109
  if (missing.length)
58
110
  throw new Error(`Role MCP servers are not configured: ${missing.join(", ")}.`);
111
+ const namespaces = new Map();
59
112
  const servers = allowlist.map((name) => {
60
- const value = configured[name];
61
- if (!isRecord(value))
62
- throw new Error(`${path}: MCP server "${name}" must be an object.`);
63
- if (value.enabled === false)
64
- throw new Error(`${path}: Role MCP server "${name}" is disabled.`);
65
- const stdio = typeof value.command === "string" && value.command.trim() !== "" && (value.type === undefined || value.type === "stdio");
66
- const http = typeof value.url === "string" && value.url.trim() !== "" && (value.type === undefined || value.type === "http" || value.type === "streamable-http");
67
- if (stdio === http) {
68
- throw new Error(`${path}: MCP server "${name}" needs either a "command" (stdio) or a "url" (http or streamable-http).`);
69
- }
70
- for (const [field, [valid, shape]] of Object.entries(FIELD_CHECKS)) {
71
- if (value[field] !== undefined && !valid(value[field]))
72
- throw new Error(`${path}: MCP server "${name}" field "${field}" must be ${shape}.`);
73
- }
74
- const { toolExposure: _toolExposure, ...config } = value;
75
- return { name, config: { ...config, exposure: "direct" }, source: path, scope: "global" };
113
+ const clash = namespaces.get(mcpNamespace(name));
114
+ if (clash)
115
+ throw new Error(`${path}: Role MCP server "${name}" conflicts with "${clash}": names that differ only in "-" and "_" share one tool namespace.`);
116
+ namespaces.set(mcpNamespace(name), name);
117
+ return { name, config: validateRoleMcpServer(path, name, configured[name]), source: path, scope: "global" };
76
118
  });
77
119
  return { servers, autoEnableCodemode: false, errors: [] };
78
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "25.0.0",
3
+ "version": "25.0.1",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -40,9 +40,9 @@
40
40
  "pack:check": "npm pack --dry-run"
41
41
  },
42
42
  "peerDependencies": {
43
- "@earendil-works/pi-ai": ">=0.99.1 <0.100.0",
44
- "@earendil-works/pi-coding-agent": ">=0.99.1 <0.100.0",
45
- "@earendil-works/pi-tui": ">=0.99.1 <0.100.0",
43
+ "@earendil-works/pi-ai": ">=0.99.1 <0.100.0 || ^1.0.0",
44
+ "@earendil-works/pi-coding-agent": ">=0.99.1 <0.100.0 || ^1.0.0",
45
+ "@earendil-works/pi-tui": ">=0.99.1 <0.100.0 || ^1.0.0",
46
46
  "typebox": "^1.3.15"
47
47
  },
48
48
  "repository": {
@@ -74,9 +74,9 @@
74
74
  "proper-lockfile": "^4.1.2"
75
75
  },
76
76
  "devDependencies": {
77
- "@earendil-works/pi-ai": "0.99.1",
78
- "@earendil-works/pi-coding-agent": "0.99.1",
79
- "@earendil-works/pi-tui": "0.99.1",
77
+ "@earendil-works/pi-ai": "1.0.0",
78
+ "@earendil-works/pi-coding-agent": "1.0.0",
79
+ "@earendil-works/pi-tui": "1.0.0",
80
80
  "@types/proper-lockfile": "^4.1.4"
81
81
  }
82
82
  }