pi-profile-switch 0.4.4 → 0.4.6

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile references a set of existing skills, extensions, MCP servers, and tools — switch between them in the same Pi process, without restarting.
6
6
 
7
- Use a lean read-only profile for code review, a full-powered one for implementation, a minimal one for a quick question — all against the same installed resources.
7
+ Use a read-only profile for Q&A and code exploration, a full-powered one for implementation — all against the same installed resources.
8
8
 
9
9
  ## Install
10
10
 
@@ -20,33 +20,32 @@ Requires [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (in
20
20
  # Launch with the built-in default profile (all resources, plain Pi behavior)
21
21
  pi-profile
22
22
 
23
- # Launch with a named profile
24
- pi-profile review
23
+ # Launch with the seeded read-only ask profile
24
+ pi-profile ask
25
25
 
26
26
  # Anything after -- is passed to pi verbatim
27
- pi-profile review -- --model openai/gpt-5.4
27
+ pi-profile ask -- --model openai/gpt-5.4
28
28
  ```
29
29
 
30
- Define profiles in `~/.pi-profile-switch/profiles.json` (global, fallback to `~/.pi/agent/profiles.json` for migration; custom root via `PI_PROFILE_SWITCH_DIR`) or `<project>/.pi/profiles.json` (project, trusted projects only):
30
+ On install, pi-profile-switch seeds `~/.pi-profile-switch/profiles.json` (global, fallback to `~/.pi/agent/profiles.json` for migration; custom root via `PI_PROFILE_SWITCH_DIR`) with a starter **`ask`** profile — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely:
31
31
 
32
32
  ```json
33
33
  {
34
34
  "schemaVersion": 1,
35
35
  "profiles": {
36
- "review": {
37
- "label": "Code review",
38
- "skills": ["code-review"],
39
- "mcp": ["github"],
40
- "tools": ["read", "grep", "find", "bash"],
41
- "instructions": "Review only; do not modify files."
36
+ "ask": {
37
+ "label": "Ask & Discuss",
38
+ "description": "Read-only Q&A and code exploration; no file modifications or command execution",
39
+ "skills": [],
40
+ "extensions": [],
41
+ "tools": ["read", "grep", "find", "ls"],
42
+ "instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
42
43
  }
43
44
  }
44
45
  }
45
46
  ```
46
47
 
47
- Profiles **reference** resources by name — they never copy them. Installed packages and files in standard locations are discovered automatically; no registration needed. Full schema with more examples: [`examples/profiles.json`](examples/profiles.json).
48
-
49
- On install, pi-profile-switch seeds `~/.pi-profile-switch/profiles.json` with a starter **`ask`** profile — read-only Q&A and code exploration (`read`/`grep`/`find`/`ls`, no skills, extensions, or MCP). It assumes nothing about your setup; edit or delete it freely.
48
+ Project-level profiles live in `<project>/.pi/profiles.json` (trusted projects only). Profiles **reference** resources by name — they never copy them. Installed packages and files in standard locations are discovered automatically; no registration needed. Complete configuration examples live in [`examples/`](examples/): `profiles.json` is the seeded starter above, and `example.json` demonstrates every available field (skills, extensions, MCP servers, tools, model defaults, instructions).
50
49
 
51
50
  ## Commands
52
51
 
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile 引用一组已存在的 skills、extensions、MCP server 和 tools,并在同一 Pi 进程内切换,无需重启。
6
6
 
7
- 代码评审用精简的只读 profile,实现需求用全量 profile,临时提问用最小 profile——全部基于同一套已安装的资源。
7
+ 问答与代码走读用只读 profile,实现需求用全量 profile——全部基于同一套已安装的资源。
8
8
 
9
9
  ## 安装
10
10
 
@@ -20,33 +20,32 @@ npm install -g pi-profile-switch
20
20
  # 使用内建 default profile 启动(全量资源,等同原生 Pi)
21
21
  pi-profile
22
22
 
23
- # 使用指定 profile 启动
24
- pi-profile review
23
+ # 使用安装时播种的只读 ask profile 启动
24
+ pi-profile ask
25
25
 
26
26
  # -- 后面的参数原样传给 pi
27
- pi-profile review -- --model openai/gpt-5.4
27
+ pi-profile ask -- --model openai/gpt-5.4
28
28
  ```
29
29
 
30
- 在 `~/.pi-profile-switch/profiles.json`(全局,支持 `PI_PROFILE_SWITCH_DIR` 环境变量自定义,向下兼容 `~/.pi/agent/profiles.json`)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)中定义 profile:
30
+ 安装时,pi-profile-switch 会向 `~/.pi-profile-switch/profiles.json`(全局,支持 `PI_PROFILE_SWITCH_DIR` 环境变量自定义,向下兼容 `~/.pi/agent/profiles.json`)写入一个初始 **`ask`** profile——只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
31
31
 
32
32
  ```json
33
33
  {
34
34
  "schemaVersion": 1,
35
35
  "profiles": {
36
- "review": {
37
- "label": "Code review",
38
- "skills": ["code-review"],
39
- "mcp": ["github"],
40
- "tools": ["read", "grep", "find", "bash"],
41
- "instructions": "Review only; do not modify files."
36
+ "ask": {
37
+ "label": "Ask & Discuss",
38
+ "description": "Read-only Q&A and code exploration; no file modifications or command execution",
39
+ "skills": [],
40
+ "extensions": [],
41
+ "tools": ["read", "grep", "find", "ls"],
42
+ "instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
42
43
  }
43
44
  }
44
45
  }
45
46
  ```
46
47
 
47
- Profile 只**引用**资源,从不复制资源。已安装的包和标准目录下的文件会被自动发现,无需注册。完整示例见 [`examples/profiles.json`](examples/profiles.json)。
48
-
49
- 安装时,pi-profile-switch 会向 `~/.pi-profile-switch/profiles.json` 写入一个初始 **`ask`** profile——只读的问答与代码走读模式(仅 `read`/`grep`/`find`/`ls`,无 skill、extension 和 MCP)。它不假设你安装过任何插件,可随意修改或删除。
48
+ 项目级 profile 定义在 `<项目>/.pi/profiles.json`(仅限已信任项目)。Profile 只**引用**资源,从不复制资源。已安装的包和标准目录下的文件会被自动发现,无需注册。完整配置案例见 [`examples/`](examples/):`profiles.json` 即上面的初始 profile,`example.json` 演示全部可用字段(skills、extensions、MCP server、tools、默认模型、instructions)。
50
49
 
51
50
  ## 命令
52
51
 
package/bin/pi-profile.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * pi-profile launcher (ADR-0005).
4
4
  *
5
5
  * Resolves the initial profile, materializes it as a generated runtime
6
- * directory (settings + symlinks + env + flags), and spawns the real `pi`
6
+ * directory (settings + symlinks + env), and spawns the real `pi`
7
7
  * binary with user arguments passed through verbatim.
8
8
  *
9
9
  * Usage:
@@ -18,8 +18,7 @@ import { parseLauncherArgs } from "../src/launcher/args.ts";
18
18
  import { UnknownProfileError, resolveInitialProfile } from "../src/launcher/initial-profile.ts";
19
19
  import { sweepStaleRuntimeDirs } from "../src/launcher/runtime-cleanup.ts";
20
20
  import { spawnPi } from "../src/launcher/spawn.ts";
21
- import { McpConfigError } from "../src/mcp-config.ts";
22
- import { MissingMcpAdapterError } from "../src/mcp-coordination.ts";
21
+ import { McpConfigError, MissingMcpAdapterError } from "../src/mcp-config.ts";
23
22
  import { CatalogError } from "../src/profile-catalog.ts";
24
23
  import { ActivationError } from "../src/profile-resolver.ts";
25
24
  import { generateRuntimeDir } from "../src/settings-generator.ts";
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Runs at package install time (`npm install pi-profile-switch` / `pi install`).
6
6
  * Idempotent and conservative:
7
- * - Writes `defaults/profiles.json` to the profile-switch dir ONLY when no
7
+ * - Writes the shipped `examples/profiles.json` starter catalog to the
8
+ * profile-switch dir ONLY when no
8
9
  * catalog exists there yet (COPYFILE_EXCL; an existing file — including
9
10
  * one written concurrently — is never touched).
10
11
  * - Skips entirely when a legacy `~/.pi/agent/profiles.json` exists, because
@@ -22,7 +23,7 @@ import { homedir } from "node:os";
22
23
  import path from "node:path";
23
24
  import { fileURLToPath, pathToFileURL } from "node:url";
24
25
 
25
- const DEFAULT_TEMPLATE = fileURLToPath(new URL("../defaults/profiles.json", import.meta.url));
26
+ const DEFAULT_TEMPLATE = fileURLToPath(new URL("../examples/profiles.json", import.meta.url));
26
27
 
27
28
  /** Mirrors getProfileSwitchDir() in src/workspace.ts.
28
29
  * @param {NodeJS.ProcessEnv} env */
@@ -0,0 +1,33 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "profiles": {
4
+ "impl": {
5
+ "label": "Implementation",
6
+ "description": "Full-powered implementation profile: every available field, pinned model",
7
+ "skills": [
8
+ "tdd",
9
+ "internal-*"
10
+ ],
11
+ "extensions": [
12
+ "pi-mcp-adapter"
13
+ ],
14
+ "mcps": [
15
+ "github",
16
+ "linear"
17
+ ],
18
+ "tools": [
19
+ "read",
20
+ "grep",
21
+ "find",
22
+ "ls",
23
+ "bash",
24
+ "edit",
25
+ "write"
26
+ ],
27
+ "defaultProvider": "anthropic",
28
+ "defaultModel": "claude-sonnet-4-5",
29
+ "defaultThinkingLevel": "high",
30
+ "instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
31
+ }
32
+ }
33
+ }
@@ -1,42 +1,18 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "profiles": {
4
- "review": {
5
- "label": "Code review",
6
- "description": "Read-heavy review workflow",
7
- "skills": [
8
- "code-review",
9
- "diagnosing-bugs"
10
- ],
11
- "extensions": [
12
- "pi-mcp-adapter"
13
- ],
14
- "mcps": [
15
- "github"
16
- ],
4
+ "ask": {
5
+ "label": "Ask & Discuss",
6
+ "description": "Read-only Q&A and code exploration; no file modifications or command execution",
7
+ "skills": [],
8
+ "extensions": [],
17
9
  "tools": [
18
10
  "read",
19
11
  "grep",
20
12
  "find",
21
- "bash"
22
- ],
23
- "instructions": "Review only; do not modify files."
24
- },
25
- "impl": {
26
- "label": "Implementation",
27
- "skills": [
28
- "tdd"
29
- ],
30
- "extensions": [
31
- "pi-mcp-adapter"
32
- ],
33
- "mcps": [
34
- "github",
35
- "linear"
13
+ "ls"
36
14
  ],
37
- "defaultProvider": "deepseek",
38
- "defaultModel": "deepseek-v4-pro",
39
- "defaultThinkingLevel": "high"
15
+ "instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
40
16
  }
41
17
  }
42
18
  }
@@ -29,14 +29,12 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
29
29
  * pi-profile extension entry.
30
30
  *
31
31
  * Loaded into the spawned pi via `-e`. Responsibilities:
32
- * - Append the profile's declared instructions to Pi's fully built system
33
- * prompt on every turn (`before_agent_start`), so the default prompt,
34
- * AGENTS.md, and other extensions keep working.
35
32
  * - After every session start (startup/reload/new/resume/fork), apply the
36
- * launch plan: re-expand tool references against Pi's live registry,
37
- * set the declared model/thinking, publish the MCP allowlist, persist the
38
- * selection + rollback anchor after switches, and produce the one-shot
39
- * change summary injected into the next turn.
33
+ * launch plan: re-expand tool references against Pi's live registry
34
+ * (including extension- and MCP-provided tools) and call setActiveTools
35
+ * for strict allowlisting, persist the selection after switches, and
36
+ * produce the one-shot change summary injected into the next turn
37
+ * via `before_agent_start`.
40
38
  * - `/profile use <name>` / `/profile reload`: in-session switching without
41
39
  * restarting the Pi process (src/switching/switch-profile.ts).
42
40
  * - `/profile customize` / `/profile reset`: runtime overlay (ticket 06).
@@ -78,11 +76,6 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
78
76
  surface: {
79
77
  getAllTools: () => pi.getAllTools(),
80
78
  setActiveTools: (names) => pi.setActiveTools(names),
81
- modelRegistry: ctx.modelRegistry,
82
- setModel: (model) => pi.setModel(model as Parameters<ExtensionAPI["setModel"]>[0]),
83
- setThinkingLevel: (level) =>
84
- pi.setThinkingLevel(level as Parameters<ExtensionAPI["setThinkingLevel"]>[0]),
85
- events: pi.events,
86
79
  notify: (message, level) => ctx.ui?.notify(message, level),
87
80
  },
88
81
  });
