pi-profile-switch 0.1.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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * ProfileResolver: turns one profile definition plus an optional runtime
3
+ * overlay into an immutable selection, resolved against Pi's LIVE resources.
4
+ *
5
+ * ADR-0007 semantics:
6
+ * - `skills` resolves to a visibility filter (see skill-selection.ts), not
7
+ * to loaded resources: every skill stays loaded and user-invocable.
8
+ * - `mcp` resolves to a runtime server allowlist; a declared MCP intent that
9
+ * cannot be satisfied (adapter absent, literal server unknown) fails the
10
+ * activation before anything is applied.
11
+ * - `tools` resolves to an active tool set; literals the live registry does
12
+ * not provide yet become `pendingTools` and are retried, because MCP and
13
+ * extension tools register after session start.
14
+ * - `model` and `instructions` pass through unchanged; applying them is the
15
+ * caller's job.
16
+ *
17
+ * The resolver is a pure function: same inputs, same selection, no I/O.
18
+ */
19
+
20
+ import { isGlob, matchesReference, suggestNames } from "./name-matching.ts";
21
+ import type { ProfileModel, ProfileSource, ResolvedProfile } from "./profile-catalog.ts";
22
+ import type { RuntimeOverlay } from "./runtime-state-store.ts";
23
+
24
+ /** The live resource view a resolution runs against. */
25
+ export interface LiveResources {
26
+ skills: Array<{ name: string; filePath: string }>;
27
+ toolNames: string[];
28
+ /** MCP adapter state: presence plus discovered server names. */
29
+ mcp: { adapterPresent: boolean; servers: string[] };
30
+ }
31
+
32
+ /** The skill references of a visibility filter: literals/globs, or `"all"`
33
+ * when the profile declares none and only the overlay narrows (nothing is
34
+ * hidden by omission). An empty array means "no skill is visible". */
35
+ export type SkillRefs = string[] | "all";
36
+
37
+ /** The skill visibility filter handed to the prompt builder each turn. */
38
+ export interface SkillsFilter {
39
+ refs: SkillRefs;
40
+ /** Skill name references removed from the visible set. */
41
+ disabled: string[];
42
+ }
43
+
44
+ /** A literal reference no live resource provides, with near-name hints. */
45
+ export interface UnresolvedRef {
46
+ reference: string;
47
+ suggestions: string[];
48
+ }
49
+
50
+ export interface SelectionWarnings {
51
+ skillsUnresolved: UnresolvedRef[];
52
+ skillsUnmatched: string[];
53
+ mcpUnmatched: string[];
54
+ toolsUnmatched: string[];
55
+ }
56
+
57
+ export interface ResolvedSelection {
58
+ name: string;
59
+ source: ProfileSource;
60
+ instructions?: string;
61
+ model?: ProfileModel;
62
+ /** Undefined means no visibility filtering (the whole loaded set). */
63
+ skills?: SkillsFilter;
64
+ /** Runtime MCP allowlist; undefined means publish nothing. */
65
+ mcp?: string[];
66
+ /** Active tool names; undefined means leave Pi's active set untouched. */
67
+ tools?: string[];
68
+ /** Tool literals the live registry does not provide yet. */
69
+ pendingTools: string[];
70
+ warnings: SelectionWarnings;
71
+ }
72
+
73
+ export class SelectionError extends Error {
74
+ constructor(message: string) {
75
+ super(message);
76
+ this.name = "SelectionError";
77
+ }
78
+ }
79
+
80
+ function resolveSkills(
81
+ declared: string[] | undefined,
82
+ disabled: string[],
83
+ live: LiveResources["skills"],
84
+ ): { filter?: SkillsFilter; warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> } {
85
+ const refs = declared ?? [];
86
+ const warning = { skillsUnresolved: [] as UnresolvedRef[], skillsUnmatched: [] as string[] };
87
+ if (declared === undefined) {
88
+ // The profile declares nothing; an overlay may still hide skills.
89
+ return { ...(disabled.length > 0 ? { filter: { refs: "all" as const, disabled } } : {}), warning };
90
+ }
91
+ for (const ref of refs) {
92
+ const hits = live.filter((skill) => matchesReference(ref, skill.name));
93
+ if (hits.length === 0) {
94
+ if (isGlob(ref)) warning.skillsUnmatched.push(ref);
95
+ else warning.skillsUnresolved.push({ reference: ref, suggestions: suggestNames(ref, live.map((s) => s.name)) });
96
+ }
97
+ }
98
+ return { filter: { refs, disabled }, warning };
99
+ }
100
+
101
+ function resolveMcp(
102
+ declared: string[] | undefined,
103
+ disabled: string[],
104
+ live: LiveResources["mcp"],
105
+ profileName: string,
106
+ ): { servers?: string[]; warning: Pick<SelectionWarnings, "mcpUnmatched"> } {
107
+ const warning = { mcpUnmatched: [] as string[] };
108
+ if (declared === undefined) {
109
+ // No declared intent: only an overlay narrowing publishes an allowlist.
110
+ if (disabled.length === 0 || !live.adapterPresent) return { warning };
111
+ return { servers: live.servers.filter((server) => !disabled.includes(server)), warning };
112
+ }
113
+ if (!live.adapterPresent) {
114
+ throw new SelectionError(
115
+ `profile "${profileName}" declares MCP servers but pi-mcp-adapter is not active in this session — ` +
116
+ `install the adapter or remove the "mcp" declaration`,
117
+ );
118
+ }
119
+ const selected: string[] = [];
120
+ const missing: string[] = [];
121
+ for (const ref of declared) {
122
+ const hits = live.servers.filter((server) => matchesReference(ref, server));
123
+ if (hits.length > 0) {
124
+ selected.push(...hits);
125
+ continue;
126
+ }
127
+ if (isGlob(ref)) warning.mcpUnmatched.push(ref);
128
+ else missing.push(ref);
129
+ }
130
+ if (missing.length > 0) {
131
+ throw new SelectionError(
132
+ `profile "${profileName}": unknown MCP server ${missing.map((name) => JSON.stringify(name)).join(", ")} — ` +
133
+ `adapter discovered: [${live.servers.join(", ")}]`,
134
+ );
135
+ }
136
+ const servers = [...new Set(selected)].filter((server) => !disabled.includes(server));
137
+ return { servers, warning };
138
+ }
139
+
140
+ function resolveTools(
141
+ refs: string[] | undefined,
142
+ live: LiveResources["toolNames"],
143
+ ): {
144
+ tools?: string[];
145
+ pendingTools: string[];
146
+ warning: Pick<SelectionWarnings, "toolsUnmatched">;
147
+ } {
148
+ const warning = { toolsUnmatched: [] as string[] };
149
+ if (refs === undefined) return { pendingTools: [], warning };
150
+ const selected: string[] = [];
151
+ const pending: string[] = [];
152
+ for (const ref of refs) {
153
+ const hits = live.filter((name) => matchesReference(ref, name));
154
+ if (hits.length > 0) {
155
+ selected.push(...hits);
156
+ continue;
157
+ }
158
+ if (isGlob(ref)) warning.toolsUnmatched.push(ref);
159
+ else pending.push(ref);
160
+ }
161
+ return { tools: [...new Set([...selected, ...pending])], pendingTools: [...new Set(pending)], warning };
162
+ }
163
+
164
+ /** Resolves one profile (plus overlay) against the live resources. Throws
165
+ * SelectionError when the profile's declared MCP intent cannot be
166
+ * satisfied; the caller applies nothing in that case.
167
+ *
168
+ * `suppressTools` drops the profile's tool selection entirely, so a
169
+ * CLI-declared `--tools`/`--exclude-tools` keeps owning the active set
170
+ * (the preference table in docs/product/prd.md). */
171
+ export function resolveSelection(input: {
172
+ profile: ResolvedProfile;
173
+ overlay?: RuntimeOverlay;
174
+ live: LiveResources;
175
+ suppressTools?: boolean;
176
+ }): ResolvedSelection {
177
+ const { profile, overlay, live } = input;
178
+ const definition = profile.definition;
179
+
180
+ const skills = resolveSkills(definition.skills, overlay?.disabledSkills ?? [], live.skills);
181
+ const mcp = resolveMcp(definition.mcp, overlay?.disabledMcp ?? [], live.mcp, profile.name);
182
+ const tools = input.suppressTools === true
183
+ ? { pendingTools: [], warning: { toolsUnmatched: [] } }
184
+ : resolveTools(overlay?.tools ?? definition.tools, live.toolNames);
185
+
186
+ const selection: ResolvedSelection = {
187
+ name: profile.name,
188
+ source: profile.source,
189
+ pendingTools: tools.pendingTools,
190
+ warnings: {
191
+ ...skills.warning,
192
+ ...mcp.warning,
193
+ ...tools.warning,
194
+ },
195
+ };
196
+ if (definition.instructions !== undefined && definition.instructions.length > 0) {
197
+ selection.instructions = definition.instructions;
198
+ }
199
+ if (definition.model !== undefined) {
200
+ selection.model = definition.model;
201
+ }
202
+ if (skills.filter !== undefined) {
203
+ selection.skills = skills.filter;
204
+ }
205
+ if (mcp.servers !== undefined) {
206
+ selection.mcp = mcp.servers;
207
+ }
208
+ if (tools.tools !== undefined) {
209
+ selection.tools = tools.tools;
210
+ }
211
+ return selection;
212
+ }
213
+
214
+ /** User-facing warning lines for one resolved selection. */
215
+ export function formatSelectionWarnings(selection: ResolvedSelection): string[] {
216
+ const lines: string[] = [];
217
+ for (const unresolved of selection.warnings.skillsUnresolved) {
218
+ const hint =
219
+ unresolved.suggestions.length > 0
220
+ ? ` — did you mean: ${unresolved.suggestions.map((name) => JSON.stringify(name)).join(", ")}?`
221
+ : "";
222
+ lines.push(
223
+ `profile "${selection.name}": skill ${JSON.stringify(unresolved.reference)} is not loaded in this session${hint}`,
224
+ );
225
+ }
226
+ if (selection.warnings.skillsUnmatched.length > 0) {
227
+ lines.push(
228
+ `profile "${selection.name}": skill glob(s) ${selection.warnings.skillsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
229
+ );
230
+ }
231
+ if (selection.warnings.mcpUnmatched.length > 0) {
232
+ lines.push(
233
+ `profile "${selection.name}": MCP glob(s) ${selection.warnings.mcpUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
234
+ );
235
+ }
236
+ if (selection.warnings.toolsUnmatched.length > 0) {
237
+ lines.push(
238
+ `profile "${selection.name}": tool glob(s) ${selection.warnings.toolsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
239
+ );
240
+ }
241
+ if (selection.pendingTools.length > 0) {
242
+ lines.push(
243
+ `profile "${selection.name}": tool(s) ${selection.pendingTools.map((name) => JSON.stringify(name)).join(", ")} are not registered yet — applied when they appear`,
244
+ );
245
+ }
246
+ return lines;
247
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * RuntimeStateStore: reads and writes a `pi-profile-state.json` runtime
3
+ * state file.
4
+ *
5
+ * Constructed with the directory holding the state file: the real agent dir
6
+ * for global state, the project's `.pi` dir for project state (project
7
+ * state is only touched when Pi reports the project trusted).
8
+ *
9
+ * `activeProfile` is the saved selection applied on the next start;
10
+ * `overlay` is the temporary narrowing of the active profile, written by
11
+ * `/profile customize` and deleted by `/profile reset`.
12
+ *
13
+ * A missing or malformed state file is not an error on read — it simply
14
+ * means "fall back to the default profile". Unexpected I/O errors
15
+ * propagate. A state file written by an older pi-profile-switch is read with its
16
+ * retired fields (`lastVerifiedProfile`, `overlay.disabledExtensions`)
17
+ * ignored; the next write drops them.
18
+ */
19
+
20
+ import { mkdir, writeFile } from "node:fs/promises";
21
+ import path from "node:path";
22
+
23
+ import { isRecord, readJsonFile } from "./json-file.ts";
24
+ import type { ProfileSource } from "./profile-catalog.ts";
25
+
26
+ /** The state directory for one profile's source scope: the project's `.pi`
27
+ * dir for project profiles, the agent dir otherwise (built-in `default` is
28
+ * treated as global). */
29
+ export function stateDirFor(source: ProfileSource, dirs: { agentDir: string; cwd: string }): string {
30
+ return source === "project" ? path.join(dirs.cwd, ".pi") : dirs.agentDir;
31
+ }
32
+
33
+ export interface RuntimeState {
34
+ activeProfile?: string;
35
+ overlay?: RuntimeOverlay;
36
+ }
37
+
38
+ export interface RuntimeOverlay {
39
+ disabledSkills?: string[];
40
+ disabledMcp?: string[];
41
+ /** Replaces the profile's tool references when set. */
42
+ tools?: string[];
43
+ }
44
+
45
+ function parseOverlay(value: unknown): RuntimeOverlay | undefined {
46
+ if (!isRecord(value)) return undefined;
47
+ const overlay: RuntimeOverlay = {};
48
+ for (const key of ["disabledSkills", "disabledMcp", "tools"] as const) {
49
+ const list = value[key];
50
+ if (Array.isArray(list) && list.every((entry) => typeof entry === "string")) {
51
+ overlay[key] = list;
52
+ }
53
+ }
54
+ return Object.keys(overlay).length > 0 ? overlay : undefined;
55
+ }
56
+
57
+ export class RuntimeStateStore {
58
+ readonly #statePath: string;
59
+
60
+ /** @param stateDir Directory holding `pi-profile-state.json` (agent dir or
61
+ * project `.pi` dir). */
62
+ constructor(stateDir: string) {
63
+ this.#statePath = path.join(stateDir, "pi-profile-state.json");
64
+ }
65
+
66
+ async read(): Promise<RuntimeState> {
67
+ const result = await readJsonFile(this.#statePath);
68
+ if (!result.ok || !isRecord(result.value)) return {};
69
+ const state: RuntimeState = {};
70
+ if (typeof result.value.activeProfile === "string") {
71
+ state.activeProfile = result.value.activeProfile;
72
+ }
73
+ const overlay = parseOverlay(result.value.overlay);
74
+ if (overlay !== undefined) {
75
+ state.overlay = overlay;
76
+ }
77
+ return state;
78
+ }
79
+
80
+ async write(state: RuntimeState): Promise<void> {
81
+ await mkdir(path.dirname(this.#statePath), { recursive: true });
82
+ const document: RuntimeState = {};
83
+ if (state.activeProfile !== undefined) document.activeProfile = state.activeProfile;
84
+ if (state.overlay !== undefined) document.overlay = state.overlay;
85
+ await writeFile(this.#statePath, `${JSON.stringify(document, null, 2)}\n`);
86
+ }
87
+
88
+ /** Read-modify-write merge. A field set to `undefined` is deleted; absent
89
+ * fields keep their stored value. Used by the switch/customize paths so
90
+ * one concern (selection, overlay) never clobbers another. */
91
+ async update(patch: Partial<RuntimeState>): Promise<RuntimeState> {
92
+ const current = await this.read();
93
+ const next: RuntimeState = { ...current };
94
+ if ("activeProfile" in patch) {
95
+ if (patch.activeProfile === undefined) delete next.activeProfile;
96
+ else next.activeProfile = patch.activeProfile;
97
+ }
98
+ if ("overlay" in patch) {
99
+ if (patch.overlay === undefined) delete next.overlay;
100
+ else next.overlay = patch.overlay;
101
+ }
102
+ await this.write(next);
103
+ return next;
104
+ }
105
+ }
@@ -0,0 +1,81 @@
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
+ }
@@ -0,0 +1,147 @@
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[] = [
109
+ ...catalog.warnings,
110
+ ...(await legacyRegistryWarnings(input)),
111
+ ];
112
+
113
+ if (input.requested !== undefined) {
114
+ if (catalog.resolve(input.requested) === undefined) {
115
+ throw new UnknownProfileError(input.requested, catalog.list().map((profile) => profile.name));
116
+ }
117
+ return { name: input.requested, warnings };
118
+ }
119
+
120
+ let saved: string | undefined;
121
+ if (input.projectTrusted) {
122
+ saved = (await new RuntimeStateStore(path.join(input.cwd, ".pi")).read()).activeProfile;
123
+ }
124
+ saved ??= (await new RuntimeStateStore(input.agentDir).read()).activeProfile;
125
+
126
+ if (saved === undefined || saved === DEFAULT_PROFILE_NAME) {
127
+ return { name: DEFAULT_PROFILE_NAME, warnings };
128
+ }
129
+ if (catalog.resolve(saved) === undefined) {
130
+ warnings.push(
131
+ `saved profile ${JSON.stringify(saved)} no longer exists — starting with "${DEFAULT_PROFILE_NAME}"; ` +
132
+ `available: [${catalog.list().map((profile) => profile.name).join(", ")}]`,
133
+ );
134
+ return { name: DEFAULT_PROFILE_NAME, warnings };
135
+ }
136
+ return { name: saved, warnings };
137
+ }
138
+
139
+ export class UnknownProfileError extends Error {
140
+ readonly requested: string;
141
+
142
+ constructor(requested: string, available: string[]) {
143
+ super(`unknown profile ${JSON.stringify(requested)} — available: [${available.join(", ")}]`);
144
+ this.name = "UnknownProfileError";
145
+ this.requested = requested;
146
+ }
147
+ }
@@ -0,0 +1,113 @@
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
+ warnings: string[];
57
+ }
58
+
59
+ /** Resolves a profile against the live resources without applying it.
60
+ * Throws ActivationError for unknown names and SelectionError for declared
61
+ * MCP intents that cannot be satisfied. A CLI-declared
62
+ * `--tools`/`--exclude-tools` suppresses the profile's tool selection
63
+ * unless the user made an explicit `/profile use` choice. */
64
+ export async function resolveProfileSelection(
65
+ name: string,
66
+ deps: Pick<ActivationDeps, "agentDir" | "cwd" | "projectTrusted" | "live" | "presetInputs">,
67
+ overlay?: RuntimeOverlay,
68
+ ): Promise<{ selection: ResolvedSelection; warnings: string[] }> {
69
+ const catalog = await ProfileCatalog.load(deps.agentDir, {
70
+ projectDir: deps.projectTrusted ? deps.cwd : undefined,
71
+ });
72
+ const profile = catalog.resolve(name);
73
+ if (profile === undefined) {
74
+ throw new ActivationError(
75
+ `unknown profile ${JSON.stringify(name)} — available: [${catalog.list().map((entry) => entry.name).join(", ")}]`,
76
+ );
77
+ }
78
+ const suppressTools = !deps.presetInputs.force && deps.presetInputs.explicit.tools;
79
+ const selection = resolveSelection({ profile, overlay, live: deps.live, suppressTools });
80
+ return { selection, warnings: [...catalog.warnings] };
81
+ }
82
+
83
+ /** Activates a profile: resolve, validate, optionally persist, apply. */
84
+ export async function activateProfile(
85
+ name: string,
86
+ deps: ActivationDeps,
87
+ options?: { overlay?: RuntimeOverlay | null; persist?: boolean },
88
+ ): Promise<ActivationResult> {
89
+ const overlay = options?.overlay ?? undefined;
90
+ const { selection, warnings } = await resolveProfileSelection(name, deps, overlay);
91
+ const preset = decidePreset({ model: selection.model, ...deps.presetInputs });
92
+
93
+ // Validate before touching anything: the model preset is the only
94
+ // fallible runtime step.
95
+ const validationError = validateSelection(selection, deps.surface, preset);
96
+ if (validationError !== undefined) {
97
+ throw new ActivationError(validationError);
98
+ }
99
+
100
+ if (options?.persist !== false) {
101
+ await new RuntimeStateStore(stateDirFor(selection.source, deps)).update({
102
+ activeProfile: selection.name,
103
+ overlay: overlay ?? undefined,
104
+ });
105
+ }
106
+
107
+ const result = await applySelection({ selection, surface: deps.surface, preset });
108
+ if (!result.applied) {
109
+ throw new ActivationError(result.error ?? `profile "${name}" could not be applied`);
110
+ }
111
+
112
+ return { selection, warnings };
113
+ }