pi-profile-switch 0.3.0 → 0.4.0

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