pi-session-mcp 0.6.3 → 0.7.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 (60) hide show
  1. package/.pi-session-mcp-cli/config.js +11 -1
  2. package/.pi-session-mcp-cli/doctor-cli.js +5 -1
  3. package/.pi-session-mcp-cli/doctor.js +22 -1
  4. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  5. package/.pi-session-mcp-cli/extension-spec.js +21 -0
  6. package/.pi-session-mcp-cli/pi-extensions.js +67 -0
  7. package/CHANGELOG.md +54 -0
  8. package/CONTRIBUTING.md +1 -1
  9. package/README.md +2 -2
  10. package/SECURITY.md +1 -1
  11. package/dist/capabilities.d.ts +1 -0
  12. package/dist/capabilities.js +2 -1
  13. package/dist/client-adapters/mcp-verifier.js +5 -2
  14. package/dist/config.d.ts +1 -0
  15. package/dist/config.js +11 -1
  16. package/dist/doctor-cli.js +5 -1
  17. package/dist/doctor.d.ts +4 -1
  18. package/dist/doctor.js +22 -1
  19. package/dist/execution-profile.d.ts +3 -0
  20. package/dist/execution-profile.js +1 -0
  21. package/dist/extension-spec.d.ts +15 -0
  22. package/dist/extension-spec.js +21 -0
  23. package/dist/main.js +6 -2
  24. package/dist/pi-adapter.d.ts +1 -1
  25. package/dist/pi-adapter.js +4 -0
  26. package/dist/pi-extensions.d.ts +17 -0
  27. package/dist/pi-extensions.js +67 -0
  28. package/dist/sdk-pi-adapter.d.ts +24 -6
  29. package/dist/sdk-pi-adapter.js +253 -86
  30. package/dist/server.js +1 -0
  31. package/dist/session-tools.d.ts +9 -0
  32. package/dist/session-tools.js +25 -0
  33. package/dist/setup-command-guardian.js +29 -73
  34. package/dist/setup-main.js +2 -1
  35. package/dist/setup-ownership.js +18 -28
  36. package/dist/setup-platform.d.ts +57 -0
  37. package/dist/setup-platform.js +195 -0
  38. package/dist/setup-process.d.ts +6 -3
  39. package/dist/setup-process.js +85 -93
  40. package/dist/setup.js +2 -1
  41. package/dist/stdout-guard-install.d.ts +5 -0
  42. package/dist/stdout-guard-install.js +6 -0
  43. package/dist/stdout-guard.d.ts +20 -0
  44. package/dist/stdout-guard.js +72 -0
  45. package/docs/acceptance.md +245 -0
  46. package/docs/adr/0005-macos-setup-platform.md +84 -0
  47. package/docs/adr/0006-pi-extension-allowlist.md +58 -0
  48. package/docs/architecture.md +1 -1
  49. package/docs/client-setup-design.md +21 -9
  50. package/docs/client-setup.md +56 -10
  51. package/docs/doctor-design.md +9 -2
  52. package/docs/installation.md +129 -4
  53. package/docs/macos-setup-design.md +386 -0
  54. package/docs/pi-extensions-design.md +421 -0
  55. package/docs/releasing.md +1 -1
  56. package/docs/superpowers/plans/2026-10-10-macos-setup.md +1725 -0
  57. package/docs/superpowers/plans/2026-10-11-pi-extensions.md +2450 -0
  58. package/docs/threat-model.md +54 -18
  59. package/docs/tool-contracts.md +8 -3
  60. package/package.json +1 -1
@@ -2,19 +2,28 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { z } from "zod";
5
+ import { MAX_PROFILE_EXTENSIONS, parseExtensionSpec } from "./extension-spec.js";
5
6
  import { isMcpServers, snapshotMcpServers } from "./mcp-config.js";
6
7
  import { ALIAS_PATTERN, ImmutableMap, } from "./execution-profile.js";
7
8
  const alias = z.string().regex(ALIAS_PATTERN);
8
9
  const permissionProfile = z.enum(["read-only", "coding"]);
9
10
  const thinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
11
+ const extensionSpec = z.string().refine((value) => parseExtensionSpec(value) !== undefined);
10
12
  const profileSchema = z.object({
11
13
  default: z.boolean(), permissionProfile, provider: z.string().trim().min(1).max(256),
12
14
  model: z.string().trim().min(1).max(256), thinkingLevel,
13
15
  mcpServers: z.custom((value) => isMcpServers(value)).optional(),
16
+ extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
14
17
  }).strict().superRefine((profile, context) => {
15
18
  if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
16
19
  context.addIssue({ code: "custom", path: ["mcpServers"], message: "MCP tool grants do not match the permission profile" });
17
20
  }
21
+ if (profile.extensions !== undefined) {
22
+ const names = profile.extensions.flatMap((spec) => parseExtensionSpec(spec)?.name ?? []);
23
+ if (new Set(names).size !== names.length) {
24
+ context.addIssue({ code: "custom", path: ["extensions"], message: "An extension package may appear only once per profile" });
25
+ }
26
+ }
18
27
  });
