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.
Files changed (54) hide show
  1. package/README.md +31 -36
  2. package/README.zh-CN.md +31 -36
  3. package/bin/pi-profile.js +11 -0
  4. package/bin/pi-profile.ts +65 -0
  5. package/bin/postinstall.d.ts +13 -0
  6. package/bin/postinstall.js +88 -0
  7. package/defaults/profiles.json +18 -0
  8. package/examples/profiles.json +31 -5
  9. package/extensions/pi-profile/index.ts +451 -0
  10. package/package.json +10 -13
  11. package/schemas/profiles.schema.json +22 -24
  12. package/src/extension-discovery.ts +347 -0
  13. package/src/json-file.ts +1 -21
  14. package/src/launcher/args.ts +57 -0
  15. package/src/launcher/discovery.ts +64 -0
  16. package/src/launcher/initial-profile.ts +179 -0
  17. package/src/launcher/model-check.ts +52 -0
  18. package/src/launcher/runtime-cleanup.ts +85 -0
  19. package/src/launcher/spawn.ts +82 -0
  20. package/src/mcp-config.ts +37 -153
  21. package/src/mcp-coordination.ts +29 -10
  22. package/src/profile-catalog-store.ts +31 -12
  23. package/src/profile-catalog.ts +45 -93
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -41
  27. package/src/settings-generator.ts +541 -0
  28. package/src/skill-registry.ts +94 -0
  29. package/src/switching/apply-plan.ts +197 -0
  30. package/src/switching/customize.ts +62 -33
  31. package/src/switching/list-profiles.ts +9 -6
  32. package/src/switching/mcp-toggle.ts +14 -26
  33. package/src/switching/profile-crud.ts +31 -24
  34. package/src/switching/profile-wizard.ts +21 -49
  35. package/src/switching/status.ts +142 -72
  36. package/src/switching/switch-profile.ts +219 -0
  37. package/src/switching/tool-references.ts +40 -0
  38. package/src/workspace.ts +57 -0
  39. package/LICENSE +0 -21
  40. package/examples/profiles.example.json +0 -74
  41. package/extensions/pi-profile-switch/index.ts +0 -778
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/default-profiles.ts +0 -59
  44. package/src/mcp-overlay-file.ts +0 -35
  45. package/src/mcp-overlay.ts +0 -122
  46. package/src/model-selection.ts +0 -64
  47. package/src/name-matching.ts +0 -50
  48. package/src/profile-badge.ts +0 -142
  49. package/src/profile-presets.ts +0 -61
  50. package/src/skill-selection.ts +0 -81
  51. package/src/startup-mcp-scope.ts +0 -271
  52. package/src/startup-selection.ts +0 -201
  53. package/src/switching/activate-profile.ts +0 -144
  54. package/src/switching/apply-profile.ts +0 -131
