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.
- package/.pi-session-mcp-cli/config.js +11 -1
- package/.pi-session-mcp-cli/doctor-cli.js +5 -1
- package/.pi-session-mcp-cli/doctor.js +22 -1
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/extension-spec.js +21 -0
- package/.pi-session-mcp-cli/pi-extensions.js +67 -0
- package/CHANGELOG.md +54 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +2 -1
- package/dist/client-adapters/mcp-verifier.js +5 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +11 -1
- package/dist/doctor-cli.js +5 -1
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +22 -1
- package/dist/execution-profile.d.ts +3 -0
- package/dist/execution-profile.js +1 -0
- package/dist/extension-spec.d.ts +15 -0
- package/dist/extension-spec.js +21 -0
- package/dist/main.js +6 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +4 -0
- package/dist/pi-extensions.d.ts +17 -0
- package/dist/pi-extensions.js +67 -0
- package/dist/sdk-pi-adapter.d.ts +24 -6
- package/dist/sdk-pi-adapter.js +253 -86
- package/dist/server.js +1 -0
- package/dist/session-tools.d.ts +9 -0
- package/dist/session-tools.js +25 -0
- package/dist/setup-command-guardian.js +29 -73
- package/dist/setup-main.js +2 -1
- package/dist/setup-ownership.js +18 -28
- package/dist/setup-platform.d.ts +57 -0
- package/dist/setup-platform.js +195 -0
- package/dist/setup-process.d.ts +6 -3
- package/dist/setup-process.js +85 -93
- package/dist/setup.js +2 -1
- package/dist/stdout-guard-install.d.ts +5 -0
- package/dist/stdout-guard-install.js +6 -0
- package/dist/stdout-guard.d.ts +20 -0
- package/dist/stdout-guard.js +72 -0
- package/docs/acceptance.md +245 -0
- package/docs/adr/0005-macos-setup-platform.md +84 -0
- package/docs/adr/0006-pi-extension-allowlist.md +58 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup-design.md +21 -9
- package/docs/client-setup.md +56 -10
- package/docs/doctor-design.md +9 -2
- package/docs/installation.md +129 -4
- package/docs/macos-setup-design.md +386 -0
- package/docs/pi-extensions-design.md +421 -0
- package/docs/releasing.md +1 -1
- package/docs/superpowers/plans/2026-10-10-macos-setup.md +1725 -0
- package/docs/superpowers/plans/2026-10-11-pi-extensions.md +2450 -0
- package/docs/threat-model.md +54 -18
- package/docs/tool-contracts.md +8 -3
- 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
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.
|
|
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
|
|
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
|
-
|
|
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.
|
package/dist/capabilities.d.ts
CHANGED
package/dist/capabilities.js
CHANGED
|
@@ -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)
|
package/dist/doctor-cli.js
CHANGED
|
@@ -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 {
|
|
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), {
|
|
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));
|
package/dist/pi-adapter.d.ts
CHANGED
|
@@ -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);
|
package/dist/pi-adapter.js
CHANGED
|
@@ -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>;
|