19
28
  const CONFIG_ERROR_MESSAGES = {
20
29
  config_path_required: "Configuration path is required",
@@ -77,9 +86,10 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
77
86
  }
78
87
  const base = dirname(absoluteConfig);
79
88
  const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
80
- const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, ...value }]) => [key, Object.freeze({
89
+ const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, ...value }]) => [key, Object.freeze({
81
90
  alias: key, ...value,
82
91
  ...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
92
+ ...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
83
93
  })]));
84
94
  const defaultEntry = [...executionProfiles].find(([, profile]) => profile.default);
85
95
  if (!defaultEntry)
@@ -1,11 +1,15 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import { parseDoctorArgs, renderDoctorHuman, renderDoctorJson, runDoctor } from "./doctor.js";
3
+ // The generated CLI lives one level below the package root. An npm executable runs
4
+ // from any directory, so the working directory says nothing about the package.
5
+ const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
2
6
  const options = parseDoctorArgs(process.argv.slice(2));
3
7
  if (!options) {
4
8
  process.stderr.write("Usage: doctor [--json]\n");
5
9
  process.exitCode = 64;
6
10
  }
7
11
  else {
8
- const result = await runDoctor();
12
+ const result = await runDoctor({ packageRoot: PACKAGE_ROOT });
9
13
  process.stdout.write(options.json ? renderDoctorJson(result) : renderDoctorHuman(result));
10
14
  process.exitCode = result.exitCode;
11
15
  }
@@ -2,9 +2,10 @@ import { access, readFile, readdir, stat } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { loadConfig } from "./config.js";
5
+ import { PiExtensionError } from "./extension-spec.js";
5
6
  import { readPackageMetadata } from "./package-metadata.js";
6
7
  const defaultFileSystem = { readFile, stat, access };
7
- const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable"];
8
+ const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "extension_missing", "extension_version_mismatch"];
8
9
  function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
9
10
  function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
10
11
  function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
@@ -174,6 +175,26 @@ export async function runDoctor(dependencies = {}) {
174
175
  }
175
176
  }
176
177
  }
178
+ if (config) {
179
+ const resolveExtension = dependencies.resolveExtension
180
+ ?? (async (extension) => (await import("./pi-extensions.js")).resolvePiExtension(extension));
181
+ for (const [alias, profile] of [...config.executionProfiles].sort(([a], [b]) => a.localeCompare(b))) {
182
+ for (const extension of profile.extensions ?? []) {
183
+ const subject = `${alias}:${extension.name}`;
184
+ try {
185
+ await resolveExtension(extension);
186
+ checks.push(ok("extension_missing", subject), ok("extension_version_mismatch", subject));
187
+ }
188
+ catch (error) {
189
+ const code = error instanceof PiExtensionError ? error.code : "extension_load_failed";
190
+ if (code === "extension_not_installed")
191
+ checks.push(failure("extension_missing", subject, "install the pinned extension version with Pi"));
192
+ else
193
+ checks.push(ok("extension_missing", subject), failure("extension_version_mismatch", subject, "install the pinned extension version with Pi"));
194
+ }
195
+ }
196
+ }
197
+ }
177
198
  const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
178
199
  checks.sort((a, b) => (rank.get(a.id) ?? 999) - (rank.get(b.id) ?? 999) || a.subject.localeCompare(b.subject));
179
200
  const hasError = checks.some((check) => check.severity === "error");
@@ -8,6 +8,7 @@ export function toResolvedExecutionProfile(profile) {
8
8
  model: profile.model,
9
9
  thinkingLevel: profile.thinkingLevel,
10
10
  ...(profile.mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(profile.mcpServers) }),
11
+ ...(profile.extensions === undefined ? {} : { extensions: profile.extensions }),
11
12
  });
12
13
  }
13
14
  /** A small read-only map implementation which has no mutating methods at runtime. */
