pi-profile-switch 0.3.1 → 0.4.2
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 +31 -36
- package/README.zh-CN.md +31 -36
- package/bin/pi-profile.js +11 -0
- package/bin/pi-profile.ts +65 -0
- package/bin/postinstall.d.ts +13 -0
- package/bin/postinstall.js +88 -0
- package/defaults/profiles.json +18 -0
- package/examples/profiles.json +31 -5
- package/extensions/pi-profile/index.ts +451 -0
- package/package.json +10 -13
- package/schemas/profiles.schema.json +22 -24
- package/src/extension-discovery.ts +347 -0
- package/src/json-file.ts +1 -21
- package/src/launcher/args.ts +57 -0
- package/src/launcher/discovery.ts +64 -0
- package/src/launcher/initial-profile.ts +179 -0
- package/src/launcher/model-check.ts +52 -0
- package/src/launcher/runtime-cleanup.ts +85 -0
- package/src/launcher/spawn.ts +82 -0
- package/src/mcp-config.ts +37 -153
- package/src/mcp-coordination.ts +29 -10
- package/src/profile-catalog-store.ts +31 -12
- package/src/profile-catalog.ts +45 -93
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -41
- package/src/settings-generator.ts +541 -0
- package/src/skill-registry.ts +94 -0
- package/src/switching/apply-plan.ts +197 -0
- package/src/switching/customize.ts +62 -33
- package/src/switching/list-profiles.ts +9 -6
- package/src/switching/mcp-toggle.ts +14 -26
- package/src/switching/profile-crud.ts +31 -24
- package/src/switching/profile-wizard.ts +21 -49
- package/src/switching/status.ts +142 -72
- package/src/switching/switch-profile.ts +219 -0
- package/src/switching/tool-references.ts +40 -0
- package/src/workspace.ts +57 -0
- package/LICENSE +0 -21
- package/examples/profiles.example.json +0 -74
- package/extensions/pi-profile-switch/index.ts +0 -778
- package/src/adapter-presence.ts +0 -75
- package/src/default-profiles.ts +0 -59
- package/src/mcp-overlay-file.ts +0 -35
- package/src/mcp-overlay.ts +0 -122
- package/src/model-selection.ts +0 -64
- package/src/name-matching.ts +0 -50
- package/src/profile-badge.ts +0 -142
- package/src/profile-presets.ts +0 -61
- package/src/skill-selection.ts +0 -81
- package/src/startup-mcp-scope.ts +0 -271
- package/src/startup-selection.ts +0 -201
- package/src/switching/activate-profile.ts +0 -144
- package/src/switching/apply-profile.ts +0 -131
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-spawn validation for a profile's declared model (ticket 02).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Pi's own session preflight: the model must resolve against the
|
|
5
|
+
* user's real model catalog (built-ins + models.json custom providers), and
|
|
6
|
+
* the provider must have configured auth (stored credential, static key, or
|
|
7
|
+
* ambient source). A missing or unauthenticated declared model fails
|
|
8
|
+
* activation before Pi is spawned, so a session never runs on an unexpected
|
|
9
|
+
* model.
|
|
10
|
+
*
|
|
11
|
+
* Note: Pi deliberately accepts unlisted model IDs under a known provider
|
|
12
|
+
* (custom/self-hosted models), so an unknown *provider* is the hard failure.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { ModelRuntime, resolveCliModel } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
import type { ProfileModel } from "../profile-catalog.ts";
|
|
20
|
+
|
|
21
|
+
/** Returns an error message, or undefined when the model exists and is
|
|
22
|
+
* authenticated. Reads the user's real agent dir state; never writes. */
|
|
23
|
+
export async function checkDeclaredModel(agentDir: string, model: ProfileModel): Promise<string | undefined> {
|
|
24
|
+
const runtime = await ModelRuntime.create({
|
|
25
|
+
authPath: path.join(agentDir, "auth.json"),
|
|
26
|
+
modelsPath: path.join(agentDir, "models.json"),
|
|
27
|
+
modelsStorePath: path.join(agentDir, "models-store.json"),
|
|
28
|
+
refreshOnCreate: false,
|
|
29
|
+
allowModelNetwork: false,
|
|
30
|
+
});
|
|
31
|
+
const resolved = resolveCliModel({ cliModel: `${model.provider}/${model.id}`, modelRuntime: runtime });
|
|
32
|
+
if (resolved.error !== undefined) {
|
|
33
|
+
return resolved.error;
|
|
34
|
+
}
|
|
35
|
+
// resolveCliModel fuzzy-matches partial IDs; a stored profile must resolve
|
|
36
|
+
// to exactly the declared model (Pi's custom-model-id fallback also yields
|
|
37
|
+
// the declared ID verbatim, so exactness is compatible with it).
|
|
38
|
+
if (
|
|
39
|
+
resolved.model === undefined ||
|
|
40
|
+
resolved.model.id !== model.id ||
|
|
41
|
+
resolved.model.provider.toLowerCase() !== model.provider.toLowerCase()
|
|
42
|
+
) {
|
|
43
|
+
return `model not found: ${model.provider}/${model.id}`;
|
|
44
|
+
}
|
|
45
|
+
const authenticated =
|
|
46
|
+
runtime.hasConfiguredAuth(resolved.model.provider) ||
|
|
47
|
+
(await runtime.checkAuth(resolved.model.provider)) !== undefined;
|
|
48
|
+
if (!authenticated) {
|
|
49
|
+
return `no credentials configured for provider "${resolved.model.provider}"`;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuntimeCleanup: sweeps stale per-launch runtime directories at startup.
|
|
3
|
+
*
|
|
4
|
+
* The generated agent dirs under `<agentDir>/pi-profile/runtime/launch-*`
|
|
5
|
+
* (ADR-0005) would otherwise accumulate forever. Startup sweep is the ONLY
|
|
6
|
+
* cleanup mechanism by design: any exit — graceful, signal, SIGKILL, power
|
|
7
|
+
* loss — kills the child pid, so the next launch's sweep converges. There is
|
|
8
|
+
* no exit-time deletion; it would only buy immediacy at the cost of deletion
|
|
9
|
+
* logic on the signal path.
|
|
10
|
+
*
|
|
11
|
+
* Liveness token: a `pid` file written by spawnPi into the runtime dir.
|
|
12
|
+
* (Naming the dir after the pid is impossible — the pid does not exist
|
|
13
|
+
* before spawn, and the running process's PI_CODING_AGENT_DIR path is
|
|
14
|
+
* frozen.) Rules per launch-* dir:
|
|
15
|
+
* - pid file parses and the process is alive (or EPERM) → keep;
|
|
16
|
+
* ESRCH → delete.
|
|
17
|
+
* - no/unparsable pid file → delete only when the dir mtime is older than
|
|
18
|
+
* NO_PID_GRACE_MS. The grace window guards the concurrent-launch race (a
|
|
19
|
+
* second launcher between mkdtemp and its pid write must not be reaped);
|
|
20
|
+
* it also covers pre-feature dirs and post-mkdtemp crashes.
|
|
21
|
+
* PID reuse needs no /proc check: a wrong keep only delays cleanup and
|
|
22
|
+
* self-heals once the reused pid dies.
|
|
23
|
+
*
|
|
24
|
+
* Everything is best-effort: sweep errors never block a launch.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
/** Grace period for launch dirs without a (parseable) pid file. */
|
|
31
|
+
export const NO_PID_GRACE_MS = 10 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
function isProcessAlive(pid: number): boolean {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// EPERM means the process exists but is not signal-able by us: keep.
|
|
39
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function runtimeRootOf(agentDir: string): string {
|
|
44
|
+
return path.join(agentDir, "pi-profile", "runtime");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function sweepEntry(dir: string): Promise<void> {
|
|
48
|
+
let pid: number | undefined;
|
|
49
|
+
try {
|
|
50
|
+
const raw = await readFile(path.join(dir, "pid"), "utf8");
|
|
51
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
52
|
+
if (Number.isInteger(parsed) && parsed > 0) pid = parsed;
|
|
53
|
+
} catch {
|
|
54
|
+
// No pid file (or unreadable): fall through to the mtime guard.
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (pid !== undefined) {
|
|
58
|
+
if (!isProcessAlive(pid)) await rm(dir, { recursive: true, force: true });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const info = await stat(dir);
|
|
63
|
+
if (Date.now() - info.mtimeMs > NO_PID_GRACE_MS) {
|
|
64
|
+
await rm(dir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Deletes stale launch dirs under the agent dir's runtime root. Never throws. */
|
|
69
|
+
export async function sweepStaleRuntimeDirs(agentDir: string): Promise<void> {
|
|
70
|
+
let entries: string[];
|
|
71
|
+
try {
|
|
72
|
+
entries = await readdir(runtimeRootOf(agentDir));
|
|
73
|
+
} catch {
|
|
74
|
+
return; // No runtime root yet: nothing to sweep.
|
|
75
|
+
}
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (!entry.startsWith("launch-")) continue;
|
|
78
|
+
const dir = path.join(runtimeRootOf(agentDir), entry);
|
|
79
|
+
try {
|
|
80
|
+
if ((await stat(dir)).isDirectory()) await sweepEntry(dir);
|
|
81
|
+
} catch {
|
|
82
|
+
// Best-effort: one bad entry must not stop the sweep or the launch.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawns the real `pi` binary as a subprocess (ADR-0005).
|
|
3
|
+
*
|
|
4
|
+
* The spawned pi gets: the pi-profile extension via `-e`, any generated
|
|
5
|
+
* flags, and the user's arguments verbatim. stdio is inherited so interactive
|
|
6
|
+
* TUI, RPC, and print modes all behave natively; exit codes and signals
|
|
7
|
+
* propagate.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { writeFile } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
import type { GeneratedRuntime } from "../settings-generator.ts";
|
|
16
|
+
|
|
17
|
+
export interface SpawnPiOptions {
|
|
18
|
+
/** Generated runtime dir + env + flags. */
|
|
19
|
+
generated: GeneratedRuntime;
|
|
20
|
+
/** User arguments, forwarded verbatim. */
|
|
21
|
+
piArgs: string[];
|
|
22
|
+
/** Trust override recorded by the launcher; re-applied natively for the default profile. */
|
|
23
|
+
trustOverride: boolean | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXTENSION_ENTRY = fileURLToPath(new URL("../../extensions/pi-profile/index.ts", import.meta.url));
|
|
27
|
+
|
|
28
|
+
/** Pure argv construction for the spawned pi: extension entry, generated
|
|
29
|
+
* flags, trust re-application, then user args verbatim. */
|
|
30
|
+
export function buildPiArgs(options: SpawnPiOptions): string[] {
|
|
31
|
+
const args = ["-e", EXTENSION_ENTRY, ...options.generated.flags];
|
|
32
|
+
// default profile keeps trust behavior native: re-apply the recorded flag.
|
|
33
|
+
if (options.trustOverride === true) args.push("--approve");
|
|
34
|
+
if (options.trustOverride === false) args.push("--no-approve");
|
|
35
|
+
args.push(...options.piArgs);
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function spawnPi(options: SpawnPiOptions): Promise<number> {
|
|
40
|
+
const env = { ...process.env, ...options.generated.env };
|
|
41
|
+
delete env.PI_CODING_AGENT_SESSION_DIR;
|
|
42
|
+
|
|
43
|
+
const child = spawn("pi", buildPiArgs(options), {
|
|
44
|
+
stdio: "inherit",
|
|
45
|
+
env,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Liveness token for the next launch's startup sweep (runtime-cleanup.ts).
|
|
49
|
+
// Best-effort: the dir was just written by generateRuntimeDir, so this can
|
|
50
|
+
// only fail under disk/permission trouble that would have surfaced earlier.
|
|
51
|
+
if (child.pid !== undefined) {
|
|
52
|
+
try {
|
|
53
|
+
await writeFile(path.join(options.generated.runtimeDir, "pid"), String(child.pid));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(`pi-profile: warning: could not write pid file: ${(error as Error).message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const onSigint = () => child.kill("SIGINT");
|
|
60
|
+
const onSigterm = () => child.kill("SIGTERM");
|
|
61
|
+
process.on("SIGINT", onSigint);
|
|
62
|
+
process.on("SIGTERM", onSigterm);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return await new Promise<number>((resolve, reject) => {
|
|
66
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
67
|
+
if (error.code === "ENOENT") {
|
|
68
|
+
reject(new Error(`pi binary not found on PATH`));
|
|
69
|
+
} else {
|
|
70
|
+
reject(error);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
child.on("exit", (code, signal) => {
|
|
74
|
+
if (code !== null) resolve(code);
|
|
75
|
+
else resolve(signal === "SIGINT" ? 130 : 1);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
} finally {
|
|
79
|
+
process.off("SIGINT", onSigint);
|
|
80
|
+
process.off("SIGTERM", onSigterm);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/mcp-config.ts
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AdapterConfigDiscovery: reads
|
|
3
|
-
* pi-native
|
|
4
|
-
* "Pi global override" slot document for the generated overlay.
|
|
2
|
+
* AdapterConfigDiscovery: reads the MCP server NAMES pi-mcp-adapter would
|
|
3
|
+
* discover from its pi-native config files, without ever managing them.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* project's `.mcp.json` and `.pi/mcp.json` (project sources only when Pi
|
|
10
|
-
* reports the project trusted — an untrusted project's config is never read).
|
|
5
|
+
* pi-profile never stores MCP connection parameters or credentials
|
|
6
|
+
* (ADR-0002); this module reads only the `mcpServers` key names so the
|
|
7
|
+
* launcher can validate a profile's `mcp` references before spawn.
|
|
11
8
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
9
|
+
* Discovery scope (documented limitation): the pi-native files only — the
|
|
10
|
+
* global `<agentDir>/mcp.json` and, when trusted, the project's
|
|
11
|
+
* `.pi/mcp.json`. Servers defined solely in the adapter's editor-specific
|
|
12
|
+
* legacy locations (~/.claude/mcp.json et al.) are invisible here; profiles
|
|
13
|
+
* referencing them fail launch validation. The pi-native files are the
|
|
14
|
+
* adapter's documented default, and no in-session re-validation exists (the
|
|
15
|
+
* adapter's status snapshots arrive too late and only post-init), so the
|
|
16
|
+
* launch check is the only name validation — keep configs in the pi-native
|
|
17
|
+
* files.
|
|
21
18
|
*
|
|
22
19
|
* Malformed config files fail loudly — a broken mcp.json must not silently
|
|
23
20
|
* read as "no servers" and reject every reference.
|
|
24
21
|
*/
|
|
25
22
|
|
|
26
|
-
import {
|
|
27
|
-
import path from "node:path";
|
|
28
|
-
|
|
29
|
-
import { isRecord, readJsonFileSync } from "./json-file.ts";
|
|
23
|
+
import { isRecord, readJsonFile } from "./json-file.ts";
|
|
30
24
|
|
|
31
25
|
export class McpConfigError extends Error {
|
|
32
26
|
readonly filePath: string;
|
|
@@ -38,145 +32,35 @@ export class McpConfigError extends Error {
|
|
|
38
32
|
}
|
|
39
33
|
}
|
|
40
34
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
filePath: string;
|
|
44
|
-
scope: "global" | "project";
|
|
45
|
-
/** True for the slot `--mcp-config` replaces. */
|
|
46
|
-
slot: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface AdapterMcpDiscoveryInput {
|
|
50
|
-
agentDir: string;
|
|
51
|
-
cwd: string;
|
|
52
|
-
projectTrusted: boolean;
|
|
53
|
-
/** The effective `--mcp-config` value, when one is in play. */
|
|
54
|
-
overridePath?: string;
|
|
55
|
-
/** Home directory override; tests point this at their fixture. */
|
|
56
|
-
homeDir?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The adapter's file sources, in its own precedence order. Paths are
|
|
60
|
-
* de-duplicated: the adapter skips a source whose read path equals the slot. */
|
|
61
|
-
export function adapterMcpSources(input: AdapterMcpDiscoveryInput): AdapterMcpSource[] {
|
|
62
|
-
const home = input.homeDir ?? homedir();
|
|
63
|
-
const slotPath = path.resolve(input.overridePath ?? path.join(input.agentDir, "mcp.json"));
|
|
64
|
-
const candidates: AdapterMcpSource[] = [
|
|
65
|
-
{ label: "shared global MCP config", filePath: path.join(home, ".config", "mcp", "mcp.json"), scope: "global", slot: false },
|
|
66
|
-
{ label: ".agents MCP config", filePath: path.join(home, ".agents", "mcp.json"), scope: "global", slot: false },
|
|
67
|
-
{ label: ".agents/mcp MCP config", filePath: path.join(home, ".agents", "mcp", "mcp.json"), scope: "global", slot: false },
|
|
68
|
-
{ label: "Pi global MCP override", filePath: slotPath, scope: "global", slot: true },
|
|
69
|
-
];
|
|
70
|
-
if (input.projectTrusted) {
|
|
71
|
-
candidates.push(
|
|
72
|
-
{ label: "project MCP config", filePath: path.resolve(input.cwd, ".mcp.json"), scope: "project", slot: false },
|
|
73
|
-
{ label: "project Pi MCP override", filePath: path.resolve(input.cwd, ".pi", "mcp.json"), scope: "project", slot: false },
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
const seen = new Set<string>();
|
|
77
|
-
return candidates.filter((source) => {
|
|
78
|
-
if (source.slot) return true; // the slot is always the read path
|
|
79
|
-
if (seen.has(source.filePath)) return false;
|
|
80
|
-
seen.add(source.filePath);
|
|
81
|
-
return true;
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface AdapterMcpView {
|
|
86
|
-
/** Read path of the slot `--mcp-config` replaces. */
|
|
87
|
-
slotPath: string;
|
|
88
|
-
/** Parsed slot document (verbatim), when the file exists. */
|
|
89
|
-
slotDocument?: Record<string, unknown>;
|
|
90
|
-
slotNames: string[];
|
|
91
|
-
/** Server names from the other file sources. */
|
|
92
|
-
otherNames: string[];
|
|
93
|
-
/** Union of slot and other names, sorted. */
|
|
94
|
-
serverNames: string[];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Synchronous read: the extension-load pass must finish before the adapter's
|
|
98
|
-
* session initialization, and the files are tiny. */
|
|
99
|
-
export function readAdapterMcpViewSync(input: AdapterMcpDiscoveryInput): AdapterMcpView {
|
|
100
|
-
const sources = adapterMcpSources(input);
|
|
101
|
-
const slot = sources.find((source) => source.slot);
|
|
102
|
-
const slotPath = slot?.filePath ?? path.join(input.agentDir, "mcp.json");
|
|
103
|
-
let slotDocument: Record<string, unknown> | undefined;
|
|
104
|
-
const slotNames: string[] = [];
|
|
105
|
-
const otherNames = new Set<string>();
|
|
106
|
-
for (const source of sources) {
|
|
107
|
-
if (source.slot) {
|
|
108
|
-
const document = readMcpDocumentSync(source.filePath);
|
|
109
|
-
if (document === undefined) continue;
|
|
110
|
-
slotDocument = document;
|
|
111
|
-
slotNames.push(...serverNames(document, source.filePath));
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
const document = readMcpDocumentSync(source.filePath);
|
|
115
|
-
if (document === undefined) continue;
|
|
116
|
-
for (const name of serverNames(document, source.filePath)) otherNames.add(name);
|
|
117
|
-
}
|
|
118
|
-
for (const name of slotNames) otherNames.delete(name);
|
|
119
|
-
return {
|
|
120
|
-
slotPath,
|
|
121
|
-
...(slotDocument === undefined ? {} : { slotDocument }),
|
|
122
|
-
slotNames,
|
|
123
|
-
otherNames: [...otherNames].sort(),
|
|
124
|
-
serverNames: [...new Set([...slotNames, ...otherNames])].sort(),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Async twin for the runtime paths (selection, status, toggles). */
|
|
129
|
-
export async function readAdapterMcpView(input: AdapterMcpDiscoveryInput): Promise<AdapterMcpView> {
|
|
130
|
-
return readAdapterMcpViewSync(input);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Server names from every source EXCEPT the slot file. The slot is generated
|
|
134
|
-
* by pi-profile-switch, so it must not feed back into the next generation —
|
|
135
|
-
* otherwise a stub would look like a source server and vanish on the next
|
|
136
|
-
* write. */
|
|
137
|
-
export function readAdapterOtherServerNamesSync(input: AdapterMcpDiscoveryInput): string[] {
|
|
138
|
-
const names = new Set<string>();
|
|
139
|
-
for (const source of adapterMcpSources(input)) {
|
|
140
|
-
if (source.slot) continue;
|
|
141
|
-
const document = readMcpDocumentSync(source.filePath);
|
|
142
|
-
if (document === undefined) continue;
|
|
143
|
-
for (const name of serverNames(document, source.filePath)) names.add(name);
|
|
144
|
-
}
|
|
145
|
-
return [...names].sort();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Server names the adapter would discover. `projectDir` is passed only when
|
|
149
|
-
* the trust check passed. */
|
|
150
|
-
export async function discoverAdapterServerNames(
|
|
151
|
-
agentDir: string,
|
|
152
|
-
projectDir?: string,
|
|
153
|
-
homeDir?: string,
|
|
154
|
-
): Promise<string[]> {
|
|
155
|
-
return readAdapterMcpViewSync({
|
|
156
|
-
agentDir,
|
|
157
|
-
cwd: projectDir ?? process.cwd(),
|
|
158
|
-
projectTrusted: projectDir !== undefined,
|
|
159
|
-
...(homeDir === undefined ? {} : { homeDir }),
|
|
160
|
-
}).serverNames;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Missing file → undefined; malformed → McpConfigError. */
|
|
164
|
-
export function readMcpDocumentSync(filePath: string): Record<string, unknown> | undefined {
|
|
165
|
-
const result = readJsonFileSync(filePath);
|
|
35
|
+
async function readServerNames(filePath: string): Promise<string[]> {
|
|
36
|
+
const result = await readJsonFile(filePath);
|
|
166
37
|
if (!result.ok) {
|
|
167
|
-
if (result.reason === "missing")
|
|
38
|
+
if (result.reason === "missing") {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
168
41
|
throw new McpConfigError(`MCP config is not valid JSON: ${filePath}`, filePath);
|
|
169
42
|
}
|
|
170
43
|
if (!isRecord(result.value)) {
|
|
171
44
|
throw new McpConfigError(`MCP config must be a JSON object: ${filePath}`, filePath);
|
|
172
45
|
}
|
|
173
|
-
|
|
46
|
+
if (result.value.mcpServers === undefined) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (!isRecord(result.value.mcpServers)) {
|
|
50
|
+
throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
|
|
51
|
+
}
|
|
52
|
+
return Object.keys(result.value.mcpServers);
|
|
174
53
|
}
|
|
175
54
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
55
|
+
/** Server names the adapter would discover: global agentDir config plus the
|
|
56
|
+
* trusted project's config. Pass `projectDir` only when the trust check
|
|
57
|
+
* passed — an untrusted project's config is never read. */
|
|
58
|
+
export async function discoverAdapterServerNames(agentDir: string, projectDir?: string): Promise<string[]> {
|
|
59
|
+
const names = new Set(await readServerNames(`${agentDir}/mcp.json`));
|
|
60
|
+
if (projectDir !== undefined) {
|
|
61
|
+
for (const name of await readServerNames(`${projectDir}/.pi/mcp.json`)) {
|
|
62
|
+
names.add(name);
|
|
63
|
+
}
|
|
180
64
|
}
|
|
181
|
-
return
|
|
65
|
+
return [...names].sort();
|
|
182
66
|
}
|
package/src/mcp-coordination.ts
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* McpCoordination: the pi-profile
|
|
2
|
+
* McpCoordination: the pi-profile ↔ pi-mcp-adapter contract (ADR-0002).
|
|
3
3
|
*
|
|
4
4
|
* The adapter's released versions (≤2.33) offer no in-memory server
|
|
5
|
-
* allowlist, so pi-profile
|
|
5
|
+
* allowlist, so pi-profile defines the coordination channel the locked
|
|
6
6
|
* adapter implements:
|
|
7
7
|
*
|
|
8
|
-
* - pi-profile
|
|
9
|
-
* `pi-profile:mcp-allowlist:v1` at
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* - pi-profile publishes the active profile's runtime server allowlist on
|
|
9
|
+
* `pi-profile:mcp-allowlist:v1` at session start (and after every profile
|
|
10
|
+
* switch reload, ticket 05). The allowlist is memory-only: pi-profile
|
|
11
|
+
* never writes the adapter's `.pi/mcp.json` overlay.
|
|
12
12
|
* - Adapter presence is probed via the adapter's documented
|
|
13
13
|
* request/result event pattern: emit a snapshot request for a bogus
|
|
14
14
|
* server name; an installed adapter fills `request.result` synchronously
|
|
15
15
|
* (with `{ok: false}` — the name is bogus), an absent adapter leaves it
|
|
16
16
|
* undefined.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Launch-time, the launcher additionally refuses to spawn when a profile
|
|
19
|
+
* declares `mcp` but no active extension identifies as the adapter
|
|
20
|
+
* (`isAdapterExtension`) — failing before spawn beats failing in-session.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
|
-
/** pi-profile
|
|
23
|
+
/** pi-profile's allowlist channel (the locked adapter subscribes). */
|
|
23
24
|
export const MCP_ALLOWLIST_EVENT = "pi-profile:mcp-allowlist:v1";
|
|
24
25
|
export const MCP_ALLOWLIST_VERSION = 1 as const;
|
|
25
26
|
|
|
@@ -30,10 +31,28 @@ export interface McpAllowlistMessage {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** The adapter's snapshot channel, used here purely as a presence probe
|
|
33
|
-
* (see the module docblock). Kept as a literal so pi-profile
|
|
34
|
+
* (see the module docblock). Kept as a literal so pi-profile doesn't
|
|
34
35
|
* import adapter internals. */
|
|
35
36
|
export const MCP_ADAPTER_SNAPSHOT_EVENT = "pi-mcp-adapter:runtime-snapshot:v1";
|
|
36
37
|
|
|
38
|
+
export class MissingMcpAdapterError extends Error {
|
|
39
|
+
constructor(profile: string) {
|
|
40
|
+
super(
|
|
41
|
+
`profile "${profile}" declares MCP servers but pi-mcp-adapter is not active. ` +
|
|
42
|
+
`Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcps" declaration.`,
|
|
43
|
+
);
|
|
44
|
+
this.name = "MissingMcpAdapterError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Identifies the adapter among active extension entries by its install
|
|
49
|
+
* path containing "pi-mcp-adapter" (npm package roots and local dirs both
|
|
50
|
+
* match). Heuristic by design — activation-plan entries carry no package
|
|
51
|
+
* source string, and the in-session probe is the authoritative check. */
|
|
52
|
+
export function isAdapterExtension(entry: { entry: string }): boolean {
|
|
53
|
+
return entry.entry.includes("pi-mcp-adapter");
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
/** True when the adapter answered the probe (filled `result` on the
|
|
38
57
|
* request object), regardless of the answer — presence, not health. */
|
|
39
58
|
export function probeAdapterPresence(events: { emit(channel: string, data: unknown): void }): boolean {
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ProfileCatalogStore: the WRITE side of a profile catalog file
|
|
3
|
-
* separate from the read-only ProfileCatalog.
|
|
2
|
+
* ProfileCatalogStore: the WRITE side of a profile catalog file
|
|
3
|
+
* (ticket 09), kept separate from the read-only ProfileCatalog.
|
|
4
4
|
*
|
|
5
5
|
* Invariants:
|
|
6
|
-
* - Whole-file overwrites (pretty-printed,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* last-write-wins.
|
|
6
|
+
* - Whole-file overwrites (pretty-printed, schemaVersion envelope);
|
|
7
|
+
* wizard saves never block on concurrent edits — re-read at write time,
|
|
8
|
+
* same-name conflicts resolve last-write-wins.
|
|
10
9
|
* - Definitions are complete and self-contained: no inheritance fields
|
|
11
10
|
* (`extends`, merge, array append) exist or are accepted.
|
|
12
11
|
* - Definitions are re-parsed through the catalog's own
|
|
@@ -22,7 +21,6 @@ import { isRecord, readJsonFile } from "./json-file.ts";
|
|
|
22
21
|
import {
|
|
23
22
|
CatalogError,
|
|
24
23
|
DEFAULT_PROFILE_NAME,
|
|
25
|
-
parseCatalogDocument,
|
|
26
24
|
parseProfileDefinition,
|
|
27
25
|
PROFILE_SCHEMA_VERSION,
|
|
28
26
|
type ProfileDefinition,
|
|
@@ -30,16 +28,20 @@ import {
|
|
|
30
28
|
|
|
31
29
|
export class ProfileCatalogStore {
|
|
32
30
|
readonly #filePath: string;
|
|
31
|
+
readonly #fallbackPath?: string;
|
|
33
32
|
|
|
34
|
-
constructor(catalogPath: string) {
|
|
33
|
+
constructor(catalogPath: string, fallbackPath?: string) {
|
|
35
34
|
this.#filePath = catalogPath;
|
|
35
|
+
this.#fallbackPath = fallbackPath;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Validated definitions: missing file → empty; malformed → CatalogError
|
|
39
|
-
* (catalog errors never pass silently, even on the write path).
|
|
40
|
-
* Unknown fields (an `extensions` key left over from v0.1.0) are dropped. */
|
|
39
|
+
* (catalog errors never pass silently, even on the write path). */
|
|
41
40
|
async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
|
|
42
|
-
|
|
41
|
+
let result = await readJsonFile(this.#filePath);
|
|
42
|
+
if (!result.ok && result.reason === "missing" && this.#fallbackPath) {
|
|
43
|
+
result = await readJsonFile(this.#fallbackPath);
|
|
44
|
+
}
|
|
43
45
|
if (!result.ok) {
|
|
44
46
|
if (result.reason === "missing") return new Map();
|
|
45
47
|
throw new CatalogError(`invalid JSON in ${this.#filePath}`);
|
|
@@ -47,7 +49,24 @@ export class ProfileCatalogStore {
|
|
|
47
49
|
if (!isRecord(result.value)) {
|
|
48
50
|
throw new CatalogError(`${this.#filePath}: catalog must be an object`);
|
|
49
51
|
}
|
|
50
|
-
|
|
52
|
+
if (result.value.schemaVersion !== PROFILE_SCHEMA_VERSION) {
|
|
53
|
+
throw new CatalogError(
|
|
54
|
+
`${this.#filePath}: unsupported schemaVersion ${JSON.stringify(result.value.schemaVersion)} (expected ${PROFILE_SCHEMA_VERSION})`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (!isRecord(result.value.profiles)) {
|
|
58
|
+
throw new CatalogError(`${this.#filePath}: "profiles" must be an object mapping names to definitions`);
|
|
59
|
+
}
|
|
60
|
+
const definitions = new Map<string, ProfileDefinition>();
|
|
61
|
+
for (const [name, raw] of Object.entries(result.value.profiles)) {
|
|
62
|
+
if (name === DEFAULT_PROFILE_NAME) {
|
|
63
|
+
throw new CatalogError(
|
|
64
|
+
`${this.#filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
definitions.set(name, parseProfileDefinition(name, raw));
|
|
68
|
+
}
|
|
69
|
+
return definitions;
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/** Overwrites the file with the given definitions (last write wins). */
|