@@ -1,81 +0,0 @@
1
- /**
2
- * SkillSelection: the skills visibility filter (ADR-0007).
3
- *
4
- * A profile's `skills` references do not load or unload anything. Pi loads
5
- * every installed skill; `/skill:name` stays available to the user for all
6
- * of them. What the profile controls is what the MODEL sees: the
7
- * `<available_skills>` section of the system prompt.
8
- *
9
- * The filter recomputes that section with Pi's own exported formatter over
10
- * the profile's selection and replaces it inside the chained system prompt.
11
- * Because the original section was produced by the same formatter from the
12
- * same skill array (`BuildSystemPromptOptions.skills`), the recomputation is
13
- * byte-identical; when it is not found the turn proceeds unfiltered and the
14
- * caller reports a warning.
15
- */
16
-
17
- import { formatSkillsForPrompt, type BuildSystemPromptOptions, type Skill } from "@earendil-works/pi-coding-agent";
18
-
19
- import { matchesReference } from "./name-matching.ts";
20
- import type { SkillsFilter } from "./profile-resolver.ts";
21
-
22
- export type SkillsFilterOutcome =
23
- /** The prompt's skills section was replaced with the filtered one. */
24
- | "filtered"
25
- /** No filter applies (default profile without an overlay). */
26
- | "no-filter"
27
- /** Neither `read` nor `bash` is active, so Pi emitted no skills section. */
28
- | "no-read-tool"
29
- /** The expected section is absent from the prompt; left unfiltered. */
30
- | "section-missing";
31
-
32
- /** The skills the model may see. `undefined` means "no filtering". */
33
- export function visibleSkills(skills: Skill[], filter: SkillsFilter | undefined): Skill[] | undefined {
34
- if (filter === undefined) return undefined;
35
- const visible = new Set(visibleSkillNames(skills, filter) ?? []);
36
- return skills.filter((skill) => visible.has(skill.name));
37
- }
38
-
39
- /** The visible skill names, for status reporting and prompt filtering.
40
- * `undefined` means "no filtering". */
41
- export function visibleSkillNames(
42
- all: Array<{ name: string }>,
43
- filter: SkillsFilter | undefined,
44
- ): string[] | undefined {
45
- if (filter === undefined) return undefined;
46
- const refs = filter.refs;
47
- const base = refs === "all" ? all : all.filter((skill) => refs.some((ref) => matchesReference(ref, skill.name)));
48
- return base
49
- .filter((skill) => !filter.disabled.some((ref) => matchesReference(ref, skill.name)))
50
- .map((skill) => skill.name);
51
- }
52
-
53
- /** Replaces the system prompt's skills section with the filtered one. */
54
- export function applySkillsFilter(input: {
55
- systemPrompt: string;
56
- options: BuildSystemPromptOptions;
57
- filter: SkillsFilter | undefined;
58
- }): { systemPrompt: string; outcome: SkillsFilterOutcome } {
59
- const { systemPrompt, options, filter } = input;
60
- if (filter === undefined) {
61
- return { systemPrompt, outcome: "no-filter" };
62
- }
63
- // Pi emits the section only when a skill-reading tool is active; without
64
- // one there is nothing to replace.
65
- const fileReadTool = (["read", "bash"] as const).find((tool) => options.selectedTools?.includes(tool));
66
- if (fileReadTool === undefined) {
67
- return { systemPrompt, outcome: "no-read-tool" };
68
- }
69
- const all = options.skills ?? [];
70
- const original = formatSkillsForPrompt(all, fileReadTool);
71
- if (original.length === 0 || !systemPrompt.includes(original)) {
72
- return { systemPrompt, outcome: "section-missing" };
73
- }
74
- const filtered = formatSkillsForPrompt(visibleSkills(all, filter) ?? [], fileReadTool);
75
- return { systemPrompt: systemPrompt.replace(original, filtered), outcome: "filtered" };
76
- }
77
-
78
- /** The per-profile instructions block appended after the (filtered) prompt. */
79
- export function formatInstructionsBlock(profile: string, instructions: string): string {
80
- return `\n\n<profile_instructions name="${profile}">\n${instructions}\n</profile_instructions>`;
81
- }
@@ -1,271 +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 profile this pass generates the overlay for: this process's already
77
- * applied selection (`continuation`) first — a reload must not regress to
78
- * the `--profile` flag — then the flag, then the saved selection: project
79
- * state wins over global state (project state only when trusted), then the
80
- * built-in default. */
81
- export function resolveStartupProfileNameSync(input: {
82
- agentDir: string;
83
- cwd: string;
84
- projectTrusted: boolean;
85
- argv: readonly string[];
86
- continuation?: string;
87
- }): string {
88
- if (input.continuation !== undefined && input.continuation.length > 0) return input.continuation;
89
- const requested = readFlagFromArgv(input.argv, "profile");
90
- if (requested !== undefined && requested.length > 0) return requested;
91
- const project = input.projectTrusted
92
- ? readActiveProfile(path.join(input.cwd, ".pi", "pi-profile-state.json"))
93
- : undefined;
94
- return project ?? readActiveProfile(path.join(input.agentDir, "pi-profile-state.json")) ?? DEFAULT_PROFILE_NAME;
95
- }
96
-
97
- export interface McpOverlaySyncInput {
98
- agentDir: string;
99
- cwd: string;
100
- /** Pi's trust decision. Omitted on the extension-load pass, where Pi has
101
- * not resolved trust yet; the module mirrors Pi's own order then. */
102
- projectTrusted?: boolean;
103
- /** Effective `--mcp-config`; undefined means the managed overlay path. */
104
- overridePath?: string;
105
- /** The selection this process already applied: a reload's overlay belongs
106
- * to it, not to the `--profile` flag. */
107
- continuation?: string;
108
- /** The command line, for the load pass (Pi applies flag values only after
109
- * extension loading, so `--profile` is read from here). */
110
- argv?: readonly string[];
111
- homeDir?: string;
112
- }
113
-
114
- export interface McpOverlaySyncResult {
115
- overlayPath: string;
116
- /** False when a foreign `--mcp-config` owns the adapter's slot. */
117
- managed: boolean;
118
- changed: boolean;
119
- /** Set when nothing was written; the caller surfaces it as a warning. */
120
- error?: string;
121
- }
122
-
123
- /** Load-time (and `session_start` re-check) pass. `profileName` short-circuits
124
- * the state lookup when the caller already activated a profile. */
125
- export function syncStartupMcpOverlay(
126
- input: McpOverlaySyncInput & { profileName?: string; mcpRefs?: readonly string[] | undefined },
127
- ): McpOverlaySyncResult {
128
- try {
129
- const trust = input.projectTrusted;
130
- const profileName =
131
- input.profileName ??
132
- resolveStartupProfileNameSync({
133
- agentDir: input.agentDir,
134
- cwd: input.cwd,
135
- projectTrusted: trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd),
136
- argv: input.argv ?? [],
137
- ...(input.continuation === undefined ? {} : { continuation: input.continuation }),
138
- });
139
- const refs = input.mcpRefs ?? readProfileMcpRefsSync({ ...input, name: profileName, trust });
140
- return writeOverlayForRefs({ ...input, refs });
141
- } catch (error) {
142
- return {
143
- overlayPath: mcpSlotPath(input.agentDir),
144
- managed: true,
145
- changed: false,
146
- error: error instanceof Error ? error.message : String(error),
147
- };
148
- }
149
- }
150
-
151
- /** Switch pass: the caller already resolved the allowlist. */
152
- export function syncMcpOverlayForSelection(
153
- input: McpOverlaySyncInput & { allowed: readonly string[] | "all" },
154
- ): McpOverlaySyncResult {
155
- try {
156
- return writeOverlay({ ...input, refs: input.allowed });
157
- } catch (error) {
158
- return {
159
- overlayPath: mcpSlotPath(input.agentDir),
160
- managed: true,
161
- changed: false,
162
- error: error instanceof Error ? error.message : String(error),
163
- };
164
- }
165
- }
166
-
167
- function writeOverlayForRefs(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "unknown" }): McpOverlaySyncResult {
168
- // An unresolvable profile is not an error here: the generated overlay
169
- // then filters nothing and the activation reports the real problem.
170
- const refs = input.refs === "unknown" ? undefined : input.refs;
171
- return writeOverlay({ ...input, refs });
172
- }
173
-
174
- function writeOverlay(input: McpOverlaySyncInput & { refs: readonly string[] | undefined | "all" }): McpOverlaySyncResult {
175
- const overlayPath = mcpSlotPath(input.agentDir);
176
- if (input.overridePath !== undefined && path.resolve(input.overridePath) !== overlayPath) {
177
- return { overlayPath, managed: false, changed: false };
178
- }
179
- const projectTrusted = input.projectTrusted ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
180
- const discovery = {
181
- agentDir: input.agentDir,
182
- cwd: input.cwd,
183
- projectTrusted,
184
- ...(input.homeDir === undefined ? {} : { homeDir: input.homeDir }),
185
- };
186
- // The slot is ours; a slot written by hand is adopted into the sidecar
187
- // before the first overwrite, so a user's Pi-global servers survive.
188
- const sourcePath = mcpSourcePath(input.agentDir);
189
- adoptHandWrittenSlotSync(overlayPath, sourcePath);
190
- const sourceDocument = readMcpDocumentSync(sourcePath);
191
- const sourceNames = isRecord(sourceDocument?.mcpServers) ? Object.keys(sourceDocument.mcpServers) : [];
192
- const otherNames = readAdapterOtherServerNamesSync(discovery);
193
- const allowed =
194
- input.refs === "all"
195
- ? "all"
196
- : resolveAllowedServers(input.refs, [...new Set([...sourceNames, ...otherNames])].sort());
197
- const content = serializeMcpOverlay(
198
- buildMcpOverlay({
199
- ...(sourceDocument === undefined ? {} : { slotDocument: sourceDocument }),
200
- otherServerNames: otherNames,
201
- allowed,
202
- }),
203
- );
204
- return { overlayPath, managed: true, changed: writeMcpOverlayIfChangedSync(overlayPath, content) };
205
- }
206
-
207
- /** A slot file without the generated marker holds the user's own Pi-global
208
- * servers (from before this package managed the slot, or from a manual
209
- * edit). Move them — plus the slot's non-server keys — into the sidecar
210
- * before the slot is overwritten. Stub entries are skipped: they describe
211
- * servers owned by the adapter's other sources. */
212
- function adoptHandWrittenSlotSync(slotPath: string, sourcePath: string): void {
213
- const slot = readMcpDocumentSync(slotPath);
214
- if (slot === undefined || isGeneratedOverlay(slot)) return;
215
- const sidecar = readMcpDocumentSync(sourcePath) ?? {};
216
- const sidecarServers = isRecord(sidecar.mcpServers) ? sidecar.mcpServers : {};
217
- const servers: Record<string, unknown> = { ...sidecarServers };
218
- const slotServers = isRecord(slot.mcpServers) ? slot.mcpServers : {};
219
- for (const [name, definition] of Object.entries(slotServers)) {
220
- if (isDisabledStub(definition)) continue;
221
- servers[name] = definition;
222
- }
223
- const document: Record<string, unknown> = { ...sidecar };
224
- delete document[MCP_GENERATED_MARKER];
225
- for (const [key, value] of Object.entries(slot)) {
226
- if (key === "mcpServers" || key === MCP_GENERATED_MARKER) continue;
227
- document[key] = value;
228
- }
229
- document.mcpServers = Object.fromEntries(Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)));
230
- writeMcpOverlayIfChangedSync(sourcePath, `${JSON.stringify(document, null, 2)}\n`);
231
- }
232
-
233
- function readActiveProfile(statePath: string): string | undefined {
234
- const result = readJsonFileSync(statePath);
235
- if (!result.ok || !isRecord(result.value)) return undefined;
236
- const name = result.value.activeProfile;
237
- return typeof name === "string" && name.length > 0 ? name : undefined;
238
- }
239
-
240
- /** The profile's raw `mcps` references; `"unknown"` when the profile or its
241
- * catalog cannot be read (caller falls back to "no filtering"). */
242
- function readProfileMcpRefsSync(
243
- input: McpOverlaySyncInput & { name: string; trust: boolean | undefined },
244
- ): readonly string[] | undefined | "unknown" {
245
- if (input.name === DEFAULT_PROFILE_NAME) return undefined;
246
- const trusted = input.trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd);
247
- const global = readCatalogSync(path.join(input.agentDir, "profiles.json"));
248
- const project = trusted
249
- ? readCatalogSync(path.join(input.cwd, ".pi", "profiles.json"))
250
- : new Map<string, ProfileDefinition>();
251
- if (global === "error" || project === "error") return "unknown";
252
- const definition = project.get(input.name) ?? global.get(input.name);
253
- return definition === undefined ? "unknown" : definition.mcps;
254
- }
255
-
256
- function readCatalogSync(filePath: string): Map<string, ProfileDefinition> | "error" {
257
- const result = readJsonFileSync(filePath);
258
- if (!result.ok) return result.reason === "missing" ? new Map<string, ProfileDefinition>() : "error";
259
- try {
260
- return parseCatalogDocument(result.value, filePath);
261
- } catch {
262
- return "error";
263
- }
264
- }
265
-
266
- function readDefaultProjectTrustSync(agentDir: string): string | undefined {
267
- const result = readJsonFileSync(path.join(agentDir, "settings.json"));
268
- if (!result.ok || !isRecord(result.value)) return undefined;
269
- const value = result.value.defaultProjectTrust;
270
- return typeof value === "string" ? value : undefined;
271
- }
@@ -1,201 +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
- * - Every session start after the first continues THIS process's selection
9
- * (`continuation`): the flag is a startup directive, and re-reading it
10
- * after an in-session switch would resurrect the profile the user just
11
- * left. A continuation that no longer resolves falls back like a saved
12
- * selection, with a warning, because a deleted profile must not strand the
13
- * runtime.
14
- * - A saved selection that no longer resolves falls back to `default` with
15
- * a warning — restore is a convenience, not a commitment. An explicit
16
- * flag value never falls back: an unknown name is a loud error.
17
- *
18
- * Explicit CLI declarations (`--model`, `--thinking`, `--tools`,
19
- * `--exclude-tools`) outrank the profile's matching declaration. They are
20
- * detected by re-parsing this process's argv with Pi's own exported parser,
21
- * so the aliases and value forms stay Pi's rather than a hand-rolled copy.
22
- */
23
-
24
- import { parseArgs } from "@earendil-works/pi-coding-agent";
25
- import { stat } from "node:fs/promises";
26
- import path from "node:path";
27
-
28
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
-
30
- import { DEFAULT_PROFILE_NAME, ProfileCatalog } from "./profile-catalog.ts";
31
- import { RuntimeStateStore } from "./runtime-state-store.ts";
32
-
33
- /** Which settings the user stated on the command line this run. */
34
- export interface ExplicitDeclarations {
35
- model: boolean;
36
- thinking: boolean;
37
- tools: boolean;
38
- }
39
-
40
- export const PROFILE_FLAG = "profile";
41
-
42
- /** Registers the `--profile <name>` CLI flag. Pi has no flag of that name;
43
- * unknown flags are collected by its parser and validated against flags
44
- * registered by loaded extensions. */
45
- export function registerProfileFlag(pi: ExtensionAPI): void {
46
- pi.registerFlag(PROFILE_FLAG, {
47
- type: "string",
48
- description: "pi-profile-switch: activate a profile for this run (not saved)",
49
- });
50
- }
51
-
52
- /** The `--profile` value, when the user passed one. */
53
- export function readProfileFlag(pi: ExtensionAPI): string | undefined {
54
- const value = pi.getFlag(PROFILE_FLAG);
55
- return typeof value === "string" && value.length > 0 ? value : undefined;
56
- }
57
-
58
- /** Detects explicit CLI declarations by re-parsing argv with Pi's parser. */
59
- export function detectExplicitDeclarations(argv: string[]): ExplicitDeclarations {
60
- const parsed = parseArgs(argv);
61
- return {
62
- model: parsed.model !== undefined,
63
- thinking: parsed.thinking !== undefined,
64
- tools: parsed.tools !== undefined || parsed.excludeTools !== undefined,
65
- };
66
- }
67
-
68
- export interface StartupProfile {
69
- name: string;
70
- /** Non-fatal notices (e.g. a saved profile that no longer exists). */
71
- warnings: string[];
72
- }
73
-
74
- async function exists(filePath: string): Promise<boolean> {
75
- try {
76
- await stat(filePath);
77
- return true;
78
- } catch {
79
- return false;
80
- }
81
- }
82
-
83
- /** Files the retired extension registry lived in; they are no longer read
84
- * (ADR-0007), and a leftover file is called out once per startup. */
85
- async function legacyRegistryWarnings(input: {
86
- agentDir: string;
87
- cwd: string;
88
- projectTrusted: boolean;
89
- }): Promise<string[]> {
90
- const candidates = [path.join(input.agentDir, "resources.json")];
91
- if (input.projectTrusted) candidates.push(path.join(input.cwd, ".pi", "resources.json"));
92
- const warnings: string[] = [];
93
- for (const filePath of candidates) {
94
- if (await exists(filePath)) {
95
- warnings.push(
96
- `${filePath} is no longer read (ADR-0007): extensions load natively in every profile — manage them with pi install`,
97
- );
98
- }
99
- }
100
- return warnings;
101
- }
102
-
103
- /**
104
- * The profile this process last applied. Kept on `globalThis` because Pi
105
- * re-imports extension modules on every reload (jiti) — module state resets
106
- * exactly when it is needed. It is a run-scoped fact, not saved state: the
107
- * `--profile` flag must not resurrect itself over an in-session switch when
108
- * the MCP overlay change rebuilds the runtime.
109
- */
110
- const RUN_SELECTION_KEY = "pi-profile-switch:applied-profile";
111
-
112
- interface RunSelection {
113
- appliedProfile?: string;
114
- }
115
-
116
- function runSelection(): RunSelection {
117
- const holder = globalThis as unknown as Record<string, RunSelection | undefined>;
118
- const current = holder[RUN_SELECTION_KEY] ?? {};
119
- holder[RUN_SELECTION_KEY] = current;
120
- return current;
121
- }
122
-
123
- /** Records the profile this process applied (startup or in-session).
124
- * `undefined` clears the record; a fresh process starts empty. */
125
- export function recordAppliedProfile(name: string | undefined): void {
126
- runSelection().appliedProfile = name;
127
- }
128
-
129
- /** The profile this process is running, or undefined before the first
130
- * activation. */
131
- export function appliedProfile(): string | undefined {
132
- return runSelection().appliedProfile;
133
- }
134
-
135
- /** Resolves the profile name for this session: the run's current selection
136
- * (`continuation`) → explicit flag → trusted project state → global state →
137
- * default. Never writes state.
138
- *
139
- * `requested` is the CLI flag: an unknown name is a loud error.
140
- * `continuation` is what this process already applied, passed on every
141
- * session start after the first: it keeps the in-session switch, and falls
142
- * back (with a warning) when the name no longer resolves. */
143
- export async function resolveStartupProfile(input: {
144
- agentDir: string;
145
- cwd: string;
146
- projectTrusted: boolean;
147
- requested?: string;
148
- continuation?: string;
149
- }): Promise<StartupProfile> {
150
- const catalog = await ProfileCatalog.load(input.agentDir, {
151
- projectDir: input.projectTrusted ? input.cwd : undefined,
152
- });
153
- const warnings: string[] = await legacyRegistryWarnings(input);
154
-
155
- // The continuation first: this process already applied it, so the flag
156
- // (a startup directive) has had its say.
157
- if (input.continuation !== undefined) {
158
- if (catalog.resolve(input.continuation) !== undefined) {
159
- return { name: input.continuation, warnings };
160
- }
161
- warnings.push(
162
- `profile ${JSON.stringify(input.continuation)} no longer exists — restoring the saved selection; ` +
163
- `available: [${catalog.list().map((profile) => profile.name).join(", ")}]`,
164
- );
165
- }
166
-
167
- if (input.requested !== undefined) {
168
- if (catalog.resolve(input.requested) === undefined) {
169
- throw new UnknownProfileError(input.requested, catalog.list().map((profile) => profile.name));
170
- }
171
- return { name: input.requested, warnings };
172
- }
173
-
174
- let saved: string | undefined;
175
- if (input.projectTrusted) {
176
- saved = (await new RuntimeStateStore(path.join(input.cwd, ".pi")).read()).activeProfile;
177
- }
178
- saved ??= (await new RuntimeStateStore(input.agentDir).read()).activeProfile;
179
-
180
- if (saved === undefined || saved === DEFAULT_PROFILE_NAME) {
181
- return { name: DEFAULT_PROFILE_NAME, warnings };
182
- }
183
- if (catalog.resolve(saved) === undefined) {
184
- warnings.push(
185
- `saved profile ${JSON.stringify(saved)} no longer exists — starting with "${DEFAULT_PROFILE_NAME}"; ` +
186
- `available: [${catalog.list().map((profile) => profile.name).join(", ")}]`,
187
- );
188
- return { name: DEFAULT_PROFILE_NAME, warnings };
189
- }
190
- return { name: saved, warnings };
191
- }
192
-
193
- export class UnknownProfileError extends Error {
194
- readonly requested: string;
195
-
196
- constructor(requested: string, available: string[]) {
197
- super(`unknown profile ${JSON.stringify(requested)} — available: [${available.join(", ")}]`);
198
- this.name = "UnknownProfileError";
199
- this.requested = requested;
200
- }
201
- }