pi-profile-switch 0.2.0 → 0.3.1

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile selects
6
6
 
7
7
  A shipped `read-only` preset covers the read-and-report workflow; other workflows you write yourself — a profile can narrow the tool set, hide skills, pin a model, or add instructions.
8
8
 
9
- `pi-profile-switch` is a plain Pi package (ADR-0007). It installs like any other extension, leaves Pi's configuration directory untouched, and keeps sessions, packages, project trust, and every other installed extension native.
9
+ `pi-profile-switch` is a plain Pi package (ADR-0007). It installs like any other extension, keeps Pi's configuration directory as the single source of truth, and keeps sessions, packages, project trust, and every other installed extension native. The only file it adds is the default catalog below.
10
10
 
11
11
  ## Install
12
12
 
@@ -16,6 +16,8 @@ pi install npm:pi-profile-switch
16
16
 
17
17
  Requires [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (installed automatically as a peer dependency).
18
18
 
19
+ Pi packages have no install hook, so the default catalog is written the first time the extension loads (in any mode, including `--mode rpc`): if `~/.pi/agent/profiles.json` does not exist, it is created from the shipped `read-only` profile. An existing file is never read, rewritten, or backed up — delete it to get the default back.
20
+
19
21
  ## Quick start
20
22
 
21
23
  ```bash
@@ -29,30 +31,13 @@ pi --profile read-only
29
31
  pi --profile default
30
32
  ```
31
33
 
32
- Create profiles with `/profile create`, which writes `~/.pi/agent/profiles.json` (global) or `<project>/.pi/profiles.json` (project, trusted projects only). The wizard offers the shipped `read-only` preset Pi's built-in tools only, no skills, MCP servers, or model assumed — or a blank definition:
33
-
34
- ```json
35
- {
36
- "schemaVersion": 1,
37
- "profiles": {
38
- "read-only": {
39
- "label": "Read-only",
40
- "description": "Read-only session; no skills or MCP servers assumed — add your own.",
41
- "tools": [
42
- "read",
43
- "grep",
44
- "find",
45
- "ls"
46
- ],
47
- "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
48
- }
49
- }
50
- }
51
- ```
34
+ A fresh install already holds one profile, `read-only`; activate it with `pi --profile read-only` or `/profile use read-only`.
35
+
36
+ Create more with `/profile create`, which writes `~/.pi/agent/profiles.json` (global) or `<project>/.pi/profiles.json` (project, trusted projects only). The wizard offers the shipped `read-only` preset — Pi's built-in tools only, no skills, MCP servers, or model assumed — or a blank definition.
52
37
 
53
- A preset is a one-time copy into your catalog: it is not tracked, so a package update never changes a profile you already created. Profiles **reference** resources by name — they never copy them. [`examples/profiles.json`](examples/profiles.json) is that same preset as a catalog; the field reference is [`schemas/profiles.schema.json`](schemas/profiles.schema.json).
38
+ A preset is a one-time copy into your catalog: it is not tracked, so a package update never changes a profile you already created. Profiles **reference** resources by name — they never copy them. [`examples/profiles.example.json`](examples/profiles.example.json) is a complete catalog with several profiles covering every field; replace its resource names with ones you own.
54
39
 
55
- `schemaVersion` is 1. A profile cannot select extensions: they load natively in every profile, so manage them with `pi install`.
40
+ `schemaVersion` is 1 and is the only accepted value. A profile cannot select extensions: they load natively in every profile, so manage them with `pi install`.
56
41
 
57
42
  ## What a profile controls
58
43
 
@@ -61,7 +46,7 @@ A preset is a one-time copy into your catalog: it is not tracked, so a package u
61
46
  | `instructions` | Text appended to the system prompt every turn |
62
47
  | `model` | Session-start model preset; an explicit `--model`/`--thinking` or a model recorded in the session wins |
63
48
  | `skills` | What the model sees in the prompt's skills list. Every installed skill stays loaded and callable by the user through `/skill:name` |
64
- | `mcp` | Runtime server allowlist published to `pi-mcp-adapter`; connection details stay in the adapter's own config |
49
+ | `mcps` | Which MCP servers the session exposes: the profile's allowlist is written into the adapter's own config (`~/.pi/agent/mcp.json`), disabling every other server. Servers you keep in that file move to `mcp.user.json`; connection details stay in your config, never in the profile. The legacy key `mcp` is still read and becomes `mcps` the next time the profile is saved |
65
50
  | `tools` | Active tool set: declared names/globs become the active set; names that register later (MCP, extensions) are applied when they appear |
66
51
 
67
52
  Anything a profile does not declare keeps Pi's native behavior, and `default` declares nothing.
@@ -74,7 +59,7 @@ In the TUI, the `/profile` command family manages everything in-session:
74
59
  | --- | --- |
75
60
  | `/profile` | Interactive profile picker |
76
61
  | `/profile list` / `/profile status` | Show profiles / active profile details |
77
- | `/profile use <name>` | Switch instantly — same session, no reload |
62
+ | `/profile use <name>` | Switch in place — same session; when the MCP selection changes, the runtime reloads automatically to re-apply it |
78
63
  | `/profile create\|edit\|delete\|duplicate` | Guided profile CRUD (TUI only) |
79
64
  | `/profile customize` / `/profile reset` | Narrow the active profile for this session only |
80
65
  | `/mcp enable\|disable <server>` | Toggle MCP servers in the active profile |
@@ -88,12 +73,11 @@ All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizar
88
73
  - **Reference, never copy** — profiles point at resources you already own and maintain.
89
74
  - **Pi-native** — the configuration directory is Pi's own, so sessions, extension config, packages, context files, and trust behave exactly as they do in plain Pi.
90
75
  - **Fail safe** — untrusted project directories are never read; a failed activation applies nothing and reports the cause.
91
- - **No reload** — switching re-applies runtime state in place; the next turn's prompt carries the new selection.
76
+ - **No manual reload** — switching re-applies runtime state in place and the next turn's prompt carries the new selection. Only a change to the MCP selection rebuilds the runtime, automatically, in the same session, and the switch survives that rebuild: the rebuild continues the selection you made, it does not fall back to the profile you started with.
92
77
 
93
78
  ## Docs
94
79
 
95
80
  - [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md) (Chinese)
96
- - JSON Schema: [`schemas/profiles.schema.json`](schemas/profiles.schema.json)
97
81
 
98
82
  ## License
99
83
 
package/README.zh-CN.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  随包提供一个 `read-only` 预设覆盖「读代码 + 报告」的工作流;其余工作流由你自己定义——profile 可以收窄工具集、隐藏 skill、固定模型或附加指令。
8
8
 
9
- `pi-profile-switch` 是普通 Pi package(ADR-0007):像其他扩展一样安装,不改动 Pi 的配置目录,session、packages、项目信任和其他扩展全部保持原生。
9
+ `pi-profile-switch` 是普通 Pi package(ADR-0007):像其他扩展一样安装,配置目录始终是 Pi 自己的那一份,session、packages、项目信任和其他扩展全部保持原生。它只在配置目录里新增一个文件,就是下面这个默认 catalog。
10
10
 
11
11
  ## 安装
12
12
 
@@ -16,6 +16,8 @@ pi install npm:pi-profile-switch
16
16
 
17
17
  依赖 [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)(作为 peer dependency 自动安装)。
18
18
 
19
+ Pi package 没有安装钩子,因此默认 catalog 在扩展首次加载时写入(任何模式都会执行,包括 `--mode rpc`):`~/.pi/agent/profiles.json` 不存在时,用随包的 `read-only` profile 创建它。已存在的文件不会被读取、改写或备份——删掉它即可拿回默认值。
20
+
19
21
  ## 快速上手
20
22
 
21
23
  ```bash
@@ -29,30 +31,13 @@ pi --profile read-only
29
31
  pi --profile default
30
32
  ```
31
33
 
32
- `/profile create` 创建 profile,写入 `~/.pi/agent/profiles.json`(全局)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)。向导提供随包的 `read-only` 预设——只用 Pi 内建工具,不假设任何 skill、MCP server 或模型——也可以从空定义开始:
33
-
34
- ```json
35
- {
36
- "schemaVersion": 1,
37
- "profiles": {
38
- "read-only": {
39
- "label": "Read-only",
40
- "description": "Read-only session; no skills or MCP servers assumed — add your own.",
41
- "tools": [
42
- "read",
43
- "grep",
44
- "find",
45
- "ls"
46
- ],
47
- "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
48
- }
49
- }
50
- }
51
- ```
34
+ 新安装已经带有一个 `read-only` profile,用 `pi --profile read-only` `/profile use read-only` 激活。
35
+
36
+ 用 `/profile create` 创建更多 profile,写入 `~/.pi/agent/profiles.json`(全局)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)。向导提供随包的 `read-only` 预设——只用 Pi 内建工具,不假设任何 skill、MCP server 或模型——也可以从空定义开始。
52
37
 