@@ -90,12 +83,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
90
83
  });
91
84
 
92
85
  pi.on("before_agent_start", async (event) => {
93
- const plan = await readLaunchPlanFile(runtimeDir);
94
- const instructions = plan?.instructions;
95
86
  let systemPrompt = event.systemPrompt;
96
- if (instructions !== undefined && instructions.length > 0 && !systemPrompt.includes(instructions)) {
97
- systemPrompt = `${systemPrompt}\n\n${instructions}`;
98
- }
99
87
  if (pendingSummary !== undefined) {
100
88
  systemPrompt = `${systemPrompt}\n\n[${pendingSummary}]`;
101
89
  pendingSummary = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -45,7 +45,6 @@
45
45
  },
46
46
  "files": [
47
47
  "bin",
48
- "defaults",
49
48
  "extensions",
50
49
  "src",
51
50
  "schemas",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ExtensionDiscovery: implicit, read-only discovery of selectable extensions
3
- * (ADR-0006), so profiles can reference extensions without registering them
4
- * in resources.json first.
3
+ * (ADR-0006), so profiles can reference extensions without any
4
+ * registration step.
5
5
  *
6
6
  * Two implicit sources, both Pi-native and side-effect free:
7
7
  * - Configured user packages: each package's `package.json#pi.extensions`
@@ -13,9 +13,6 @@
13
13
  *
14
14
  * Discovery never executes extension code and never installs anything: a
15
15
  * package contributes entries only for declared files that exist on disk.
16
- * Explicit resources.json entries merge over this discovery result in
17
- * ResourceRegistry.load (registration remains the override, never the
18
- * prerequisite).
19
16
  */
20
17
 
21
18
  import { readdir, readFile, stat } from "node:fs/promises";
@@ -328,7 +325,7 @@ export async function discoverImplicitExtensions(options: {
328
325
  const merged = new Map<string, DiscoveredLocalExtension>(globalLocal.map((entry) => [entry.id, entry]));
329
326
  if (options.projectDir !== undefined) {
330
327
  // Project loose files override same-ID global ones, mirroring the
331
- // catalog/registry override convention.
328
+ // project-over-global catalog override convention.
332
329
  for (const entry of await scanLooseDir(path.join(options.projectDir, ".pi", "extensions"), warnings)) {
333
330
  merged.set(entry.id, entry);
334
331
  }
package/src/json-file.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Shared JSON-file reading for pi-profile's file-backed stores (catalog,
3
- * resource registry, runtime state). Each store maps read failures to its
4
- * own error policy (loud CatalogError/RegistryError vs. quiet state
5
- * fallback); this helper only classifies the outcome.
3
+ * runtime state). Each store maps read failures to its own error policy
4
+ * (loud CatalogError vs. quiet state fallback); this helper only classifies
5
+ * the outcome.
6
6
  */
7
7
 
8
8
  import { readFile } from "node:fs/promises";
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * Flow: trust check (gatekeeper for everything project-scoped) → positional
5
5
  * name or saved state (project state wins when trusted) → catalog lookup →
6
- * skill discovery + resource registry → resolver (glob expansion, dependency
7
- * closure, alwaysOn, model validation) → ActivationPlan + full discovery
8
- * results for the settings generator. Unknown profiles, malformed
9
- * catalogs/registries, and unresolvable resources all fail before Pi spawns.
6
+ * discovery (skills, extensions, MCP server names) → resolver (glob
7
+ * expansion, overlay, model validation) → ActivationPlan + full discovery
8
+ * results for the settings generator. Unknown profiles, malformed catalogs,
9
+ * and unresolvable resources all fail before Pi spawns.
10
10
  *
11
11
  * The CLI's initial selection is transient: no runtime state is written here.
12
12
  */
@@ -15,7 +15,7 @@ import path from "node:path";
15
15
 
16
16
  import { isRecord, readJsonFile } from "../json-file.ts";
17
17
  import { discoverAdapterServerNames } from "../mcp-config.ts";
18
- import { isAdapterExtension, MissingMcpAdapterError } from "../mcp-coordination.ts";
18
+ import { isAdapterExtension, MissingMcpAdapterError } from "../mcp-config.ts";
19
19
  import { ProfileCatalog, type ResolvedProfile } from "../profile-catalog.ts";
20
20
  import { ActivationError, defaultPlan, resolveProfile, type ActivationPlan } from "../profile-resolver.ts";
21
21
  import { resolveProjectTrust } from "../project-trust.ts";
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Spawns the real `pi` binary as a subprocess (ADR-0005).
3
3
  *
4
- * The spawned pi gets: the pi-profile extension via `-e`, any generated
5
- * flags, and the user's arguments verbatim. stdio is inherited so interactive
4
+ * The spawned pi gets: the pi-profile extension via `-e` and the user's
5
+ * arguments verbatim. stdio is inherited so interactive
6
6
  * TUI, RPC, and print modes all behave natively; exit codes and signals
7
7
  * propagate.
8
8
  */
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
  import type { GeneratedRuntime } from "../settings-generator.ts";
16
16
 
17
17
  export interface SpawnPiOptions {
18
- /** Generated runtime dir + env + flags. */
18
+ /** Generated runtime dir + env. */
19
19
  generated: GeneratedRuntime;
20
20
  /** User arguments, forwarded verbatim. */
21
21
  piArgs: string[];
@@ -25,10 +25,10 @@ export interface SpawnPiOptions {
25
25
 
26
26
  const EXTENSION_ENTRY = fileURLToPath(new URL("../../extensions/pi-profile/index.ts", import.meta.url));
27
27
 
28
- /** Pure argv construction for the spawned pi: extension entry, generated
29
- * flags, trust re-application, then user args verbatim. */
28
+ /** Pure argv construction for the spawned pi: extension entry,
29
+ * trust re-application, then user args verbatim. */
30
30
  export function buildPiArgs(options: SpawnPiOptions): string[] {
31
- const args = ["-e", EXTENSION_ENTRY, ...options.generated.flags];
31
+ const args = ["-e", EXTENSION_ENTRY];
32
32
  // default profile keeps trust behavior native: re-apply the recorded flag.
33
33
  if (options.trustOverride === true) args.push("--approve");
34
34
  if (options.trustOverride === false) args.push("--no-approve");
package/src/mcp-config.ts CHANGED
@@ -134,3 +134,22 @@ export async function discoverAdapterServerNames(
134
134
  const { servers } = await loadMergedMcpServers(agentDir, projectDir, options);
135
135
  return Object.keys(servers).sort();
136
136
  }
137
+
138
+ export class MissingMcpAdapterError extends Error {
139
+ constructor(profile: string) {
140
+ super(
141
+ `profile "${profile}" declares MCP servers but pi-mcp-adapter is not active. ` +
142
+ `Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcps" declaration.`,
143
+ );
144
+ this.name = "MissingMcpAdapterError";
145
+ }
146
+ }
147
+
148
+ /** Identifies the adapter among active extension entries by its install
149
+ * path containing "pi-mcp-adapter" (npm package roots and local dirs both
150
+ * match). Heuristic by design — activation-plan entries carry no package
151
+ * source string. */
152
+ export function isAdapterExtension(entry: { entry: string }): boolean {
153
+ return entry.entry.includes("pi-mcp-adapter");
154
+ }
155
+
@@ -7,23 +7,21 @@
7
7
  * resolution; zero matches is fine (new matches join on the next start) and
8
8
  * is reported in the plan's `unmatched` list so typos are visible.
9
9
  * - Literal references must exist; a missing literal fails activation.
10
- * Extension references resolve through ResourceRegistry.select (ADR-0006):
11
- * registry ID, installed package name/alias, loose-file stem, or an
12
- * on-disk path — no pre-registration required.
13
- * - `alwaysOn` resources and the recursive `dependsOn` closure join every
14
- * plan; cycles and missing entries fail loudly (via ResourceRegistry).
10
+ * Extension references resolve through ExtensionDiscovery.select (ADR-0007):
11
+ * package name/alias, loose-file stem, or an on-disk path — no
12
+ * pre-registration required.
15
13
  * - Undeclared model/thinking/instructions never enter the plan, so Pi's
16
14
  * current state stays untouched.
17
15
  * - MCP references (`mcp`) expand against the server names the launcher
18
16
  * discovered from pi-mcp-adapter's pi-native config files: literal misses
19
17
  * fail loudly; globs expand to zero or more matches (consistent with
20
18
  * skills/extensions). Adapter presence is checked separately by the
21
- * launcher/extension (ADR-0002).
22
- * - Tool globs expand against Pi's built-in tool names only: extension- and
23
- * MCP-provided tool names are unknowable before spawn (extension code must
24
- * not execute here), so literal tool names pass through unvalidated and
25
- * glob matching for contributed tools happens when the extension applies
26
- * the plan against Pi's actual registrations (tickets 05+).
19
+ * launcher (ADR-0002).
20
+ * - Tool globs expand against Pi's built-in tool names for settings
21
+ * `defaultTools`; raw tool references are also carried into the launch
22
+ * plan so the in-session extension can expand them against Pi's live
23
+ * registry (including extension and MCP tools) via setActiveTools for
24
+ * strict allowlisting.
27
25
  */
28
26
 
29
27
  import { minimatch } from "minimatch";
@@ -75,10 +73,13 @@ export interface ActivationPlan {
75
73
  toolReferences?: string[];
76
74
  /** Declared model; undefined leaves Pi's current model untouched. */
77
75
  model?: ProfileModel;
78
- /** Declared instructions; appended to Pi's system prompt by the extension. */
76
+ /** Declared instructions; written to the generated APPEND_SYSTEM.md,
77
+ * which Pi natively appends to the system prompt. */
79
78
  instructions?: string;
80
- /** Expanded MCP server allowlist for pi-mcp-adapter coordination;
81
- * undefined when the profile declares no `mcps` (no coordination). */
79
+ /** Expanded MCP server allowlist: written by SettingsGenerator into the
80
+ * filtered instance `mcp.json` (the adapter's runtime restriction) and
81
+ * surfaced in `/profile status`; undefined when the profile declares
82
+ * no `mcps` (no restriction). */
82
83
  mcps?: string[];
83
84
  /** Glob references (skills/extensions/MCP) that matched nothing this
84
85
  * resolution — surfaced as warnings so zero-match typos are never silent.
@@ -110,7 +111,7 @@ export interface ResolveInput {
110
111
  * The runtime overlay (ticket 06): temporary narrowing applied on top of
111
112
  * the profile definition at every resolution. Overlay references must
112
113
  * name resources the profile actually resolves (typos fail loudly), and
113
- * `alwaysOn` extensions and their dependency chains cannot be disabled.
114
+ * any resolved reference may be narrowed or disabled.
114
115
  */
115
116
  overlay?: RuntimeOverlay;
116
117
  }
@@ -4,7 +4,7 @@
4
4
  * `defaultProjectTrust: "never"` and Pi therefore never auto-discovers them.
5
5
  *
6
6
  * Mirrors Pi's own trust decision order (`resolveProjectTrusted`), with
7
- * pi-profile's catalog/registry/state files added to the trust-requiring
7
+ * pi-profile's catalog/state files added to the trust-requiring
8
8
  * resource set (they are pi-profile's project attack surface; Pi doesn't
9
9
  * know about them):
10
10
  * 1. one-run `--approve` / `--no-approve` override (consumed by the
@@ -6,17 +6,13 @@
6
6
  * for global state, the project's `.pi` dir for project state (project
7
7
  * state is only touched when the trust check passed). The launcher only
8
8
  * reads — the initial CLI selection is transient by design; `/profile use`
9
- * (ticket 05) writes both the selection and the rollback anchor.
9
+ * (ticket 05) writes the selection.
10
10
  *
11
- * `activeProfile` is the saved selection restored on launch;
12
- * `lastVerifiedProfile` is the rollback anchor: the last profile whose
13
- * activation completed successfully. They differ only between a failed
14
- * activation and its rollback.
11
+ * `activeProfile` is the saved selection restored on launch.
15
12
  *
16
13
  * A missing or malformed state file is not an error on read — it simply
17
14
  * means "fall back to the default profile". Unexpected I/O errors
18
- * propagate. Writes replace the file wholesale (both fields are always
19
- * written together by the switch path).
15
+ * propagate. Writes replace the file wholesale.
20
16
  */
21
17
 
22
18
  import { mkdir, writeFile } from "node:fs/promises";
@@ -26,7 +22,6 @@ import { isRecord, readJsonFile } from "./json-file.ts";
26
22
 
27
23
  export interface RuntimeState {
28
24
  activeProfile?: string;
29
- lastVerifiedProfile?: string;
30
25
  /** The runtime overlay: temporary narrowing of the active profile
31
26
  * (ticket 06). Never written to catalogs, never applied at launch —
32
27
  * only in-session switches/reloads read it. */
@@ -69,9 +64,6 @@ export class RuntimeStateStore {
69
64
  if (typeof result.value.activeProfile === "string") {
70
65
  state.activeProfile = result.value.activeProfile;
71
66
  }
72
- if (typeof result.value.lastVerifiedProfile === "string") {
73
- state.lastVerifiedProfile = result.value.lastVerifiedProfile;
74
- }
75
67
  const overlay = parseOverlay(result.value.overlay);
76
68
  if (overlay !== undefined) {
77
69
  state.overlay = overlay;
@@ -94,10 +86,6 @@ export class RuntimeStateStore {
94
86
  if (patch.activeProfile === undefined) delete next.activeProfile;
95
87
  else next.activeProfile = patch.activeProfile;
96
88
  }
97
- if ("lastVerifiedProfile" in patch) {
98
- if (patch.lastVerifiedProfile === undefined) delete next.lastVerifiedProfile;
99
- else next.lastVerifiedProfile = patch.lastVerifiedProfile;
100
- }
101
89
  if ("overlay" in patch) {
102
90
  if (patch.overlay === undefined) delete next.overlay;
103
91
  else next.overlay = patch.overlay;
@@ -5,7 +5,7 @@
5
5
  * Two entry points:
6
6
  * - `generateRuntimeDir` (launcher): mkdtemp a fresh runtime dir, write the
7
7
  * files, link state (auth/models/mcp/npm/git/bin; trust.json only for
8
- * default), derive env + flags.
8
+ * default), derive env.
9
9
  * - `writeRuntimeFiles` (in-session switch, ticket 05): rewrite
10
10
  * settings.json + pi-profile.json inside the EXISTING runtime dir (the
11
11
  * running process's PI_CODING_AGENT_DIR cannot move), and transition the
@@ -31,8 +31,9 @@
31
31
  * root as a launch side effect)
32
32
  * - unmanaged kinds (prompts, themes) pass through: the user's arrays are
33
33
  * preserved and the real agent dir's prompts/themes dirs re-included
34
- * - tools/model become generated flags; the launch plan file feeds the
35
- * in-pi extension (instructions injection, status)
34
+ * - tools/model are written to generated settings (defaultTools,
35
+ * defaultProvider, defaultModel, defaultThinkingLevel); the launch plan
36
+ * file feeds the in-pi extension (tools strict allowlist, status)
36
37
  *
37
38
  * User configuration files are never modified.
38
39
  */
@@ -85,8 +86,6 @@ export interface GeneratedRuntime {
85
86
  runtimeDir: string;
86
87
  /** Environment variables for the spawned pi process. */
87
88
  env: Record<string, string>;
88
- /** Extra pi flags derived from the plan (e.g. --tools, --model). Empty for default. */
89
- flags: string[];
90
89
  }
91
90
 
92
91
  /** Files managed explicitly by pi-profile in runtimeDir; excluded from auto-symlinking. */
@@ -117,6 +116,18 @@ async function exists(filePath: string): Promise<boolean> {
117
116
  }
118
117
  }
119
118
 
119
+ /** Symlink-aware existence check (lstat): a dangling symlink still counts as
120
+ * existing — a stat-based check misses it, which would skip its removal or
121
+ * collide on symlink creation. Use this for paths pi-profile links itself. */
122
+ async function existsLexical(filePath: string): Promise<boolean> {
123
+ try {
124
+ await lstat(filePath);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
120
131
  function toPosix(filePath: string): string {
121
132
  return filePath.split(path.sep).join("/");
122
133
  }
@@ -291,8 +302,8 @@ export interface RuntimeFileOptions {
291
302
  projectSettings?: Record<string, unknown>;
292
303
  /** Extra launch-plan fields written by the in-session switch path:
293
304
  * `switchedFrom` triggers the one-shot change summary; `persistSelection`
294
- * tells the post-reload extension instance to save the selection and
295
- * record the rollback anchor; `clearOverlay` drops the stored overlay
305
+ * tells the post-reload extension instance to save the selection;
306
+ * `clearOverlay` drops the stored overlay
296
307
  * (a profile switch discards the previous profile's overlay).
297
308
  * `previousResolved` carries the pre-switch resolved name sets so
298
309
  * `/profile status` can report glob deltas (ticket 07). */
@@ -372,10 +383,11 @@ export async function writeRuntimeFiles(
372
383
  const settings = await computeSettings(plan, options, runtimeDir);
373
384
  await writeFile(path.join(runtimeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
374
385
 
375
- // The launch plan feeds the in-pi extension: instructions injection,
376
- // tool/model re-application after reload, MCP coordination, switching.
386
+ // The launch plan feeds the in-pi extension: tool re-application after
387
+ // reload (the tools strict allowlist), in-session switching, status
388
+ // reporting, and post-reload state persistence.
377
389
  // agentDir is the REAL agent dir — the extension needs it for trust
378
- // checks, state files, and catalog/registry reads (its own
390
+ // checks, state files, and catalog reads (its own
379
391
  // PI_CODING_AGENT_DIR points at this runtime dir).
380
392
  await writeFile(
381
393
  path.join(runtimeDir, "pi-profile.json"),
@@ -384,8 +396,6 @@ export async function writeRuntimeFiles(
384
396
  profile: plan.profile,
385
397
  source: plan.source,
386
398
  agentDir: options.agentDir,
387
- ...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}),
388
- ...(plan.model !== undefined ? { model: plan.model } : {}),
389
399
  ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
390
400
  ...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
391
401
  ...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
@@ -408,10 +418,14 @@ export async function writeRuntimeFiles(
408
418
  const trustLink = path.join(runtimeDir, "trust.json");
409
419
  const trustTarget = path.join(options.agentDir, "trust.json");
410
420
  if (plan.filter === "none") {
411
- if ((await exists(trustTarget)) && !(await exists(trustLink))) {
421
+ if ((await exists(trustTarget)) && !(await existsLexical(trustLink))) {
412
422
  await symlink(trustTarget, trustLink);
413
423
  }
414
- } else if (await exists(trustLink)) {
424
+ } else if (await existsLexical(trustLink)) {
425
+ // Lexical check: a dangling trust.json symlink (real trust.json deleted
426
+ // after the link was made) must still be removed — otherwise a later
427
+ // re-created real trust.json silently resurrects stored trust inside a
428
+ // named profile, defeating defaultProjectTrust: "never".
415
429
  await rm(trustLink);
416
430
  }
417
431
 
@@ -543,13 +557,10 @@ export async function generateRuntimeDir(
543
557
 
544
558
  await writeRuntimeFiles(runtimeDir, plan, options);
545
559
 
546
- const flags: string[] = [];
547
-
548
560
  return {
549
561
  runtimeDir,
550
562
  env: {
551
563
  PI_CODING_AGENT_DIR: runtimeDir,
552
564
  },
553
- flags,
554
565
  };
555
566
  }
@@ -9,17 +9,16 @@
9
9
  *
10
10
  * Steps:
11
11
  * 1. tools: re-expand the profile's raw tool references against Pi's LIVE
12
- * tool registry (includes extension-provided tools the pre-spawn
13
- * expansion cannot know) and setActiveTools. Literals that no tool
14
- * provides are dropped with a warning — Pi silently ignores unknown
15
- * names, so the warning is the only signal.
16
- * 2. model: setModel + setThinkingLevel when declared.
17
- * 3. mcp: probe the adapter and publish the runtime allowlist (ticket 04).
18
- * 4. persistence: when the plan is marked `persistSelection` and this is a
19
- * reload, save the selection and the rollback anchor
20
- * (activeProfile = lastVerifiedProfile = plan.profile) to the
12
+ * tool registry (including extension- and MCP-provided tools) and
13
+ * call setActiveTools. This is the CURRENT strict-allowlist enforcement
14
+ * ensuring non-builtin tools obey profile restrictions; settings
15
+ * `defaultTools` provides only the boot baseline for built-ins.
16
+ * Literals that no tool provides are dropped with a warning — Pi
17
+ * silently ignores unknown names, so the warning is the only signal.
18
+ * 2. persistence: when the plan is marked `persistSelection` and this is a
19
+ * reload, save the selection (activeProfile = plan.profile) to the
21
20
  * profile's scope state file. Launch-transient selections never write.
22
- * 5. change summary: a `switchedFrom` marker produces a one-shot summary
21
+ * 3. change summary: a `switchedFrom` marker produces a one-shot summary
23
22
  * for the next agent turn and is cleared from the plan file.
24
23
  *
25
24
  * Pi's reload re-executes extension modules, so no stale handler or command
@@ -31,12 +30,6 @@ import { writeFile } from "node:fs/promises";
31
30
  import path from "node:path";
32
31
 
33
32
  import { isRecord, readJsonFile } from "../json-file.ts";
34
- import {
35
- MCP_ALLOWLIST_EVENT,
36
- MCP_ALLOWLIST_VERSION,
37
- MissingMcpAdapterError,
38
- probeAdapterPresence,
39
- } from "../mcp-coordination.ts";
40
33
  import { RuntimeStateStore } from "../runtime-state-store.ts";
41
34
  import { getGlobalStateDir } from "../workspace.ts";
42
35
  import { expandToolReferences } from "./tool-references.ts";
@@ -45,8 +38,6 @@ export interface LaunchPlanFile {
45
38
  profile: string;
46
39
  source: string;
47
40
  agentDir?: string;
48
- instructions?: string;
49
- model?: { provider: string; id: string; thinkingLevel?: string };
50
41
  tools?: string[];
51
42
  toolReferences?: string[];
52
43
  mcps?: string[];
@@ -71,10 +62,6 @@ export interface LaunchPlanFile {
71
62
  export interface PlanApplicationSurface {
72
63
  getAllTools(): Array<{ name: string }>;
73
64
  setActiveTools(names: string[]): void;
74
- modelRegistry: { find(provider: string, id: string): unknown | undefined };
75
- setModel(model: unknown): Promise<boolean>;
76
- setThinkingLevel(level: unknown): void;
77
- events: { emit(channel: string, data: unknown): void };
78
65
  notify?(message: string, level: "info" | "warning" | "error"): void;
79
66
  }
80
67
 
@@ -119,46 +106,13 @@ export async function applyLaunchPlan(input: {
119
106
  surface.setActiveTools(expanded);
120
107
  }
121
108
 
122
- // --- model ---
123
- if (plan.model !== undefined) {
124
- const found = surface.modelRegistry.find(plan.model.provider, plan.model.id);
125
- if (found === undefined) {
126
- warnings.push(`profile "${plan.profile}": declared model ${plan.model.provider}/${plan.model.id} not found`);
127
- } else {
128
- const applied = await surface.setModel(found);
129
- if (!applied) {
130
- warnings.push(
131
- `profile "${plan.profile}": model ${plan.model.provider}/${plan.model.id} has no configured auth`,
132
- );
133
- }
134
- }
135
- if (plan.model.thinkingLevel !== undefined) {
136
- surface.setThinkingLevel(plan.model.thinkingLevel);
137
- }
138
- }
139
-
140
- // --- mcp coordination (ticket 04 contract) ---
141
- if (plan.mcps !== undefined && plan.mcps.length > 0) {
142
- if (!probeAdapterPresence(surface.events)) {
143
- const error = new MissingMcpAdapterError(plan.profile);
144
- surface.notify?.(error.message, "error");
145
- throw error;
146
- }
147
- surface.events.emit(MCP_ALLOWLIST_EVENT, {
148
- version: MCP_ALLOWLIST_VERSION,
149
- profile: plan.profile,
150
- servers: plan.mcps,
151
- });
152
- }
153
-
154
- // --- persistence + rollback anchor (post-reload only) ---
109
+ // --- persistence (post-reload only) ---
155
110
  if (plan.persistSelection === true && input.reason === "reload" && plan.agentDir !== undefined) {
156
111
  const stateDir = plan.source === "project" ? path.join(input.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
157
112
  // Merge: the overlay belongs to customize/reset, not to this write.
158
113
  // A switch (clearOverlay) explicitly drops it.
159
114
  await new RuntimeStateStore(stateDir).update({
160
115
  activeProfile: plan.profile,
161
- lastVerifiedProfile: plan.profile,
162
116
  ...(plan.clearOverlay === true ? { overlay: undefined } : {}),
163
117
  });
164
118
  }
@@ -182,7 +136,6 @@ function buildSwitchSummary(plan: LaunchPlanFile): string {
182
136
  `profile switched: ${plan.switchedFrom} → ${plan.profile}`,
183
137
  plan.tools !== undefined ? `tools: [${plan.tools.join(", ")}]` : undefined,
184
138
  plan.mcps !== undefined && plan.mcps.length > 0 ? `mcp: [${plan.mcps.join(", ")}]` : undefined,
185
- plan.model !== undefined ? `model: ${plan.model.provider}/${plan.model.id}` : undefined,
186
139
  ].filter((part): part is string => part !== undefined);
187
140
  return parts.join("; ");
188
141
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Ordering invariants:
11
11
  * - customize: re-resolve with the candidate overlay FIRST (validation:
12
- * unknown references and alwaysOn protection fail here, before anything
12
+ * unknown references fail here, before anything
13
13
  * is written), then switch+reload, then persist the overlay to state. A
14
14
  * failed switch leaves the stored overlay untouched, consistent with the
15
15
  * rolled-back runtime.
@@ -50,7 +50,7 @@ export async function customizeOverlay(
50
50
  const candidate = mutate(state.overlay ?? {});
51
51
 
52
52
  // switchProfile re-resolves with the candidate overlay; resolution-time
53
- // validation (unknown references, alwaysOn protection) fails before any
53
+ // validation (unknown references) fails before any
54
54
  // write. persistSelection is preserved by the reload-current path.
55
55
  const result = await switchProfile(profile, deps, { reloadCurrent: true, overlay: candidate });
56
56
 
@@ -7,14 +7,16 @@
7
7
  * Flow (`/profile use <name>`):
8
8
  * 1. wait for the agent to be idle (Pi's native `ctx.waitForIdle()`) — a
9
9
  * running turn is never torn down
10
- * 2. snapshot the runtime dir's settings.json + pi-profile.json in memory
10
+ * 2. snapshot ALL pi-profile-managed runtime files in memory
11
+ * (settings.json, pi-profile.json, mcp.json, APPEND_SYSTEM.md, and the
12
+ * trust.json link state — each as absent | symlink | file)
11
13
  * 3. re-resolve through the full launcher path (trust check, catalogs,
12
- * discovery, registry, model/MCP validation) against the REAL agent
14
+ * discovery, model/MCP validation) against the REAL agent
13
15
  * dir — any failure here leaves the runtime untouched
14
16
  * 4. rewrite the runtime files in place (the running process's
15
17
  * PI_CODING_AGENT_DIR cannot move) and mark the plan
16
18
  * `persistSelection` so the post-reload extension instance saves the
17
- * selection + rollback anchor
19
+ * selection
18
20
  * 5. `ctx.reload()` — Pi re-reads settings from disk, re-executes
19
21
  * extensions, preserves the session
20
22
  * 6. VERIFY the reload ran: interactive Pi swallows reload refusals and
@@ -29,7 +31,7 @@
29
31
  * (transient launch selections stay transient).
30
32
  */
31
33
 
32
- import { readFile, writeFile } from "node:fs/promises";
34
+ import { chmod, lstat, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
33
35
  import path from "node:path";
34
36
 
35
37
  import { resolveInitialProfile } from "../launcher/initial-profile.ts";
@@ -70,38 +72,74 @@ export interface SwitchResult {
70
72
  warnings: string[];
71
73
  }
72
74
 
75
+ /** The pre-switch state of one pi-profile-managed runtime file. Absence is
76
+ * a real state (the switch may create the file); a symlink keeps its raw
77
+ * target so restore can rebuild it exactly; a file keeps content AND
78
+ * permission bits so restore doesn't widen a user-tightened mode. */
79
+ type FileSnapshot =
80
+ | { kind: "absent" }
81
+ | { kind: "symlink"; target: string }
82
+ | { kind: "file"; content: string; mode: number };
83
+
73
84
  interface RuntimeSnapshot {
74
- settings?: string;
75
- plan?: string;
85
+ settings: FileSnapshot;
86
+ plan: FileSnapshot;
87
+ mcp: FileSnapshot;
88
+ appendSystem: FileSnapshot;
89
+ trust: FileSnapshot;
76
90
  }
77
91
 
78
- async function readIfExists(filePath: string): Promise<string | undefined> {
79
- try {
80
- return await readFile(filePath, "utf8");
81
- } catch (error) {
82
- // Absence is expected (first launch); anything else (permissions,
83
- // unreadable dir) must not silently disable rollback protection.
84
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
92
+ /** Snapshots one managed runtime file. lstat (never stat) detects symlinks
93
+ * without following them; ENOENT is the only tolerated error — absence is
94
+ * expected (first launch), while anything else (permissions, unreadable
95
+ * dir) must not silently disable rollback protection. */
96
+ async function snapshotFile(filePath: string): Promise<FileSnapshot> {
97
+ const info = await lstat(filePath).catch((error: NodeJS.ErrnoException) => {
98
+ // ENOENT is the only tolerated error — absence is expected (first
99
+ // launch); anything else (permissions, unreadable dir) must not
100
+ // silently disable rollback protection.
101
+ if (error.code === "ENOENT") return null;
85
102
  throw error;
103
+ });
104
+ if (info === null) return { kind: "absent" };
105
+ if (info.isSymbolicLink()) {
106
+ return { kind: "symlink", target: await readlink(filePath) };
86
107
  }
108
+ return { kind: "file", content: await readFile(filePath, "utf8"), mode: info.mode & 0o777 };
87
109
  }
88
110
 
89
111
  async function snapshotRuntimeFiles(runtimeDir: string): Promise<RuntimeSnapshot> {
90
112
  return {
91
- settings: await readIfExists(path.join(runtimeDir, "settings.json")),
92
- plan: await readIfExists(path.join(runtimeDir, "pi-profile.json")),
113
+ settings: await snapshotFile(path.join(runtimeDir, "settings.json")),
114
+ plan: await snapshotFile(path.join(runtimeDir, "pi-profile.json")),
115
+ mcp: await snapshotFile(path.join(runtimeDir, "mcp.json")),
116
+ appendSystem: await snapshotFile(path.join(runtimeDir, "APPEND_SYSTEM.md")),
117
+ trust: await snapshotFile(path.join(runtimeDir, "trust.json")),
93
118
  };
94
119
  }
95
120
 
96
- async function restoreRuntimeFiles(runtimeDir: string, snapshot: RuntimeSnapshot): Promise<void> {
97
- if (snapshot.settings !== undefined) {
98
- await writeFile(path.join(runtimeDir, "settings.json"), snapshot.settings);
99
- }
100
- if (snapshot.plan !== undefined) {
101
- await writeFile(path.join(runtimeDir, "pi-profile.json"), snapshot.plan);
121
+ async function restoreFile(filePath: string, snapshot: FileSnapshot): Promise<void> {
122
+ // rm first, always: restoring a snapshotted FILE must never writeFile
123
+ // through a symlink the failed switch left on disk — that would write
124
+ // THROUGH to the link target (the user's real ~/.pi/agent/mcp.json)
125
+ // instead of replacing the link.
126
+ await rm(filePath, { force: true });
127
+ if (snapshot.kind === "symlink") {
128
+ await symlink(snapshot.target, filePath);
129
+ } else if (snapshot.kind === "file") {
130
+ await writeFile(filePath, snapshot.content);
131
+ await chmod(filePath, snapshot.mode);
102
132
  }
103
133
  }
104
134
 
135
+ async function restoreRuntimeFiles(runtimeDir: string, snapshot: RuntimeSnapshot): Promise<void> {
136
+ await restoreFile(path.join(runtimeDir, "settings.json"), snapshot.settings);
137
+ await restoreFile(path.join(runtimeDir, "pi-profile.json"), snapshot.plan);
138
+ await restoreFile(path.join(runtimeDir, "mcp.json"), snapshot.mcp);
139
+ await restoreFile(path.join(runtimeDir, "APPEND_SYSTEM.md"), snapshot.appendSystem);
140
+ await restoreFile(path.join(runtimeDir, "trust.json"), snapshot.trust);
141
+ }
142
+
105
143
  /** Waits are delegated to Pi's native `ctx.waitForIdle()` (see SwitchDeps);
106
144
  * no polling loop lives here. */
107
145
 
@@ -141,8 +179,8 @@ export async function switchProfile(
141
179
  // Snapshot before resolving so the rollback target always exists.
142
180
  const snapshot = await snapshotRuntimeFiles(deps.runtimeDir);
143
181
 
144
- // Full launcher resolution: trust gate, catalogs, discovery, dependency
145
- // closure, model + MCP validation. Failures here leave the runtime
182
+ // Full launcher resolution: trust gate, catalogs, discovery, model +
183
+ // MCP validation. Failures here leave the runtime
146
184
  // untouched — nothing was written yet.
147
185
  const resolved = await resolveInitialProfile(
148
186
  target,
@@ -1,18 +0,0 @@
1
- {
2
- "schemaVersion": 1,
3
- "profiles": {
4
- "ask": {
5
- "label": "Ask & Discuss",
6
- "description": "Read-only Q&A and code exploration; no file modifications or command execution",
7
- "skills": [],
8
- "extensions": [],
9
- "tools": [
10
- "read",
11
- "grep",
12
- "find",
13
- "ls"
14
- ],
15
- "instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
16
- }
17
- }
18
- }
@@ -1,65 +0,0 @@
1
- /**
2
- * McpCoordination: the pi-profile ↔ pi-mcp-adapter contract (ADR-0002).
3
- *
4
- * The adapter's released versions (≤2.33) offer no in-memory server
5
- * allowlist, so pi-profile defines the coordination channel the locked
6
- * adapter implements:
7
- *
8
- * - pi-profile publishes the active profile's runtime server allowlist on
9
- * `pi-profile:mcp-allowlist:v1` at session start (and after every profile
10
- * switch reload, ticket 05). The allowlist is memory-only: pi-profile
11
- * never writes the adapter's `.pi/mcp.json` overlay.
12
- * - Adapter presence is probed via the adapter's documented
13
- * request/result event pattern: emit a snapshot request for a bogus
14
- * server name; an installed adapter fills `request.result` synchronously
15
- * (with `{ok: false}` — the name is bogus), an absent adapter leaves it
16
- * undefined.
17
- *
18
- * Launch-time, the launcher additionally refuses to spawn when a profile
19
- * declares `mcp` but no active extension identifies as the adapter
20
- * (`isAdapterExtension`) — failing before spawn beats failing in-session.
21
- */
22
-
23
- /** pi-profile's allowlist channel (the locked adapter subscribes). */
24
- export const MCP_ALLOWLIST_EVENT = "pi-profile:mcp-allowlist:v1";
25
- export const MCP_ALLOWLIST_VERSION = 1 as const;
26
-
27
- export interface McpAllowlistMessage {
28
- version: typeof MCP_ALLOWLIST_VERSION;
29
- profile: string;
30
- servers: string[];
31
- }
32
-
33
- /** The adapter's snapshot channel, used here purely as a presence probe
34
- * (see the module docblock). Kept as a literal so pi-profile doesn't
35
- * import adapter internals. */
36
- export const MCP_ADAPTER_SNAPSHOT_EVENT = "pi-mcp-adapter:runtime-snapshot:v1";
37
-
38
- export class MissingMcpAdapterError extends Error {
39
- constructor(profile: string) {
40
- super(
41
- `profile "${profile}" declares MCP servers but pi-mcp-adapter is not active. ` +
42
- `Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcps" declaration.`,
43
- );
44
- this.name = "MissingMcpAdapterError";
45
- }
46
- }
47
-
48
- /** Identifies the adapter among active extension entries by its install
49
- * path containing "pi-mcp-adapter" (npm package roots and local dirs both
50
- * match). Heuristic by design — activation-plan entries carry no package
51
- * source string, and the in-session probe is the authoritative check. */
52
- export function isAdapterExtension(entry: { entry: string }): boolean {
53
- return entry.entry.includes("pi-mcp-adapter");
54
- }
55
-
56
- /** True when the adapter answered the probe (filled `result` on the
57
- * request object), regardless of the answer — presence, not health. */
58
- export function probeAdapterPresence(events: { emit(channel: string, data: unknown): void }): boolean {
59
- const request: { version: 1; name: string; result?: unknown } = {
60
- version: 1,
61
- name: "pi-profile:presence-probe",
62
- };
63
- events.emit(MCP_ADAPTER_SNAPSHOT_EVENT, request);
64
- return request.result !== undefined;
65
- }