pi-profile-switch 0.3.0 → 0.4.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/README.md +32 -32
- package/README.zh-CN.md +32 -32
- 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 -17
- 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 +44 -63
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -35
- 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 +21 -25
- 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 -744
- package/src/adapter-presence.ts +0 -75
- 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 -262
- package/src/startup-selection.ts +0 -144
- package/src/switching/activate-profile.ts +0 -115
- package/src/switching/apply-profile.ts +0 -131
package/src/startup-mcp-scope.ts
DELETED
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* StartupMcpScope: derives the profile-scoped MCP overlay at times when a
|
|
3
|
-
* full activation has not (or may not yet) run.
|
|
4
|
-
*
|
|
5
|
-
* Two callers:
|
|
6
|
-
* - The extension-load pass: must be synchronous and must finish before
|
|
7
|
-
* pi-mcp-adapter reads its config at `session_start` (and, for eager
|
|
8
|
-
* servers, at its own load time). Pi applies CLI flag values only AFTER
|
|
9
|
-
* extension loading, so the `--profile` / `--mcp-config` values are read
|
|
10
|
-
* from argv here; trust is mirrored from Pi's own resolution order
|
|
11
|
-
* (`hasTrustRequiringProjectResources` → stored decision →
|
|
12
|
-
* `defaultProjectTrust`; an interactive first-time prompt is not yet
|
|
13
|
-
* answerable at load time and counts as untrusted).
|
|
14
|
-
* - The switch pass: the caller already has the resolved allowlist, so the
|
|
15
|
-
* overlay is derived from it directly.
|
|
16
|
-
*
|
|
17
|
-
* The generated file IS the adapter's Pi-global slot (`<agentDir>/mcp.json`):
|
|
18
|
-
* flag injection is impossible (Pi rejects two extensions registering the
|
|
19
|
-
* same flag), so the mechanism works with the slot the adapter already reads.
|
|
20
|
-
* The user's own Pi-global servers live in the sidecar `mcp.user.json`.
|
|
21
|
-
*
|
|
22
|
-
* Failure policy: a profile name that cannot be resolved (missing, malformed
|
|
23
|
-
* catalog, unknown name) falls back to "no filtering" — the generated overlay
|
|
24
|
-
* then changes nothing, and the real problem is reported loudly by the
|
|
25
|
-
* activation that runs right after. A foreign `--mcp-config` disables
|
|
26
|
-
* management entirely: the user's explicit file is never overwritten.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
|
|
30
|
-
import path from "node:path";
|
|
31
|
-
|
|
32
|
-
import { isRecord, readJsonFileSync } from "./json-file.ts";
|
|
33
|
-
import { readAdapterOtherServerNamesSync, readMcpDocumentSync } from "./mcp-config.ts";
|
|
34
|
-
import {
|
|
35
|
-
buildMcpOverlay,
|
|
36
|
-
isDisabledStub,
|
|
37
|
-
isGeneratedOverlay,
|
|
38
|
-
MCP_GENERATED_MARKER,
|
|
39
|
-
mcpSlotPath,
|
|
40
|
-
mcpSourcePath,
|
|
41
|
-
resolveAllowedServers,
|
|
42
|
-
serializeMcpOverlay,
|
|
43
|
-
} from "./mcp-overlay.ts";
|
|
44
|
-
import { writeMcpOverlayIfChangedSync } from "./mcp-overlay-file.ts";
|
|
45
|
-
import { DEFAULT_PROFILE_NAME, parseCatalogDocument, type ProfileDefinition } from "./profile-catalog.ts";
|
|
46
|
-
|
|
47
|
-
/** Reads `--<name> <value>` / `--<name>=<value>` without Pi's parser (Pi
|
|
48
|
-
* applies extension flag values only after extension loading). Last wins. */
|
|
49
|
-
export function readFlagFromArgv(argv: readonly string[], name: string): string | undefined {
|
|
50
|
-
const long = `--${name}`;
|
|
51
|
-
let value: string | undefined;
|
|
52
|
-
for (let index = 0; index < argv.length; index++) {
|
|
53
|
-
const token = argv[index] ?? "";
|
|
54
|
-
if (token === long) {
|
|
55
|
-
const next = argv[index + 1];
|
|
56
|
-
if (next !== undefined && !next.startsWith("--")) value = next;
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (token.startsWith(`${long}=`)) {
|
|
60
|
-
const inline = token.slice(long.length + 1);
|
|
61
|
-
if (inline.length > 0) value = inline;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return value;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** True when Pi itself would consider `cwd` trusted right now. Mirrors
|
|
68
|
-
* `resolveProjectTrusted` minus extension votes and the interactive prompt. */
|
|
69
|
-
export function resolveProjectTrustedSync(agentDir: string, cwd: string): boolean {
|
|
70
|
-
if (!hasTrustRequiringProjectResources(cwd)) return true;
|
|
71
|
-
const stored = new ProjectTrustStore(agentDir).get(cwd);
|
|
72
|
-
if (stored !== null) return stored;
|
|
73
|
-
return readDefaultProjectTrustSync(agentDir) === "always";
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The saved selection for this run: project state wins over global state
|
|
77
|
-
* (project state only when trusted), then the built-in default. */
|
|
78
|
-
export function resolveStartupProfileNameSync(input: {
|
|
79
|
-
agentDir: string;
|
|
80
|
-
cwd: string;
|
|
81
|
-
projectTrusted: boolean;
|
|
82
|
-
argv: readonly string[];
|
|
83
|
-
}): string {
|
|
84
|
-
const requested = readFlagFromArgv(input.argv, "profile");
|
|
85
|
-
if (requested !== undefined && requested.length > 0) return requested;
|
|
86
|
-
const project = input.projectTrusted
|
|
87
|
-
? readActiveProfile(path.join(input.cwd, ".pi", "pi-profile-state.json"))
|
|
88
|
-
: undefined;
|
|
89
|
-
return project ?? readActiveProfile(path.join(input.agentDir, "pi-profile-state.json")) ?? DEFAULT_PROFILE_NAME;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface McpOverlaySyncInput {
|
|
93
|
-
agentDir: string;
|
|
94
|
-
cwd: string;
|
|
95
|
-
/** Pi's trust decision. Omitted on the extension-load pass, where Pi has
|
|
96
|
-
* not resolved trust yet; the module mirrors Pi's own order then. */
|
|
97
|
-
projectTrusted?: boolean;
|
|
98
|
-
/** Effective `--mcp-config`; undefined means the managed overlay path. */
|
|
99
|
-
overridePath?: string;
|
|
100
|
-
/** The command line, for the load pass (Pi applies flag values only after
|
|
101
|
-
* extension loading, so `--profile` is read from here). */
|
|
102
|
-
argv?: readonly string[];
|
|
103
|
-
homeDir?: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface McpOverlaySyncResult {
|
|
107
|
-
overlayPath: string;
|
|
108
|
-
/** False when a foreign `--mcp-config` owns the adapter's slot. */
|
|
109
|
-
managed: boolean;
|
|
110
|
-
changed: boolean;
|
|
111
|
-
/** Set when nothing was written; the caller surfaces it as a warning. */
|
|
112
|
-
error?: string;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Load-time (and `session_start` re-check) pass. `profileName` short-circuits
|
|
116
|
-
* the state lookup when the caller already activated a profile. */
|
|
117
|
-
export function syncStartupMcpOverlay(
|
|
118
|
-
input: McpOverlaySyncInput & { profileName?: string; mcpRefs?: readonly string[] | undefined },
|
|
119
|
-
): McpOverlaySyncResult {
|
|
120
|
-
try {
|
|
121
|
-
const trust = input.projectTrusted;
|
|
122
|
-
const profileName =
|
|
123
|
-
input.profileName ??
|
|
124
|
-
resolveStartupProfileNameSync({
|
|
125
|
-
agentDir: input.agentDir,
|
|
126
|
-
cwd: input.cwd,
|
|
127
|
-
projectTrusted: trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd),
|
|
128
|
-
argv: input.argv ?? [],
|
|
129
|
-
});
|
|
130
|
-
const refs = input.mcpRefs ?? readProfileMcpRefsSync({ ...input, name: profileName, trust });
|
|
131
|
-
return writeOverlayForRefs({ ...input, refs });
|
|
132
|
-
} catch (error) {
|
|
133
|
-
return {
|
|
134
|
-
overlayPath: mcpSlotPath(input.agentDir),
|
|
135
|
-
managed: true,
|
|
136
|
-
changed: false,
|
|
137
|
-
error: error instanceof Error ? error.message : String(error),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Switch pass: the caller already resolved the allowlist. */
|
|
143
|
-
export function syncMcpOverlayForSelection(
|
|
144
|
-
input: McpOverlaySyncInput & { allowed: readonly string[] | "all" },
|
|
145
|
-
): McpOverlaySyncResult {
|
|
146
|
-
try {
|
|
147
|
-
return writeOverlay({ ...input, refs: input.allowed });
|
|
148
|
-
} catch (error) {
|
|
149
|
-
return {
|
|
150
|
-
overlayPath: mcpSlotPath(input.agentDir),
|
|
151
|
-
managed: true,
|
|
152
|
-
changed: false,
|
|
153
|
-
error: error instanceof Error ? error.message : String(error),
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function writeOverlayForRefs(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "unknown" }): McpOverlaySyncResult {
|
|
159
|
-
// An unresolvable profile is not an error here: the generated overlay
|
|
160
|
-
// then filters nothing and the activation reports the real problem.
|
|
161
|
-
const refs = input.refs === "unknown" ? undefined : input.refs;
|
|
162
|
-
return writeOverlay({ ...input, refs });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function writeOverlay(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "all" }): McpOverlaySyncResult {
|
|
166
|
-
const overlayPath = mcpSlotPath(input.agentDir);
|
|
167
|
-
if (input.overridePath !== undefined && path.resolve(input.overridePath) !== overlayPath) {
|
|
168
|
-
return { overlayPath, managed: false, changed: false };
|
|
169
|
-
}
|
|
170
|
-
const projectTrusted = input.projectTrusted ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
|
|
171
|
-
const discovery = {
|
|
172
|
-
agentDir: input.agentDir,
|
|
173
|
-
cwd: input.cwd,
|
|
174
|
-
projectTrusted,
|
|
175
|
-
...(input.homeDir === undefined ? {} : { homeDir: input.homeDir }),
|
|
176
|
-
};
|
|
177
|
-
// The slot is ours; a slot written by hand is adopted into the sidecar
|
|
178
|
-
// before the first overwrite, so a user's Pi-global servers survive.
|
|
179
|
-
const sourcePath = mcpSourcePath(input.agentDir);
|
|
180
|
-
adoptHandWrittenSlotSync(overlayPath, sourcePath);
|
|
181
|
-
const sourceDocument = readMcpDocumentSync(sourcePath);
|
|
182
|
-
const sourceNames = isRecord(sourceDocument?.mcpServers) ? Object.keys(sourceDocument.mcpServers) : [];
|
|
183
|
-
const otherNames = readAdapterOtherServerNamesSync(discovery);
|
|
184
|
-
const allowed =
|
|
185
|
-
input.refs === "all"
|
|
186
|
-
? "all"
|
|
187
|
-
: resolveAllowedServers(input.refs, [...new Set([...sourceNames, ...otherNames])].sort());
|
|
188
|
-
const content = serializeMcpOverlay(
|
|
189
|
-
buildMcpOverlay({
|
|
190
|
-
...(sourceDocument === undefined ? {} : { slotDocument: sourceDocument }),
|
|
191
|
-
otherServerNames: otherNames,
|
|
192
|
-
allowed,
|
|
193
|
-
}),
|
|
194
|
-
);
|
|
195
|
-
return { overlayPath, managed: true, changed: writeMcpOverlayIfChangedSync(overlayPath, content) };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** A slot file without the generated marker holds the user's own Pi-global
|
|
199
|
-
* servers (from before this package managed the slot, or from a manual
|
|
200
|
-
* edit). Move them — plus the slot's non-server keys — into the sidecar
|
|
201
|
-
* before the slot is overwritten. Stub entries are skipped: they describe
|
|
202
|
-
* servers owned by the adapter's other sources. */
|
|
203
|
-
function adoptHandWrittenSlotSync(slotPath: string, sourcePath: string): void {
|
|
204
|
-
const slot = readMcpDocumentSync(slotPath);
|
|
205
|
-
if (slot === undefined || isGeneratedOverlay(slot)) return;
|
|
206
|
-
const sidecar = readMcpDocumentSync(sourcePath) ?? {};
|
|
207
|
-
const sidecarServers = isRecord(sidecar.mcpServers) ? sidecar.mcpServers : {};
|
|
208
|
-
const servers: Record<string, unknown> = { ...sidecarServers };
|
|
209
|
-
const slotServers = isRecord(slot.mcpServers) ? slot.mcpServers : {};
|
|
210
|
-
for (const [name, definition] of Object.entries(slotServers)) {
|
|
211
|
-
if (isDisabledStub(definition)) continue;
|
|
212
|
-
servers[name] = definition;
|
|
213
|
-
}
|
|
214
|
-
const document: Record<string, unknown> = { ...sidecar };
|
|
215
|
-
delete document[MCP_GENERATED_MARKER];
|
|
216
|
-
for (const [key, value] of Object.entries(slot)) {
|
|
217
|
-
if (key === "mcpServers" || key === MCP_GENERATED_MARKER) continue;
|
|
218
|
-
document[key] = value;
|
|
219
|
-
}
|
|
220
|
-
document.mcpServers = Object.fromEntries(Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)));
|
|
221
|
-
writeMcpOverlayIfChangedSync(sourcePath, `${JSON.stringify(document, null, 2)}\n`);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function readActiveProfile(statePath: string): string | undefined {
|
|
225
|
-
const result = readJsonFileSync(statePath);
|
|
226
|
-
if (!result.ok || !isRecord(result.value)) return undefined;
|
|
227
|
-
const name = result.value.activeProfile;
|
|
228
|
-
return typeof name === "string" && name.length > 0 ? name : undefined;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** The profile's raw `mcp` references; `"unknown"` when the profile or its
|
|
232
|
-
* catalog cannot be read (caller falls back to "no filtering"). */
|
|
233
|
-
function readProfileMcpRefsSync(
|
|
234
|
-
input: McpOverlaySyncInput & { name: string; trust: boolean | undefined },
|
|
235
|
-
): readonly string[] | undefined | "unknown" {
|
|
236
|
-
if (input.name === DEFAULT_PROFILE_NAME) return undefined;
|
|
237
|
-
const trusted = input.trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
|
|
238
|
-
const global = readCatalogSync(path.join(input.agentDir, "profiles.json"));
|
|
239
|
-
const project = trusted
|
|
240
|
-
? readCatalogSync(path.join(input.cwd, ".pi", "profiles.json"))
|
|
241
|
-
: new Map<string, ProfileDefinition>();
|
|
242
|
-
if (global === "error" || project === "error") return "unknown";
|
|
243
|
-
const definition = project.get(input.name) ?? global.get(input.name);
|
|
244
|
-
return definition === undefined ? "unknown" : definition.mcp;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function readCatalogSync(filePath: string): Map<string, ProfileDefinition> | "error" {
|
|
248
|
-
const result = readJsonFileSync(filePath);
|
|
249
|
-
if (!result.ok) return result.reason === "missing" ? new Map<string, ProfileDefinition>() : "error";
|
|
250
|
-
try {
|
|
251
|
-
return parseCatalogDocument(result.value, filePath);
|
|
252
|
-
} catch {
|
|
253
|
-
return "error";
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function readDefaultProjectTrustSync(agentDir: string): string | undefined {
|
|
258
|
-
const result = readJsonFileSync(path.join(agentDir, "settings.json"));
|
|
259
|
-
if (!result.ok || !isRecord(result.value)) return undefined;
|
|
260
|
-
const value = result.value.defaultProjectTrust;
|
|
261
|
-
return typeof value === "string" ? value : undefined;
|
|
262
|
-
}
|
package/src/startup-selection.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* StartupSelection: how a session decides which profile to activate.
|
|
3
|
-
*
|
|
4
|
-
* - `--profile <name>` (a flag this extension registers) is an explicit,
|
|
5
|
-
* one-run selection: it is never written back to runtime state.
|
|
6
|
-
* - Without the flag the saved selection applies: the trusted project's
|
|
7
|
-
* state wins over the global state, then the built-in `default`.
|
|
8
|
-
* - A saved selection that no longer resolves falls back to `default` with
|
|
9
|
-
* a warning — restore is a convenience, not a commitment. An explicit
|
|
10
|
-
* flag value never falls back: an unknown name is a loud error.
|
|
11
|
-
*
|
|
12
|
-
* Explicit CLI declarations (`--model`, `--thinking`, `--tools`,
|
|
13
|
-
* `--exclude-tools`) outrank the profile's matching declaration. They are
|
|
14
|
-
* detected by re-parsing this process's argv with Pi's own exported parser,
|
|
15
|
-
* so the aliases and value forms stay Pi's rather than a hand-rolled copy.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { parseArgs } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import { stat } from "node:fs/promises";
|
|
20
|
-
import path from "node:path";
|
|
21
|
-
|
|
22
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
|
|
24
|
-
import { DEFAULT_PROFILE_NAME, ProfileCatalog } from "./profile-catalog.ts";
|
|
25
|
-
import { RuntimeStateStore } from "./runtime-state-store.ts";
|
|
26
|
-
|
|
27
|
-
/** Which settings the user stated on the command line this run. */
|
|
28
|
-
export interface ExplicitDeclarations {
|
|
29
|
-
model: boolean;
|
|
30
|
-
thinking: boolean;
|
|
31
|
-
tools: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export const PROFILE_FLAG = "profile";
|
|
35
|
-
|
|
36
|
-
/** Registers the `--profile <name>` CLI flag. Pi has no flag of that name;
|
|
37
|
-
* unknown flags are collected by its parser and validated against flags
|
|
38
|
-
* registered by loaded extensions. */
|
|
39
|
-
export function registerProfileFlag(pi: ExtensionAPI): void {
|
|
40
|
-
pi.registerFlag(PROFILE_FLAG, {
|
|
41
|
-
type: "string",
|
|
42
|
-
description: "pi-profile-switch: activate a profile for this run (not saved)",
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The `--profile` value, when the user passed one. */
|
|
47
|
-
export function readProfileFlag(pi: ExtensionAPI): string | undefined {
|
|
48
|
-
const value = pi.getFlag(PROFILE_FLAG);
|
|
49
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Detects explicit CLI declarations by re-parsing argv with Pi's parser. */
|
|
53
|
-
export function detectExplicitDeclarations(argv: string[]): ExplicitDeclarations {
|
|
54
|
-
const parsed = parseArgs(argv);
|
|
55
|
-
return {
|
|
56
|
-
model: parsed.model !== undefined,
|
|
57
|
-
thinking: parsed.thinking !== undefined,
|
|
58
|
-
tools: parsed.tools !== undefined || parsed.excludeTools !== undefined,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface StartupProfile {
|
|
63
|
-
name: string;
|
|
64
|
-
/** Non-fatal notices (e.g. a saved profile that no longer exists). */
|
|
65
|
-
warnings: string[];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function exists(filePath: string): Promise<boolean> {
|
|
69
|
-
try {
|
|
70
|
-
await stat(filePath);
|
|
71
|
-
return true;
|
|
72
|
-
} catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Files the retired extension registry lived in; they are no longer read
|
|
78
|
-
* (ADR-0007), and a leftover file is called out once per startup. */
|
|
79
|
-
async function legacyRegistryWarnings(input: {
|
|
80
|
-
agentDir: string;
|
|
81
|
-
cwd: string;
|
|
82
|
-
projectTrusted: boolean;
|
|
83
|
-
}): Promise<string[]> {
|
|
84
|
-
const candidates = [path.join(input.agentDir, "resources.json")];
|
|
85
|
-
if (input.projectTrusted) candidates.push(path.join(input.cwd, ".pi", "resources.json"));
|
|
86
|
-
const warnings: string[] = [];
|
|
87
|
-
for (const filePath of candidates) {
|
|
88
|
-
if (await exists(filePath)) {
|
|
89
|
-
warnings.push(
|
|
90
|
-
`${filePath} is no longer read (ADR-0007): extensions load natively in every profile — manage them with pi install`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return warnings;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Resolves the profile name for this session: explicit flag → trusted
|
|
98
|
-
* project state → global state → default. Never writes state. */
|
|
99
|
-
export async function resolveStartupProfile(input: {
|
|
100
|
-
agentDir: string;
|
|
101
|
-
cwd: string;
|
|
102
|
-
projectTrusted: boolean;
|
|
103
|
-
requested?: string;
|
|
104
|
-
}): Promise<StartupProfile> {
|
|
105
|
-
const catalog = await ProfileCatalog.load(input.agentDir, {
|
|
106
|
-
projectDir: input.projectTrusted ? input.cwd : undefined,
|
|
107
|
-
});
|
|
108
|
-
const warnings: string[] = await legacyRegistryWarnings(input);
|
|
109
|
-
|
|
110
|
-
if (input.requested !== undefined) {
|
|
111
|
-
if (catalog.resolve(input.requested) === undefined) {
|
|
112
|
-
throw new UnknownProfileError(input.requested, catalog.list().map((profile) => profile.name));
|
|
113
|
-
}
|
|
114
|
-
return { name: input.requested, warnings };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
let saved: string | undefined;
|
|
118
|
-
if (input.projectTrusted) {
|
|
119
|
-
saved = (await new RuntimeStateStore(path.join(input.cwd, ".pi")).read()).activeProfile;
|
|
120
|
-
}
|
|
121
|
-
saved ??= (await new RuntimeStateStore(input.agentDir).read()).activeProfile;
|
|
122
|
-
|
|
123
|
-
if (saved === undefined || saved === DEFAULT_PROFILE_NAME) {
|
|
124
|
-
return { name: DEFAULT_PROFILE_NAME, warnings };
|
|
125
|
-
}
|
|
126
|
-
if (catalog.resolve(saved) === undefined) {
|
|
127
|
-
warnings.push(
|
|
128
|
-
`saved profile ${JSON.stringify(saved)} no longer exists — starting with "${DEFAULT_PROFILE_NAME}"; ` +
|
|
129
|
-
`available: [${catalog.list().map((profile) => profile.name).join(", ")}]`,
|
|
130
|
-
);
|
|
131
|
-
return { name: DEFAULT_PROFILE_NAME, warnings };
|
|
132
|
-
}
|
|
133
|
-
return { name: saved, warnings };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export class UnknownProfileError extends Error {
|
|
137
|
-
readonly requested: string;
|
|
138
|
-
|
|
139
|
-
constructor(requested: string, available: string[]) {
|
|
140
|
-
super(`unknown profile ${JSON.stringify(requested)} — available: [${available.join(", ")}]`);
|
|
141
|
-
this.name = "UnknownProfileError";
|
|
142
|
-
this.requested = requested;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SwitchProfile: the in-session activation orchestrator (ADR-0007).
|
|
3
|
-
*
|
|
4
|
-
* There is no reload and no generated-settings rewrite. Activating a profile
|
|
5
|
-
* means: resolve it against Pi's live resources, apply the runtime parts
|
|
6
|
-
* (model, tools, MCP allowlist), and remember the choice. Skills and
|
|
7
|
-
* instructions need no action here — they are applied by the next
|
|
8
|
-
* `before_agent_start` from the returned selection.
|
|
9
|
-
*
|
|
10
|
-
* Flow:
|
|
11
|
-
* 1. load the catalog (project entries only when Pi reports the project
|
|
12
|
-
* trusted) and resolve the name — unknown names fail with candidates
|
|
13
|
-
* 2. resolve the selection against the live resources; a declared MCP
|
|
14
|
-
* intent that cannot be satisfied fails here
|
|
15
|
-
* 3. validate the model preset (exists, has auth) — still nothing applied
|
|
16
|
-
* 4. persist the choice to the target profile's scope state file
|
|
17
|
-
* 5. apply model/thinking/tools/MCP; a failure reports loudly
|
|
18
|
-
*
|
|
19
|
-
* Startup activation passes `persist: false` (the flag selection must not
|
|
20
|
-
* overwrite the saved one) and `overlay: null` (a stored overlay never
|
|
21
|
-
* outlives its runtime).
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { ProfileCatalog } from "../profile-catalog.ts";
|
|
25
|
-
import { decidePreset, type SessionChoices } from "../model-selection.ts";
|
|
26
|
-
import { resolveSelection, type LiveResources, type ResolvedSelection } from "../profile-resolver.ts";
|
|
27
|
-
import type { ExplicitDeclarations } from "../startup-selection.ts";
|
|
28
|
-
import { RuntimeStateStore, stateDirFor, type RuntimeOverlay } from "../runtime-state-store.ts";
|
|
29
|
-
import { applySelection, validateSelection, type ApplySurface } from "./apply-profile.ts";
|
|
30
|
-
|
|
31
|
-
export class ActivationError extends Error {
|
|
32
|
-
constructor(message: string) {
|
|
33
|
-
super(message);
|
|
34
|
-
this.name = "ActivationError";
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface ActivationDeps {
|
|
39
|
-
/** The user's real agent dir (e.g. ~/.pi/agent). */
|
|
40
|
-
agentDir: string;
|
|
41
|
-
/** The project working directory. */
|
|
42
|
-
cwd: string;
|
|
43
|
-
/** Pi's trust decision for `cwd` (`ctx.isProjectTrusted()`). */
|
|
44
|
-
projectTrusted: boolean;
|
|
45
|
-
/** The live resource view loaded by the caller. */
|
|
46
|
-
live: LiveResources;
|
|
47
|
-
/** The narrow Pi surface the selection is applied to. */
|
|
48
|
-
surface: ApplySurface;
|
|
49
|
-
/** Inputs for the model-preset precedence decision (decided after the
|
|
50
|
-
* profile resolves, since it depends on the declared model). */
|
|
51
|
-
presetInputs: { explicit: ExplicitDeclarations; session: SessionChoices; force: boolean };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface ActivationResult {
|
|
55
|
-
selection: ResolvedSelection;
|
|
56
|
-
/** The overlay this activation applied; absent when the runtime runs the
|
|
57
|
-
* profile exactly as it is declared. Callers surface it (the footer badge)
|
|
58
|
-
* instead of re-reading the state file. */
|
|
59
|
-
overlay?: RuntimeOverlay;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Resolves a profile against the live resources without applying it.
|
|
63
|
-
* Throws ActivationError for unknown names and SelectionError for declared
|
|
64
|
-
* MCP intents that cannot be satisfied. A CLI-declared
|
|
65
|
-
* `--tools`/`--exclude-tools` suppresses the profile's tool selection
|
|
66
|
-
* unless the user made an explicit `/profile use` choice. */
|
|
67
|
-
export async function resolveProfileSelection(
|
|
68
|
-
name: string,
|
|
69
|
-
deps: Pick<ActivationDeps, "agentDir" | "cwd" | "projectTrusted" | "live" | "presetInputs">,
|
|
70
|
-
overlay?: RuntimeOverlay,
|
|
71
|
-
): Promise<ResolvedSelection> {
|
|
72
|
-
const catalog = await ProfileCatalog.load(deps.agentDir, {
|
|
73
|
-
projectDir: deps.projectTrusted ? deps.cwd : undefined,
|
|
74
|
-
});
|
|
75
|
-
const profile = catalog.resolve(name);
|
|
76
|
-
if (profile === undefined) {
|
|
77
|
-
throw new ActivationError(
|
|
78
|
-
`unknown profile ${JSON.stringify(name)} — available: [${catalog.list().map((entry) => entry.name).join(", ")}]`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
const suppressTools = !deps.presetInputs.force && deps.presetInputs.explicit.tools;
|
|
82
|
-
return resolveSelection({ profile, overlay, live: deps.live, suppressTools });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Activates a profile: resolve, validate, optionally persist, apply. */
|
|
86
|
-
export async function activateProfile(
|
|
87
|
-
name: string,
|
|
88
|
-
deps: ActivationDeps,
|
|
89
|
-
options?: { overlay?: RuntimeOverlay | null; persist?: boolean },
|
|
90
|
-
): Promise<ActivationResult> {
|
|
91
|
-
const overlay = options?.overlay ?? undefined;
|
|
92
|
-
const selection = await resolveProfileSelection(name, deps, overlay);
|
|
93
|
-
const preset = decidePreset({ model: selection.model, ...deps.presetInputs });
|
|
94
|
-
|
|
95
|
-
// Validate before touching anything: the model preset is the only
|
|
96
|
-
// fallible runtime step.
|
|
97
|
-
const validationError = validateSelection(selection, deps.surface, preset);
|
|
98
|
-
if (validationError !== undefined) {
|
|
99
|
-
throw new ActivationError(validationError);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (options?.persist !== false) {
|
|
103
|
-
await new RuntimeStateStore(stateDirFor(selection.source, deps)).update({
|
|
104
|
-
activeProfile: selection.name,
|
|
105
|
-
overlay: overlay ?? undefined,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const result = await applySelection({ selection, surface: deps.surface, preset });
|
|
110
|
-
if (!result.applied) {
|
|
111
|
-
throw new ActivationError(result.error ?? `profile "${name}" could not be applied`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return { selection, ...(overlay === undefined ? {} : { overlay }) };
|
|
115
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ApplyProfile: applies a resolved selection to the running Pi.
|
|
3
|
-
*
|
|
4
|
-
* Replaces the launch-plan application of the host architecture: there is no
|
|
5
|
-
* plan file and no reload. Application is ordered so that the only
|
|
6
|
-
* fallible step — the model preset — runs first; a failure leaves the
|
|
7
|
-
* runtime untouched and the caller keeps the previous state.
|
|
8
|
-
*
|
|
9
|
-
* Steps:
|
|
10
|
-
* 1. validate: the declared model exists and has configured auth.
|
|
11
|
-
* 2. model: `setModel` + `setThinkingLevel` (when the preset applies).
|
|
12
|
-
* 3. tools: `setActiveTools` with the resolved active set. Literals the
|
|
13
|
-
* live registry does not provide yet stay in `pendingTools` and are
|
|
14
|
-
* retried by the caller each turn (MCP and extension tools register
|
|
15
|
-
* after session start).
|
|
16
|
-
* 4. mcp: publish the runtime server allowlist to pi-mcp-adapter.
|
|
17
|
-
*
|
|
18
|
-
* Dependency-injected against a narrow surface so unit tests never need a
|
|
19
|
-
* real Pi.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
MCP_ALLOWLIST_EVENT,
|
|
24
|
-
MCP_ALLOWLIST_VERSION,
|
|
25
|
-
} from "../mcp-coordination.ts";
|
|
26
|
-
import type { PresetDecisions } from "../model-selection.ts";
|
|
27
|
-
import type { ResolvedSelection } from "../profile-resolver.ts";
|
|
28
|
-
|
|
29
|
-
/** The narrow slice of ExtensionAPI/Context the application needs. */
|
|
30
|
-
export interface ApplySurface {
|
|
31
|
-
getAllTools(): Array<{ name: string }>;
|
|
32
|
-
setActiveTools(names: string[]): void;
|
|
33
|
-
modelRegistry: {
|
|
34
|
-
find(provider: string, id: string): unknown | undefined;
|
|
35
|
-
hasConfiguredAuth(model: unknown): boolean;
|
|
36
|
-
};
|
|
37
|
-
setModel(model: unknown): Promise<boolean>;
|
|
38
|
-
setThinkingLevel(level: string): void;
|
|
39
|
-
events: { emit(channel: string, data: unknown): void };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface ApplyResult {
|
|
43
|
-
applied: boolean;
|
|
44
|
-
/** Present when nothing was applied. */
|
|
45
|
-
error?: string;
|
|
46
|
-
warnings: string[];
|
|
47
|
-
/** Tool literals still missing from the live registry. */
|
|
48
|
-
pendingTools: string[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Checks everything that can be checked before mutating the runtime. */
|
|
52
|
-
export function validateSelection(
|
|
53
|
-
selection: ResolvedSelection,
|
|
54
|
-
surface: ApplySurface,
|
|
55
|
-
preset: PresetDecisions,
|
|
56
|
-
): string | undefined {
|
|
57
|
-
if (selection.model === undefined || !preset.model) return undefined;
|
|
58
|
-
const model = surface.modelRegistry.find(selection.model.provider, selection.model.id);
|
|
59
|
-
if (model === undefined) {
|
|
60
|
-
return `profile "${selection.name}": declared model ${selection.model.provider}/${selection.model.id} not found`;
|
|
61
|
-
}
|
|
62
|
-
if (!surface.modelRegistry.hasConfiguredAuth(model)) {
|
|
63
|
-
return `profile "${selection.name}": model ${selection.model.provider}/${selection.model.id} has no configured auth`;
|
|
64
|
-
}
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Applies the selection. Validation runs first; on failure nothing is
|
|
69
|
-
* applied and the runtime keeps its previous state. */
|
|
70
|
-
export async function applySelection(input: {
|
|
71
|
-
selection: ResolvedSelection;
|
|
72
|
-
surface: ApplySurface;
|
|
73
|
-
preset: PresetDecisions;
|
|
74
|
-
}): Promise<ApplyResult> {
|
|
75
|
-
const { selection, surface, preset } = input;
|
|
76
|
-
const error = validateSelection(selection, surface, preset);
|
|
77
|
-
if (error !== undefined) {
|
|
78
|
-
return { applied: false, error, warnings: [], pendingTools: [] };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (selection.model !== undefined && preset.model) {
|
|
82
|
-
const model = surface.modelRegistry.find(selection.model.provider, selection.model.id);
|
|
83
|
-
const applied = await surface.setModel(model);
|
|
84
|
-
if (!applied) {
|
|
85
|
-
return {
|
|
86
|
-
applied: false,
|
|
87
|
-
error: `profile "${selection.name}": model ${selection.model.provider}/${selection.model.id} could not be activated`,
|
|
88
|
-
warnings: [],
|
|
89
|
-
pendingTools: [],
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
if (selection.model.thinkingLevel !== undefined && preset.thinking) {
|
|
93
|
-
surface.setThinkingLevel(selection.model.thinkingLevel);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (selection.tools !== undefined) {
|
|
98
|
-
surface.setActiveTools(selection.tools);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (selection.mcp !== undefined) {
|
|
102
|
-
surface.events.emit(MCP_ALLOWLIST_EVENT, {
|
|
103
|
-
version: MCP_ALLOWLIST_VERSION,
|
|
104
|
-
profile: selection.name,
|
|
105
|
-
servers: selection.mcp,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return { applied: true, warnings: [], pendingTools: selection.pendingTools };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Retries pending tool literals against the live registry. Once every
|
|
113
|
-
* literal resolves the caller stops retrying, so a user's later native tool
|
|
114
|
-
* toggle is never clobbered. */
|
|
115
|
-
export function retryPendingTools(input: {
|
|
116
|
-
selection: ResolvedSelection;
|
|
117
|
-
surface: ApplySurface;
|
|
118
|
-
}): { pendingTools: string[]; active?: string[]; applied: boolean } {
|
|
119
|
-
const { selection, surface } = input;
|
|
120
|
-
if (selection.pendingTools.length === 0) {
|
|
121
|
-
return { pendingTools: [], applied: false };
|
|
122
|
-
}
|
|
123
|
-
const live = new Set(surface.getAllTools().map((tool) => tool.name));
|
|
124
|
-
const resolved = selection.pendingTools.filter((name) => live.has(name));
|
|
125
|
-
if (resolved.length === 0) {
|
|
126
|
-
return { pendingTools: selection.pendingTools, applied: false };
|
|
127
|
-
}
|
|
128
|
-
const active = [...new Set([...(selection.tools ?? []), ...resolved])];
|
|
129
|
-
surface.setActiveTools(active);
|
|
130
|
-
return { pendingTools: selection.pendingTools.filter((name) => !live.has(name)), active, applied: true };
|
|
131
|
-
}
|