53
- 预设只被复制一次:它不被跟踪,包升级不会改动你已经创建的 profile。Profile 只**引用**资源,从不复制资源。[`examples/profiles.json`](examples/profiles.json) 就是同一个预设构成的 catalog;字段参考见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
38
+ 预设只被复制一次:它不被跟踪,包升级不会改动你已经创建的 profile。Profile 只**引用**资源,从不复制资源。[`examples/profiles.example.json`](examples/profiles.example.json) 是覆盖全部字段的完整 catalog,示例中的资源名请替换成你本机已有的名字。
54
39
 
55
- `schemaVersion` 为 1Profile 不选择 extension:extensions 在所有 profile 中原生加载,请用 `pi install` 管理。
40
+ `schemaVersion` 为 1,也是唯一接受的值。Profile 不选择 extension:extensions 在所有 profile 中原生加载,请用 `pi install` 管理。
56
41
 
57
42
  ## Profile 控制的范围
58
43
 
@@ -61,7 +46,7 @@ pi --profile default
61
46
  | `instructions` | 每个 turn 追加到 system prompt 末尾 |
62
47
  | `model` | 会话启动的模型预设;显式 `--model`/`--thinking` 或 session 历史中记录的模型优先 |
63
48
  | `skills` | 模型在 prompt skills 列表中看到的内容;所有已安装 skill 仍保持加载,用户可用 `/skill:name` 手动调用 |
