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,778 +0,0 @@
1
- import {
2
- getAgentDir,
3
- type BuildSystemPromptOptions,
4
- type ExtensionAPI,
5
- type ExtensionCommandContext,
6
- type ExtensionContext,
7
- } from "@earendil-works/pi-coding-agent";
8
-
9
- import { adapterPresent } from "../../src/adapter-presence.ts";
10
- import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
11
- import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
12
- import { readSessionChoices } from "../../src/model-selection.ts";
13
- import { buildProfileBadge, PROFILE_STATUS_KEY, renderProfileBadge } from "../../src/profile-badge.ts";
14
- import type { ProfileDefinition } from "../../src/profile-catalog.ts";
15
- import { seedDefaultProfilesSync } from "../../src/default-profiles.ts";
16
- import {
17
- formatSelectionWarnings,
18
- formatSkillWarnings,
19
- skillWarnings,
20
- type LiveResources,
21
- type ResolvedSelection,
22
- } from "../../src/profile-resolver.ts";
23
- import { RuntimeStateStore, overlayNarrows, stateDirFor, type RuntimeOverlay } from "../../src/runtime-state-store.ts";
24
- import {
25
- applySkillsFilter,
26
- formatInstructionsBlock,
27
- type SkillsFilterOutcome,
28
- } from "../../src/skill-selection.ts";
29
- import {
30
- appliedProfile,
31
- detectExplicitDeclarations,
32
- readProfileFlag,
33
- recordAppliedProfile,
34
- registerProfileFlag,
35
- resolveStartupProfile,
36
- } from "../../src/startup-selection.ts";
37
- import {
38
- readFlagFromArgv,
39
- syncMcpOverlayForSelection,
40
- syncStartupMcpOverlay,
41
- } from "../../src/startup-mcp-scope.ts";
42
- import { retryPendingTools, type ApplySurface } from "../../src/switching/apply-profile.ts";
43
- import {
44
- activateProfile,
45
- type ActivationDeps,
46
- type ActivationResult,
47
- } from "../../src/switching/activate-profile.ts";
48
- import { CUSTOMIZE_USAGE, customizeOverlay, parseCustomizeArgs, resetOverlay } from "../../src/switching/customize.ts";
49
- import { formatProfileList, listProfiles, type ProfileListEntry } from "../../src/switching/list-profiles.ts";
50
- import { setMcpServerEnabled } from "../../src/switching/mcp-toggle.ts";
51
- import {
52
- createProfile,
53
- deleteProfile,
54
- duplicateProfile,
55
- editProfile,
56
- readCatalogScope,
57
- type CatalogInput,
58
- } from "../../src/switching/profile-crud.ts";
59
- import {
60
- runProfileCreateWizard,
61
- runProfileDuplicateWizard,
62
- runProfileEditWizard,
63
- } from "../../src/switching/profile-wizard.ts";
64
- import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
65
-
66
- /**
67
- * pi-profile-switch extension entry (ADR-0007).
68
- *
69
- * Installed like any other Pi package: no launcher, no environment override,
70
- * no generated settings. The agent dir stays Pi's own, so sessions,
71
- * extension configuration, packages, project trust, and context files are
72
- * all native.
73
- *
74
- * Responsibilities:
75
- * - load: seed `<agentDir>/profiles.json` from the shipped default catalog
76
- * when the file does not exist yet (Pi packages have no install hook), and
77
- * report a failure once at `session_start`.
78
- * - `session_start`: resolve the startup profile (`--profile <flag>`, else
79
- * the saved selection, else `default`; every start after the first
80
- * continues the selection this process already applied), then apply the
81
- * runtime parts of the selection — model preset, active tools, MCP
82
- * allowlist. A failed activation applies nothing and reports loudly.
83
- * - `before_agent_start`: rebuild the system prompt each turn — replace the
84
- * skills section with the profile's visible set and append the profile's
85
- * instructions. Unselected skills stay loaded and `/skill:`-invocable.
86
- * - `/profile …` command family and `/mcp enable|disable`.
87
- * - Retry pending tool literals each turn until MCP/extension tools register.
88
- * - Footer badge: `profile: <name>` (plus `*` for a runtime overlay) in Pi's
89
- * footer status line while a non-`default` profile is active.
90
- */
91
-
92
- type ContextWithOptions = ExtensionContext & { getSystemPromptOptions?: () => BuildSystemPromptOptions };
93
-
94
- /** The active profile in this runtime and the last prompt-filter result. */
95
- interface Activation {
96
- selection: ResolvedSelection;
97
- skillsOutcome?: SkillsFilterOutcome;
98
- /** Whether the skill references were checked against Pi's loaded set.
99
- * False after a startup activation: `session_start`'s event context
100
- * cannot read that list, so the check moves to the first turn. */
101
- skillsChecked: boolean;
102
- /** The overlay this runtime was activated with, when one is in effect. */
103
- overlay?: RuntimeOverlay;
104
- }
105
-
106
- /** Maps an activation result onto the runtime state `current` mirrors. */
107
- function activationOf(result: ActivationResult, skillsChecked: boolean): Activation {
108
- return {
109
- selection: result.selection,
110
- skillsChecked,
111
- ...(result.overlay === undefined ? {} : { overlay: result.overlay }),
112
- };
113
- }
114
-
115
- /** Subcommands that mutate a catalog; they need dialog-capable UI. */
116
- const CRUD_SUBCOMMANDS = ["create", "duplicate", "edit", "delete"] as const;
117
- const SUBCOMMANDS = ["use", "list", "status", "customize", "reset", ...CRUD_SUBCOMMANDS] as const;
118
- const REQUIRED_ARGUMENT: Readonly<Record<string, string>> = { use: "<name>", edit: "<name>", delete: "<name>" };
119
- const PROFILE_USAGE = [
120
- "/profile [picker]",
121
- "/profile use <name>",
122
- "/profile list | /profile status",
123
- CUSTOMIZE_USAGE,
124
- "/profile reset",
125
- "/profile create | /profile duplicate",
126
- "/profile edit <name> | /profile delete <name>",
127
- ].join(" · ");
128
-
129
- export default function piProfileExtension(pi: ExtensionAPI): void {
130
- registerProfileFlag(pi);
131
- const argv = process.argv.slice(2);
132
- const explicit = detectExplicitDeclarations(argv);
133
- const loadAgentDir = getAgentDir();
134
- // Install-time default: Pi packages have no install hook, so the first
135
- // load seeds <agentDir>/profiles.json from the shipped catalog. The write
136
- // is idempotent, never overwrites a user catalog, and a failure is
137
- // reported at session_start instead of blocking the load.
138
- let seedWarning: string | undefined;
139
- try {
140
- seedDefaultProfilesSync(loadAgentDir);
141
- } catch (error) {
142
- seedWarning = `pi-profile-switch: could not write the default profiles.json — ${error instanceof Error ? error.message : String(error)}`;
143
- }
144
- // pi-mcp-adapter reads its config before any session event fires (and, for
145
- // eager servers, at its own load time), so the startup profile's overlay
146
- // is generated here, synchronously. Pi applies CLI flag values only after
147
- // extension loading, hence argv.
148
- const adapterInstalled = adapterPresent({
149
- agentDir: loadAgentDir,
150
- argv,
151
- probeAnswered: probeAdapterPresence(pi.events),
152
- });
153
- if (adapterInstalled) {
154
- const requestedConfigPath = readFlagFromArgv(argv, "mcp-config");
155
- // A reload's overlay belongs to the run's current selection: the same
156
- // continuation `session_start` applies right after this pass.
157
- const runProfile = appliedProfile();
158
- syncStartupMcpOverlay({
159
- agentDir: loadAgentDir,
160
- cwd: process.cwd(),
161
- argv,
162
- ...(requestedConfigPath === undefined ? {} : { overridePath: requestedConfigPath }),
163
- ...(runProfile === undefined ? {} : { continuation: runProfile }),
164
- });
165
- }
166
- let current: Activation | undefined;
167
- let filterWarningShown = false;
168
- /** The last badge written to the footer, so a refresh only talks to Pi
169
- * when the rendering actually changed. */
170
- let badgeText: string | undefined;
171
-
172
- /** The only writer of `current`'s profile identity (`selection.name` and
173
- * `overlay`) and of the footer badge. Both mirror the selection this
174
- * runtime applied, so a failed activation (which throws before reaching
175
- * here) never claims to be active. The per-turn updates (`pendingTools`,
176
- * `skillsOutcome`) leave the identity and the badge untouched. */
177
- function setCurrent(ctx: ExtensionContext, next: Activation | undefined): void {
178
- current = next;
179
- refreshBadge(ctx);
180
- }
181
-
182
- /** Re-renders the badge from `current`. `default` and an unapplied profile
183
- * render no badge, which removes Pi's footer status line entirely. */
184
- function refreshBadge(ctx: ExtensionContext): void {
185
- if (!ctx.hasUI) return;
186
- const badge =
187
- current === undefined
188
- ? undefined
189
- : buildProfileBadge(current.selection.name, { overlay: overlayNarrows(current.overlay) });
190
- const text = badge === undefined ? undefined : renderProfileBadge(badge, ctx.ui.theme);
191
- if (text === badgeText) return;
192
- badgeText = text;
193
- ctx.ui.setStatus(PROFILE_STATUS_KEY, text);
194
- }
195
-
196
- const surface = (ctx: ExtensionContext): ApplySurface => ({
197
- getAllTools: () => pi.getAllTools(),
198
- setActiveTools: (names) => pi.setActiveTools(names),
199
- modelRegistry: ctx.modelRegistry,
200
- // The package does not export Pi's ThinkingLevel type, so the surface
201
- // takes the API's own parameter types at the wiring boundary.
202
- setModel: (model) => pi.setModel(model as Parameters<ExtensionAPI["setModel"]>[0]),
203
- setThinkingLevel: (level) =>
204
- pi.setThinkingLevel(level as Parameters<ExtensionAPI["setThinkingLevel"]>[0]),
205
- events: pi.events,
206
- });
207
-
208
- function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void {
209
- ctx.ui.notify(message, level);
210
- }
211
-
212
- function reportWarnings(ctx: ExtensionContext, warnings: string[]): void {
213
- for (const warning of warnings) notify(ctx, warning, "warning");
214
- }
215
-
216
- async function loadLive(ctx: ExtensionContext, projectTrusted: boolean): Promise<LiveResources> {
217
- // Only command contexts expose the system-prompt options; the
218
- // `session_start` event context has no accessor, so the loaded skills
219
- // stay unknown there and their existence check moves to the first turn.
220
- const options = (ctx as ContextWithOptions).getSystemPromptOptions?.();
221
- const adapterPresent = probeAdapterPresence(pi.events);
222
- let servers: string[] = [];
223
- if (adapterPresent) {
224
- try {
225
- servers = await discoverAdapterServerNames(getAgentDir(), projectTrusted ? ctx.cwd : undefined);
226
- } catch (error) {
227
- notify(ctx, error instanceof Error ? error.message : String(error), "warning");
228
- }
229
- }
230
- return {
231
- ...(options?.skills === undefined
232
- ? {}
233
- : { skills: options.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })) }),
234
- toolNames: pi.getAllTools().map((tool) => tool.name),
235
- mcp: { adapterPresent, servers },
236
- };
237
- }
238
-
239
- /** Builds the dependencies for one activation. `force` marks an explicit
240
- * user choice (`/profile use`, picker, CRUD reactivation): the profile's
241
- * model and tool declarations then outrank the session state and the
242
- * CLI flags. */
243
- async function activationDeps(ctx: ExtensionContext, force: boolean): Promise<ActivationDeps> {
244
- const projectTrusted = ctx.isProjectTrusted();
245
- return {
246
- agentDir: getAgentDir(),
247
- cwd: ctx.cwd,
248
- projectTrusted,
249
- live: await loadLive(ctx, projectTrusted),
250
- surface: surface(ctx),
251
- presetInputs: { explicit, session: readSessionChoices(ctx.sessionManager.getEntries()), force },
252
- };
253
- }
254
-
255
- /** Resolves, validates, persists and applies one profile, then records it
256
- * as the active selection and reports its warnings. */
257
- async function activate(
258
- ctx: ExtensionContext,
259
- name: string,
260
- options?: { force?: boolean; overlay?: RuntimeOverlay | null; persist?: boolean },
261
- ): Promise<ActivationResult> {
262
- const deps = await activationDeps(ctx, options?.force ?? false);
263
- const result = await activateProfile(name, deps, {
264
- overlay: options?.overlay ?? null,
265
- persist: options?.persist ?? true,
266
- });
267
- // The run's current selection, for the reload continuation.
268
- recordAppliedProfile(result.selection.name);
269
- setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
270
- reportWarnings(ctx, formatSelectionWarnings(result.selection));
271
- return result;
272
- }
273
-
274
- async function profileEntries(ctx: ExtensionContext): Promise<ProfileListEntry[]> {
275
- return listProfiles({
276
- realAgentDir: getAgentDir(),
277
- cwd: ctx.cwd,
278
- projectTrusted: ctx.isProjectTrusted(),
279
- });
280
- }
281
-
282
- function sendListMessage(entries: ProfileListEntry[]): void {
283
- pi.sendMessage({
284
- customType: "pi-profile-switch",
285
- content: formatProfileList(entries, current?.selection.name),
286
- display: true,
287
- details: { kind: "list", profiles: entries },
288
- });
289
- }
290
-
291
- /** `session_start` re-check. The load-time pass cannot know an interactive
292
- * trust answer, and the adapter has already read its config by now — so a
293
- * difference is written for the next reload and reported instead. */
294
- function reportMcpOverlayState(
295
- ctx: ExtensionContext,
296
- profileName: string,
297
- selection: ResolvedSelection,
298
- input: { agentDir: string; cwd: string; projectTrusted: boolean },
299
- ): void {
300
- const configOverride = readFlagFromArgv(argv, "mcp-config");
301
- const sync = syncStartupMcpOverlay({
302
- agentDir: input.agentDir,
303
- cwd: input.cwd,
304
- projectTrusted: input.projectTrusted,
305
- profileName,
306
- argv,
307
- ...(configOverride === undefined ? {} : { overridePath: configOverride }),
308
- });
309
- if (sync.error !== undefined) {
310
- notify(ctx, `pi-profile-switch: MCP overlay unavailable — ${sync.error}`, "warning");
311
- return;
312
- }
313
- if (!sync.managed) {
314
- if (selection.mcp !== undefined) {
315
- notify(
316
- ctx,
317
- `pi-profile-switch: --mcp-config points at another file — profile "${profileName}" cannot filter MCP servers`,
318
- "warning",
319
- );
320
- }
321
- return;
322
- }
323
- if (!sync.changed) return;
324
- notify(ctx, `pi-profile-switch: MCP config updated for profile "${profileName}" — run /reload to apply`, "warning");
325
- }
326
-
327
- /** Repoints the adapter at the overlay for a just-activated selection and
328
- * rebuilds the runtime when the MCP surface actually moved. Must be the
329
- * caller's LAST use of `ctx`: `reload()` invalidates the old context. */
330
- async function reloadForMcpOverlay(ctx: ExtensionCommandContext, selection: ResolvedSelection): Promise<void> {
331
- if (!adapterInstalled) return;
332
- const configOverride = readFlagFromArgv(argv, "mcp-config");
333
- const sync = syncMcpOverlayForSelection({
334
- agentDir: getAgentDir(),
335
- cwd: ctx.cwd,
336
- projectTrusted: ctx.isProjectTrusted(),
337
- allowed: selection.mcp === undefined ? "all" : selection.mcp,
338
- ...(configOverride === undefined ? {} : { overridePath: configOverride }),
339
- });
340
- if (sync.error !== undefined) {
341
- notify(ctx, `pi-profile-switch: MCP overlay not updated — ${sync.error}`, "warning");
342
- return;
343
- }
344
- if (!sync.managed) {
345
- if (selection.mcp !== undefined) {
346
- notify(
347
- ctx,
348
- `pi-profile-switch: --mcp-config points at another file — profile "${selection.name}" cannot filter MCP servers`,
349
- "warning",
350
- );
351
- }
352
- return;
353
- }
354
- if (!sync.changed) return;
355
- notify(ctx, `profile "${selection.name}": MCP servers updated — reloading runtime`, "info");
356
- await ctx.waitForIdle();
357
- await ctx.reload();
358
- }
359
-
360
- /** Bare `/profile`: the interactive picker, with a list fallback for
361
- * modes without dialogs. */
362
- async function runPicker(ctx: ExtensionCommandContext, entries: ProfileListEntry[]): Promise<void> {
363
- if (!ctx.hasUI) {
364
- sendListMessage(entries);
365
- return;
366
- }
367
- const choice = await ctx.ui.select(
368
- "select a profile",
369
- entries.map((entry) => {
370
- const label = entry.label ?? entry.description;
371
- return `${entry.name} [${entry.source}]${label !== undefined ? ` — ${label}` : ""}`;
372
- }),
373
- );
374
- if (choice === undefined) return; // cancelled
375
- const chosen = entries.find((entry) => choice.startsWith(`${entry.name} [`));
376
- if (chosen === undefined || chosen.name === current?.selection.name) return;
377
- const result = await activate(ctx, chosen.name, { force: true, overlay: null, persist: true });
378
- notify(ctx, `profile active: ${result.selection.name}`, "info");
379
- await reloadForMcpOverlay(ctx, result.selection);
380
- }
381
-
382
- /** `/profile create|duplicate|edit|delete`: TUI-only catalog CRUD. */
383
- async function runCatalogCrud(
384
- ctx: ExtensionCommandContext,
385
- subcommand: string,
386
- rest: string[],
387
- scopeInput: CatalogInput,
388
- ): Promise<void> {
389
- if (subcommand === "create") {
390
- const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: scopeInput.projectTrusted });
391
- if (wizard === undefined) return;
392
- await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
393
- const origin = wizard.preset !== undefined ? ` from preset "${wizard.preset}"` : "";
394
- notify(
395
- ctx,
396
- `created profile "${wizard.name}" (${wizard.scope})${origin} — activate with /profile use ${wizard.name}`,
397
- "info",
398
- );
399
- return;
400
- }
401
- if (subcommand === "duplicate") {
402
- const entries = await profileEntries(ctx);
403
- const byScope = {
404
- global: await readCatalogScope(scopeInput, "global"),
405
- project: await readCatalogScope(scopeInput, "project"),
406
- };
407
- const candidates: Array<{ name: string; source: "global" | "project"; definition: ProfileDefinition }> = [];
408
- for (const entry of entries) {
409
- if (entry.source === "builtin") continue;
410
- const definition = byScope[entry.source].get(entry.name);
411
- if (definition !== undefined) {
412
- candidates.push({ name: entry.name, source: entry.source, definition });
413
- }
414
- }
415
- const wizard = await runProfileDuplicateWizard(ctx.ui, { candidates });
416
- if (wizard === undefined) return;
417
- await duplicateProfile(scopeInput, wizard.scope, wizard.sourceName, wizard.newName);
418
- notify(ctx, `duplicated "${wizard.sourceName}" → "${wizard.newName}" (${wizard.scope})`, "info");
419
- return;
420
- }
421
-
422
- const name = rest[0] as string;
423
- const entries = await profileEntries(ctx);
424
- const existing = entries.find((entry) => entry.name === name);
425
- if (existing === undefined || existing.source === "builtin") {
426
- notify(ctx, `profile "${name}" not found in a writable catalog`, "error");
427
- return;
428
- }
429
- if (subcommand === "edit") {
430
- const definition = (await readCatalogScope(scopeInput, existing.source)).get(name);
431
- if (definition === undefined) {
432
- notify(ctx, `profile "${name}" not found in the ${existing.source} catalog`, "error");
433
- return;
434
- }
435
- const wizard = await runProfileEditWizard(ctx.ui, {
436
- existing: { name, source: existing.source, definition },
437
- });
438
- if (wizard === undefined) return;
439
- await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
440
- if (current !== undefined && name === current.selection.name) {
441
- const reactivated = await activate(ctx, name, { persist: true });
442
- notify(ctx, `saved and reactivated profile "${name}"`, "info");
443
- await reloadForMcpOverlay(ctx, reactivated.selection);
444
- } else {
445
- notify(ctx, `saved profile "${name}" (inactive — runtime untouched)`, "info");
446
- }
447
- return;
448
- }
449
-
450
- // delete: choose the scope when both catalogs hold the name, and the
451
- // replacement when the active profile disappears for good.
452
- let scope = existing.source as "global" | "project";
453
- const projectCatalog = await readCatalogScope(scopeInput, "project");
454
- const globalCatalog = await readCatalogScope(scopeInput, "global");
455
- if (projectCatalog.has(name) && globalCatalog.has(name)) {
456
- const chosen = await ctx.ui.select(`delete "${name}" from which catalog?`, ["global", "project"]);
457
- if (chosen === undefined) return;
458
- scope = chosen as "global" | "project";
459
- }
460
- const survivesElsewhere =
461
- (scope === "project" && globalCatalog.has(name)) || (scope === "global" && projectCatalog.has(name));
462
- const isActive = current !== undefined && name === current.selection.name;
463
- let replacement: string | undefined;
464
- if (isActive && !survivesElsewhere) {
465
- const survivors = entries.filter((entry) => entry.name !== name);
466
- const chosen = await ctx.ui.select(
467
- `"${name}" is active — switch to which profile?`,
468
- survivors.map((entry) => `${entry.name} [${entry.source}]`),
469
- );
470
- if (chosen === undefined) return;
471
- replacement = survivors.find((entry) => chosen.startsWith(`${entry.name} [`))?.name;
472
- if (replacement === undefined) return;
473
- }
474
- await deleteProfile(scopeInput, scope, name, {
475
- ...(isActive ? { activeProfile: name } : {}),
476
- ...(replacement !== undefined ? { replacement } : {}),
477
- });
478
- if (isActive) {
479
- const result = await activate(ctx, replacement ?? name, { force: replacement !== undefined, persist: true });
480
- notify(ctx, `deleted "${name}" (${scope}); profile active: ${result.selection.name}`, "info");
481
- await reloadForMcpOverlay(ctx, result.selection);
482
- } else {
483
- notify(ctx, `deleted profile "${name}" (${scope})`, "info");
484
- }
485
- }
486
-
487
- pi.on("session_start", async (event, ctx) => {
488
- setCurrent(ctx, undefined);
489
- filterWarningShown = false;
490
- if (seedWarning !== undefined) {
491
- notify(ctx, seedWarning, "warning");
492
- seedWarning = undefined;
493
- }
494
- const agentDir = getAgentDir();
495
- const projectTrusted = ctx.isProjectTrusted();
496
- // Every session start after the first (reload, new, resume, fork)
497
- // continues the selection this process already applied. The `--profile`
498
- // flag is a startup directive: re-reading it here would silently
499
- // resurrect the profile the user just left.
500
- const continuation = event.reason === "startup" ? undefined : appliedProfile();
501
- const requested = readProfileFlag(pi);
502
- let startup;
503
- try {
504
- startup = await resolveStartupProfile({
505
- agentDir,
506
- cwd: ctx.cwd,
507
- projectTrusted,
508
- ...(requested !== undefined ? { requested } : {}),
509
- ...(continuation !== undefined ? { continuation } : {}),
510
- });
511
- } catch (error) {
512
- notify(ctx, error instanceof Error ? error.message : String(error), "error");
513
- return;
514
- }
515
- try {
516
- // Startup activation never persists (a `--profile` selection is for
517
- // this run only) and never applies a stored overlay.
518
- const activated = await activate(ctx, startup.name, { persist: false, overlay: null });
519
- reportWarnings(ctx, startup.warnings);
520
- if (adapterInstalled) {
521
- reportMcpOverlayState(ctx, startup.name, activated.selection, {
522
- agentDir,
523
- cwd: ctx.cwd,
524
- projectTrusted,
525
- });
526
- }
527
- } catch (error) {
528
- recordAppliedProfile(undefined);
529
- notify(ctx, error instanceof Error ? error.message : String(error), "error");
530
- reportWarnings(ctx, startup.warnings);
531
- }
532
- });
533
-
534
- pi.on("before_agent_start", async (event, ctx) => {
535
- if (current === undefined) return;
536
- // Retry tool literals that were not registered at activation time;
537
- // once all of them resolve, retrying stops so a native manual toggle
538
- // is never clobbered.
539
- const retry = retryPendingTools({ selection: current.selection, surface: surface(ctx) });
540
- if (retry.applied) {
541
- current = {
542
- ...current,
543
- selection: {
544
- ...current.selection,
545
- pendingTools: retry.pendingTools,
546
- ...(retry.active !== undefined ? { tools: retry.active } : {}),
547
- },
548
- };
549
- }
550
- // A startup activation cannot see Pi's skill list, so its references are
551
- // checked here, on the first turn that carries the complete set — by then
552
- // `resources_discover` has contributed every extension's skills. The
553
- // corrected warnings replace the (empty) startup ones, so `/profile
554
- // status` reports the same thing the user was told.
555
- if (!current.skillsChecked) {
556
- const refs = current.selection.skills?.refs;
557
- const corrected =
558
- refs === undefined || refs === "all"
559
- ? undefined
560
- : skillWarnings(
561
- refs,
562
- (event.systemPromptOptions.skills ?? []).map((skill) => ({
563
- name: skill.name,
564
- filePath: skill.filePath,
565
- })),
566
- );
567
- current = {
568
- ...current,
569
- skillsChecked: true,
570
- ...(corrected === undefined
571
- ? {}
572
- : { selection: { ...current.selection, warnings: { ...current.selection.warnings, ...corrected } } }),
573
- };
574
- if (corrected !== undefined) {
575
- reportWarnings(ctx, formatSkillWarnings(current.selection.name, corrected));
576
- }
577
- }
578
- // Pi has no extension-visible theme-change event and footer statuses are
579
- // stored as finished strings, so re-render once per turn: a `/theme`
580
- // switch is picked up without waiting for the next profile change.
581
- // `refreshBadge` dedupes, so an unchanged badge sends nothing.
582
- refreshBadge(ctx);
583
- const filtered = applySkillsFilter({
584
- systemPrompt: event.systemPrompt,
585
- options: event.systemPromptOptions,
586
- filter: current.selection.skills,
587
- });
588
- if (
589
- current.selection.skills !== undefined &&
590
- (filtered.outcome === "section-missing" || filtered.outcome === "no-read-tool") &&
591
- !filterWarningShown
592
- ) {
593
- filterWarningShown = true;
594
- const cause =
595
- filtered.outcome === "no-read-tool"
596
- ? "neither the read nor the bash tool is active"
597
- : "the system prompt carries no skills section";
598
- notify(
599
- ctx,
600
- `pi-profile-switch: ${cause} — profile "${current.selection.name}" skills are not narrowed this session`,
601
- "warning",
602
- );
603
- }
604
- current = { ...current, skillsOutcome: filtered.outcome };
605
- let systemPrompt = filtered.systemPrompt;
606
- if (current.selection.instructions !== undefined) {
607
- systemPrompt += formatInstructionsBlock(current.selection.name, current.selection.instructions);
608
- }
609
- if (systemPrompt === event.systemPrompt) return;
610
- return { systemPrompt };
611
- });
612
-
613
- pi.registerCommand("profile", {
614
- description: `pi-profile-switch: ${PROFILE_USAGE}`,
615
- handler: async (args, ctx) => {
616
- const [subcommandRaw, ...rest] = args.trim().split(/\s+/).filter(Boolean);
617
- const subcommand = subcommandRaw ?? "";
618
- if (subcommand !== "" && !(SUBCOMMANDS as readonly string[]).includes(subcommand)) {
619
- notify(ctx, PROFILE_USAGE, "error");
620
- return;
621
- }
622
- const required = REQUIRED_ARGUMENT[subcommand];
623
- if (required !== undefined && rest[0] === undefined) {
624
- notify(ctx, `usage: /profile ${subcommand} ${required}`, "error");
625
- return;
626
- }
627
- if ((CRUD_SUBCOMMANDS as readonly string[]).includes(subcommand) && ctx.mode !== "tui") {
628
- notify(ctx, `/profile ${subcommand} requires TUI mode (current mode: ${ctx.mode})`, "error");
629
- return;
630
- }
631
- try {
632
- switch (subcommand) {
633
- case "": {
634
- await runPicker(ctx, await profileEntries(ctx));
635
- return;
636
- }
637
- case "use": {
638
- const result = await activate(ctx, rest[0] as string, { force: true, overlay: null, persist: true });
639
- notify(ctx, `profile active: ${result.selection.name}`, "info");
640
- await reloadForMcpOverlay(ctx, result.selection);
641
- return;
642
- }
643
- case "customize": {
644
- if (current === undefined) {
645
- notify(ctx, "no active profile — nothing to customize", "error");
646
- return;
647
- }
648
- const deps = await activationDeps(ctx, false);
649
- const target = { profile: { name: current.selection.name, source: current.selection.source } };
650
- const result = await customizeOverlay({ ...deps, ...target }, parseCustomizeArgs(rest.join(" ")));
651
- setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
652
- notify(ctx, `overlay updated: ${result.selection.name}`, "info");
653
- return;
654
- }
655
- case "reset": {
656
- if (current === undefined) {
657
- notify(ctx, "no active profile — nothing to reset", "error");
658
- return;
659
- }
660
- const deps = await activationDeps(ctx, false);
661
- const target = { profile: { name: current.selection.name, source: current.selection.source } };
662
- const result = await resetOverlay({ ...deps, ...target });
663
- setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
664
- notify(ctx, `overlay cleared: ${result.selection.name}`, "info");
665
- return;
666
- }
667
- case "list": {
668
- sendListMessage(await profileEntries(ctx));
669
- return;
670
- }
671
- case "status": {
672
- await sendStatus(pi, ctx, current);
673
- return;
674
- }
675
- default: {
676
- await runCatalogCrud(ctx, subcommand, rest, {
677
- realAgentDir: getAgentDir(),
678
- cwd: ctx.cwd,
679
- projectTrusted: ctx.isProjectTrusted(),
680
- });
681
- }
682
- }
683
- } catch (error) {
684
- notify(ctx, error instanceof Error ? error.message : String(error), "error");
685
- }
686
- },
687
- });
688
-
689
- pi.registerCommand("mcp", {
690
- description: "pi-profile-switch: /mcp enable <server> | /mcp disable <server>",
691
- handler: async (args, ctx) => {
692
- const [action, server] = args.trim().split(/\s+/).filter(Boolean);
693
- if (!["enable", "disable"].includes(action ?? "") || server === undefined) {
694
- notify(ctx, "usage: /mcp enable <server> | /mcp disable <server>", "error");
695
- return;
696
- }
697
- if (current === undefined) {
698
- notify(ctx, "no active profile — /mcp enable|disable edits the active profile's catalog entry", "error");
699
- return;
700
- }
701
- try {
702
- if (!probeAdapterPresence(pi.events)) {
703
- throw new Error("pi-mcp-adapter is not active in this session — /mcp enable|disable requires it");
704
- }
705
- const agentDir = getAgentDir();
706
- const projectTrusted = ctx.isProjectTrusted();
707
- const profile = { name: current.selection.name, source: current.selection.source };
708
- const result = await setMcpServerEnabled(
709
- { realAgentDir: agentDir, cwd: ctx.cwd, projectTrusted, profile },
710
- server,
711
- action === "enable",
712
- );
713
- if (!result.changed) {
714
- notify(ctx, `MCP server "${server}" is already ${action}d in profile "${profile.name}"`, "info");
715
- return;
716
- }
717
- // Re-activate so the runtime allowlist matches the edited
718
- // catalog; the stored overlay is preserved.
719
- const overlay = (await new RuntimeStateStore(stateDirFor(profile.source, { agentDir, cwd: ctx.cwd })).read())
720
- .overlay;
721
- const reactivated = await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
722
- notify(
723
- ctx,
724
- `${action}d MCP server "${server}" in profile "${profile.name}" (mcps: [${result.mcps.join(", ")}])`,
725
- "info",
726
- );
727
- await reloadForMcpOverlay(ctx, reactivated.selection);
728
- } catch (error) {
729
- notify(ctx, error instanceof Error ? error.message : String(error), "error");
730
- }
731
- },
732
- });
733
- }
734
-
735
- /** `/profile status`: the active selection against the live view. */
736
- async function sendStatus(
737
- pi: ExtensionAPI,
738
- ctx: ExtensionCommandContext,
739
- current: Activation | undefined,
740
- ): Promise<void> {
741
- if (current === undefined) {
742
- pi.sendMessage({
743
- customType: "pi-profile-switch",
744
- content: "no active profile (activation failed or nothing was resolved — a plain Pi session)",
745
- display: true,
746
- details: { kind: "status", report: undefined },
747
- });
748
- return;
749
- }
750
- const options = ctx.getSystemPromptOptions();
751
- const allSkills = (options.skills ?? []).map((skill) => ({ name: skill.name, filePath: skill.filePath }));
752
- let discovered: string[] = [];
753
- try {
754
- discovered = await discoverAdapterServerNames(getAgentDir(), ctx.isProjectTrusted() ? ctx.cwd : undefined);
755
- } catch (error) {
756
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
757
- }
758
- const overlay = (
759
- await new RuntimeStateStore(
760
- stateDirFor(current.selection.source, { agentDir: getAgentDir(), cwd: ctx.cwd }),
761
- ).read()
762
- ).overlay;
763
- const report = {
764
- ...buildStatusReport({
765
- selection: current.selection,
766
- allSkills,
767
- discoveredMcpServers: discovered,
768
- ...(current.skillsOutcome !== undefined ? { filterOutcome: current.skillsOutcome } : {}),
769
- }),
770
- ...(overlay !== undefined ? { overlay } : {}),
771
- };
772
- pi.sendMessage({
773
- customType: "pi-profile-switch",
774
- content: formatStatusMarkdown(report),
775
- display: true,
776
- details: { kind: "status", report },
777
- });
778
- }