@@ -0,0 +1,21 @@
1
+ export const MAX_PROFILE_EXTENSIONS = 8;
2
+ const MAX_SPEC_LENGTH = 256;
3
+ /** `npm:<name>@<exact semver>`; the name may be scoped. Ranges, tags, git sources and paths never match. */
4
+ const EXTENSION_SPEC_PATTERN = /^npm:((?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
5
+ export function parseExtensionSpec(spec) {
6
+ if (spec.length > MAX_SPEC_LENGTH)
7
+ return undefined;
8
+ const match = EXTENSION_SPEC_PATTERN.exec(spec);
9
+ if (!match)
10
+ return undefined;
11
+ return Object.freeze({ spec, name: match[1], version: match[2] });
12
+ }
13
+ /** A classified extension resolution failure. It never carries a path or extension text. */
14
+ export class PiExtensionError extends Error {
15
+ code;
16
+ constructor(code) {
17
+ super("Pi extension resolution failed");
18
+ this.name = "PiExtensionError";
19
+ this.code = code;
20
+ }
21
+ }
@@ -0,0 +1,67 @@
1
+ import { readFile, realpath, stat } from "node:fs/promises";
2
+ import { isAbsolute, join, relative, sep } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { PiExtensionError } from "./extension-spec.js";
5
+ // The SDK has no side-effect-free lookup: `resolve()` runs `npm install`, and `getInstalledPath`
6
+ // falls back to a synchronous `npm root -g` on a miss and accepts global installs made outside Pi.
7
+ // So this only computes where Pi puts user-scope npm packages; a missing directory means not installed.
8
+ function installedPathFromPi(source, agentDir) {
9
+ return join(agentDir, "npm", "node_modules", source.slice("npm:".length));
10
+ }
11
+ function inside(root, candidate) {
12
+ const path = relative(root, candidate);
13
+ return path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
14
+ }
15
+ function isMissing(error) {
16
+ return typeof error === "object" && error !== null && error.code === "ENOENT";
17
+ }
18
+ /**
19
+ * Resolves a pinned extension to the real directory of Pi's installed package.
20
+ * It never installs, never imports extension code and never reports a path.
21
+ */
22
+ export async function resolvePiExtension(extension, dependencies = {}) {
23
+ const agentDir = dependencies.agentDir ?? getAgentDir();
24
+ const read = dependencies.readFile ?? ((path) => readFile(path, "utf8"));
25
+ const real = dependencies.realpath ?? realpath;
26
+ const statFile = dependencies.stat ?? stat;
27
+ let installed;
28
+ try {
29
+ installed = (dependencies.installedPath ?? installedPathFromPi)(`npm:${extension.name}`, agentDir);
30
+ }
31
+ catch {
32
+ throw new PiExtensionError("extension_load_failed");
33
+ }
34
+ if (installed === undefined)
35
+ throw new PiExtensionError("extension_not_installed");
36
+ let root;
37
+ let manifest;
38
+ try {
39
+ root = await real(installed);
40
+ manifest = JSON.parse((await read(join(root, "package.json"))).replace(/^\uFEFF/, ""));
41
+ }
42
+ catch (error) {
43
+ throw new PiExtensionError(isMissing(error) ? "extension_not_installed" : "extension_load_failed");
44
+ }
45
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest))
46
+ throw new PiExtensionError("extension_load_failed");
47
+ const { version, pi } = manifest;
48
+ if (version !== extension.version)
49
+ throw new PiExtensionError("extension_version_mismatch");
50
+ const entries = pi?.extensions;
51
+ if (!Array.isArray(entries) || entries.length === 0
52
+ || !entries.every((entry) => typeof entry === "string" && entry.length > 0 && !isAbsolute(entry))) {
53
+ throw new PiExtensionError("extension_load_failed");
54
+ }
55
+ for (const entry of entries) {
56
+ let target;
57
+ try {
58
+ target = await real(join(root, entry));
59
+ if (!inside(root, target) || !(await statFile(target)).isFile())
60
+ throw new PiExtensionError("extension_load_failed");
61
+ }
62
+ catch {
63
+ throw new PiExtensionError("extension_load_failed");
64
+ }
65
+ }
66
+ return root;
67
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,60 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.0 — 2026-10-11
6
+
7
+ This is a minor release because the public contract changes, as ADR 0003
8
+ requires: four new error codes, and the `extensions` field of each execution
9
+ profile in `pi_capabilities_get`.
10
+
11
+ - Execution profiles can load Pi extensions that Pi has installed, pinned as
12
+ `npm:<name>@<exact version>` in a new `extensions` field. Such a session
13
+ loads the extension with its providers, tools, hooks and system-prompt
14
+ changes, in a model runtime of its own; `read-only` profiles never activate
15
+ extension tools. New errors: `extension_not_installed`,
16
+ `extension_version_mismatch`, `extension_load_failed`,
17
+ `extension_tool_conflict`. Doctor checks configured extensions offline.
18
+ `pi_capabilities_get` lists each profile's `extensions`. Client prompts no
19
+ longer dispatch extension commands, and an always-on guard keeps stdout
20
+ reserved for MCP frames. See ADR 0006 and #34.
21
+ - `setup` now supports macOS, with the same guarantees as on Linux. It runs
22
+ `--dry-run`, `--apply`, `--verify`, `--rollback` and `--remove`, and keeps
23
+ the kernel fence and the process-group proof. On macOS:
24
+ - setup takes the ownership lock with `O_EXLOCK` when it opens the lock
25
+ file;
26
+ - it proves process-group cleanup with the trusted system `/bin/ps`;
27
+ - it accepts the ownership directory only on the APFS volume type of `/`.
28
+
29
+ Every other platform still refuses with `platform_unsupported`. Result codes,
30
+ statuses and exit codes are unchanged. CI now runs on Linux, macOS 15 and
31
+ macOS 26; the required `verify` check aggregates all three. On a real Mac,
32
+ setup acceptance passed for Claude Code; it has not run for Codex. See ADR
33
+ 0005 and #32.
34
+ - The client setup reference and the security policy no longer use the
35
+ project's former name. Both sentences now name the server or Pi Session MCP.
36
+ - The installation guide now describes how to register a client without
37
+ `setup`: Doctor, the client's own registration commands, checks that are
38
+ weaker than `--verify` because no bounded direct MCP handshake exists outside
39
+ `setup`, and how to upgrade and remove. No acceptance level covers the
40
+ manual path.
41
+
42
+ ## 0.6.4 — 2026-10-10
43
+
44
+ - `pi-session-mcp-doctor` now checks the package it belongs to, whatever the
45
+ current directory is. Previously it checked the current directory: in an npm
46
+ installation, run from another directory, it failed with
47
+ `package_metadata_unreadable` and `build_missing`, and inside another
48
+ checkout it reported that checkout. `npm run doctor` in a release checkout
49
+ and `setup` were not affected.
50
+ - The installation guide and client setup reference now say that Codex
51
+ rewrites `config.toml` when a registration changes: it groups the
52
+ `[mcp_servers.*]` tables and drops comments directly above them. Copy the
53
+ file first if its comments matter.
54
+ - The client setup reference now explains that `codex exec` rejects
55
+ `pi_session_prompt`, `pi_session_abort` and `pi_session_close` unless they
56
+ are approved, because it never asks and the tools are marked destructive. It
57
+ shows the per-run `-c` approvals.
58
+
5
59
  ## 0.6.3 — 2026-10-10
6
60
 
7
61
  - Doctor no longer reports a false `build_stale` warning right after
package/CONTRIBUTING.md CHANGED
@@ -8,7 +8,7 @@ working on anything.
8
8
 
9
9
  ## Development setup
10
10
 
11
- Linux and Node.js 22.19.0 or newer:
11
+ Linux or macOS and Node.js 22.19.0 or newer:
12
12
 
13
13
  ```bash
14
14
  npm ci
package/README.md CHANGED
@@ -29,10 +29,10 @@ It is not the right tool when:
29
29
 
30
30
  ## Status
31
31
 
32
- The package version is **0.6.3**.
32
+ The package version is **0.7.0**.
33
33
 
34
34
  - **Pre-1.0.** The public tool contract is stable within a minor line: a change to tools, fields, states or error codes needs a new minor release.
35
- - **Linux only**, Node.js 22.19.0 or newer.
35
+ - **Linux and macOS**, Node.js 22.19.0 or newer. macOS is tested in CI on macOS 15 and 26 on Apple silicon; Intel Macs run the same code but are untested. Setup acceptance on a real Mac is recorded for Claude Code only; Codex is still pending. See [Acceptance](docs/acceptance.md#status). On other platforms `setup` refuses with `platform_unsupported`.
36
36
  - **Clients:** Codex CLI and Claude Code. The `setup` command does not pin client versions: it accepts a client whose public `mcp` command output matches the expected contract and fails closed otherwise.
37
37
  - **Out of scope by design:** HTTP, a web UI, persistence, resume, remote access and multi-user behavior. One trusted local MCP peer talks to one server process.
38
38
  - **Not a sandbox.** A `coding` profile restricts the Pi tools that a session may use. It does not isolate the process from the operating system.
package/SECURITY.md CHANGED
@@ -33,5 +33,5 @@ scope, for example:
33
33
  operating-system sandbox, and this is a documented limit, not a defect.
34
34
  - Vulnerabilities in Pi, in the MCP clients, in model providers or in other
35
35
  dependencies belong to those projects. A dependency advisory that affects Pi
36
- Control's own use of the dependency is in scope.
36
+ Session MCP's own use of the dependency is in scope.
37
37
  - The server trusts its one local MCP peer and the operator's configuration.
@@ -21,5 +21,6 @@ export declare function projectCapabilities(workspaces: ReadonlyMap<string, stri
21
21
  provider: string;
22
22
  model: string;
23
23
  thinkingLevel: import("./execution-profile.js").ThinkingLevel;
24
+ extensions: string[];
24
25
  }[];
25
26
  };
@@ -14,13 +14,14 @@ export function projectCapabilities(workspaces, executionProfiles, configuration
14
14
  .map((alias) => ({ alias })),
15
15
  executionProfiles: [...executionProfiles.values()]
16
16
  .sort((left, right) => compareAliases(left.alias, right.alias))
17
- .map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel }) => ({
17
+ .map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions }) => ({
18
18
  alias,
19
19
  default: isDefault,
20
20
  permissionProfile,
21
21
  provider,
22
22
  model,
23
23
  thinkingLevel,
24
+ extensions: (extensions ?? []).map((extension) => extension.spec),
24
25
  })),
25
26
  };
26
27
  }
@@ -1,3 +1,4 @@
1
+ import { MAX_PROFILE_EXTENSIONS, parseExtensionSpec } from "../extension-spec.js";
1
2
  import { PACKAGE_NAME, PACKAGE_VERSION } from "../package-metadata.js";
2
3
  export const MCP_VERIFY_TIMEOUT_MS = 5_000;
3
4
  export const MCP_MAX_FRAME_BYTES = 65_536;
@@ -98,13 +99,15 @@ function validCapabilities(value) {
98
99
  if (!profile || typeof profile !== "object")
99
100
  return false;
100
101
  const record = profile;
101
- return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel"])
102
+ return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel", "extensions"])
102
103
  && typeof record.alias === "string" && ALIAS_PATTERN.test(record.alias)
103
104
  && typeof record.default === "boolean"
104
105
  && (record.permissionProfile === "read-only" || record.permissionProfile === "coding")
105
106
  && typeof record.provider === "string" && record.provider.length > 0 && record.provider.length <= 256
106
107
  && typeof record.model === "string" && record.model.length > 0 && record.model.length <= 256
107
- && typeof record.thinkingLevel === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(record.thinkingLevel);
108
+ && typeof record.thinkingLevel === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(record.thinkingLevel)
109
+ && Array.isArray(record.extensions) && record.extensions.length <= MAX_PROFILE_EXTENSIONS
110
+ && record.extensions.every((spec) => typeof spec === "string" && parseExtensionSpec(spec) !== undefined);
108
111
  }))
109
112
  return false;
110
113
  const defaults = profiles.filter((profile) => profile.default === true);
package/dist/config.d.ts CHANGED
@@ -25,6 +25,7 @@ export declare const configSchema: z.ZodObject<{
25
25
  xhigh: "xhigh";
26
26
  }>;
27
27
  mcpServers: z.ZodOptional<z.ZodCustom<Readonly<Record<string, import("./mcp-config.js").McpServerConfig>>, Readonly<Record<string, import("./mcp-config.js").McpServerConfig>>>>;
28
+ extensions: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
29
  }, z.core.$strict>>;
29
30
  }, z.core.$strict>;
30
31
  /** The only supported reload behavior: a running controller keeps the snapshot it loaded at startup. */
package/dist/config.js CHANGED
@@ -2,19 +2,28 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { z } from "zod";
5
+ import { MAX_PROFILE_EXTENSIONS, parseExtensionSpec } from "./extension-spec.js";
5
6
  import { isMcpServers, snapshotMcpServers } from "./mcp-config.js";
6
7
  import { ALIAS_PATTERN, ImmutableMap, } from "./execution-profile.js";
7
8
  const alias = z.string().regex(ALIAS_PATTERN);
8
9
  const permissionProfile = z.enum(["read-only", "coding"]);
9
10
  const thinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
11
+ const extensionSpec = z.string().refine((value) => parseExtensionSpec(value) !== undefined);
10
12
  const profileSchema = z.object({
11
13
  default: z.boolean(), permissionProfile, provider: z.string().trim().min(1).max(256),
12
14
  model: z.string().trim().min(1).max(256), thinkingLevel,
13
15
  mcpServers: z.custom((value) => isMcpServers(value)).optional(),
16
+ extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
14
17
  }).strict().superRefine((profile, context) => {
15
18
  if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
16
19
  context.addIssue({ code: "custom", path: ["mcpServers"], message: "MCP tool grants do not match the permission profile" });
17
20
  }
21
+ if (profile.extensions !== undefined) {
22
+ const names = profile.extensions.flatMap((spec) => parseExtensionSpec(spec)?.name ?? []);
23
+ if (new Set(names).size !== names.length) {
24
+ context.addIssue({ code: "custom", path: ["extensions"], message: "An extension package may appear only once per profile" });
25
+ }
26
+ }
18
27
  });
19
28
  const CONFIG_ERROR_MESSAGES = {
20
29
  config_path_required: "Configuration path is required",
@@ -77,9 +86,10 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
77
86
  }
78
87
  const base = dirname(absoluteConfig);
79
88
  const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
80
- const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, ...value }]) => [key, Object.freeze({
89
+ const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, ...value }]) => [key, Object.freeze({
81
90
  alias: key, ...value,
82
91
  ...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
92
+ ...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
83
93
  })]));
84
94
  const defaultEntry = [...executionProfiles].find(([, profile]) => profile.default);
85
95
  if (!defaultEntry)
@@ -1,11 +1,15 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import { parseDoctorArgs, renderDoctorHuman, renderDoctorJson, runDoctor } from "./doctor.js";
3
+ // The generated CLI lives one level below the package root. An npm executable runs
4
+ // from any directory, so the working directory says nothing about the package.
5
+ const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
2
6
  const options = parseDoctorArgs(process.argv.slice(2));
3
7
  if (!options) {
4
8
  process.stderr.write("Usage: doctor [--json]\n");
5
9
  process.exitCode = 64;
6
10
  }
7
11
  else {
8
- const result = await runDoctor();
12
+ const result = await runDoctor({ packageRoot: PACKAGE_ROOT });
9
13
  process.stdout.write(options.json ? renderDoctorJson(result) : renderDoctorHuman(result));
10
14
  process.exitCode = result.exitCode;
11
15
  }
package/dist/doctor.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type AppConfig } from "./config.js";
2
+ import { type ExtensionSpec } from "./extension-spec.js";
2
3
  export type DoctorSeverity = "ok" | "warning" | "error";
3
4
  export type DoctorExitCode = 0 | 1 | 2 | 64;
4
- export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "workspace_missing" | "workspace_not_directory" | "workspace_unreadable" | "workspace_not_writable";
5
+ export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "workspace_missing" | "workspace_not_directory" | "workspace_unreadable" | "workspace_not_writable" | "extension_missing" | "extension_version_mismatch";
5
6
  export interface DoctorCheck {
6
7
  readonly id: DoctorCheckId;
7
8
  readonly severity: DoctorSeverity;
@@ -38,6 +39,8 @@ export interface DoctorDependencies {
38
39
  readonly fileSystem?: DoctorFileSystem;
39
40
  readonly loadConfig?: () => Promise<AppConfig>;
40
41
  readonly packageMetadata?: () => Promise<DoctorPackageMetadata>;
42
+ /** Reads the installed manifest of a pinned extension; never imports extension code. */
43
+ readonly resolveExtension?: (extension: ExtensionSpec) => Promise<string>;
41
44
  }
42
45
  export declare function runDoctor(dependencies?: DoctorDependencies): Promise<DoctorResult>;
43
46
  export declare function renderDoctorJson(result: DoctorResult): string;
package/dist/doctor.js CHANGED
@@ -2,9 +2,10 @@ import { access, readFile, readdir, stat } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { loadConfig } from "./config.js";
5
+ import { PiExtensionError } from "./extension-spec.js";
5
6
  import { readPackageMetadata } from "./package-metadata.js";
6
7
  const defaultFileSystem = { readFile, stat, access };
7
- const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable"];
8
+ const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "extension_missing", "extension_version_mismatch"];
8
9
  function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
9
10
  function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
10
11
  function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
@@ -174,6 +175,26 @@ export async function runDoctor(dependencies = {}) {
174
175
  }
175
176
  }
176
177
  }
178
+ if (config) {
179
+ const resolveExtension = dependencies.resolveExtension
180
+ ?? (async (extension) => (await import("./pi-extensions.js")).resolvePiExtension(extension));
181
+ for (const [alias, profile] of [...config.executionProfiles].sort(([a], [b]) => a.localeCompare(b))) {
182
+ for (const extension of profile.extensions ?? []) {
183
+ const subject = `${alias}:${extension.name}`;
184
+ try {
185
+ await resolveExtension(extension);
186
+ checks.push(ok("extension_missing", subject), ok("extension_version_mismatch", subject));
187
+ }
188
+ catch (error) {
189
+ const code = error instanceof PiExtensionError ? error.code : "extension_load_failed";
190
+ if (code === "extension_not_installed")
191
+ checks.push(failure("extension_missing", subject, "install the pinned extension version with Pi"));
192
+ else
193
+ checks.push(ok("extension_missing", subject), failure("extension_version_mismatch", subject, "install the pinned extension version with Pi"));
194
+ }
195
+ }
196
+ }
197
+ }
177
198
  const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
178
199
  checks.sort((a, b) => (rank.get(a.id) ?? 999) - (rank.get(b.id) ?? 999) || a.subject.localeCompare(b.subject));
179
200
  const hasError = checks.some((check) => check.severity === "error");
@@ -1,3 +1,4 @@
1
+ import type { ExtensionSpec } from "./extension-spec.js";
1
2
  import { type McpServers } from "./mcp-config.js";
2
3
  export declare const ALIAS_PATTERN: RegExp;
3
4
  export type PermissionProfile = "read-only" | "coding";
@@ -11,6 +12,7 @@ export interface ConfiguredExecutionProfile {
11
12
  readonly model: string;
12
13
  readonly thinkingLevel: ThinkingLevel;
13
14
  readonly mcpServers?: McpServers;
15
+ readonly extensions?: readonly ExtensionSpec[];
14
16
  }
15
17
  /** The immutable, server-side selection passed to the Pi adapter. */
16
18
  export interface ResolvedExecutionProfile {
@@ -20,6 +22,7 @@ export interface ResolvedExecutionProfile {
20
22
  readonly model: string;
21
23
  readonly thinkingLevel: ThinkingLevel;
22
24
  readonly mcpServers?: McpServers;
25
+ readonly extensions?: readonly ExtensionSpec[];
23
26
  }
24
27
  export declare function toResolvedExecutionProfile(profile: ConfiguredExecutionProfile): ResolvedExecutionProfile;
25
28
  /** A small read-only map implementation which has no mutating methods at runtime. */
@@ -8,6 +8,7 @@ export function toResolvedExecutionProfile(profile) {
8
8
  model: profile.model,
9
9
  thinkingLevel: profile.thinkingLevel,
10
10
  ...(profile.mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(profile.mcpServers) }),
11
+ ...(profile.extensions === undefined ? {} : { extensions: profile.extensions }),
11
12
  });
12
13
  }
13
14
  /** A small read-only map implementation which has no mutating methods at runtime. */
@@ -0,0 +1,15 @@
1
+ /** An exactly pinned Pi extension package, as configured on an execution profile. */
2
+ export interface ExtensionSpec {
3
+ /** The configured text, for example `npm:pi-cursor-sdk@0.5.3`. Path-free and secret-free. */
4
+ readonly spec: string;
5
+ readonly name: string;
6
+ readonly version: string;
7
+ }
8
+ export declare const MAX_PROFILE_EXTENSIONS = 8;
9
+ export declare function parseExtensionSpec(spec: string): ExtensionSpec | undefined;
10
+ export type PiExtensionErrorCode = "extension_not_installed" | "extension_version_mismatch" | "extension_load_failed";
11
+ /** A classified extension resolution failure. It never carries a path or extension text. */
12
+ export declare class PiExtensionError extends Error {
13
+ readonly code: PiExtensionErrorCode;
14
+ constructor(code: PiExtensionErrorCode);
15
+ }
@@ -0,0 +1,21 @@
1
+ export const MAX_PROFILE_EXTENSIONS = 8;
2
+ const MAX_SPEC_LENGTH = 256;
3
+ /** `npm:<name>@<exact semver>`; the name may be scoped. Ranges, tags, git sources and paths never match. */
4
+ const EXTENSION_SPEC_PATTERN = /^npm:((?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
5
+ export function parseExtensionSpec(spec) {
6
+ if (spec.length > MAX_SPEC_LENGTH)
7
+ return undefined;
8
+ const match = EXTENSION_SPEC_PATTERN.exec(spec);
9
+ if (!match)
10
+ return undefined;
11
+ return Object.freeze({ spec, name: match[1], version: match[2] });
12
+ }
13
+ /** A classified extension resolution failure. It never carries a path or extension text. */
14
+ export class PiExtensionError extends Error {
15
+ code;
16
+ constructor(code) {
17
+ super("Pi extension resolution failed");
18
+ this.name = "PiExtensionError";
19
+ this.code = code;
20
+ }
21
+ }
package/dist/main.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { serveStdio } from "@modelcontextprotocol/server/stdio";
2
+ import { stdoutGuard } from "./stdout-guard-install.js";
3
+ import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
3
4
  import { loadConfig } from "./config.js";
4
5
  import { startupFailureDiagnostic } from "./process-diagnostics.js";
5
6
  import { createServer } from "./server.js";
@@ -15,7 +16,10 @@ async function main() {
15
16
  try {
16
17
  const config = await loadConfig();
17
18
  registry = new SessionRegistry(new SdkPiSessionAdapter({ diagnosticSink: writeDiagnostic }), config.workspaces);
18
- handle = serveStdio(() => createServer(registry, config.workspaces, config.executionProfiles, config.defaultExecutionProfile, config.configuration), { onerror: () => writeDiagnostic({ level: "error", event: "mcp_transport_error", stage: "transport", code: "mcp_transport_error" }) });
19
+ handle = serveStdio(() => createServer(registry, config.workspaces, config.executionProfiles, config.defaultExecutionProfile, config.configuration), {
20
+ transport: new StdioServerTransport(process.stdin, stdoutGuard.transportStdout),
21
+ onerror: () => writeDiagnostic({ level: "error", event: "mcp_transport_error", stage: "transport", code: "mcp_transport_error" }),
22
+ });
19
23
  }
20
24
  catch (error) {
21
25
  writeDiagnostic(startupFailureDiagnostic(error));
@@ -13,7 +13,7 @@ export interface PiAppliedSelection {
13
13
  readonly model: string;
14
14
  readonly thinkingLevel: ThinkingLevel;
15
15
  }
16
- export type PiSessionCreationErrorCode = "unknown_provider" | "unknown_model" | "local_authentication_missing" | "local_authentication_unavailable" | "model_unavailable" | "thinking_level_unavailable" | "execution_selection_mismatch" | "external_mcp_unavailable" | "external_mcp_no_tools" | "external_mcp_grant_tool_missing" | "external_mcp_activation_mismatch" | "pi_session_creation_failed";
16
+ export type PiSessionCreationErrorCode = "unknown_provider" | "unknown_model" | "local_authentication_missing" | "local_authentication_unavailable" | "model_unavailable" | "thinking_level_unavailable" | "execution_selection_mismatch" | "external_mcp_unavailable" | "external_mcp_no_tools" | "external_mcp_grant_tool_missing" | "external_mcp_activation_mismatch" | "extension_not_installed" | "extension_version_mismatch" | "extension_load_failed" | "extension_tool_conflict" | "pi_session_creation_failed";
17
17
  export declare class PiSessionCreationError extends Error {
18
18
  readonly code: PiSessionCreationErrorCode;
19
19
  constructor(code: PiSessionCreationErrorCode);
@@ -10,6 +10,10 @@ const PI_SESSION_CREATION_MESSAGES = {
10
10
  external_mcp_no_tools: "Configured external MCP server returned no tools",
11
11
  external_mcp_grant_tool_missing: "Configured external MCP grant is missing from discovery",
12
12
  external_mcp_activation_mismatch: "Configured external MCP tools could not be activated",
13
+ extension_not_installed: "Configured Pi extension is not installed",
14
+ extension_version_mismatch: "Configured Pi extension version is not installed",
15
+ extension_load_failed: "Configured Pi extension could not be loaded",
16
+ extension_tool_conflict: "Configured Pi extension tools conflict",
13
17
  pi_session_creation_failed: "Pi session creation failed",
14
18
  };
15
19
  export class PiSessionCreationError extends Error {
@@ -0,0 +1,17 @@
1
+ import { type ExtensionSpec } from "./extension-spec.js";
2
+ export interface PiExtensionResolverDependencies {
3
+ /** Pi's agent directory; defaults to the SDK's `getAgentDir()`. */
4
+ readonly agentDir?: string;
5
+ /** Locates Pi's user-scope install of an npm source; the default only computes the path. */
6
+ readonly installedPath?: (source: string, agentDir: string) => string | undefined;
7
+ readonly readFile?: (path: string) => Promise<string>;
8
+ readonly realpath?: (path: string) => Promise<string>;
9
+ readonly stat?: (path: string) => Promise<{
10
+ isFile(): boolean;
11
+ }>;
12
+ }
13
+ /**
14
+ * Resolves a pinned extension to the real directory of Pi's installed package.
15
+ * It never installs, never imports extension code and never reports a path.
16
+ */
17
+ export declare function resolvePiExtension(extension: ExtensionSpec, dependencies?: PiExtensionResolverDependencies): Promise<string>;