64
- | `mcp` | 发布给 `pi-mcp-adapter` 的运行时 server allowlist;连接参数仍由 adapter 自己管理 |
49
+ | `mcps` | 本次会话暴露哪些 MCP server:白名单写入 adapter 自己的配置(`~/.pi/agent/mcp.json`),其余 server 一律标为 disabled。你原本放在该文件里的 server 会迁移到 `mcp.user.json`;连接参数只在你自己的配置里,从不进入 profile。旧键 `mcp` 仍可读取,下次保存 profile 时变为 `mcps` |
65
50
  | `tools` | 活动工具集:声明的名字/glob 成为活动集合;之后才注册的工具(MCP、扩展)在出现时补上 |
66
51
 
67
52
  未声明的字段保持 Pi 原生行为,`default` 什么都不声明。
@@ -74,7 +59,7 @@ pi --profile default
74
59
  | --- | --- |
75
60
  | `/profile` | 交互式选择 profile |
76
61
  | `/profile list` / `/profile status` | 列出 profile / 查看活动 profile 详情 |
77
- | `/profile use <name>` | 即时切换——同一 session,无 reload |
62
+ | `/profile use <name>` | 原位切换——同一 session;当 MCP 选择发生变化时会自动 reload 以重新应用 |
78
63
  | `/profile create\|edit\|delete\|duplicate` | 向导式 profile 增删改(仅 TUI) |
