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
@@ -8,14 +8,9 @@
8
8
  * keeps the current value (prefill via placeholder); there is no
9
9
  * field-clearing gesture (delete + create instead). Any cancelled step
10
10
  * aborts the wizard — nothing is written.
11
- *
12
- * Create can start from a shipped preset (src/profile-presets.ts). A preset is
13
- * prefilled form state only: the wizard copies its complete definition into the
14
- * chosen catalog, and the new profile belongs to the user from then on.
15
11
  */
16
12
 
17
13
  import type { ProfileDefinition } from "../profile-catalog.ts";
18
- import { PROFILE_PRESETS, type ProfilePreset } from "../profile-presets.ts";
19
14
  import type { CatalogScope } from "./profile-crud.ts";
20
15
 
21
16
  export interface ProfileWizardUi {
@@ -27,8 +22,6 @@ export interface ProfileWizardResult {
27
22
  scope: CatalogScope;
28
23
  name: string;
29
24
  definition: ProfileDefinition;
30
- /** Name of the preset this definition was copied from, when any. */
31
- preset?: string;
32
25
  }
33
26
 
34
27
  interface ExistingProfile {
@@ -45,7 +38,7 @@ function parseList(raw: string): string[] {
45
38
  }
46
39
 
47
40
  /** "provider/id[/thinkingLevel]" → ProfileModel; empty/undefined → none. */
48
- function parseModel(raw: string): ProfileDefinition["model"] | undefined {
41
+ function parseModel(raw: string): { defaultProvider: string; defaultModel: string; defaultThinkingLevel?: string } | undefined {
49
42
  const trimmed = raw.trim();
50
43
  if (trimmed.length === 0) return undefined;
51
44
  const [provider, id, thinkingLevel] = trimmed.split("/").map((part) => part.trim());
@@ -53,8 +46,8 @@ function parseModel(raw: string): ProfileDefinition["model"] | undefined {
53
46
  return undefined;
54
47
  }
55
48
  return thinkingLevel !== undefined && thinkingLevel.length > 0
56
- ? { provider, id, thinkingLevel }
57
- : { provider, id };
49
+ ? { defaultProvider: provider, defaultModel: id, defaultThinkingLevel: thinkingLevel }
50
+ : { defaultProvider: provider, defaultModel: id };
58
51
  }
59
52
 
60
53
  async function captureDefinition(
@@ -74,8 +67,9 @@ async function captureDefinition(
74
67
  else if (existing?.description !== undefined) definition.description = existing.description;
75
68
 
76
69
  const listFields = [
77
- ["skills", "skills (comma-separated names or globs, empty = all visible)"],
78
- ["mcps", "MCP servers (names or globs, empty = none)"],
70
+ ["skills", "skills (comma-separated names or globs, empty = none)"],
71
+ ["extensions", "extensions (names or globs, empty = none)"],
72
+ ["mcps", "mcp servers (names or globs, empty = none)"],
79
73
  ["tools", "tools (names or globs, empty = pi default set)"],
80
74
  ] as const;
81
75
  for (const [field, prompt] of listFields) {
@@ -100,64 +94,42 @@ async function captureDefinition(
100
94
  else if (existing?.instructions !== undefined) definition.instructions = existing.instructions;
101
95
 
102
96
  const currentModel =
103
- existing?.model !== undefined
104
- ? `${existing.model.provider}/${existing.model.id}${existing.model.thinkingLevel !== undefined ? `/${existing.model.thinkingLevel}` : ""}`
97
+ existing?.defaultProvider !== undefined && existing?.defaultModel !== undefined
98
+ ? `${existing.defaultProvider}/${existing.defaultModel}${existing.defaultThinkingLevel !== undefined ? `/${existing.defaultThinkingLevel}` : ""}`
105
99
  : undefined;
106
100
  const modelRaw = await ui.input("model provider/id[/thinking] (empty = none)", currentModel);
107
101
  if (modelRaw === undefined) return undefined;
108
102
  const model = parseModel(modelRaw);
109
103
  if (model !== undefined) {
110
- definition.model = model;
111
- } else if (existing?.model !== undefined && modelRaw.trim().length === 0) {
112
- definition.model = existing.model;
104
+ definition.defaultProvider = model.defaultProvider;
105
+ definition.defaultModel = model.defaultModel;
106
+ if (model.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = model.defaultThinkingLevel;
107
+ } else if (existing?.defaultProvider !== undefined && modelRaw.trim().length === 0) {
108
+ definition.defaultProvider = existing.defaultProvider;
109
+ definition.defaultModel = existing.defaultModel;
110
+ if (existing.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = existing.defaultThinkingLevel;
113
111
  }
114
112
 
115
113
  return definition;
116
114
  }
117
115
 
118
- const BLANK_OPTION = "blank start from an empty definition";
119
-
120
- /** One preset row. The caller identifies the preset by the option's INDEX, so
121
- * a preset named like another row's text cannot be misread. */
122
- function presetOption(preset: ProfilePreset): string {
123
- const hint = preset.definition.description ?? preset.definition.label;
124
- return hint !== undefined ? `${preset.name} — ${hint}` : preset.name;
125
- }
126
-
127
- /** Create: scope, optional preset, name, then fields. */
116
+ /** Create: scope first (project only when trusted), then name, then fields. */
128
117
  export async function runProfileCreateWizard(
129
118
  ui: ProfileWizardUi,
130
- input: { projectTrusted: boolean; presets?: readonly ProfilePreset[] },
119
+ input: { projectTrusted: boolean },
131
120
  ): Promise<ProfileWizardResult | undefined> {
132
121
  const scopeOptions = input.projectTrusted ? ["global", "project"] : ["global"];
133
122
  // A single available scope needs no dialog.
134
123
  const scope = scopeOptions.length === 1 ? scopeOptions[0] : await ui.select("write to which catalog?", scopeOptions);
135
124
  if (scope === undefined) return undefined;
136
125
 
137
- const presets = input.presets ?? PROFILE_PRESETS;
138
- let preset: ProfilePreset | undefined;
139
- if (presets.length > 0) {
140
- const options = [BLANK_OPTION, ...presets.map(presetOption)];
141
- const chosen = await ui.select("start from which preset?", options);
142
- if (chosen === undefined) return undefined;
143
- preset = presets[options.indexOf(chosen) - 1];
144
- }
145
-
146
- // An empty answer takes the preset's name, so Enter accepts the preset.
147
- const name = await ui.input("profile name", preset?.name);
148
- if (name === undefined) return undefined;
149
- const resolvedName = name.trim().length > 0 ? name.trim() : preset?.name;
150
- if (resolvedName === undefined) return undefined;
126
+ const name = await ui.input("profile name");
127
+ if (name === undefined || name.trim().length === 0) return undefined;
151
128
 
152
- const definition = await captureDefinition(ui, preset?.definition);
129
+ const definition = await captureDefinition(ui);
153
130
  if (definition === undefined) return undefined;
154
131
 
155
- return {
156
- scope: scope as CatalogScope,
157
- name: resolvedName,
158
- definition,
159
- ...(preset !== undefined ? { preset: preset.name } : {}),
160
- };
132
+ return { scope: scope as CatalogScope, name: name.trim(), definition };
161
133
  }
162
134
 
163
135
  /** Edit: fields prefilled from the existing complete definition. */
@@ -1,74 +1,139 @@
1
1
  /**
2
- * StatusReport: the `/profile status` observability surface.
2
+ * StatusReport: the `/profile status` observability surface (ticket 07).
3
3
  *
4
- * Pure report builder: combines the ACTIVE selection (what the runtime was
5
- * resolved to), the stored overlay, and fresh MCP discovery. Markdown
6
- * formatting is the only presentation; the extension ships it via
7
- * `pi.sendMessage`.
4
+ * Pure report builder: combines the ACTIVE launch plan (what the runtime
5
+ * was resolved to), the stored overlay, fresh MCP adapter discovery, and
6
+ * Pi's actual command registrations (the winner evidence for same-name
7
+ * conflicts). Markdown formatting is the only presentation; the extension
8
+ * ships it via `pi.sendMessage`.
9
+ *
10
+ * Conflict semantics: Pi's load order is first-wins by scope/file order,
11
+ * so the registered command IS the winner. A conflict is reported when the
12
+ * plan resolved a skill whose command name is registered from a DIFFERENT
13
+ * path (shadowed) or is absent (failed to load) — never blocked, always
14
+ * visible.
8
15
  */
9
16
 
10
- import type { ProfileSource } from "../profile-catalog.ts";
11
- import type { ResolvedSelection, UnresolvedRef } from "../profile-resolver.ts";
12
17
  import type { RuntimeOverlay } from "../runtime-state-store.ts";
13
- import { visibleSkillNames, type SkillsFilterOutcome } from "../skill-selection.ts";
18
+ import type { LaunchPlanFile } from "./apply-plan.ts";
19
+
20
+ export interface StatusConflict {
21
+ /** Command name as registered (e.g. `skill:review`). */
22
+ name: string;
23
+ /** The path the active plan resolved. */
24
+ expectedPath: string;
25
+ /** The path Pi actually registered (the winner), or "not loaded". */
26
+ winnerPath: string;
27
+ }
14
28
 
15
29
  export interface StatusReport {
16
30
  profile: string;
17
- source: ProfileSource;
31
+ source: string;
18
32
  overlay?: RuntimeOverlay;
19
- /** The skills the model can see (all loaded skills when unfiltered). */
20
- skills: {
21
- filtered: boolean;
22
- visible: Array<{ name: string; filePath: string }>;
23
- loaded: number;
24
- /** The last prompt-filter result; `no-filter` when the profile
25
- * selects nothing. */
26
- filterOutcome: SkillsFilterOutcome;
27
- };
28
- tools?: { active: string[]; pending: string[] };
29
- mcp: { enabled: string[]; discovered: string[]; missing: string[] };
30
- unresolved: { skills: UnresolvedRef[]; unmatched: string[] };
33
+ skills: Array<{ name: string; filePath: string }>;
34
+ extensions: Array<{ id: string; entry: string; origin?: string }>;
35
+ tools?: string[];
36
+ mcp: { enabled: string[]; disabled: string[]; missing: string[] };
37
+ /** Glob delta versus the previous activation (prefixed names). */
38
+ delta?: { added: string[]; removed: string[] };
39
+ /** Glob references that matched nothing at resolution (ADR-0006). */
40
+ unmatched?: string[];
41
+ conflicts: StatusConflict[];
42
+ }
43
+
44
+ interface RegisteredCommand {
45
+ name: string;
46
+ sourceInfo?: { path: string };
47
+ }
48
+
49
+ interface RegisteredTool {
50
+ name: string;
51
+ sourceInfo?: { path: string; source: string };
52
+ }
53
+
54
+ function currentNames(plan: LaunchPlanFile): string[] {
55
+ const names = [
56
+ ...(plan.resolved?.skills ?? []).map((skill) => `skill:${skill.name}`),
57
+ ...(plan.resolved?.extensions ?? []).map((entry) => `extension:${entry.id}`),
58
+ ...(plan.tools ?? []).map((tool) => `tool:${tool}`),
59
+ ...(plan.mcps ?? []).map((server) => `mcp:${server}`),
60
+ ];
61
+ return names.sort();
31
62
  }
32
63
 
33
64
  export function buildStatusReport(input: {
34
- selection: ResolvedSelection;
35
- allSkills: Array<{ name: string; filePath: string }>;
65
+ plan: LaunchPlanFile;
66
+ overlay?: RuntimeOverlay;
36
67
  discoveredMcpServers: string[];
37
- filterOutcome?: SkillsFilterOutcome;
68
+ commands: RegisteredCommand[];
69
+ tools: RegisteredTool[];
38
70
  }): StatusReport {
39
- const { selection } = input;
40
- const visibleNames = visibleSkillNames(input.allSkills, selection.skills);
41
- const visible =
42
- visibleNames === undefined
43
- ? input.allSkills
44
- : input.allSkills.filter((skill) => visibleNames.includes(skill.name));
71
+ const { plan } = input;
45
72
 
46
- const enabled = selection.mcp ?? [];
73
+ const enabled = plan.mcps ?? [];
47
74
  const discovered = new Set(input.discoveredMcpServers);
48
- const unmatched = [
49
- ...selection.warnings.skillsUnmatched,
50
- ...selection.warnings.mcpUnmatched,
51
- ...selection.warnings.toolsUnmatched,
52
- ];
75
+ const mcp = {
76
+ enabled,
77
+ disabled: input.discoveredMcpServers.filter((name) => !enabled.includes(name)),
78
+ missing: enabled.filter((name) => !discovered.has(name)),
79
+ };
80
+
81
+ let delta: StatusReport["delta"];
82
+ if (plan.previousResolved !== undefined) {
83
+ const before = new Set(
84
+ [
85
+ ...plan.previousResolved.skills.map((name) => `skill:${name}`),
86
+ ...plan.previousResolved.extensions.map((id) => `extension:${id}`),
87
+ ...(plan.previousResolved.tools ?? []).map((name) => `tool:${name}`),
88
+ ...(plan.previousResolved.mcps ?? []).map((name) => `mcp:${name}`),
89
+ ].sort(),
90
+ );
91
+ const after = new Set(currentNames(plan));
92
+ const added = [...after].filter((name) => !before.has(name));
93
+ const removed = [...before].filter((name) => !after.has(name));
94
+ if (added.length > 0 || removed.length > 0) {
95
+ delta = { added, removed };
96
+ }
97
+ }
98
+
99
+ const conflicts: StatusConflict[] = [];
100
+ for (const skill of plan.resolved?.skills ?? []) {
101
+ const command = input.commands.find((entry) => entry.name === `skill:${skill.name}`);
102
+ const winnerPath = command?.sourceInfo?.path;
103
+ if (winnerPath === undefined) {
104
+ conflicts.push({ name: `skill:${skill.name}`, expectedPath: skill.filePath, winnerPath: "not loaded" });
105
+ } else if (winnerPath !== skill.filePath) {
106
+ conflicts.push({ name: `skill:${skill.name}`, expectedPath: skill.filePath, winnerPath });
107
+ }
108
+ }
109
+
110
+ // Tool conflicts: a plan tool whose registered winner is neither a pi
111
+ // builtin nor a tool from one of the plan's selected extensions was
112
+ // shadowed by (or shadows) an unexpected source.
113
+ const extensionDirs = (plan.resolved?.extensions ?? []).map((entry) =>
114
+ entry.entry.endsWith(".ts") ? entry.entry.slice(0, entry.entry.lastIndexOf("/")) : entry.entry,
115
+ );
116
+ for (const toolName of plan.tools ?? []) {
117
+ const winner = input.tools.find((entry) => entry.name === toolName);
118
+ const info = winner?.sourceInfo;
119
+ if (info === undefined) continue; // unknown names are dropped by pi.setActiveTools
120
+ const expected = info.source === "builtin" || extensionDirs.some((dir) => info.path.startsWith(dir));
121
+ if (!expected) {
122
+ conflicts.push({ name: `tool:${toolName}`, expectedPath: "builtin or selected extension", winnerPath: info.path });
123
+ }
124
+ }
53
125
 
54
126
  return {
55
- profile: selection.name,
56
- source: selection.source,
57
- skills: {
58
- filtered: selection.skills !== undefined,
59
- visible,
60
- loaded: input.allSkills.length,
61
- filterOutcome: input.filterOutcome ?? (selection.skills === undefined ? "no-filter" : "filtered"),
62
- },
63
- ...(selection.tools !== undefined
64
- ? { tools: { active: selection.tools, pending: selection.pendingTools } }
65
- : {}),
66
- mcp: {
67
- enabled,
68
- discovered: input.discoveredMcpServers,
69
- missing: enabled.filter((name) => !discovered.has(name)),
70
- },
71
- unresolved: { skills: selection.warnings.skillsUnresolved, unmatched },
127
+ profile: plan.profile,
128
+ source: plan.source,
129
+ overlay: input.overlay,
130
+ skills: plan.resolved?.skills ?? [],
131
+ extensions: plan.resolved?.extensions ?? [],
132
+ ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
133
+ mcp,
134
+ ...(delta !== undefined ? { delta } : {}),
135
+ ...(plan.unmatched !== undefined && plan.unmatched.length > 0 ? { unmatched: plan.unmatched } : {}),
136
+ conflicts,
72
137
  };
73
138
  }
74
139
 
@@ -78,36 +143,41 @@ export function formatStatusMarkdown(report: StatusReport): string {
78
143
  if (report.overlay !== undefined) {
79
144
  const parts = [
80
145
  ...(report.overlay.disabledSkills ?? []).map((name) => `-skill:${name}`),
81
- ...(report.overlay.disabledMcp ?? []).map((name) => `-mcp:${name}`),
146
+ ...(report.overlay.disabledExtensions ?? []).map((id) => `-extension:${id}`),
147
+ ...(report.overlay.disabledMcps ?? []).map((name) => `-mcp:${name}`),
82
148
  ...(report.overlay.tools !== undefined ? [`tools=[${report.overlay.tools.join(", ")}]`] : []),
83
149
  ];
84
150
  lines.push(`overlay: ${parts.length > 0 ? parts.join(" ") : "(empty)"}`);
85
151
  }
86
- const skillScope = report.skills.filtered
87
- ? `${report.skills.visible.length} of ${report.skills.loaded} loaded`
88
- : `all ${report.skills.loaded} loaded`;
89
- lines.push(`skills: ${skillScope}`);
90
- if (report.skills.filtered && report.skills.filterOutcome !== "filtered") {
91
- lines.push(`skills filter: not applied (${report.skills.filterOutcome})`);
152
+ if (report.skills.length > 0) {
153
+ lines.push("skills:");
154
+ for (const skill of report.skills) {
155
+ lines.push(` ${skill.name} ${skill.filePath}`);
156
+ }
92
157
  }
93
- for (const skill of report.skills.visible) {
94
- lines.push(` ${skill.name} → ${skill.filePath}`);
158
+ if (report.extensions.length > 0) {
159
+ lines.push("extensions:");
160
+ for (const extension of report.extensions) {
161
+ const originTag = extension.origin !== undefined ? ` [${extension.origin}]` : "";
162
+ lines.push(` ${extension.id}${originTag} → ${extension.entry}`);
163
+ }
95
164
  }
96
165
  if (report.tools !== undefined) {
97
- lines.push(`tools: [${report.tools.active.join(", ")}]`);
98
- if (report.tools.pending.length > 0) {
99
- lines.push(`tools pending (not registered yet): [${report.tools.pending.join(", ")}]`);
100
- }
166
+ lines.push(`tools: [${report.tools.join(", ")}]`);
101
167
  }
102
168
  lines.push(
103
- `mcp: enabled=[${report.mcp.enabled.join(", ")}] discovered=[${report.mcp.discovered.join(", ")}] missing=[${report.mcp.missing.join(", ")}]`,
169
+ `mcp: enabled=[${report.mcp.enabled.join(", ")}] disabled=[${report.mcp.disabled.join(", ")}] missing=[${report.mcp.missing.join(", ")}]`,
104
170
  );
105
- for (const unresolved of report.unresolved.skills) {
106
- const hint = unresolved.suggestions.length > 0 ? ` — did you mean: ${unresolved.suggestions.join(", ")}?` : "";
107
- lines.push(`unresolved skill: ${unresolved.reference}${hint}`);
171
+ if (report.delta !== undefined) {
172
+ lines.push(`delta: +[${report.delta.added.join(", ")}] -[${report.delta.removed.join(", ")}]`);
173
+ }
174
+ if (report.unmatched !== undefined) {
175
+ lines.push(`unmatched (zero-match globs this resolution): [${report.unmatched.join(", ")}]`);
108
176
  }
109
- if (report.unresolved.unmatched.length > 0) {
110
- lines.push(`zero-match globs: [${report.unresolved.unmatched.join(", ")}]`);
177
+ for (const conflict of report.conflicts) {
178
+ lines.push(
179
+ `conflict: ${conflict.name} — plan resolved ${conflict.expectedPath}, Pi registered ${conflict.winnerPath} (Pi first-wins load order)`,
180
+ );
111
181
  }
112
182
  return lines.join("\n");
113
183
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * SwitchProfile: the in-session switching orchestrator (ticket 05).
3
+ *
4
+ * Runs inside Pi (the extension's `/profile use` / `/profile reload`), but
5
+ * is written dependency-injected so unit tests never need a real Pi.
6
+ *
7
+ * Flow (`/profile use <name>`):
8
+ * 1. wait for the agent to be idle (Pi's native `ctx.waitForIdle()`) — a
9
+ * running turn is never torn down
10
+ * 2. snapshot the runtime dir's settings.json + pi-profile.json in memory
11
+ * 3. re-resolve through the full launcher path (trust check, catalogs,
12
+ * discovery, registry, model/MCP validation) against the REAL agent
13
+ * dir — any failure here leaves the runtime untouched
14
+ * 4. rewrite the runtime files in place (the running process's
15
+ * PI_CODING_AGENT_DIR cannot move) and mark the plan
16
+ * `persistSelection` so the post-reload extension instance saves the
17
+ * selection + rollback anchor
18
+ * 5. `ctx.reload()` — Pi re-reads settings from disk, re-executes
19
+ * extensions, preserves the session
20
+ * 6. VERIFY the reload ran: interactive Pi swallows reload refusals and
21
+ * errors (showError) instead of rejecting, so a resolved promise is
22
+ * not proof. A real reload invalidates this extension context — the
23
+ * `assertStale` probe throws iff that happened. A silent skip rolls
24
+ * back exactly like a rejection: restore the snapshot, reload again.
25
+ * The runtime never sits half-switched.
26
+ *
27
+ * `/profile reload` is the same path minus the `switchedFrom` marker (no
28
+ * change summary) and preserving however the current profile became active
29
+ * (transient launch selections stay transient).
30
+ */
31
+
32
+ import { readFile, writeFile } from "node:fs/promises";
33
+ import path from "node:path";
34
+
35
+ import { resolveInitialProfile } from "../launcher/initial-profile.ts";
36
+ import { RuntimeStateStore, type RuntimeOverlay } from "../runtime-state-store.ts";
37
+ import { getGlobalStateDir } from "../workspace.ts";
38
+ import { writeRuntimeFiles } from "../settings-generator.ts";
39
+ import { readLaunchPlanFile } from "./apply-plan.ts";
40
+
41
+ export class SwitchError extends Error {
42
+ constructor(message: string) {
43
+ super(message);
44
+ this.name = "SwitchError";
45
+ }
46
+ }
47
+
48
+ export interface SwitchDeps {
49
+ /** The active generated runtime dir (the running Pi's agent dir). */
50
+ runtimeDir: string;
51
+ /** The user's real agent dir (from the launch plan; trust, catalogs,
52
+ * registries, and state all live there). */
53
+ realAgentDir: string;
54
+ /** The project working directory. */
55
+ cwd: string;
56
+ /** Pi's native idle wait (ctx.waitForIdle): resolves when the current
57
+ * turn/compaction finishes. */
58
+ waitForIdle(): Promise<void>;
59
+ reload(): Promise<void>;
60
+ /** Throws iff this extension context has been invalidated — proof the
61
+ * reload actually re-executed extensions. Interactive Pi swallows reload
62
+ * refusals/failures instead of rejecting, so without this probe a
63
+ * skipped reload would be misreported as a successful switch. Optional
64
+ * for tests; always provided by the extension. */
65
+ assertStale?(): void;
66
+ }
67
+
68
+ export interface SwitchResult {
69
+ profile: string;
70
+ warnings: string[];
71
+ }
72
+
73
+ interface RuntimeSnapshot {
74
+ settings?: string;
75
+ plan?: string;
76
+ }
77
+
78
+ async function readIfExists(filePath: string): Promise<string | undefined> {
79
+ try {
80
+ return await readFile(filePath, "utf8");
81
+ } catch (error) {
82
+ // Absence is expected (first launch); anything else (permissions,
83
+ // unreadable dir) must not silently disable rollback protection.
84
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ async function snapshotRuntimeFiles(runtimeDir: string): Promise<RuntimeSnapshot> {
90
+ return {
91
+ settings: await readIfExists(path.join(runtimeDir, "settings.json")),
92
+ plan: await readIfExists(path.join(runtimeDir, "pi-profile.json")),
93
+ };
94
+ }
95
+
96
+ async function restoreRuntimeFiles(runtimeDir: string, snapshot: RuntimeSnapshot): Promise<void> {
97
+ if (snapshot.settings !== undefined) {
98
+ await writeFile(path.join(runtimeDir, "settings.json"), snapshot.settings);
99
+ }
100
+ if (snapshot.plan !== undefined) {
101
+ await writeFile(path.join(runtimeDir, "pi-profile.json"), snapshot.plan);
102
+ }
103
+ }
104
+
105
+ /** Waits are delegated to Pi's native `ctx.waitForIdle()` (see SwitchDeps);
106
+ * no polling loop lives here. */
107
+
108
+ /** Reads the current plan file for `switchedFrom`/persistence. A missing or
109
+ * malformed plan means the session is not profile-managed: switching still
110
+ * works, with no prior name to report. */
111
+ async function readCurrentPlan(runtimeDir: string): Promise<{ profile?: string; persistSelection: boolean }> {
112
+ const plan = await readLaunchPlanFile(runtimeDir);
113
+ return { profile: plan?.profile, persistSelection: plan?.persistSelection === true };
114
+ }
115
+
116
+ export async function switchProfile(
117
+ name: string | undefined,
118
+ deps: SwitchDeps,
119
+ options?: { reloadCurrent?: boolean; overlay?: RuntimeOverlay | null; clearOverlay?: boolean },
120
+ ): Promise<SwitchResult> {
121
+ const current = await readCurrentPlan(deps.runtimeDir);
122
+ const target = options?.reloadCurrent === true ? (current.profile ?? name) : name;
123
+ if (target === undefined) {
124
+ throw new SwitchError("no active profile to reload");
125
+ }
126
+
127
+ // Overlay resolution: explicit `overlay` wins (customize), explicit
128
+ // `null` suppresses (reset/switch), and a plain `/profile reload`
129
+ // re-applies the stored overlay so runtime and state never diverge.
130
+ let overlay = options?.overlay;
131
+ if (overlay === undefined && options?.reloadCurrent === true && current.profile !== undefined) {
132
+ const currentPlan = await readLaunchPlanFile(deps.runtimeDir);
133
+ if (currentPlan?.agentDir !== undefined) {
134
+ const stateDir = currentPlan.source === "project" ? path.join(deps.cwd, ".pi") : getGlobalStateDir(currentPlan.agentDir);
135
+ overlay = (await new RuntimeStateStore(stateDir).read()).overlay ?? null;
136
+ }
137
+ }
138
+
139
+ await deps.waitForIdle();
140
+
141
+ // Snapshot before resolving so the rollback target always exists.
142
+ const snapshot = await snapshotRuntimeFiles(deps.runtimeDir);
143
+
144
+ // Full launcher resolution: trust gate, catalogs, discovery, dependency
145
+ // closure, model + MCP validation. Failures here leave the runtime
146
+ // untouched — nothing was written yet.
147
+ const resolved = await resolveInitialProfile(
148
+ target,
149
+ { agentDir: deps.realAgentDir, cwd: deps.cwd },
150
+ { overlay: overlay ?? undefined },
151
+ );
152
+
153
+ const isSwitch = !options?.reloadCurrent && target !== current.profile;
154
+ // Carry the pre-switch resolved sets into the new plan for status deltas.
155
+ const previousPlan = await readLaunchPlanFile(deps.runtimeDir);
156
+ const previousResolved =
157
+ previousPlan?.resolved !== undefined
158
+ ? {
159
+ skills: previousPlan.resolved.skills.map((skill) => skill.name),
160
+ extensions: previousPlan.resolved.extensions.map((entry) => entry.id),
161
+ ...(previousPlan.tools !== undefined ? { tools: previousPlan.tools } : {}),
162
+ ...(previousPlan.mcps !== undefined ? { mcps: previousPlan.mcps } : {}),
163
+ }
164
+ : undefined;
165
+ await writeRuntimeFiles(deps.runtimeDir, resolved.plan, {
166
+ agentDir: deps.realAgentDir,
167
+ discovery: resolved.discovery,
168
+ projectSettings: resolved.projectSettings,
169
+ planExtras: {
170
+ ...(isSwitch && current.profile !== undefined ? { switchedFrom: current.profile } : {}),
171
+ // `/profile use` persists; `/profile reload` keeps the current
172
+ // profile's existing persistence (launch selections stay transient).
173
+ persistSelection: options?.reloadCurrent === true ? current.persistSelection : true,
174
+ // A switch discards the previous profile's overlay; the post-reload
175
+ // instance drops it from the state file. Customize/reset manage the
176
+ // overlay directly and never set this.
177
+ ...(options?.clearOverlay === true ? { clearOverlay: true } : {}),
178
+ ...(previousResolved !== undefined ? { previousResolved } : {}),
179
+ },
180
+ });
181
+
182
+ const rollback = async (cause: string): Promise<never> => {
183
+ // Restore the verified snapshot and reload again — the runtime must
184
+ // never sit half-switched. State files were not written yet (the
185
+ // post-reload extension instance owns them), so nothing else moved.
186
+ await restoreRuntimeFiles(deps.runtimeDir, snapshot);
187
+ try {
188
+ await deps.reload();
189
+ } catch {
190
+ // The restore reload failing too is reported through the original error.
191
+ }
192
+ throw new SwitchError(
193
+ `activation of profile "${target}" failed; restored the previous settings. Cause: ${cause}`,
194
+ );
195
+ };
196
+
197
+ try {
198
+ await deps.reload();
199
+ } catch (error) {
200
+ await rollback(error instanceof Error ? error.message : String(error));
201
+ }
202
+
203
+ // Interactive Pi reports reload refusals/failures via the UI instead of
204
+ // rejecting — verify the reload actually re-executed extensions (which
205
+ // invalidates this context) before calling the switch a success.
206
+ if (deps.assertStale !== undefined) {
207
+ let stale = false;
208
+ try {
209
+ deps.assertStale();
210
+ } catch {
211
+ stale = true;
212
+ }
213
+ if (!stale) {
214
+ await rollback("Pi did not run the reload (refused or failed silently)");
215
+ }
216
+ }
217
+
218
+ return { profile: resolved.plan.profile, warnings: resolved.warnings };
219
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Tool reference expansion against Pi's LIVE tool registry.
3
+ *
4
+ * Pre-spawn, the resolver expands tool globs against built-in names only
5
+ * (extension tools are unknowable before extension code runs). Post session
6
+ * start, the extension re-expands the raw references against
7
+ * `pi.getAllTools()`, which includes extension- and MCP-provided names.
8
+ * Literal references that match nothing are reported, not silently dropped
9
+ * (Pi's setActiveTools ignores unknown names).
10
+ */
11
+
12
+ import { minimatch } from "minimatch";
13
+
14
+ export interface ToolExpansion {
15
+ expanded: string[];
16
+ /** Literal references no live tool provides. */
17
+ droppedLiterals: string[];
18
+ }
19
+
20
+ export function expandToolReferences(references: string[], liveToolNames: string[]): ToolExpansion {
21
+ const live = new Set(liveToolNames);
22
+ const expanded = new Set<string>();
23
+ const droppedLiterals: string[] = [];
24
+ for (const reference of references) {
25
+ if (reference.includes("*") || reference.includes("?")) {
26
+ for (const name of liveToolNames) {
27
+ if (minimatch(name, reference)) {
28
+ expanded.add(name);
29
+ }
30
+ }
31
+ continue;
32
+ }
33
+ if (live.has(reference)) {
34
+ expanded.add(reference);
35
+ } else {
36
+ droppedLiterals.push(reference);
37
+ }
38
+ }
39
+ return { expanded: [...expanded], droppedLiterals };
40
+ }