pi-profile-switch 0.4.3 → 0.4.5
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 +0 -1
- package/README.zh-CN.md +0 -1
- package/bin/pi-profile.ts +2 -3
- package/extensions/pi-profile/index.ts +5 -87
- package/package.json +1 -1
- package/src/extension-discovery.ts +3 -6
- package/src/json-file.ts +3 -3
- package/src/launcher/initial-profile.ts +5 -5
- package/src/launcher/spawn.ts +6 -6
- package/src/mcp-config.ts +19 -0
- package/src/profile-resolver.ts +16 -15
- package/src/project-trust.ts +1 -1
- package/src/runtime-state-store.ts +3 -15
- package/src/settings-generator.ts +28 -17
- package/src/switching/apply-plan.ts +10 -57
- package/src/switching/customize.ts +2 -2
- package/src/switching/switch-profile.ts +61 -23
- package/src/mcp-coordination.ts +0 -65
- package/src/switching/mcp-toggle.ts +0 -71
package/README.md
CHANGED
|
@@ -59,7 +59,6 @@ In the TUI, the `/profile` command family manages everything in-session:
|
|
|
59
59
|
| `/profile use <name>` / `/profile reload` | Switch / reload without restarting (rollback on failure) |
|
|
60
60
|
| `/profile create\|edit\|delete\|duplicate` | Guided profile CRUD (TUI only) |
|
|
61
61
|
| `/profile customize` / `/profile reset` | Narrow the active profile for this session only |
|
|
62
|
-
| `/mcp enable\|disable <server>` | Toggle MCP servers in the active profile |
|
|
63
62
|
|
|
64
63
|
All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizards are TUI-only.
|
|
65
64
|
|
package/README.zh-CN.md
CHANGED
|
@@ -59,7 +59,6 @@ Profile 只**引用**资源,从不复制资源。已安装的包和标准目
|
|
|
59
59
|
| `/profile use <name>` / `/profile reload` | 会话内切换 / 重载(失败自动回滚) |
|
|
60
60
|
| `/profile create\|edit\|delete\|duplicate` | 向导式 profile 增删改(仅 TUI) |
|
|
61
61
|
| `/profile customize` / `/profile reset` | 仅本次会话收窄活动 profile |
|
|
62
|
-
| `/mcp enable\|disable <server>` | 在活动 profile 中开关 MCP server |
|
|
63
62
|
|
|
64
63
|
非交互模式(`--mode rpc|print|json`)下命令同样生效;CRUD 向导仅 TUI 可用。
|
|
65
64
|
|
package/bin/pi-profile.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* pi-profile launcher (ADR-0005).
|
|
4
4
|
*
|
|
5
5
|
* Resolves the initial profile, materializes it as a generated runtime
|
|
6
|
-
* directory (settings + symlinks + env
|
|
6
|
+
* directory (settings + symlinks + env), and spawns the real `pi`
|
|
7
7
|
* binary with user arguments passed through verbatim.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
@@ -18,8 +18,7 @@ import { parseLauncherArgs } from "../src/launcher/args.ts";
|
|
|
18
18
|
import { UnknownProfileError, resolveInitialProfile } from "../src/launcher/initial-profile.ts";
|
|
19
19
|
import { sweepStaleRuntimeDirs } from "../src/launcher/runtime-cleanup.ts";
|
|
20
20
|
import { spawnPi } from "../src/launcher/spawn.ts";
|
|
21
|
-
import { McpConfigError } from "../src/mcp-config.ts";
|
|
22
|
-
import { MissingMcpAdapterError } from "../src/mcp-coordination.ts";
|
|
21
|
+
import { McpConfigError, MissingMcpAdapterError } from "../src/mcp-config.ts";
|
|
23
22
|
import { CatalogError } from "../src/profile-catalog.ts";
|
|
24
23
|
import { ActivationError } from "../src/profile-resolver.ts";
|
|
25
24
|
import { generateRuntimeDir } from "../src/settings-generator.ts";
|
|
@@ -21,8 +21,6 @@ import {
|
|
|
21
21
|
runProfileDuplicateWizard,
|
|
22
22
|
runProfileEditWizard,
|
|
23
23
|
} from "../../src/switching/profile-wizard.ts";
|
|
24
|
-
import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
|
|
25
|
-
import { setMcpServerEnabled } from "../../src/switching/mcp-toggle.ts";
|
|
26
24
|
import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
|
|
27
25
|
import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
|
|
28
26
|
import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
@@ -31,14 +29,12 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
|
31
29
|
* pi-profile extension entry.
|
|
32
30
|
*
|
|
33
31
|
* Loaded into the spawned pi via `-e`. Responsibilities:
|
|
34
|
-
* - Append the profile's declared instructions to Pi's fully built system
|
|
35
|
-
* prompt on every turn (`before_agent_start`), so the default prompt,
|
|
36
|
-
* AGENTS.md, and other extensions keep working.
|
|
37
32
|
* - After every session start (startup/reload/new/resume/fork), apply the
|
|
38
|
-
* launch plan: re-expand tool references against Pi's live registry
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* change summary injected into the next turn
|
|
33
|
+
* launch plan: re-expand tool references against Pi's live registry
|
|
34
|
+
* (including extension- and MCP-provided tools) and call setActiveTools
|
|
35
|
+
* for strict allowlisting, persist the selection after switches, and
|
|
36
|
+
* produce the one-shot change summary injected into the next turn
|
|
37
|
+
* via `before_agent_start`.
|
|
42
38
|
* - `/profile use <name>` / `/profile reload`: in-session switching without
|
|
43
39
|
* restarting the Pi process (src/switching/switch-profile.ts).
|
|
44
40
|
* - `/profile customize` / `/profile reset`: runtime overlay (ticket 06).
|
|
@@ -53,9 +49,6 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
|
53
49
|
* is stale afterwards.
|
|
54
50
|
* - list/status ship structured `details` payloads (`{kind, profiles}` /
|
|
55
51
|
* `{kind, report}`) for RPC consumers (ticket 11).
|
|
56
|
-
* - `/mcp enable|disable <server>`: edit the active profile's mcp array in
|
|
57
|
-
* its owning catalog, then reload (ticket 10). Fails clearly with the
|
|
58
|
-
* adapter absent; notifies before the reload (stale context after).
|
|
59
52
|
*
|
|
60
53
|
* Pi re-executes this module on reload, so post-reload state is established
|
|
61
54
|
* exclusively through `session_start` — nothing stale survives.
|
|
@@ -83,11 +76,6 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
83
76
|
surface: {
|
|
84
77
|
getAllTools: () => pi.getAllTools(),
|
|
85
78
|
setActiveTools: (names) => pi.setActiveTools(names),
|
|
86
|
-
modelRegistry: ctx.modelRegistry,
|
|
87
|
-
setModel: (model) => pi.setModel(model as Parameters<ExtensionAPI["setModel"]>[0]),
|
|
88
|
-
setThinkingLevel: (level) =>
|
|
89
|
-
pi.setThinkingLevel(level as Parameters<ExtensionAPI["setThinkingLevel"]>[0]),
|
|
90
|
-
events: pi.events,
|
|
91
79
|
notify: (message, level) => ctx.ui?.notify(message, level),
|
|
92
80
|
},
|
|
93
81
|
});
|
|
@@ -95,12 +83,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
95
83
|
});
|
|
96
84
|
|
|
97
85
|
pi.on("before_agent_start", async (event) => {
|
|
98
|
-
const plan = await readLaunchPlanFile(runtimeDir);
|
|
99
|
-
const instructions = plan?.instructions;
|
|
100
86
|
let systemPrompt = event.systemPrompt;
|
|
101
|
-
if (instructions !== undefined && instructions.length > 0 && !systemPrompt.includes(instructions)) {
|
|
102
|
-
systemPrompt = `${systemPrompt}\n\n${instructions}`;
|
|
103
|
-
}
|
|
104
87
|
if (pendingSummary !== undefined) {
|
|
105
88
|
systemPrompt = `${systemPrompt}\n\n[${pendingSummary}]`;
|
|
106
89
|
pendingSummary = undefined;
|
|
@@ -383,69 +366,4 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
383
366
|
}
|
|
384
367
|
},
|
|
385
368
|
});
|
|
386
|
-
|
|
387
|
-
// Persistent profile-scoped MCP toggles (ticket 10): edit the active
|
|
388
|
-
// profile's mcp array in its owning catalog, then reload so the runtime
|
|
389
|
-
// and the republished allowlist match. The adapter's own configuration
|
|
390
|
-
// (mcp.json files) is never written.
|
|
391
|
-
pi.registerCommand("mcp", {
|
|
392
|
-
description: "pi-profile: /mcp enable <server> | /mcp disable <server>",
|
|
393
|
-
handler: async (args, ctx) => {
|
|
394
|
-
const notify = (message: string, level: "info" | "warning" | "error") => {
|
|
395
|
-
try {
|
|
396
|
-
ctx.ui?.notify(message, level);
|
|
397
|
-
} catch {
|
|
398
|
-
// stale context after reload — see /profile
|
|
399
|
-
}
|
|
400
|
-
};
|
|
401
|
-
const [action, server] = args.trim().split(/\s+/).filter(Boolean);
|
|
402
|
-
if (!["enable", "disable"].includes(action ?? "") || server === undefined) {
|
|
403
|
-
notify("usage: /mcp enable <server> | /mcp disable <server>", "error");
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
try {
|
|
407
|
-
const plan = await readLaunchPlanFile(runtimeDir);
|
|
408
|
-
if (plan?.agentDir === undefined) {
|
|
409
|
-
notify("pi-profile: launch plan is missing agentDir — cannot toggle MCP servers", "error");
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
if (!probeAdapterPresence(pi.events)) {
|
|
413
|
-
throw new Error(
|
|
414
|
-
"pi-mcp-adapter is not active in this session — /mcp enable|disable requires it " +
|
|
415
|
-
"(select the adapter in the profile's extensions).",
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
const result = await setMcpServerEnabled(
|
|
419
|
-
{ realAgentDir: plan.agentDir, cwd: ctx.cwd, profile: { name: plan.profile, source: plan.source } },
|
|
420
|
-
server,
|
|
421
|
-
action === "enable",
|
|
422
|
-
);
|
|
423
|
-
if (!result.changed) {
|
|
424
|
-
notify(`MCP server "${server}" is already ${action}d in profile "${plan.profile}"`, "info");
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
// Notify BEFORE the reload: this context is stale afterwards.
|
|
428
|
-
notify(
|
|
429
|
-
`${action}d MCP server "${server}" in profile "${plan.profile}" (mcps: [${result.mcps.join(", ")}]); reloading`,
|
|
430
|
-
"info",
|
|
431
|
-
);
|
|
432
|
-
const switched = await switchProfile(plan.profile, {
|
|
433
|
-
runtimeDir,
|
|
434
|
-
realAgentDir: plan.agentDir,
|
|
435
|
-
cwd: ctx.cwd,
|
|
436
|
-
waitForIdle: () => ctx.waitForIdle(),
|
|
437
|
-
reload: () => ctx.reload(),
|
|
438
|
-
// Same staleness probe as /profile: interactive Pi
|
|
439
|
-
// swallows reload refusals; a live context afterwards
|
|
440
|
-
// means the reload never ran.
|
|
441
|
-
assertStale: () => {
|
|
442
|
-
void ctx.cwd;
|
|
443
|
-
},
|
|
444
|
-
}, { reloadCurrent: true });
|
|
445
|
-
for (const warning of switched.warnings) notify(warning, "warning");
|
|
446
|
-
} catch (error) {
|
|
447
|
-
notify(error instanceof Error ? error.message : String(error), "error");
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
|
-
});
|
|
451
369
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ExtensionDiscovery: implicit, read-only discovery of selectable extensions
|
|
3
|
-
* (ADR-0006), so profiles can reference extensions without
|
|
4
|
-
*
|
|
3
|
+
* (ADR-0006), so profiles can reference extensions without any
|
|
4
|
+
* registration step.
|
|
5
5
|
*
|
|
6
6
|
* Two implicit sources, both Pi-native and side-effect free:
|
|
7
7
|
* - Configured user packages: each package's `package.json#pi.extensions`
|
|
@@ -13,9 +13,6 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Discovery never executes extension code and never installs anything: a
|
|
15
15
|
* package contributes entries only for declared files that exist on disk.
|
|
16
|
-
* Explicit resources.json entries merge over this discovery result in
|
|
17
|
-
* ResourceRegistry.load (registration remains the override, never the
|
|
18
|
-
* prerequisite).
|
|
19
16
|
*/
|
|
20
17
|
|
|
21
18
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
@@ -328,7 +325,7 @@ export async function discoverImplicitExtensions(options: {
|
|
|
328
325
|
const merged = new Map<string, DiscoveredLocalExtension>(globalLocal.map((entry) => [entry.id, entry]));
|
|
329
326
|
if (options.projectDir !== undefined) {
|
|
330
327
|
// Project loose files override same-ID global ones, mirroring the
|
|
331
|
-
// catalog
|
|
328
|
+
// project-over-global catalog override convention.
|
|
332
329
|
for (const entry of await scanLooseDir(path.join(options.projectDir, ".pi", "extensions"), warnings)) {
|
|
333
330
|
merged.set(entry.id, entry);
|
|
334
331
|
}
|
package/src/json-file.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared JSON-file reading for pi-profile's file-backed stores (catalog,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* runtime state). Each store maps read failures to its own error policy
|
|
4
|
+
* (loud CatalogError vs. quiet state fallback); this helper only classifies
|
|
5
|
+
* the outcome.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Flow: trust check (gatekeeper for everything project-scoped) → positional
|
|
5
5
|
* name or saved state (project state wins when trusted) → catalog lookup →
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* results for the settings generator. Unknown profiles, malformed
|
|
9
|
-
*
|
|
6
|
+
* discovery (skills, extensions, MCP server names) → resolver (glob
|
|
7
|
+
* expansion, overlay, model validation) → ActivationPlan + full discovery
|
|
8
|
+
* results for the settings generator. Unknown profiles, malformed catalogs,
|
|
9
|
+
* and unresolvable resources all fail before Pi spawns.
|
|
10
10
|
*
|
|
11
11
|
* The CLI's initial selection is transient: no runtime state is written here.
|
|
12
12
|
*/
|
|
@@ -15,7 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
|
|
16
16
|
import { isRecord, readJsonFile } from "../json-file.ts";
|
|
17
17
|
import { discoverAdapterServerNames } from "../mcp-config.ts";
|
|
18
|
-
import { isAdapterExtension, MissingMcpAdapterError } from "../mcp-
|
|
18
|
+
import { isAdapterExtension, MissingMcpAdapterError } from "../mcp-config.ts";
|
|
19
19
|
import { ProfileCatalog, type ResolvedProfile } from "../profile-catalog.ts";
|
|
20
20
|
import { ActivationError, defaultPlan, resolveProfile, type ActivationPlan } from "../profile-resolver.ts";
|
|
21
21
|
import { resolveProjectTrust } from "../project-trust.ts";
|
package/src/launcher/spawn.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Spawns the real `pi` binary as a subprocess (ADR-0005).
|
|
3
3
|
*
|
|
4
|
-
* The spawned pi gets: the pi-profile extension via `-e
|
|
5
|
-
*
|
|
4
|
+
* The spawned pi gets: the pi-profile extension via `-e` and the user's
|
|
5
|
+
* arguments verbatim. stdio is inherited so interactive
|
|
6
6
|
* TUI, RPC, and print modes all behave natively; exit codes and signals
|
|
7
7
|
* propagate.
|
|
8
8
|
*/
|
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
import type { GeneratedRuntime } from "../settings-generator.ts";
|
|
16
16
|
|
|
17
17
|
export interface SpawnPiOptions {
|
|
18
|
-
/** Generated runtime dir + env
|
|
18
|
+
/** Generated runtime dir + env. */
|
|
19
19
|
generated: GeneratedRuntime;
|
|
20
20
|
/** User arguments, forwarded verbatim. */
|
|
21
21
|
piArgs: string[];
|
|
@@ -25,10 +25,10 @@ export interface SpawnPiOptions {
|
|
|
25
25
|
|
|
26
26
|
const EXTENSION_ENTRY = fileURLToPath(new URL("../../extensions/pi-profile/index.ts", import.meta.url));
|
|
27
27
|
|
|
28
|
-
/** Pure argv construction for the spawned pi: extension entry,
|
|
29
|
-
*
|
|
28
|
+
/** Pure argv construction for the spawned pi: extension entry,
|
|
29
|
+
* trust re-application, then user args verbatim. */
|
|
30
30
|
export function buildPiArgs(options: SpawnPiOptions): string[] {
|
|
31
|
-
const args = ["-e", EXTENSION_ENTRY
|
|
31
|
+
const args = ["-e", EXTENSION_ENTRY];
|
|
32
32
|
// default profile keeps trust behavior native: re-apply the recorded flag.
|
|
33
33
|
if (options.trustOverride === true) args.push("--approve");
|
|
34
34
|
if (options.trustOverride === false) args.push("--no-approve");
|
package/src/mcp-config.ts
CHANGED
|
@@ -134,3 +134,22 @@ export async function discoverAdapterServerNames(
|
|
|
134
134
|
const { servers } = await loadMergedMcpServers(agentDir, projectDir, options);
|
|
135
135
|
return Object.keys(servers).sort();
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
export class MissingMcpAdapterError extends Error {
|
|
139
|
+
constructor(profile: string) {
|
|
140
|
+
super(
|
|
141
|
+
`profile "${profile}" declares MCP servers but pi-mcp-adapter is not active. ` +
|
|
142
|
+
`Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcps" declaration.`,
|
|
143
|
+
);
|
|
144
|
+
this.name = "MissingMcpAdapterError";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Identifies the adapter among active extension entries by its install
|
|
149
|
+
* path containing "pi-mcp-adapter" (npm package roots and local dirs both
|
|
150
|
+
* match). Heuristic by design — activation-plan entries carry no package
|
|
151
|
+
* source string. */
|
|
152
|
+
export function isAdapterExtension(entry: { entry: string }): boolean {
|
|
153
|
+
return entry.entry.includes("pi-mcp-adapter");
|
|
154
|
+
}
|
|
155
|
+
|
package/src/profile-resolver.ts
CHANGED
|
@@ -7,23 +7,21 @@
|
|
|
7
7
|
* resolution; zero matches is fine (new matches join on the next start) and
|
|
8
8
|
* is reported in the plan's `unmatched` list so typos are visible.
|
|
9
9
|
* - Literal references must exist; a missing literal fails activation.
|
|
10
|
-
* Extension references resolve through
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - `alwaysOn` resources and the recursive `dependsOn` closure join every
|
|
14
|
-
* plan; cycles and missing entries fail loudly (via ResourceRegistry).
|
|
10
|
+
* Extension references resolve through ExtensionDiscovery.select (ADR-0007):
|
|
11
|
+
* package name/alias, loose-file stem, or an on-disk path — no
|
|
12
|
+
* pre-registration required.
|
|
15
13
|
* - Undeclared model/thinking/instructions never enter the plan, so Pi's
|
|
16
14
|
* current state stays untouched.
|
|
17
15
|
* - MCP references (`mcp`) expand against the server names the launcher
|
|
18
16
|
* discovered from pi-mcp-adapter's pi-native config files: literal misses
|
|
19
17
|
* fail loudly; globs expand to zero or more matches (consistent with
|
|
20
18
|
* skills/extensions). Adapter presence is checked separately by the
|
|
21
|
-
* launcher
|
|
22
|
-
* - Tool globs expand against Pi's built-in tool names
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
19
|
+
* launcher (ADR-0002).
|
|
20
|
+
* - Tool globs expand against Pi's built-in tool names for settings
|
|
21
|
+
* `defaultTools`; raw tool references are also carried into the launch
|
|
22
|
+
* plan so the in-session extension can expand them against Pi's live
|
|
23
|
+
* registry (including extension and MCP tools) via setActiveTools for
|
|
24
|
+
* strict allowlisting.
|
|
27
25
|
*/
|
|
28
26
|
|
|
29
27
|
import { minimatch } from "minimatch";
|
|
@@ -75,10 +73,13 @@ export interface ActivationPlan {
|
|
|
75
73
|
toolReferences?: string[];
|
|
76
74
|
/** Declared model; undefined leaves Pi's current model untouched. */
|
|
77
75
|
model?: ProfileModel;
|
|
78
|
-
/** Declared instructions;
|
|
76
|
+
/** Declared instructions; written to the generated APPEND_SYSTEM.md,
|
|
77
|
+
* which Pi natively appends to the system prompt. */
|
|
79
78
|
instructions?: string;
|
|
80
|
-
/** Expanded MCP server allowlist
|
|
81
|
-
*
|
|
79
|
+
/** Expanded MCP server allowlist: written by SettingsGenerator into the
|
|
80
|
+
* filtered instance `mcp.json` (the adapter's runtime restriction) and
|
|
81
|
+
* surfaced in `/profile status`; undefined when the profile declares
|
|
82
|
+
* no `mcps` (no restriction). */
|
|
82
83
|
mcps?: string[];
|
|
83
84
|
/** Glob references (skills/extensions/MCP) that matched nothing this
|
|
84
85
|
* resolution — surfaced as warnings so zero-match typos are never silent.
|
|
@@ -110,7 +111,7 @@ export interface ResolveInput {
|
|
|
110
111
|
* The runtime overlay (ticket 06): temporary narrowing applied on top of
|
|
111
112
|
* the profile definition at every resolution. Overlay references must
|
|
112
113
|
* name resources the profile actually resolves (typos fail loudly), and
|
|
113
|
-
*
|
|
114
|
+
* any resolved reference may be narrowed or disabled.
|
|
114
115
|
*/
|
|
115
116
|
overlay?: RuntimeOverlay;
|
|
116
117
|
}
|
package/src/project-trust.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `defaultProjectTrust: "never"` and Pi therefore never auto-discovers them.
|
|
5
5
|
*
|
|
6
6
|
* Mirrors Pi's own trust decision order (`resolveProjectTrusted`), with
|
|
7
|
-
* pi-profile's catalog/
|
|
7
|
+
* pi-profile's catalog/state files added to the trust-requiring
|
|
8
8
|
* resource set (they are pi-profile's project attack surface; Pi doesn't
|
|
9
9
|
* know about them):
|
|
10
10
|
* 1. one-run `--approve` / `--no-approve` override (consumed by the
|
|
@@ -6,17 +6,13 @@
|
|
|
6
6
|
* for global state, the project's `.pi` dir for project state (project
|
|
7
7
|
* state is only touched when the trust check passed). The launcher only
|
|
8
8
|
* reads — the initial CLI selection is transient by design; `/profile use`
|
|
9
|
-
* (ticket 05) writes
|
|
9
|
+
* (ticket 05) writes the selection.
|
|
10
10
|
*
|
|
11
|
-
* `activeProfile` is the saved selection restored on launch
|
|
12
|
-
* `lastVerifiedProfile` is the rollback anchor: the last profile whose
|
|
13
|
-
* activation completed successfully. They differ only between a failed
|
|
14
|
-
* activation and its rollback.
|
|
11
|
+
* `activeProfile` is the saved selection restored on launch.
|
|
15
12
|
*
|
|
16
13
|
* A missing or malformed state file is not an error on read — it simply
|
|
17
14
|
* means "fall back to the default profile". Unexpected I/O errors
|
|
18
|
-
* propagate. Writes replace the file wholesale
|
|
19
|
-
* written together by the switch path).
|
|
15
|
+
* propagate. Writes replace the file wholesale.
|
|
20
16
|
*/
|
|
21
17
|
|
|
22
18
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
@@ -26,7 +22,6 @@ import { isRecord, readJsonFile } from "./json-file.ts";
|
|
|
26
22
|
|
|
27
23
|
export interface RuntimeState {
|
|
28
24
|
activeProfile?: string;
|
|
29
|
-
lastVerifiedProfile?: string;
|
|
30
25
|
/** The runtime overlay: temporary narrowing of the active profile
|
|
31
26
|
* (ticket 06). Never written to catalogs, never applied at launch —
|
|
32
27
|
* only in-session switches/reloads read it. */
|
|
@@ -69,9 +64,6 @@ export class RuntimeStateStore {
|
|
|
69
64
|
if (typeof result.value.activeProfile === "string") {
|
|
70
65
|
state.activeProfile = result.value.activeProfile;
|
|
71
66
|
}
|
|
72
|
-
if (typeof result.value.lastVerifiedProfile === "string") {
|
|
73
|
-
state.lastVerifiedProfile = result.value.lastVerifiedProfile;
|
|
74
|
-
}
|
|
75
67
|
const overlay = parseOverlay(result.value.overlay);
|
|
76
68
|
if (overlay !== undefined) {
|
|
77
69
|
state.overlay = overlay;
|
|
@@ -94,10 +86,6 @@ export class RuntimeStateStore {
|
|
|
94
86
|
if (patch.activeProfile === undefined) delete next.activeProfile;
|
|
95
87
|
else next.activeProfile = patch.activeProfile;
|
|
96
88
|
}
|
|
97
|
-
if ("lastVerifiedProfile" in patch) {
|
|
98
|
-
if (patch.lastVerifiedProfile === undefined) delete next.lastVerifiedProfile;
|
|
99
|
-
else next.lastVerifiedProfile = patch.lastVerifiedProfile;
|
|
100
|
-
}
|
|
101
89
|
if ("overlay" in patch) {
|
|
102
90
|
if (patch.overlay === undefined) delete next.overlay;
|
|
103
91
|
else next.overlay = patch.overlay;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Two entry points:
|
|
6
6
|
* - `generateRuntimeDir` (launcher): mkdtemp a fresh runtime dir, write the
|
|
7
7
|
* files, link state (auth/models/mcp/npm/git/bin; trust.json only for
|
|
8
|
-
* default), derive env
|
|
8
|
+
* default), derive env.
|
|
9
9
|
* - `writeRuntimeFiles` (in-session switch, ticket 05): rewrite
|
|
10
10
|
* settings.json + pi-profile.json inside the EXISTING runtime dir (the
|
|
11
11
|
* running process's PI_CODING_AGENT_DIR cannot move), and transition the
|
|
@@ -31,8 +31,9 @@
|
|
|
31
31
|
* root as a launch side effect)
|
|
32
32
|
* - unmanaged kinds (prompts, themes) pass through: the user's arrays are
|
|
33
33
|
* preserved and the real agent dir's prompts/themes dirs re-included
|
|
34
|
-
* - tools/model
|
|
35
|
-
*
|
|
34
|
+
* - tools/model are written to generated settings (defaultTools,
|
|
35
|
+
* defaultProvider, defaultModel, defaultThinkingLevel); the launch plan
|
|
36
|
+
* file feeds the in-pi extension (tools strict allowlist, status)
|
|
36
37
|
*
|
|
37
38
|
* User configuration files are never modified.
|
|
38
39
|
*/
|
|
@@ -85,8 +86,6 @@ export interface GeneratedRuntime {
|
|
|
85
86
|
runtimeDir: string;
|
|
86
87
|
/** Environment variables for the spawned pi process. */
|
|
87
88
|
env: Record<string, string>;
|
|
88
|
-
/** Extra pi flags derived from the plan (e.g. --tools, --model). Empty for default. */
|
|
89
|
-
flags: string[];
|
|
90
89
|
}
|
|
91
90
|
|
|
92
91
|
/** Files managed explicitly by pi-profile in runtimeDir; excluded from auto-symlinking. */
|
|
@@ -117,6 +116,18 @@ async function exists(filePath: string): Promise<boolean> {
|
|
|
117
116
|
}
|
|
118
117
|
}
|
|
119
118
|
|
|
119
|
+
/** Symlink-aware existence check (lstat): a dangling symlink still counts as
|
|
120
|
+
* existing — a stat-based check misses it, which would skip its removal or
|
|
121
|
+
* collide on symlink creation. Use this for paths pi-profile links itself. */
|
|
122
|
+
async function existsLexical(filePath: string): Promise<boolean> {
|
|
123
|
+
try {
|
|
124
|
+
await lstat(filePath);
|
|
125
|
+
return true;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
function toPosix(filePath: string): string {
|
|
121
132
|
return filePath.split(path.sep).join("/");
|
|
122
133
|
}
|
|
@@ -291,8 +302,8 @@ export interface RuntimeFileOptions {
|
|
|
291
302
|
projectSettings?: Record<string, unknown>;
|
|
292
303
|
/** Extra launch-plan fields written by the in-session switch path:
|
|
293
304
|
* `switchedFrom` triggers the one-shot change summary; `persistSelection`
|
|
294
|
-
* tells the post-reload extension instance to save the selection
|
|
295
|
-
*
|
|
305
|
+
* tells the post-reload extension instance to save the selection;
|
|
306
|
+
* `clearOverlay` drops the stored overlay
|
|
296
307
|
* (a profile switch discards the previous profile's overlay).
|
|
297
308
|
* `previousResolved` carries the pre-switch resolved name sets so
|
|
298
309
|
* `/profile status` can report glob deltas (ticket 07). */
|
|
@@ -372,10 +383,11 @@ export async function writeRuntimeFiles(
|
|
|
372
383
|
const settings = await computeSettings(plan, options, runtimeDir);
|
|
373
384
|
await writeFile(path.join(runtimeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
|
|
374
385
|
|
|
375
|
-
// The launch plan feeds the in-pi extension:
|
|
376
|
-
//
|
|
386
|
+
// The launch plan feeds the in-pi extension: tool re-application after
|
|
387
|
+
// reload (the tools strict allowlist), in-session switching, status
|
|
388
|
+
// reporting, and post-reload state persistence.
|
|
377
389
|
// agentDir is the REAL agent dir — the extension needs it for trust
|
|
378
|
-
// checks, state files, and catalog
|
|
390
|
+
// checks, state files, and catalog reads (its own
|
|
379
391
|
// PI_CODING_AGENT_DIR points at this runtime dir).
|
|
380
392
|
await writeFile(
|
|
381
393
|
path.join(runtimeDir, "pi-profile.json"),
|
|
@@ -384,8 +396,6 @@ export async function writeRuntimeFiles(
|
|
|
384
396
|
profile: plan.profile,
|
|
385
397
|
source: plan.source,
|
|
386
398
|
agentDir: options.agentDir,
|
|
387
|
-
...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}),
|
|
388
|
-
...(plan.model !== undefined ? { model: plan.model } : {}),
|
|
389
399
|
...(plan.tools !== undefined ? { tools: plan.tools } : {}),
|
|
390
400
|
...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
|
|
391
401
|
...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
|
|
@@ -408,10 +418,14 @@ export async function writeRuntimeFiles(
|
|
|
408
418
|
const trustLink = path.join(runtimeDir, "trust.json");
|
|
409
419
|
const trustTarget = path.join(options.agentDir, "trust.json");
|
|
410
420
|
if (plan.filter === "none") {
|
|
411
|
-
if ((await exists(trustTarget)) && !(await
|
|
421
|
+
if ((await exists(trustTarget)) && !(await existsLexical(trustLink))) {
|
|
412
422
|
await symlink(trustTarget, trustLink);
|
|
413
423
|
}
|
|
414
|
-
} else if (await
|
|
424
|
+
} else if (await existsLexical(trustLink)) {
|
|
425
|
+
// Lexical check: a dangling trust.json symlink (real trust.json deleted
|
|
426
|
+
// after the link was made) must still be removed — otherwise a later
|
|
427
|
+
// re-created real trust.json silently resurrects stored trust inside a
|
|
428
|
+
// named profile, defeating defaultProjectTrust: "never".
|
|
415
429
|
await rm(trustLink);
|
|
416
430
|
}
|
|
417
431
|
|
|
@@ -543,13 +557,10 @@ export async function generateRuntimeDir(
|
|
|
543
557
|
|
|
544
558
|
await writeRuntimeFiles(runtimeDir, plan, options);
|
|
545
559
|
|
|
546
|
-
const flags: string[] = [];
|
|
547
|
-
|
|
548
560
|
return {
|
|
549
561
|
runtimeDir,
|
|
550
562
|
env: {
|
|
551
563
|
PI_CODING_AGENT_DIR: runtimeDir,
|
|
552
564
|
},
|
|
553
|
-
flags,
|
|
554
565
|
};
|
|
555
566
|
}
|
|
@@ -9,17 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Steps:
|
|
11
11
|
* 1. tools: re-expand the profile's raw tool references against Pi's LIVE
|
|
12
|
-
* tool registry (
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* reload, save the selection
|
|
20
|
-
* (activeProfile = lastVerifiedProfile = plan.profile) to the
|
|
12
|
+
* tool registry (including extension- and MCP-provided tools) and
|
|
13
|
+
* call setActiveTools. This is the CURRENT strict-allowlist enforcement
|
|
14
|
+
* ensuring non-builtin tools obey profile restrictions; settings
|
|
15
|
+
* `defaultTools` provides only the boot baseline for built-ins.
|
|
16
|
+
* Literals that no tool provides are dropped with a warning — Pi
|
|
17
|
+
* silently ignores unknown names, so the warning is the only signal.
|
|
18
|
+
* 2. persistence: when the plan is marked `persistSelection` and this is a
|
|
19
|
+
* reload, save the selection (activeProfile = plan.profile) to the
|
|
21
20
|
* profile's scope state file. Launch-transient selections never write.
|
|
22
|
-
*
|
|
21
|
+
* 3. change summary: a `switchedFrom` marker produces a one-shot summary
|
|
23
22
|
* for the next agent turn and is cleared from the plan file.
|
|
24
23
|
*
|
|
25
24
|
* Pi's reload re-executes extension modules, so no stale handler or command
|
|
@@ -31,12 +30,6 @@ import { writeFile } from "node:fs/promises";
|
|
|
31
30
|
import path from "node:path";
|
|
32
31
|
|
|
33
32
|
import { isRecord, readJsonFile } from "../json-file.ts";
|
|
34
|
-
import {
|
|
35
|
-
MCP_ALLOWLIST_EVENT,
|
|
36
|
-
MCP_ALLOWLIST_VERSION,
|
|
37
|
-
MissingMcpAdapterError,
|
|
38
|
-
probeAdapterPresence,
|
|
39
|
-
} from "../mcp-coordination.ts";
|
|
40
33
|
import { RuntimeStateStore } from "../runtime-state-store.ts";
|
|
41
34
|
import { getGlobalStateDir } from "../workspace.ts";
|
|
42
35
|
import { expandToolReferences } from "./tool-references.ts";
|
|
@@ -45,8 +38,6 @@ export interface LaunchPlanFile {
|
|
|
45
38
|
profile: string;
|
|
46
39
|
source: string;
|
|
47
40
|
agentDir?: string;
|
|
48
|
-
instructions?: string;
|
|
49
|
-
model?: { provider: string; id: string; thinkingLevel?: string };
|
|
50
41
|
tools?: string[];
|
|
51
42
|
toolReferences?: string[];
|
|
52
43
|
mcps?: string[];
|
|
@@ -71,10 +62,6 @@ export interface LaunchPlanFile {
|
|
|
71
62
|
export interface PlanApplicationSurface {
|
|
72
63
|
getAllTools(): Array<{ name: string }>;
|
|
73
64
|
setActiveTools(names: string[]): void;
|
|
74
|
-
modelRegistry: { find(provider: string, id: string): unknown | undefined };
|
|
75
|
-
setModel(model: unknown): Promise<boolean>;
|
|
76
|
-
setThinkingLevel(level: unknown): void;
|
|
77
|
-
events: { emit(channel: string, data: unknown): void };
|
|
78
65
|
notify?(message: string, level: "info" | "warning" | "error"): void;
|
|
79
66
|
}
|
|
80
67
|
|
|
@@ -119,46 +106,13 @@ export async function applyLaunchPlan(input: {
|
|
|
119
106
|
surface.setActiveTools(expanded);
|
|
120
107
|
}
|
|
121
108
|
|
|
122
|
-
// ---
|
|
123
|
-
if (plan.model !== undefined) {
|
|
124
|
-
const found = surface.modelRegistry.find(plan.model.provider, plan.model.id);
|
|
125
|
-
if (found === undefined) {
|
|
126
|
-
warnings.push(`profile "${plan.profile}": declared model ${plan.model.provider}/${plan.model.id} not found`);
|
|
127
|
-
} else {
|
|
128
|
-
const applied = await surface.setModel(found);
|
|
129
|
-
if (!applied) {
|
|
130
|
-
warnings.push(
|
|
131
|
-
`profile "${plan.profile}": model ${plan.model.provider}/${plan.model.id} has no configured auth`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
if (plan.model.thinkingLevel !== undefined) {
|
|
136
|
-
surface.setThinkingLevel(plan.model.thinkingLevel);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// --- mcp coordination (ticket 04 contract) ---
|
|
141
|
-
if (plan.mcps !== undefined && plan.mcps.length > 0) {
|
|
142
|
-
if (!probeAdapterPresence(surface.events)) {
|
|
143
|
-
const error = new MissingMcpAdapterError(plan.profile);
|
|
144
|
-
surface.notify?.(error.message, "error");
|
|
145
|
-
throw error;
|
|
146
|
-
}
|
|
147
|
-
surface.events.emit(MCP_ALLOWLIST_EVENT, {
|
|
148
|
-
version: MCP_ALLOWLIST_VERSION,
|
|
149
|
-
profile: plan.profile,
|
|
150
|
-
servers: plan.mcps,
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// --- persistence + rollback anchor (post-reload only) ---
|
|
109
|
+
// --- persistence (post-reload only) ---
|
|
155
110
|
if (plan.persistSelection === true && input.reason === "reload" && plan.agentDir !== undefined) {
|
|
156
111
|
const stateDir = plan.source === "project" ? path.join(input.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
|
|
157
112
|
// Merge: the overlay belongs to customize/reset, not to this write.
|
|
158
113
|
// A switch (clearOverlay) explicitly drops it.
|
|
159
114
|
await new RuntimeStateStore(stateDir).update({
|
|
160
115
|
activeProfile: plan.profile,
|
|
161
|
-
lastVerifiedProfile: plan.profile,
|
|
162
116
|
...(plan.clearOverlay === true ? { overlay: undefined } : {}),
|
|
163
117
|
});
|
|
164
118
|
}
|
|
@@ -182,7 +136,6 @@ function buildSwitchSummary(plan: LaunchPlanFile): string {
|
|
|
182
136
|
`profile switched: ${plan.switchedFrom} → ${plan.profile}`,
|
|
183
137
|
plan.tools !== undefined ? `tools: [${plan.tools.join(", ")}]` : undefined,
|
|
184
138
|
plan.mcps !== undefined && plan.mcps.length > 0 ? `mcp: [${plan.mcps.join(", ")}]` : undefined,
|
|
185
|
-
plan.model !== undefined ? `model: ${plan.model.provider}/${plan.model.id}` : undefined,
|
|
186
139
|
].filter((part): part is string => part !== undefined);
|
|
187
140
|
return parts.join("; ");
|
|
188
141
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Ordering invariants:
|
|
11
11
|
* - customize: re-resolve with the candidate overlay FIRST (validation:
|
|
12
|
-
* unknown references
|
|
12
|
+
* unknown references fail here, before anything
|
|
13
13
|
* is written), then switch+reload, then persist the overlay to state. A
|
|
14
14
|
* failed switch leaves the stored overlay untouched, consistent with the
|
|
15
15
|
* rolled-back runtime.
|
|
@@ -50,7 +50,7 @@ export async function customizeOverlay(
|
|
|
50
50
|
const candidate = mutate(state.overlay ?? {});
|
|
51
51
|
|
|
52
52
|
// switchProfile re-resolves with the candidate overlay; resolution-time
|
|
53
|
-
// validation (unknown references
|
|
53
|
+
// validation (unknown references) fails before any
|
|
54
54
|
// write. persistSelection is preserved by the reload-current path.
|
|
55
55
|
const result = await switchProfile(profile, deps, { reloadCurrent: true, overlay: candidate });
|
|
56
56
|
|
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
* Flow (`/profile use <name>`):
|
|
8
8
|
* 1. wait for the agent to be idle (Pi's native `ctx.waitForIdle()`) — a
|
|
9
9
|
* running turn is never torn down
|
|
10
|
-
* 2. snapshot
|
|
10
|
+
* 2. snapshot ALL pi-profile-managed runtime files in memory
|
|
11
|
+
* (settings.json, pi-profile.json, mcp.json, APPEND_SYSTEM.md, and the
|
|
12
|
+
* trust.json link state — each as absent | symlink | file)
|
|
11
13
|
* 3. re-resolve through the full launcher path (trust check, catalogs,
|
|
12
|
-
* discovery,
|
|
14
|
+
* discovery, model/MCP validation) against the REAL agent
|
|
13
15
|
* dir — any failure here leaves the runtime untouched
|
|
14
16
|
* 4. rewrite the runtime files in place (the running process's
|
|
15
17
|
* PI_CODING_AGENT_DIR cannot move) and mark the plan
|
|
16
18
|
* `persistSelection` so the post-reload extension instance saves the
|
|
17
|
-
* selection
|
|
19
|
+
* selection
|
|
18
20
|
* 5. `ctx.reload()` — Pi re-reads settings from disk, re-executes
|
|
19
21
|
* extensions, preserves the session
|
|
20
22
|
* 6. VERIFY the reload ran: interactive Pi swallows reload refusals and
|
|
@@ -29,7 +31,7 @@
|
|
|
29
31
|
* (transient launch selections stay transient).
|
|
30
32
|
*/
|
|
31
33
|
|
|
32
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
34
|
+
import { chmod, lstat, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
|
|
33
35
|
import path from "node:path";
|
|
34
36
|
|
|
35
37
|
import { resolveInitialProfile } from "../launcher/initial-profile.ts";
|
|
@@ -70,38 +72,74 @@ export interface SwitchResult {
|
|
|
70
72
|
warnings: string[];
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/** The pre-switch state of one pi-profile-managed runtime file. Absence is
|
|
76
|
+
* a real state (the switch may create the file); a symlink keeps its raw
|
|
77
|
+
* target so restore can rebuild it exactly; a file keeps content AND
|
|
78
|
+
* permission bits so restore doesn't widen a user-tightened mode. */
|
|
79
|
+
type FileSnapshot =
|
|
80
|
+
| { kind: "absent" }
|
|
81
|
+
| { kind: "symlink"; target: string }
|
|
82
|
+
| { kind: "file"; content: string; mode: number };
|
|
83
|
+
|
|
73
84
|
interface RuntimeSnapshot {
|
|
74
|
-
settings
|
|
75
|
-
plan
|
|
85
|
+
settings: FileSnapshot;
|
|
86
|
+
plan: FileSnapshot;
|
|
87
|
+
mcp: FileSnapshot;
|
|
88
|
+
appendSystem: FileSnapshot;
|
|
89
|
+
trust: FileSnapshot;
|
|
76
90
|
}
|
|
77
91
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
/** Snapshots one managed runtime file. lstat (never stat) detects symlinks
|
|
93
|
+
* without following them; ENOENT is the only tolerated error — absence is
|
|
94
|
+
* expected (first launch), while anything else (permissions, unreadable
|
|
95
|
+
* dir) must not silently disable rollback protection. */
|
|
96
|
+
async function snapshotFile(filePath: string): Promise<FileSnapshot> {
|
|
97
|
+
const info = await lstat(filePath).catch((error: NodeJS.ErrnoException) => {
|
|
98
|
+
// ENOENT is the only tolerated error — absence is expected (first
|
|
99
|
+
// launch); anything else (permissions, unreadable dir) must not
|
|
100
|
+
// silently disable rollback protection.
|
|
101
|
+
if (error.code === "ENOENT") return null;
|
|
85
102
|
throw error;
|
|
103
|
+
});
|
|
104
|
+
if (info === null) return { kind: "absent" };
|
|
105
|
+
if (info.isSymbolicLink()) {
|
|
106
|
+
return { kind: "symlink", target: await readlink(filePath) };
|
|
86
107
|
}
|
|
108
|
+
return { kind: "file", content: await readFile(filePath, "utf8"), mode: info.mode & 0o777 };
|
|
87
109
|
}
|
|
88
110
|
|
|
89
111
|
async function snapshotRuntimeFiles(runtimeDir: string): Promise<RuntimeSnapshot> {
|
|
90
112
|
return {
|
|
91
|
-
settings: await
|
|
92
|
-
plan: await
|
|
113
|
+
settings: await snapshotFile(path.join(runtimeDir, "settings.json")),
|
|
114
|
+
plan: await snapshotFile(path.join(runtimeDir, "pi-profile.json")),
|
|
115
|
+
mcp: await snapshotFile(path.join(runtimeDir, "mcp.json")),
|
|
116
|
+
appendSystem: await snapshotFile(path.join(runtimeDir, "APPEND_SYSTEM.md")),
|
|
117
|
+
trust: await snapshotFile(path.join(runtimeDir, "trust.json")),
|
|
93
118
|
};
|
|
94
119
|
}
|
|
95
120
|
|
|
96
|
-
async function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
121
|
+
async function restoreFile(filePath: string, snapshot: FileSnapshot): Promise<void> {
|
|
122
|
+
// rm first, always: restoring a snapshotted FILE must never writeFile
|
|
123
|
+
// through a symlink the failed switch left on disk — that would write
|
|
124
|
+
// THROUGH to the link target (the user's real ~/.pi/agent/mcp.json)
|
|
125
|
+
// instead of replacing the link.
|
|
126
|
+
await rm(filePath, { force: true });
|
|
127
|
+
if (snapshot.kind === "symlink") {
|
|
128
|
+
await symlink(snapshot.target, filePath);
|
|
129
|
+
} else if (snapshot.kind === "file") {
|
|
130
|
+
await writeFile(filePath, snapshot.content);
|
|
131
|
+
await chmod(filePath, snapshot.mode);
|
|
102
132
|
}
|
|
103
133
|
}
|
|
104
134
|
|
|
135
|
+
async function restoreRuntimeFiles(runtimeDir: string, snapshot: RuntimeSnapshot): Promise<void> {
|
|
136
|
+
await restoreFile(path.join(runtimeDir, "settings.json"), snapshot.settings);
|
|
137
|
+
await restoreFile(path.join(runtimeDir, "pi-profile.json"), snapshot.plan);
|
|
138
|
+
await restoreFile(path.join(runtimeDir, "mcp.json"), snapshot.mcp);
|
|
139
|
+
await restoreFile(path.join(runtimeDir, "APPEND_SYSTEM.md"), snapshot.appendSystem);
|
|
140
|
+
await restoreFile(path.join(runtimeDir, "trust.json"), snapshot.trust);
|
|
141
|
+
}
|
|
142
|
+
|
|
105
143
|
/** Waits are delegated to Pi's native `ctx.waitForIdle()` (see SwitchDeps);
|
|
106
144
|
* no polling loop lives here. */
|
|
107
145
|
|
|
@@ -141,8 +179,8 @@ export async function switchProfile(
|
|
|
141
179
|
// Snapshot before resolving so the rollback target always exists.
|
|
142
180
|
const snapshot = await snapshotRuntimeFiles(deps.runtimeDir);
|
|
143
181
|
|
|
144
|
-
// Full launcher resolution: trust gate, catalogs, discovery,
|
|
145
|
-
//
|
|
182
|
+
// Full launcher resolution: trust gate, catalogs, discovery, model +
|
|
183
|
+
// MCP validation. Failures here leave the runtime
|
|
146
184
|
// untouched — nothing was written yet.
|
|
147
185
|
const resolved = await resolveInitialProfile(
|
|
148
186
|
target,
|
package/src/mcp-coordination.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* McpCoordination: the pi-profile ↔ pi-mcp-adapter contract (ADR-0002).
|
|
3
|
-
*
|
|
4
|
-
* The adapter's released versions (≤2.33) offer no in-memory server
|
|
5
|
-
* allowlist, so pi-profile defines the coordination channel the locked
|
|
6
|
-
* adapter implements:
|
|
7
|
-
*
|
|
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
|
-
* - Adapter presence is probed via the adapter's documented
|
|
13
|
-
* request/result event pattern: emit a snapshot request for a bogus
|
|
14
|
-
* server name; an installed adapter fills `request.result` synchronously
|
|
15
|
-
* (with `{ok: false}` — the name is bogus), an absent adapter leaves it
|
|
16
|
-
* undefined.
|
|
17
|
-
*
|
|
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.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/** pi-profile's allowlist channel (the locked adapter subscribes). */
|
|
24
|
-
export const MCP_ALLOWLIST_EVENT = "pi-profile:mcp-allowlist:v1";
|
|
25
|
-
export const MCP_ALLOWLIST_VERSION = 1 as const;
|
|
26
|
-
|
|
27
|
-
export interface McpAllowlistMessage {
|
|
28
|
-
version: typeof MCP_ALLOWLIST_VERSION;
|
|
29
|
-
profile: string;
|
|
30
|
-
servers: string[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** The adapter's snapshot channel, used here purely as a presence probe
|
|
34
|
-
* (see the module docblock). Kept as a literal so pi-profile doesn't
|
|
35
|
-
* import adapter internals. */
|
|
36
|
-
export const MCP_ADAPTER_SNAPSHOT_EVENT = "pi-mcp-adapter:runtime-snapshot:v1";
|
|
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
|
-
|
|
56
|
-
/** True when the adapter answered the probe (filled `result` on the
|
|
57
|
-
* request object), regardless of the answer — presence, not health. */
|
|
58
|
-
export function probeAdapterPresence(events: { emit(channel: string, data: unknown): void }): boolean {
|
|
59
|
-
const request: { version: 1; name: string; result?: unknown } = {
|
|
60
|
-
version: 1,
|
|
61
|
-
name: "pi-profile:presence-probe",
|
|
62
|
-
};
|
|
63
|
-
events.emit(MCP_ADAPTER_SNAPSHOT_EVENT, request);
|
|
64
|
-
return request.result !== undefined;
|
|
65
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* McpToggle: persistent, profile-scoped `/mcp enable|disable` (ticket 10).
|
|
3
|
-
*
|
|
4
|
-
* The profile's `mcps` array in its OWNING catalog is the profile-scoped
|
|
5
|
-
* state store (ticket 04 established that pi-mcp-adapter@2.33.0 has no
|
|
6
|
-
* allowlist/profile-state API — ADR-0002's assumed store does not exist;
|
|
7
|
-
* pi-profile owns the contract). Runtime effect flows through the standard
|
|
8
|
-
* rewrite-settings-and-reload path: the post-reload session_start
|
|
9
|
-
* republishes the allowlist over the coordination channel.
|
|
10
|
-
*
|
|
11
|
-
* Invariants:
|
|
12
|
-
* - Enable accepts only adapter-discovered names (fail fast on typos);
|
|
13
|
-
* disable also removes names the adapter no longer reports (stale
|
|
14
|
-
* cleanup).
|
|
15
|
-
* - The built-in default profile has no catalog entry — toggling it is a
|
|
16
|
-
* clear error, not a synthetic write.
|
|
17
|
-
* - Only the ACTIVE profile's array changes; other profiles and the
|
|
18
|
-
* adapter's own configuration (global mcp.json, project .pi/mcp.json)
|
|
19
|
-
* are never modified.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { discoverAdapterServerNames } from "../mcp-config.ts";
|
|
23
|
-
import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
|
|
24
|
-
import { readTrustInputs as readTrust } from "../launcher/initial-profile.ts";
|
|
25
|
-
import { catalogStore, readCatalogScope, type CatalogScope } from "./profile-crud.ts";
|
|
26
|
-
|
|
27
|
-
export async function setMcpServerEnabled(
|
|
28
|
-
input: { realAgentDir: string; cwd: string; profile: { name: string; source: string } },
|
|
29
|
-
server: string,
|
|
30
|
-
enabled: boolean,
|
|
31
|
-
): Promise<{ mcps: string[]; changed: boolean }> {
|
|
32
|
-
if (input.profile.name === DEFAULT_PROFILE_NAME || input.profile.source === "builtin") {
|
|
33
|
-
throw new CatalogError(
|
|
34
|
-
`the built-in default profile has no catalog entry — create a named profile (/profile create) to toggle MCP servers`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
const scope = input.profile.source as CatalogScope;
|
|
38
|
-
if (scope !== "global" && scope !== "project") {
|
|
39
|
-
throw new CatalogError(`profile "${input.profile.name}" has no writable owning catalog (source: ${input.profile.source})`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const { projectTrusted } = await readTrust({ agentDir: input.realAgentDir, cwd: input.cwd });
|
|
43
|
-
if (scope === "project" && !projectTrusted) {
|
|
44
|
-
throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
|
|
45
|
-
}
|
|
46
|
-
const discovered = await discoverAdapterServerNames(input.realAgentDir, projectTrusted ? input.cwd : undefined);
|
|
47
|
-
if (enabled && !discovered.includes(server)) {
|
|
48
|
-
throw new CatalogError(
|
|
49
|
-
`unknown MCP server "${server}" — adapter discovered: [${discovered.join(", ") || "(none)"}]`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const definitions = await readCatalogScope(input, scope);
|
|
54
|
-
const definition = definitions.get(input.profile.name);
|
|
55
|
-
if (definition === undefined) {
|
|
56
|
-
throw new CatalogError(`profile "${input.profile.name}" not found in the ${scope} catalog`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const current = definition.mcps ?? [];
|
|
60
|
-
if (enabled === current.includes(server)) {
|
|
61
|
-
return { mcps: current, changed: false }; // already in the requested state
|
|
62
|
-
}
|
|
63
|
-
const next = enabled ? [...current, server] : current.filter((name) => name !== server);
|
|
64
|
-
// Drop the key entirely when empty (exactOptionalPropertyTypes; a
|
|
65
|
-
// written `mcps: undefined` would also misrepresent the definition).
|
|
66
|
-
const rest = { ...definition };
|
|
67
|
-
delete rest.mcps;
|
|
68
|
-
const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcps: next } : rest;
|
|
69
|
-
await catalogStore(input, scope).upsert(input.profile.name, updated);
|
|
70
|
-
return { mcps: next, changed: true };
|
|
71
|
-
}
|