79
64
  | `/profile customize` / `/profile reset` | 仅本次会话收窄活动 profile |
80
65
  | `/mcp enable\|disable <server>` | 在活动 profile 中开关 MCP server |
@@ -88,12 +73,11 @@ pi --profile default
88
73
  - **引用而非复制**——profile 指向你自己拥有和维护的资源。
89
74
  - **Pi 原生**——配置目录就是 Pi 自己的目录,session、扩展配置、packages、context 文件和信任行为与原生 Pi 完全一致。
90
75
  - **失败安全**——未信任的项目目录从不读取;激活失败时不应用任何设置并报出原因。
91
- - **无 reload**——切换在原位重新应用运行时状态;下一个 turn 的 prompt 直接带上新选择。
76
+ - **无需手动 reload**——切换在原位重新应用运行时状态,下一个 turn 的 prompt 直接带上新选择;只有 MCP 选择变化时才会自动重建运行时(同一 session),且切换结果在重建后保持不变:重建沿用你刚做的选择,不会退回启动时的 profile
92
77
 
93
78
  ## 文档
94
79
 
95
80
  - [架构设计](docs/architecture/overview.md) · [ADR](docs/adr/) · [术语表](CONTEXT.md)
96
- - JSON Schema:[`schemas/profiles.schema.json`](schemas/profiles.schema.json)
97
81
 
98
82
  ## 许可证
99
83
 
