pi-profile-switch 0.3.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,7 +31,9 @@ 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.
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.
33
37
 
34
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.
35
39
 
@@ -42,7 +46,7 @@ A preset is a one-time copy into your catalog: it is not tracked, so a package u
42
46
  | `instructions` | Text appended to the system prompt every turn |
43
47
  | `model` | Session-start model preset; an explicit `--model`/`--thinking` or a model recorded in the session wins |
44
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` |
45
- | `mcp` | 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 |
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 |
46
50
  | `tools` | Active tool set: declared names/globs become the active set; names that register later (MCP, extensions) are applied when they appear |
47
51
 
48
52
  Anything a profile does not declare keeps Pi's native behavior, and `default` declares nothing.
@@ -69,7 +73,7 @@ All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizar
69
73
  - **Reference, never copy** — profiles point at resources you already own and maintain.
70
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.
71
75
  - **Fail safe** — untrusted project directories are never read; a failed activation applies nothing and reports the cause.
72
- - **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.
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.
73
77
 
74
78
  ## Docs
75
79
 
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,7 +31,9 @@ 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 或模型——也可以从空定义开始。
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 或模型——也可以从空定义开始。
33
37
 
34
38
  预设只被复制一次:它不被跟踪,包升级不会改动你已经创建的 profile。Profile 只**引用**资源,从不复制资源。[`examples/profiles.example.json`](examples/profiles.example.json) 是覆盖全部字段的完整 catalog,示例中的资源名请替换成你本机已有的名字。
35
39
 
@@ -42,7 +46,7 @@ pi --profile default
42
46
  | `instructions` | 每个 turn 追加到 system prompt 末尾 |
43
47
  | `model` | 会话启动的模型预设;显式 `--model`/`--thinking` 或 session 历史中记录的模型优先 |
44
48
  | `skills` | 模型在 prompt skills 列表中看到的内容;所有已安装 skill 仍保持加载,用户可用 `/skill:name` 手动调用 |
45
- | `mcp` | 本次会话暴露哪些 MCP server:白名单写入 adapter 自己的配置(`~/.pi/agent/mcp.json`),其余 server 一律标为 disabled。你原本放在该文件里的 server 会迁移到 `mcp.user.json`;连接参数只在你自己的配置里,从不进入 profile |
49
+ | `mcps` | 本次会话暴露哪些 MCP server:白名单写入 adapter 自己的配置(`~/.pi/agent/mcp.json`),其余 server 一律标为 disabled。你原本放在该文件里的 server 会迁移到 `mcp.user.json`;连接参数只在你自己的配置里,从不进入 profile。旧键 `mcp` 仍可读取,下次保存 profile 时变为 `mcps` |
46
50
  | `tools` | 活动工具集:声明的名字/glob 成为活动集合;之后才注册的工具(MCP、扩展)在出现时补上 |
47
51
 
48
52
  未声明的字段保持 Pi 原生行为,`default` 什么都不声明。
@@ -69,7 +73,7 @@ pi --profile default
69
73
  - **引用而非复制**——profile 指向你自己拥有和维护的资源。
70
74
  - **Pi 原生**——配置目录就是 Pi 自己的目录,session、扩展配置、packages、context 文件和信任行为与原生 Pi 完全一致。
71
75
  - **失败安全**——未信任的项目目录从不读取;激活失败时不应用任何设置并报出原因。
72
- - **无需手动 reload**——切换在原位重新应用运行时状态,下一个 turn 的 prompt 直接带上新选择;只有 MCP 选择变化时才会自动重建运行时(同一 session
76
+ - **无需手动 reload**——切换在原位重新应用运行时状态,下一个 turn 的 prompt 直接带上新选择;只有 MCP 选择变化时才会自动重建运行时(同一 session),且切换结果在重建后保持不变:重建沿用你刚做的选择,不会退回启动时的 profile
73
77
 
74
78
  ## 文档
75
79
 
@@ -19,7 +19,7 @@
19
19
  "code-review",
20
20
  "git-*"
21
21
  ],
22
- "mcp": [
22
+ "mcps": [
23
23
  "github"
24
24
  ],
25
25
  "tools": [
@@ -56,7 +56,7 @@
56
56
  "git-commit",
57
57
  "test-*"
58
58
  ],
59
- "mcp": [
59
+ "mcps": [
60
60
  "github"
61
61
  ],
62
62
  "tools": [
@@ -12,6 +12,7 @@ import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
12
12
  import { readSessionChoices } from "../../src/model-selection.ts";
13
13
  import { buildProfileBadge, PROFILE_STATUS_KEY, renderProfileBadge } from "../../src/profile-badge.ts";
14
14
  import type { ProfileDefinition } from "../../src/profile-catalog.ts";
15
+ import { seedDefaultProfilesSync } from "../../src/default-profiles.ts";
15
16
  import {
16
17
  formatSelectionWarnings,
17
18
  formatSkillWarnings,
@@ -26,8 +27,10 @@ import {
26
27
  type SkillsFilterOutcome,
27
28
  } from "../../src/skill-selection.ts";
28
29
  import {
30
+ appliedProfile,
29
31
  detectExplicitDeclarations,
30
32
  readProfileFlag,
33
+ recordAppliedProfile,
31
34
  registerProfileFlag,
32
35
  resolveStartupProfile,
33
36
  } from "../../src/startup-selection.ts";
@@ -69,10 +72,14 @@ import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/sta
69
72
  * all native.
70
73
  *
71
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`.
72
78
  * - `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.
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.
76
83
  * - `before_agent_start`: rebuild the system prompt each turn — replace the
77
84
  * skills section with the profile's visible set and append the profile's
78
85
  * instructions. Unselected skills stay loaded and `/skill:`-invocable.
@@ -123,11 +130,21 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
123
130
  registerProfileFlag(pi);
124
131
  const argv = process.argv.slice(2);
125
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
+ }
126
144
  // pi-mcp-adapter reads its config before any session event fires (and, for
127
145
  // eager servers, at its own load time), so the startup profile's overlay
128
146
  // is generated here, synchronously. Pi applies CLI flag values only after
129
147
  // extension loading, hence argv.
130
- const loadAgentDir = getAgentDir();
131
148
  const adapterInstalled = adapterPresent({
132
149
  agentDir: loadAgentDir,
133
150
  argv,
@@ -135,11 +152,15 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
135
152
  });
136
153
  if (adapterInstalled) {
137
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();
138
158
  syncStartupMcpOverlay({
139
159
  agentDir: loadAgentDir,
140
160
  cwd: process.cwd(),
141
161
  argv,
142
162
  ...(requestedConfigPath === undefined ? {} : { overridePath: requestedConfigPath }),
163
+ ...(runProfile === undefined ? {} : { continuation: runProfile }),
143
164
  });
144
165
  }
145
166
  let current: Activation | undefined;
@@ -243,6 +264,8 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
243
264
  overlay: options?.overlay ?? null,
244
265
  persist: options?.persist ?? true,
245
266
  });
267
+ // The run's current selection, for the reload continuation.
268
+ recordAppliedProfile(result.selection.name);
246
269
  setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
247
270
  reportWarnings(ctx, formatSelectionWarnings(result.selection));
248
271
  return result;
@@ -461,11 +484,20 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
461
484
  }
462
485
  }
463
486
 
464
- pi.on("session_start", async (_event, ctx) => {
487
+ pi.on("session_start", async (event, ctx) => {
465
488
  setCurrent(ctx, undefined);
466
489
  filterWarningShown = false;
490
+ if (seedWarning !== undefined) {
491
+ notify(ctx, seedWarning, "warning");
492
+ seedWarning = undefined;
493
+ }
467
494
  const agentDir = getAgentDir();
468
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();
469
501
  const requested = readProfileFlag(pi);
470
502
  let startup;
471
503
  try {
@@ -474,6 +506,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
474
506
  cwd: ctx.cwd,
475
507
  projectTrusted,
476
508
  ...(requested !== undefined ? { requested } : {}),
509
+ ...(continuation !== undefined ? { continuation } : {}),
477
510
  });
478
511
  } catch (error) {
479
512
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
@@ -492,6 +525,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
492
525
  });
493
526
  }
494
527
  } catch (error) {
528
+ recordAppliedProfile(undefined);
495
529
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
496
530
  reportWarnings(ctx, startup.warnings);
497
531
  }
@@ -687,7 +721,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
687
721
  const reactivated = await activate(ctx, profile.name, { overlay: overlay ?? null, persist: true });
688
722
  notify(
689
723
  ctx,
690
- `${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(", ")}])`,
691
725
  "info",
692
726
  );
693
727
  await reloadForMcpOverlay(ctx, reactivated.selection);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.3.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": [
@@ -44,12 +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
- "mcp": {
47
+ "mcps": {
48
48
  "type": "array",
49
49
  "items": {
50
50
  "type": "string"
51
51
  },
52
- "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."
53
53
  },
54
54
  "tools": {
55
55
  "type": "array",
@@ -0,0 +1,59 @@
1
+ /**
2
+ * DefaultProfiles: the catalog a fresh install starts from.
3
+ *
4
+ * Pi packages have no install hook (docs/packages.md) — `pi install` only
5
+ * unpacks the package and runs `npm install` — so "installing the default
6
+ * profiles" can only happen the first time the extension actually loads.
7
+ * `seedDefaultProfilesSync` therefore runs at load time, before any session
8
+ * event, and is idempotent: an existing catalog file is never read, rewritten,
9
+ * or backed up, and the package never seeds again once the file exists.
10
+ *
11
+ * The seeded content is the shipped preset catalog, exactly what
12
+ * `examples/profiles.json` publishes, reached through the same
13
+ * `PROFILE_PRESETS` data `/profile create` offers — one definition, so the
14
+ * three cannot drift. The `read-only` profile declares built-in tools and
15
+ * instructions only: it activates on a machine with no skills, no
16
+ * `pi-mcp-adapter`, and no credentials, and it changes nothing until the
17
+ * user selects it.
18
+ *
19
+ * `profile-presets.test.ts` pins the content contract: this catalog equals
20
+ * the published `examples/profiles.json` and every definition is
21
+ * resource-free.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
25
+ import path from "node:path";
26
+
27
+ import { DEFAULT_PROFILE_NAME, PROFILE_SCHEMA_VERSION } from "./profile-catalog.ts";
28
+ import { PROFILE_PRESETS } from "./profile-presets.ts";
29
+
30
+ /** The catalog written to `<agentDir>/profiles.json` on first load. */
31
+ export const DEFAULT_PROFILE_CATALOG = {
32
+ schemaVersion: PROFILE_SCHEMA_VERSION,
33
+ profiles: Object.fromEntries(
34
+ PROFILE_PRESETS.filter((preset) => preset.name !== DEFAULT_PROFILE_NAME).map((preset) => [
35
+ preset.name,
36
+ preset.definition,
37
+ ]),
38
+ ),
39
+ };
40
+
41
+ export type DefaultSeedResult =
42
+ /** The agent dir had no catalog and now holds the default one. */
43
+ | "seeded"
44
+ /** A catalog file was already there; it was not touched. */
45
+ | "present";
46
+
47
+ /**
48
+ * Writes `<agentDir>/profiles.json` when it does not exist yet. Synchronous
49
+ * on purpose: the load-time call site cannot await, and the write is one small
50
+ * file. Existing files win unconditionally — an empty or hand-written catalog
51
+ * is a user decision, not a missing default.
52
+ */
53
+ export function seedDefaultProfilesSync(agentDir: string, catalog: unknown = DEFAULT_PROFILE_CATALOG): DefaultSeedResult {
54
+ const target = path.join(agentDir, "profiles.json");
55
+ if (existsSync(target)) return "present";
56
+ mkdirSync(agentDir, { recursive: true });
57
+ writeFileSync(target, `${JSON.stringify(catalog, null, 2)}\n`);
58
+ return "seeded";
59
+ }
@@ -12,6 +12,12 @@
12
12
  * - Unknown fields (an `extensions` declaration left over from v0.1.0, an
13
13
  * inheritance key) are ignored silently; saving drops them, so a written
14
14
  * definition always matches the current shape.
15
+ * - The catalog key for MCP servers is `mcps` (plural, like `skills` and
16
+ * `tools`). The legacy name `mcp` is still READ as an alias so an
17
+ * existing catalog keeps its allowlist; `mcps` wins when both keys are
18
+ * present, and a value of the wrong type fails loudly either way.
19
+ * Definitions always carry the canonical `mcps` field in memory, and
20
+ * profile-catalog-store.ts drops a file's legacy key on its next write.
15
21
  *
16
22
  * Invariants:
17
23
  * - The built-in `default` profile never exists in either file and cannot be
@@ -44,7 +50,7 @@ export interface ProfileDefinition {
44
50
  label?: string;
45
51
  description?: string;
46
52
  skills?: string[];
47
- mcp?: string[];
53
+ mcps?: string[];
48
54
  tools?: string[];
49
55
  model?: ProfileModel;
50
56
  instructions?: string;
@@ -79,6 +85,27 @@ function readStringArray(value: unknown, field: string, profileName: string): st
79
85
  return value as string[];
80
86
  }
81
87
 
88
+ /** Legacy catalog key names, still accepted on read (canonical name wins).
89
+ * The write paths (/profile CRUD, /mcp enable|disable) delete the alias, so
90
+ * a file migrates to the canonical spelling the next time it is saved. */
91
+ export const LEGACY_FIELD_ALIASES = { mcps: "mcp" } as const;
92
+
93
+ /** Reads an array-of-strings field under its canonical name or a legacy
94
+ * alias. Both spellings are validated when present, so a value of the
95
+ * wrong type never passes silently through either key. */
96
+ function readAliasedStringArray(
97
+ raw: Record<string, unknown>,
98
+ canonical: string,
99
+ legacy: string,
100
+ profileName: string,
101
+ ): string[] | undefined {
102
+ for (const key of [canonical, legacy]) {
103
+ if (raw[key] !== undefined) readStringArray(raw[key], key, profileName);
104
+ }
105
+ const source = raw[canonical] !== undefined ? canonical : legacy;
106
+ return readStringArray(raw[source], source, profileName);
107
+ }
108
+
82
109
  function readOptionalString(value: unknown, field: string, profileName: string): string | undefined {
83
110
  if (value === undefined) return undefined;
84
111
  if (typeof value !== "string") {
@@ -100,10 +127,12 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
100
127
  if (label !== undefined) definition.label = label;
101
128
  const description = readOptionalString(raw.description, "description", name);
102
129
  if (description !== undefined) definition.description = description;
103
- for (const field of ["skills", "mcp", "tools"] as const) {
130
+ for (const field of ["skills", "tools"] as const) {
104
131
  const entries = readStringArray(raw[field], field, name);
105
132
  if (entries !== undefined) definition[field] = entries;
106
133
  }
134
+ const mcps = readAliasedStringArray(raw, "mcps", LEGACY_FIELD_ALIASES.mcps, name);
135
+ if (mcps !== undefined) definition.mcps = mcps;
107
136
  if (raw.model !== undefined) {
108
137
  if (!isRecord(raw.model) || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string") {
109
138
  throw new CatalogError(`profile "${name}": "model" must be an object with string "provider" and "id"`);
@@ -5,7 +5,7 @@
5
5
  * ADR-0007 semantics:
6
6
  * - `skills` resolves to a visibility filter (see skill-selection.ts), not
7
7
  * to loaded resources: every skill stays loaded and user-invocable.
8
- * - `mcp` resolves to a runtime server allowlist; a declared MCP intent that
8
+ * - `mcps` resolves to a runtime server allowlist; a declared MCP intent that
9
9
  * cannot be satisfied (adapter absent, literal server unknown) fails the
10
10
  * activation before anything is applied.
11
11
  * - `tools` resolves to an active tool set; literals the live registry does
@@ -136,7 +136,7 @@ function resolveMcp(
136
136
  if (!live.adapterPresent) {
137
137
  throw new SelectionError(
138
138
  `profile "${profileName}" declares MCP servers but pi-mcp-adapter is not active in this session — ` +
139
- `install the adapter or remove the "mcp" declaration`,
139
+ `install the adapter or remove the "mcps" declaration`,
140
140
  );
141
141
  }
142
142
  const selected: string[] = [];
@@ -201,7 +201,7 @@ export function resolveSelection(input: {
201
201
  const definition = profile.definition;
202
202
 
203
203
  const skills = resolveSkills(definition.skills, overlay?.disabledSkills ?? [], live.skills);
204
- const mcp = resolveMcp(definition.mcp, overlay?.disabledMcp ?? [], live.mcp, profile.name);
204
+ const mcp = resolveMcp(definition.mcps, overlay?.disabledMcp ?? [], live.mcp, profile.name);
205
205
  const tools = input.suppressTools === true
206
206
  ? { pendingTools: [], warning: { toolsUnmatched: [] } }
207
207
  : resolveTools(overlay?.tools ?? definition.tools, live.toolNames);
@@ -33,6 +33,11 @@ export function stateDirFor(source: ProfileSource, dirs: { agentDir: string; cwd
33
33
  export interface RuntimeState {
34
34
  activeProfile?: string;
35
35
  overlay?: RuntimeOverlay;
36
+ /** Patch-only marker: clears `activeProfile` from the store it is sent
37
+ * to, and to that store alone. A profile switch sends it to the OTHER
38
+ * scope's store, so a stale project selection cannot shadow the new
39
+ * global choice on the next startup. Never stored, never read back. */
40
+ otherActiveProfile?: undefined;
36
41
  }
37
42
 
38
43
  export interface RuntimeOverlay {
@@ -112,6 +117,7 @@ export class RuntimeStateStore {
112
117
  if (patch.overlay === undefined) delete next.overlay;
113
118
  else next.overlay = patch.overlay;
114
119
  }
120
+ if ("otherActiveProfile" in patch) delete next.activeProfile;
115
121
  await this.write(next);
116
122
  return next;
117
123
  }
@@ -73,14 +73,19 @@ export function resolveProjectTrustedSync(agentDir: string, cwd: string): boolea
73
73
  return readDefaultProjectTrustSync(agentDir) === "always";
74
74
  }
75
75
 
76
- /** The saved selection for this run: project state wins over global state
77
- * (project state only when trusted), then the built-in default. */
76
+ /** The profile this pass generates the overlay for: this process's already
77
+ * applied selection (`continuation`) first a reload must not regress to
78
+ * the `--profile` flag — then the flag, then the saved selection: project
79
+ * state wins over global state (project state only when trusted), then the
80
+ * built-in default. */
78
81
  export function resolveStartupProfileNameSync(input: {
79
82
  agentDir: string;
80
83
  cwd: string;
81
84
  projectTrusted: boolean;
82
85
  argv: readonly string[];
86
+ continuation?: string;
83
87
  }): string {
88
+ if (input.continuation !== undefined && input.continuation.length > 0) return input.continuation;
84
89
  const requested = readFlagFromArgv(input.argv, "profile");
85
90
  if (requested !== undefined && requested.length > 0) return requested;
86
91
  const project = input.projectTrusted
@@ -97,6 +102,9 @@ export interface McpOverlaySyncInput {
97
102
  projectTrusted?: boolean;
98
103
  /** Effective `--mcp-config`; undefined means the managed overlay path. */
99
104
  overridePath?: string;
105
+ /** The selection this process already applied: a reload's overlay belongs
106
+ * to it, not to the `--profile` flag. */
107
+ continuation?: string;
100
108
  /** The command line, for the load pass (Pi applies flag values only after
101
109
  * extension loading, so `--profile` is read from here). */
102
110
  argv?: readonly string[];
@@ -126,6 +134,7 @@ export function syncStartupMcpOverlay(
126
134
  cwd: input.cwd,
127
135
  projectTrusted: trust ?? resolveProjectTrustedSync(input.agentDir, input.cwd),
128
136
  argv: input.argv ?? [],
137
+ ...(input.continuation === undefined ? {} : { continuation: input.continuation }),
129
138
  });
130
139
  const refs = input.mcpRefs ?? readProfileMcpRefsSync({ ...input, name: profileName, trust });
131
140
  return writeOverlayForRefs({ ...input, refs });
@@ -228,7 +237,7 @@ function readActiveProfile(statePath: string): string | undefined {
228
237
  return typeof name === "string" && name.length > 0 ? name : undefined;
229
238
  }
230
239
 
231
- /** The profile's raw `mcp` references; `"unknown"` when the profile or its
240
+ /** The profile's raw `mcps` references; `"unknown"` when the profile or its
232
241
  * catalog cannot be read (caller falls back to "no filtering"). */
233
242
  function readProfileMcpRefsSync(
234
243
  input: McpOverlaySyncInput & { name: string; trust: boolean | undefined },
@@ -241,7 +250,7 @@ function readProfileMcpRefsSync(
241
250
  : new Map<string, ProfileDefinition>();
242
251
  if (global === "error" || project === "error") return "unknown";
243
252
  const definition = project.get(input.name) ?? global.get(input.name);
244
- return definition === undefined ? "unknown" : definition.mcp;
253
+ return definition === undefined ? "unknown" : definition.mcps;
245
254
  }
246
255
 
247
256
  function readCatalogSync(filePath: string): Map<string, ProfileDefinition> | "error" {
@@ -5,6 +5,12 @@
5
5
  * one-run selection: it is never written back to runtime state.
6
6
  * - Without the flag the saved selection applies: the trusted project's
7
7
  * state wins over the global state, then the built-in `default`.
8
+ * - Every session start after the first continues THIS process's selection
9
+ * (`continuation`): the flag is a startup directive, and re-reading it
10
+ * after an in-session switch would resurrect the profile the user just
11
+ * left. A continuation that no longer resolves falls back like a saved
12
+ * selection, with a warning, because a deleted profile must not strand the
13
+ * runtime.
8
14
  * - A saved selection that no longer resolves falls back to `default` with
9
15
  * a warning — restore is a convenience, not a commitment. An explicit
10
16
  * flag value never falls back: an unknown name is a loud error.
@@ -94,19 +100,70 @@ async function legacyRegistryWarnings(input: {
94
100
  return warnings;
95
101
  }
96
102
 
97
- /** Resolves the profile name for this session: explicit flag → trusted
98
- * project state global state default. Never writes state. */
103
+ /**
104
+ * The profile this process last applied. Kept on `globalThis` because Pi
105
+ * re-imports extension modules on every reload (jiti) — module state resets
106
+ * exactly when it is needed. It is a run-scoped fact, not saved state: the
107
+ * `--profile` flag must not resurrect itself over an in-session switch when
108
+ * the MCP overlay change rebuilds the runtime.
109
+ */
110
+ const RUN_SELECTION_KEY = "pi-profile-switch:applied-profile";
111
+
112
+ interface RunSelection {
113
+ appliedProfile?: string;
114
+ }
115
+
116
+ function runSelection(): RunSelection {
117
+ const holder = globalThis as unknown as Record<string, RunSelection | undefined>;
118
+ const current = holder[RUN_SELECTION_KEY] ?? {};
119
+ holder[RUN_SELECTION_KEY] = current;
120
+ return current;
121
+ }
122
+
123
+ /** Records the profile this process applied (startup or in-session).
124
+ * `undefined` clears the record; a fresh process starts empty. */
125
+ export function recordAppliedProfile(name: string | undefined): void {
126
+ runSelection().appliedProfile = name;
127
+ }
128
+
129
+ /** The profile this process is running, or undefined before the first
130
+ * activation. */
131
+ export function appliedProfile(): string | undefined {
132
+ return runSelection().appliedProfile;
133
+ }
134
+
135
+ /** Resolves the profile name for this session: the run's current selection
136
+ * (`continuation`) → explicit flag → trusted project state → global state →
137
+ * default. Never writes state.
138
+ *
139
+ * `requested` is the CLI flag: an unknown name is a loud error.
140
+ * `continuation` is what this process already applied, passed on every
141
+ * session start after the first: it keeps the in-session switch, and falls
142
+ * back (with a warning) when the name no longer resolves. */
99
143
  export async function resolveStartupProfile(input: {
100
144
  agentDir: string;
101
145
  cwd: string;
102
146
  projectTrusted: boolean;
103
147
  requested?: string;
148
+ continuation?: string;
104
149
  }): Promise<StartupProfile> {
105
150
  const catalog = await ProfileCatalog.load(input.agentDir, {
106
151
  projectDir: input.projectTrusted ? input.cwd : undefined,
107
152
  });
108
153
  const warnings: string[] = await legacyRegistryWarnings(input);
109
154
 
155
+ // The continuation first: this process already applied it, so the flag
156
+ // (a startup directive) has had its say.
157
+ if (input.continuation !== undefined) {
158
+ if (catalog.resolve(input.continuation) !== undefined) {
159
+ return { name: input.continuation, warnings };
160
+ }
161
+ warnings.push(
162
+ `profile ${JSON.stringify(input.continuation)} no longer exists — restoring the saved selection; ` +
163
+ `available: [${catalog.list().map((profile) => profile.name).join(", ")}]`,
164
+ );
165
+ }
166
+
110
167
  if (input.requested !== undefined) {
111
168
  if (catalog.resolve(input.requested) === undefined) {
112
169
  throw new UnknownProfileError(input.requested, catalog.list().map((profile) => profile.name));
@@ -82,6 +82,18 @@ export async function resolveProfileSelection(
82
82
  return resolveSelection({ profile, overlay, live: deps.live, suppressTools });
83
83
  }
84
84
 
85
+ /** Drops a stale saved selection from the scope that no longer owns it. A
86
+ * project state file left behind by a previous project profile shadows the
87
+ * new global choice on the next startup (project state wins), so the switch
88
+ * path clears it; the scope's overlay stays, because that belongs to the
89
+ * profile it was created for. */
90
+ async function clearOtherSelection(stateDir: string): Promise<void> {
91
+ const store = new RuntimeStateStore(stateDir);
92
+ if ((await store.read()).activeProfile !== undefined) {
93
+ await store.update({ otherActiveProfile: undefined });
94
+ }
95
+ }
96
+
85
97
  /** Activates a profile: resolve, validate, optionally persist, apply. */
86
98
  export async function activateProfile(
87
99
  name: string,
@@ -100,10 +112,27 @@ export async function activateProfile(
100
112
  }
101
113
 
102
114
  if (options?.persist !== false) {
103
- await new RuntimeStateStore(stateDirFor(selection.source, deps)).update({
104
- activeProfile: selection.name,
105
- overlay: overlay ?? undefined,
106
- });
115
+ // Exactly one scope holds the saved selection: the project state file
116
+ // for a project profile, the global one otherwise (including the
117
+ // built-in `default`). A project profile is only reachable in a
118
+ // trusted project, so an untrusted project is skipped exactly like
119
+ // its catalog read — its state file stays untouched (ADR-0007).
120
+ const ownDir = stateDirFor(selection.source, deps);
121
+ const projectDir = stateDirFor("project", deps);
122
+ const writable = selection.source !== "project" || deps.projectTrusted;
123
+ if (writable) {
124
+ await new RuntimeStateStore(ownDir).update({
125
+ activeProfile: selection.name,
126
+ overlay: overlay ?? undefined,
127
+ });
128
+ // The scope that owned the previous selection must let go of it,
129
+ // or the next startup restores it: project state wins over global.
130
+ // An untrusted project is never read or written here either.
131
+ const otherDir = selection.source === "project" ? stateDirFor("global", deps) : projectDir;
132
+ if (otherDir !== ownDir && (otherDir !== projectDir || deps.projectTrusted)) {
133
+ await clearOtherSelection(otherDir);
134
+ }
135
+ }
107
136
  }
108
137
 
109
138
  const result = await applySelection({ selection, surface: deps.surface, preset });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * McpToggle: persistent, profile-scoped `/mcp enable|disable` (ticket 10).
3
3
  *
4
- * The profile's `mcp` array in its OWNING catalog is the profile-scoped
4
+ * The profile's `mcps` array in its OWNING catalog is the profile-scoped
5
5
  * state store (ticket 04 established that pi-mcp-adapter@2.33.0 has no
6
6
  * allowlist/profile-state API — ADR-0002's assumed store does not exist;
7
7
  * pi-profile-switch owns the contract). The runtime effect is the caller's
@@ -20,7 +20,13 @@
20
20
  */
21
21
 
22
22
  import { discoverAdapterServerNames } from "../mcp-config.ts";
23
- import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition, type ProfileSource } from "../profile-catalog.ts";
23
+ import {
24
+ CatalogError,
25
+ DEFAULT_PROFILE_NAME,
26
+ LEGACY_FIELD_ALIASES,
27
+ type ProfileDefinition,
28
+ type ProfileSource,
29
+ } from "../profile-catalog.ts";
24
30
  import { catalogStore, readCatalogScope, type CatalogScope } from "./profile-crud.ts";
25
31
 
26
32
  export async function setMcpServerEnabled(
@@ -32,7 +38,7 @@ export async function setMcpServerEnabled(
32
38
  },
33
39
  server: string,
34
40
  enabled: boolean,
35
- ): Promise<{ mcp: string[]; changed: boolean }> {
41
+ ): Promise<{ mcps: string[]; changed: boolean }> {
36
42
  const { name, source } = input.profile;
37
43
  if (name === DEFAULT_PROFILE_NAME || source === "builtin") {
38
44
  throw new CatalogError(
@@ -60,16 +66,18 @@ export async function setMcpServerEnabled(
60
66
  throw new CatalogError(`profile "${input.profile.name}" not found in the ${scope} catalog`);
61
67
  }
62
68
 
63
- const current = definition.mcp ?? [];
69
+ const current = definition.mcps ?? [];
64
70
  if (enabled === current.includes(server)) {
65
- return { mcp: current, changed: false }; // already in the requested state
71
+ return { mcps: current, changed: false }; // already in the requested state
66
72
  }
67
73
  const next = enabled ? [...current, server] : current.filter((name) => name !== server);
68
74
  // Drop the key entirely when empty (exactOptionalPropertyTypes; a
69
- // written `mcp: undefined` would also misrepresent the definition).
75
+ // written `mcps: undefined` would also misrepresent the definition) and
76
+ // drop the legacy alias, so one save migrates the file to `mcps`.
70
77
  const rest = { ...definition };
71
- delete rest.mcp;
72
- const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcp: next } : rest;
78
+ delete rest.mcps;
79
+ const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcps: next } : rest;
80
+ delete (updated as Record<string, unknown>)[LEGACY_FIELD_ALIASES.mcps];
73
81
  await catalogStore(input, scope).upsert(input.profile.name, updated);
74
- return { mcp: next, changed: true };
82
+ return { mcps: next, changed: true };
75
83
  }
@@ -75,7 +75,7 @@ async function captureDefinition(
75
75
 
76
76
  const listFields = [
77
77
  ["skills", "skills (comma-separated names or globs, empty = all visible)"],
78
- ["mcp", "mcp servers (names or globs, empty = none)"],
78
+ ["mcps", "MCP servers (names or globs, empty = none)"],
79
79
  ["tools", "tools (names or globs, empty = pi default set)"],
80
80
  ] as const;
81
81
  for (const [field, prompt] of listFields) {