@@ -0,0 +1,74 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "profiles": {
4
+ "read-only": {
5
+ "label": "Read-only",
6
+ "description": "Read-only session; no skills or MCP servers assumed — add your own.",
7
+ "tools": [
8
+ "read",
9
+ "grep",
10
+ "find",
11
+ "ls"
12
+ ],
13
+ "instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
14
+ },
15
+ "review": {
16
+ "label": "Code review",
17
+ "description": "Review a diff or a branch: read the change, report findings, modify nothing.",
18
+ "skills": [
19
+ "code-review",
20
+ "git-*"
21
+ ],
22
+ "mcps": [
23
+ "github"
24
+ ],
25
+ "tools": [
26
+ "read",
27
+ "grep",
28
+ "find",
29
+ "ls",
30
+ "bash"
31
+ ],
32
+ "model": {
33
+ "provider": "anthropic",
34
+ "id": "claude-sonnet-4-5",
35
+ "thinkingLevel": "high"
36
+ },
37
+ "instructions": "Review only: read the change under review, then report findings with file:line evidence.\nNever edit tracked files, commit, or push; describe the required fix instead.\nSeparate verified behavior from suspected issues."
38
+ },
39
+ "plan": {
40
+ "label": "Plan",
41
+ "description": "Research a change and write the plan; no edits, no implementation.",
42
+ "skills": [],
43
+ "tools": [
44
+ "read",
45
+ "grep",
46
+ "find",
47
+ "ls",
48
+ "bash"
49
+ ],
50
+ "instructions": "Planning session: research the code first, then write the plan in your reply.\nDo not create, edit, rename, or delete files."
51
+ },
52
+ "implement": {
53
+ "label": "Implement",
54
+ "description": "Full tool access for implementing a change end to end.",
55
+ "skills": [
56
+ "git-commit",
57
+ "test-*"
58
+ ],
59
+ "mcps": [
60
+ "github"
61
+ ],
62
+ "tools": [
63
+ "read",
64
+ "grep",
65
+ "find",
66
+ "ls",
67
+ "bash",
68
+ "edit",
69
+ "write"
70
+ ],
71
+ "instructions": "Implement the requested change: read before writing, keep the diff minimal.\nRun the tests that cover your change and report the exact commands."
72
+ }
73
+ }
74
+ }
@@ -6,11 +6,13 @@ import {
6
6
  type ExtensionContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
 
9
+ import { adapterPresent } from "../../src/adapter-presence.ts";
9
10
  import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
10
11
  import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
11
12
  import { readSessionChoices } from "../../src/model-selection.ts";
12
13
  import { buildProfileBadge, PROFILE_STATUS_KEY, renderProfileBadge } from "../../src/profile-badge.ts";
13
14
  import type { ProfileDefinition } from "../../src/profile-catalog.ts";
15
+ import { seedDefaultProfilesSync } from "../../src/default-profiles.ts";
14
16
  import {
15
17
  formatSelectionWarnings,
16
18
  formatSkillWarnings,
@@ -25,11 +27,18 @@ import {
25
27
  type SkillsFilterOutcome,
26
28
  } from "../../src/skill-selection.ts";
27
29
  import {
30
+ appliedProfile,
28
31
  detectExplicitDeclarations,
29
32
  readProfileFlag,
33
+ recordAppliedProfile,
30
34
  registerProfileFlag,
31
35
  resolveStartupProfile,
32
36
  } from "../../src/startup-selection.ts";
37
+ import {
38
+ readFlagFromArgv,
39
+ syncMcpOverlayForSelection,
40
+ syncStartupMcpOverlay,
41
+ } from "../../src/startup-mcp-scope.ts";
33
42
  import { retryPendingTools, type ApplySurface } from "../../src/switching/apply-profile.ts";
34
43
  import {
35
44
  activateProfile,
@@ -63,10 +72,14 @@ import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/sta
63
72
  * all native.
64
73
  *
65
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`.
66
78
  * - `session_start`: resolve the startup profile (`--profile <flag>`, else
67
- * the saved selection, else `default`), then apply the runtime parts of
68
- * the selection model preset, active tools, MCP allowlist. A failed
69
- * activation applies nothing and reports loudly.
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.
70
83
  * - `before_agent_start`: rebuild the system prompt each turn — replace the
71
84
  * skills section with the profile's visible set and append the profile's
72
85
  * instructions. Unselected skills stay loaded and `/skill:`-invocable.
@@ -115,7 +128,41 @@ const PROFILE_USAGE = [
115
128
 
116
129
  export default function piProfileExtension(pi: ExtensionAPI): void {
117
130
  registerProfileFlag(pi);
118
- const explicit = detectExplicitDeclarations(process.argv.slice(2));
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
+ }
119
166
  let current: Activation | undefined;
120
167
  let filterWarningShown = false;
121
168
  /** The last badge written to the footer, so a refresh only talks to Pi
@@ -217,6 +264,8 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
217
264
  overlay: options?.overlay ?? null,
218
265
  persist: options?.persist ?? true,
219
266
  });
267
+ // The run's current selection, for the reload continuation.
268
+ recordAppliedProfile(result.selection.name);
220
269
  setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
221
270
  reportWarnings(ctx, formatSelectionWarnings(result.selection));
222
271
  return result;
@@ -239,6 +288,75 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
239
288
  });
240
289
  }
241
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
+
242
360
  /** Bare `/profile`: the interactive picker, with a list fallback for
243
361
  * modes without dialogs. */
244
362
  async function runPicker(ctx: ExtensionCommandContext, entries: ProfileListEntry[]): Promise<void> {
@@ -258,6 +376,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
258
376
  if (chosen === undefined || chosen.name === current?.selection.name) return;
259
377
  const result = await activate(ctx, chosen.name, { force: true, overlay: null, persist: true });
260
378
  notify(ctx, `profile active: ${result.selection.name}`, "info");
379
+ await reloadForMcpOverlay(ctx, result.selection);
261
380
  }
262
381
 
263
382
  /** `/profile create|duplicate|edit|delete`: TUI-only catalog CRUD. */
@@ -319,8 +438,9 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
319
438
  if (wizard === undefined) return;
320
439
  await editProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
321
440
  if (current !== undefined && name === current.selection.name) {
322
- await activate(ctx, name, { persist: true });
441
+ const reactivated = await activate(ctx, name, { persist: true });
323
442
  notify(ctx, `saved and reactivated profile "${name}"`, "info");
443
+ await reloadForMcpOverlay(ctx, reactivated.selection);
324
444
  } else {
325
445
  notify(ctx, `saved profile "${name}" (inactive — runtime untouched)`, "info");
326
446
  }
@@ -358,16 +478,26 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
358
478
  if (isActive) {
359
479
  const result = await activate(ctx, replacement ?? name, { force: replacement !== undefined, persist: true });
360
480
  notify(ctx, `deleted "${name}" (${scope}); profile active: ${result.selection.name}`, "info");
481
+ await reloadForMcpOverlay(ctx, result.selection);
361
482
  } else {
362
483
  notify(ctx, `deleted profile "${name}" (${scope})`, "info");
363
484
  }
364
485
  }
365
486
 
366
- pi.on("session_start", async (_event, ctx) => {
487
+ pi.on("session_start", async (event, ctx) => {
367
488
  setCurrent(ctx, undefined);
368
489
  filterWarningShown = false;
490
+ if (seedWarning !== undefined) {
491
+ notify(ctx, seedWarning, "warning");
492
+ seedWarning = undefined;
493
+ }
369
494
  const agentDir = getAgentDir();
370
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();
371
501
  const requested = readProfileFlag(pi);
372
502
  let startup;
373
503
  try {
@@ -376,6 +506,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
376
506
  cwd: ctx.cwd,
377
507
  projectTrusted,
378
508
  ...(requested !== undefined ? { requested } : {}),
509
+ ...(continuation !== undefined ? { continuation } : {}),
379
510
  });
380
511
  } catch (error) {
381
512
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
@@ -384,9 +515,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
384
515
  try {
385
516
  // Startup activation never persists (a `--profile` selection is for
386
517
  // this run only) and never applies a stored overlay.
387
- await activate(ctx, startup.name, { persist: false, overlay: null });
518
+ const activated = await activate(ctx, startup.name, { persist: false, overlay: null });
388
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
+ }
389
527
  } catch (error) {
528
+ recordAppliedProfile(undefined);
390
529
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
391
530
  reportWarnings(ctx, startup.warnings);
392
531
  }
@@ -498,6 +637,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
498
637
  case "use": {
499
638
  const result = await activate(ctx, rest[0] as string, { force: true, overlay: null, persist: true });
500
639
  notify(ctx, `profile active: ${result.selection.name}`, "info");
640
+ await reloadForMcpOverlay(ctx, result.selection);
501
641
  return;
502
642
  }
503
643
  case "customize": {
@@ -578,12 +718,13 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
578
718
  // catalog; the stored overlay is preserved.
579
719
  const overlay = (await new RuntimeStateStore(stateDirFor(profile.source, { agentDir, cwd: ctx.cwd })).read())
580
720
  .overlay;
581
- await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
721
+ const reactivated = await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
582
722
  notify(
583
723
  ctx,
584
- `${action}d MCP server "${server}" in profile "${profile.name}" (mcp: [${result.mcp.join(", ")}])`,
724
+ `${action}d MCP server "${server}" in profile "${profile.name}" (mcps: [${result.mcps.join(", ")}])`,
585
725
  "info",
586
726
  );
727
+ await reloadForMcpOverlay(ctx, reactivated.selection);
587
728
  } catch (error) {
588
729
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
589
730
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Named profiles for Pi: skills, MCP servers, tools, model, and instructions per workflow — switched in place in the same session.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,8 +10,8 @@
10
10
  "additionalProperties": false,
11
11
  "properties": {
12
12
  "schemaVersion": {
13
- "description": "1 is current. 2 is the number v0.1.0 wrote for the same field shape and still reads; saves always write 1.",
14
- "enum": [1, 2]
13
+ "description": "Catalog format version. Only 1 is accepted.",
14
+ "const": 1
15
15
  },
16
16
  "profiles": {
17
17
  "type": "object",
@@ -44,19 +44,12 @@
44
44
  },
45
45
  "description": "Skill names or globs. Controls what the model sees in the system prompt's skills section; every loaded skill stays callable by the user through /skill:name."
46
46
  },
47
- "extensions": {
47
+ "mcps": {
48
48
  "type": "array",
49
49
  "items": {
50
50
  "type": "string"
51
51
  },
52
- "description": "Ignored. Extensions always load natively (ADR-0007) and are managed with pi install; the field is accepted so existing catalogs keep validating."
53
- },
54
- "mcp": {
55
- "type": "array",
56
- "items": {
57
- "type": "string"
58
- },
59
- "description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config."
52
+ "description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config. The legacy key \"mcp\" is still accepted on read and migrates to \"mcps\" on the next save."
60
53
  },
61
54
  "tools": {
62
55
  "type": "array",
@@ -0,0 +1,75 @@
1
+ /**
2
+ * AdapterPresence: a cheap, deterministic "is pi-mcp-adapter installed?"
3
+ * check that does not depend on extension load order.
4
+ *
5
+ * The overlay mechanism only exists to serve the adapter; when the adapter is
6
+ * absent the extension must not register the `mcp-config` flag default and
7
+ * must not write anything. Signals, in order of reliability:
8
+ *
9
+ * 1. Pi's npm package root (`<agentDir>/npm/node_modules/pi-mcp-adapter`).
10
+ * 2. The command line (`-e <path>` / `--extension <path>`).
11
+ * 3. Pi settings `packages` entries.
12
+ * 4. The adapter's own event-bus presence probe, when it answered during
13
+ * extension loading (only reliable when the adapter loaded first).
14
+ *
15
+ * Any failure reads as "absent": a false negative only disables the overlay,
16
+ * while a false positive could hide the user's own Pi-global slot file.
17
+ */
18
+
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import path from "node:path";
21
+
22
+ import { isRecord } from "./json-file.ts";
23
+
24
+ const ADAPTER_PACKAGE = "pi-mcp-adapter";
25
+
26
+ export interface AdapterPresenceInput {
27
+ agentDir: string;
28
+ argv: readonly string[];
29
+ /** Result of the adapter's event-bus probe, when the caller ran one. */
30
+ probeAnswered?: boolean;
31
+ }
32
+
33
+ export function adapterPresent(input: AdapterPresenceInput): boolean {
34
+ if (input.probeAnswered === true) return true;
35
+ try {
36
+ for (const candidate of [
37
+ path.join(input.agentDir, "npm", "node_modules", ADAPTER_PACKAGE),
38
+ path.join(input.agentDir, "node_modules", ADAPTER_PACKAGE),
39
+ ]) {
40
+ if (existsSync(candidate)) return true;
41
+ }
42
+ return argvMentionsAdapter(input.argv) || settingsListAdapter(input.agentDir);
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function argvMentionsAdapter(argv: readonly string[]): boolean {
49
+ for (let index = 0; index < argv.length; index++) {
50
+ const token = argv[index] ?? "";
51
+ if (token.includes(ADAPTER_PACKAGE)) return true;
52
+ if ((token === "-e" || token === "--extension") && (argv[index + 1] ?? "").includes(ADAPTER_PACKAGE)) {
53
+ return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function settingsListAdapter(agentDir: string): boolean {
60
+ try {
61
+ const raw: unknown = JSON.parse(readFileSync(path.join(agentDir, "settings.json"), "utf8"));
62
+ if (!isRecord(raw) || !Array.isArray(raw.packages)) return false;
63
+ return raw.packages.some((entry) => {
64
+ const source =
65
+ typeof entry === "string"
66
+ ? entry
67
+ : isRecord(entry) && typeof entry.source === "string"
68
+ ? entry.source
69
+ : undefined;
70
+ return source !== undefined && source.includes(ADAPTER_PACKAGE);
71
+ });
72
+ } catch {
73
+ return false;
74
+ }
75
+ }