pi-profile-switch 0.3.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +31 -36
  2. package/README.zh-CN.md +31 -36
  3. package/bin/pi-profile.js +11 -0
  4. package/bin/pi-profile.ts +65 -0
  5. package/bin/postinstall.d.ts +13 -0
  6. package/bin/postinstall.js +88 -0
  7. package/defaults/profiles.json +18 -0
  8. package/examples/profiles.json +31 -5
  9. package/extensions/pi-profile/index.ts +451 -0
  10. package/package.json +10 -13
  11. package/schemas/profiles.schema.json +22 -24
  12. package/src/extension-discovery.ts +347 -0
  13. package/src/json-file.ts +1 -21
  14. package/src/launcher/args.ts +57 -0
  15. package/src/launcher/discovery.ts +64 -0
  16. package/src/launcher/initial-profile.ts +179 -0
  17. package/src/launcher/model-check.ts +52 -0
  18. package/src/launcher/runtime-cleanup.ts +85 -0
  19. package/src/launcher/spawn.ts +82 -0
  20. package/src/mcp-config.ts +37 -153
  21. package/src/mcp-coordination.ts +29 -10
  22. package/src/profile-catalog-store.ts +31 -12
  23. package/src/profile-catalog.ts +45 -93
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -41
  27. package/src/settings-generator.ts +541 -0
  28. package/src/skill-registry.ts +94 -0
  29. package/src/switching/apply-plan.ts +197 -0
  30. package/src/switching/customize.ts +62 -33
  31. package/src/switching/list-profiles.ts +9 -6
  32. package/src/switching/mcp-toggle.ts +14 -26
  33. package/src/switching/profile-crud.ts +31 -24
  34. package/src/switching/profile-wizard.ts +21 -49
  35. package/src/switching/status.ts +142 -72
  36. package/src/switching/switch-profile.ts +219 -0
  37. package/src/switching/tool-references.ts +40 -0
  38. package/src/workspace.ts +57 -0
  39. package/LICENSE +0 -21
  40. package/examples/profiles.example.json +0 -74
  41. package/extensions/pi-profile-switch/index.ts +0 -778
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/default-profiles.ts +0 -59
  44. package/src/mcp-overlay-file.ts +0 -35
  45. package/src/mcp-overlay.ts +0 -122
  46. package/src/model-selection.ts +0 -64
  47. package/src/name-matching.ts +0 -50
  48. package/src/profile-badge.ts +0 -142
  49. package/src/profile-presets.ts +0 -61
  50. package/src/skill-selection.ts +0 -81
  51. package/src/startup-mcp-scope.ts +0 -271
  52. package/src/startup-selection.ts +0 -201
  53. package/src/switching/activate-profile.ts +0 -144
  54. package/src/switching/apply-profile.ts +0 -131
@@ -4,66 +4,47 @@
4
4
  *
5
5
  * Constructed with the directory holding the state file: the real agent dir
6
6
  * for global state, the project's `.pi` dir for project state (project
7
- * state is only touched when Pi reports the project trusted).
7
+ * state is only touched when the trust check passed). The launcher only
8
+ * reads — the initial CLI selection is transient by design; `/profile use`
9
+ * (ticket 05) writes both the selection and the rollback anchor.
8
10
  *
9
- * `activeProfile` is the saved selection applied on the next start;
10
- * `overlay` is the temporary narrowing of the active profile, written by
11
- * `/profile customize` and deleted by `/profile reset`.
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.
12
15
  *
13
16
  * A missing or malformed state file is not an error on read — it simply
14
17
  * means "fall back to the default profile". Unexpected I/O errors
15
- * propagate. A state file written by an older pi-profile-switch is read with its
16
- * retired fields (`lastVerifiedProfile`, `overlay.disabledExtensions`)
17
- * ignored; the next write drops them.
18
+ * propagate. Writes replace the file wholesale (both fields are always
19
+ * written together by the switch path).
18
20
  */
19
21
 
20
22
  import { mkdir, writeFile } from "node:fs/promises";
21
23
  import path from "node:path";
22
24
 
23
25
  import { isRecord, readJsonFile } from "./json-file.ts";
24
- import type { ProfileSource } from "./profile-catalog.ts";
25
-
26
- /** The state directory for one profile's source scope: the project's `.pi`
27
- * dir for project profiles, the agent dir otherwise (built-in `default` is
28
- * treated as global). */
29
- export function stateDirFor(source: ProfileSource, dirs: { agentDir: string; cwd: string }): string {
30
- return source === "project" ? path.join(dirs.cwd, ".pi") : dirs.agentDir;
31
- }
32
26
 
33
27
  export interface RuntimeState {
34
28
  activeProfile?: string;
29
+ lastVerifiedProfile?: string;
30
+ /** The runtime overlay: temporary narrowing of the active profile
31
+ * (ticket 06). Never written to catalogs, never applied at launch —
32
+ * only in-session switches/reloads read it. */
35
33
  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;
41
34
  }
42
35
 
43
36
  export interface RuntimeOverlay {
44
37
  disabledSkills?: string[];
45
- disabledMcp?: string[];
38
+ disabledExtensions?: string[];
39
+ disabledMcps?: string[];
46
40
  /** Replaces the profile's tool references when set. */
47
41
  tools?: string[];
48
42
  }
49
43
 
50
- /** True when an overlay actually narrows the active profile. An overlay whose
51
- * fields were all removed again (`/profile customize enable …`) is not a
52
- * difference from the catalog, and `parseOverlay` drops an empty overlay on
53
- * read. A `tools: []` override is a difference: it selects no tools. */
54
- export function overlayNarrows(overlay: RuntimeOverlay | undefined): boolean {
55
- if (overlay === undefined) return false;
56
- return (
57
- (overlay.disabledSkills?.length ?? 0) > 0 ||
58
- (overlay.disabledMcp?.length ?? 0) > 0 ||
59
- overlay.tools !== undefined
60
- );
61
- }
62
-
63
44
  function parseOverlay(value: unknown): RuntimeOverlay | undefined {
64
45
  if (!isRecord(value)) return undefined;
65
46
  const overlay: RuntimeOverlay = {};
66
- for (const key of ["disabledSkills", "disabledMcp", "tools"] as const) {
47
+ for (const key of ["disabledSkills", "disabledExtensions", "disabledMcps", "tools"] as const) {
67
48
  const list = value[key];
68
49
  if (Array.isArray(list) && list.every((entry) => typeof entry === "string")) {
69
50
  overlay[key] = list;
@@ -88,6 +69,9 @@ export class RuntimeStateStore {
88
69
  if (typeof result.value.activeProfile === "string") {
89
70
  state.activeProfile = result.value.activeProfile;
90
71
  }
72
+ if (typeof result.value.lastVerifiedProfile === "string") {
73
+ state.lastVerifiedProfile = result.value.lastVerifiedProfile;
74
+ }
91
75
  const overlay = parseOverlay(result.value.overlay);
92
76
  if (overlay !== undefined) {
93
77
  state.overlay = overlay;
@@ -97,15 +81,12 @@ export class RuntimeStateStore {
97
81
 
98
82
  async write(state: RuntimeState): Promise<void> {
99
83
  await mkdir(path.dirname(this.#statePath), { recursive: true });
100
- const document: RuntimeState = {};
101
- if (state.activeProfile !== undefined) document.activeProfile = state.activeProfile;
102
- if (state.overlay !== undefined) document.overlay = state.overlay;
103
- await writeFile(this.#statePath, `${JSON.stringify(document, null, 2)}\n`);
84
+ await writeFile(this.#statePath, `${JSON.stringify(state, null, 2)}\n`);
104
85
  }
105
86
 
106
87
  /** Read-modify-write merge. A field set to `undefined` is deleted; absent
107
88
  * fields keep their stored value. Used by the switch/customize paths so
108
- * one concern (selection, overlay) never clobbers another. */
89
+ * one concern (selection, anchor, overlay) never clobbers another. */
109
90
  async update(patch: Partial<RuntimeState>): Promise<RuntimeState> {
110
91
  const current = await this.read();
111
92
  const next: RuntimeState = { ...current };
@@ -113,11 +94,14 @@ export class RuntimeStateStore {
113
94
  if (patch.activeProfile === undefined) delete next.activeProfile;
114
95
  else next.activeProfile = patch.activeProfile;
115
96
  }
97
+ if ("lastVerifiedProfile" in patch) {
98
+ if (patch.lastVerifiedProfile === undefined) delete next.lastVerifiedProfile;
99
+ else next.lastVerifiedProfile = patch.lastVerifiedProfile;
100
+ }
116
101
  if ("overlay" in patch) {
117
102
  if (patch.overlay === undefined) delete next.overlay;
118
103
  else next.overlay = patch.overlay;
119
104
  }
120
- if ("otherActiveProfile" in patch) delete next.activeProfile;
121
105
  await this.write(next);
122
106
  return next;
123
107
  }
@@ -0,0 +1,541 @@
1
+ /**
2
+ * SettingsGenerator: materializes an ActivationPlan as a pi-profile-owned
3
+ * runtime directory (ADR-0005).
4
+ *
5
+ * Two entry points:
6
+ * - `generateRuntimeDir` (launcher): mkdtemp a fresh runtime dir, write the
7
+ * files, link state (auth/models/mcp/npm/git/bin; trust.json only for
8
+ * default), derive env + flags.
9
+ * - `writeRuntimeFiles` (in-session switch, ticket 05): rewrite
10
+ * settings.json + pi-profile.json inside the EXISTING runtime dir (the
11
+ * running process's PI_CODING_AGENT_DIR cannot move), and transition the
12
+ * trust.json link to match the new plan's filter mode.
13
+ *
14
+ * For the built-in `default` profile the generated settings preserve the
15
+ * user's global settings untouched and re-include the real agent dir's
16
+ * resource dirs (their discovery root moves with `PI_CODING_AGENT_DIR`), so
17
+ * the spawned pi behaves exactly like native `pi`.
18
+ *
19
+ * For named profiles the generated settings encode the profile's selection
20
+ * per the filtering model (see docs/architecture/overview.md):
21
+ * - agentDir-scope resources: additive allowlist paths (the discovery root
22
+ * moved, so nothing auto-discovered from the real agent dir)
23
+ * - `~/.agents` skills: always auto-discovered, so unselected ones are
24
+ * force-excluded with `-<path>` entries
25
+ * - packages: user-configured package entries rewritten to object form with
26
+ * per-type allowlists (unmanaged types keep the user's key or Pi's default)
27
+ * - `defaultProjectTrust: "never"` suppresses all project auto-discovery
28
+ * (project resources enter only through the trust-gated resolver)
29
+ * - project `packages` are stripped from the settings merge (project
30
+ * packages are unsupported — the key would install into the global npm
31
+ * root as a launch side effect)
32
+ * - unmanaged kinds (prompts, themes) pass through: the user's arrays are
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)
36
+ *
37
+ * User configuration files are never modified.
38
+ */
39
+
40
+ import { existsSync, realpathSync } from "node:fs";
41
+ import { mkdir, mkdtemp, lstat, readdir, readFile, readlink, rm, stat, symlink, writeFile } from "node:fs/promises";
42
+ import { homedir } from "node:os";
43
+ import path from "node:path";
44
+
45
+ import type { ActivationPlan } from "./profile-resolver.ts";
46
+ import { getInstancesRootDir } from "./workspace.ts";
47
+ import { isRecord } from "./json-file.ts";
48
+ import type { SkillEntry } from "./skill-registry.ts";
49
+
50
+ /** A configured global package and its resolved install/local root. */
51
+ export interface ConfiguredPackageRoot {
52
+ /** The source string exactly as written in the user's settings. */
53
+ source: string;
54
+ /** Absolute install/local root; undefined when not resolvable offline. */
55
+ root?: string;
56
+ }
57
+
58
+ /** Full discovery results the generator needs beyond the plan itself:
59
+ * the complete skill set (for `~/.agents` exclusions) and configured
60
+ * package roots (for classifying extension entries). */
61
+ export interface DiscoveryContext {
62
+ skills: SkillEntry[];
63
+ packages: ConfiguredPackageRoot[];
64
+ }
65
+
66
+ export interface GenerateOptions {
67
+ /** The user's real agent dir (e.g. ~/.pi/agent). */
68
+ agentDir: string;
69
+ /** Required for selection plans; unused for the default profile. */
70
+ discovery?: DiscoveryContext;
71
+ /** The trusted project's `.pi/settings.json` content (already parsed).
72
+ * Only pass when the resolver's trust check passed; merged into the
73
+ * generated base per Pi's merge rules for selection plans. Ignored for
74
+ * the default profile (Pi reads project settings natively there). */
75
+ projectSettings?: Record<string, unknown>;
76
+ }
77
+
78
+ export interface GeneratedRuntime {
79
+ /** The generated runtime directory (becomes PI_CODING_AGENT_DIR). */
80
+ runtimeDir: string;
81
+ /** Environment variables for the spawned pi process. */
82
+ env: Record<string, string>;
83
+ /** Extra pi flags derived from the plan (e.g. --tools, --model). Empty for default. */
84
+ flags: string[];
85
+ }
86
+
87
+ /** Files managed explicitly by pi-profile in runtimeDir; excluded from auto-symlinking. */
88
+ export const MANAGED_INSTANCE_FILES = new Set([
89
+ "settings.json",
90
+ "mcp.json",
91
+ "APPEND_SYSTEM.md",
92
+ "pi-profile.json",
93
+ "trust.json",
94
+ "pid",
95
+ "extensions",
96
+ ]);
97
+
98
+ /** Resource dirs rooted at the real agent dir, re-included for the default
99
+ * profile because PI_CODING_AGENT_DIR moves the discovery root. */
100
+ const RESOURCE_DIR_KINDS = ["skills", "extensions", "prompts", "themes"] as const;
101
+
102
+ /** Unmanaged resource dirs re-included for every profile (pi-profile does
103
+ * not manage prompt templates or themes). */
104
+ const UNMANAGED_DIR_KINDS = ["prompts", "themes"] as const;
105
+
106
+ async function exists(filePath: string): Promise<boolean> {
107
+ try {
108
+ await stat(filePath);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ function toPosix(filePath: string): string {
116
+ return filePath.split(path.sep).join("/");
117
+ }
118
+
119
+ /** Mirrors Pi's own deepMergeSettings: plain objects merge recursively,
120
+ * everything else (arrays, primitives) is replaced by the override. */
121
+ function deepMergeSettings(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
122
+ const result: Record<string, unknown> = { ...base };
123
+ for (const [key, overrideValue] of Object.entries(overrides)) {
124
+ if (overrideValue === undefined) continue;
125
+ const baseValue = result[key];
126
+ result[key] =
127
+ isRecord(baseValue) && isRecord(overrideValue)
128
+ ? deepMergeSettings(baseValue, overrideValue)
129
+ : overrideValue;
130
+ }
131
+ return result;
132
+ }
133
+
134
+ function tryRealpath(p: string): string {
135
+ try {
136
+ return realpathSync(p);
137
+ } catch {
138
+ return path.resolve(p);
139
+ }
140
+ }
141
+
142
+ function isUnderPath(target: string, root: string): boolean {
143
+ const relative = path.relative(root, target);
144
+ if (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) {
145
+ return true;
146
+ }
147
+ const normRelative = path.relative(tryRealpath(root), tryRealpath(target));
148
+ return normRelative !== "" && !normRelative.startsWith("..") && !path.isAbsolute(normRelative);
149
+ }
150
+
151
+ /** The HOME-level ~/.agents/skills dir: always auto-discovered by Pi,
152
+ * unsuppressible via PI_CODING_AGENT_DIR, so it needs exclusion entries. */
153
+ function homeAgentsSkillsDir(): string {
154
+ return path.join(process.env.HOME ?? homedir(), ".agents", "skills");
155
+ }
156
+
157
+ function buildSelectionSettings(
158
+ plan: ActivationPlan,
159
+ userSettings: Record<string, unknown>,
160
+ agentDir: string,
161
+ discovery: DiscoveryContext,
162
+ runtimeDir: string,
163
+ ): Record<string, unknown> {
164
+ const settings = { ...userSettings };
165
+
166
+ // --- skills ---
167
+ // Project-scope selections are emitted before user-scope ones: Pi's
168
+ // same-name collision rule is first-wins, and project resources must keep
169
+ // their native priority (ticket 03).
170
+ const orderedSelectedSkills = [...plan.skills].sort((a, b) => {
171
+ const aProject = a.scope === "project" ? 0 : 1;
172
+ const bProject = b.scope === "project" ? 0 : 1;
173
+ return aProject - bProject;
174
+ });
175
+ const selectedPaths = new Set(plan.skills.map((skill) => skill.filePath));
176
+ const skillEntries: string[] = [];
177
+ for (const skill of orderedSelectedSkills) {
178
+ if (skill.origin === "package") continue; // encoded in the packages allowlist
179
+ if (isUnderPath(skill.filePath, homeAgentsSkillsDir())) continue; // auto-discovered anyway
180
+ skillEntries.push(skill.filePath);
181
+ }
182
+ for (const skill of discovery.skills) {
183
+ if (skill.origin === "package") continue;
184
+ if (selectedPaths.has(skill.filePath)) continue;
185
+
186
+ // If the skill is in the real agentDir, Pi will discover it via the symlink.
187
+ // We must exclude the symlink path so Pi actually excludes it.
188
+ // Lexical paths only: Pi matches `-` exclusions against the raw
189
+ // discovered path without resolving symlinks. Resolving realpaths here
190
+ // escapes runtimeDir whenever an agentDir skill is a symlink to outside
191
+ // the agent dir, and the exclusion then silently matches nothing.
192
+ if (isUnderPath(skill.filePath, agentDir)) {
193
+ const rel = path.relative(agentDir, skill.filePath);
194
+ skillEntries.push(`-${path.join(runtimeDir, rel)}`);
195
+ } else {
196
+ skillEntries.push(`-${skill.filePath}`);
197
+ }
198
+ }
199
+ settings.skills = skillEntries;
200
+
201
+ // --- extensions ---
202
+ // Entries under a package root are encoded in that package's allowlist;
203
+ // everything else becomes an additive absolute path.
204
+ const packageRoots = discovery.packages
205
+ .filter((pkg): pkg is ConfiguredPackageRoot & { root: string } => pkg.root !== undefined)
206
+ .map((pkg) => ({ ...pkg, root: pkg.root }));
207
+ const packageExtensions = new Map<string, string[]>();
208
+ const extensionEntries: string[] = [];
209
+ for (const extension of plan.extensions) {
210
+ const owner = packageRoots.find((pkg) => isUnderPath(extension.entry, pkg.root));
211
+ if (owner === undefined) {
212
+ extensionEntries.push(extension.entry);
213
+ } else {
214
+ const list = packageExtensions.get(owner.source) ?? [];
215
+ list.push(toPosix(path.relative(owner.root, extension.entry)));
216
+ packageExtensions.set(owner.source, list);
217
+ }
218
+ }
219
+ settings.extensions = extensionEntries;
220
+
221
+ // --- packages ---
222
+ const userPackages = Array.isArray(userSettings.packages) ? userSettings.packages : [];
223
+ if (userPackages.length > 0) {
224
+ const packageSkills = new Map<string, string[]>();
225
+ for (const skill of plan.skills) {
226
+ if (skill.origin !== "package" || skill.baseDir === undefined) continue;
227
+ const list = packageSkills.get(skill.source) ?? [];
228
+ list.push(toPosix(path.relative(skill.baseDir, skill.filePath)));
229
+ packageSkills.set(skill.source, list);
230
+ }
231
+ settings.packages = userPackages.map((pkg) => {
232
+ const source = typeof pkg === "string" ? pkg : (pkg as { source: string }).source;
233
+ const base: Record<string, unknown> =
234
+ typeof pkg === "object" && pkg !== null ? { ...(pkg as Record<string, unknown>) } : { source };
235
+ delete base.extensions;
236
+ delete base.skills;
237
+ const rewritten: Record<string, unknown> = {
238
+ source,
239
+ ...base,
240
+ skills: packageSkills.get(source) ?? [],
241
+ extensions: packageExtensions.get(source) ?? [],
242
+ };
243
+ return rewritten;
244
+ });
245
+ }
246
+
247
+ // --- unmanaged dirs pass through (prompts/themes) ---
248
+ for (const kind of UNMANAGED_DIR_KINDS) {
249
+ const resourceDir = path.join(agentDir, kind);
250
+ if (existsSync(resourceDir)) {
251
+ const entries = Array.isArray(settings[kind]) ? (settings[kind] as unknown[]) : [];
252
+ settings[kind] = [...entries, resourceDir];
253
+ }
254
+ }
255
+
256
+ // --- profile defaults (Ticket 04) ---
257
+ if (plan.model !== undefined) {
258
+ settings.defaultProvider = plan.model.provider;
259
+ settings.defaultModel = plan.model.id;
260
+ if (plan.model.thinkingLevel !== undefined) {
261
+ settings.defaultThinkingLevel = plan.model.thinkingLevel;
262
+ } else {
263
+ delete settings.defaultThinkingLevel;
264
+ }
265
+ }
266
+ if (plan.tools !== undefined) {
267
+ settings.defaultTools = plan.tools;
268
+ }
269
+
270
+ // Project auto-discovery is suppressed entirely; selected project
271
+ // resources enter additively through the trust-gated resolver.
272
+ settings.defaultProjectTrust = "never";
273
+ return settings;
274
+ }
275
+
276
+ export interface RuntimeFileOptions {
277
+ /** The user's real agent dir (e.g. ~/.pi/agent). */
278
+ agentDir: string;
279
+ /** Required for selection plans; unused for the default profile. */
280
+ discovery?: DiscoveryContext;
281
+ /** The trusted project's `.pi/settings.json` content (already parsed). */
282
+ projectSettings?: Record<string, unknown>;
283
+ /** Extra launch-plan fields written by the in-session switch path:
284
+ * `switchedFrom` triggers the one-shot change summary; `persistSelection`
285
+ * tells the post-reload extension instance to save the selection and
286
+ * record the rollback anchor; `clearOverlay` drops the stored overlay
287
+ * (a profile switch discards the previous profile's overlay).
288
+ * `previousResolved` carries the pre-switch resolved name sets so
289
+ * `/profile status` can report glob deltas (ticket 07). */
290
+ planExtras?: {
291
+ switchedFrom?: string;
292
+ persistSelection?: boolean;
293
+ clearOverlay?: boolean;
294
+ previousResolved?: ResolvedNames;
295
+ };
296
+ }
297
+
298
+ /** Computes the generated settings for a plan (pure-ish: reads the user's
299
+ * real settings + unmanaged dir existence, writes nothing). */
300
+ async function computeSettings(
301
+ plan: ActivationPlan,
302
+ options: RuntimeFileOptions,
303
+ runtimeDir: string,
304
+ ): Promise<Record<string, unknown>> {
305
+ const { agentDir } = options;
306
+ const userSettingsPath = path.join(agentDir, "settings.json");
307
+ const userSettings: Record<string, unknown> = (await exists(userSettingsPath))
308
+ ? JSON.parse(await readFile(userSettingsPath, "utf8"))
309
+ : {};
310
+
311
+ if (plan.filter === "none") {
312
+ // default profile: the user's global settings plus re-inclusion of the
313
+ // real agent dir's resource dirs. User-defined keys, including their own
314
+ // resource patterns and enable/disable state, are preserved untouched.
315
+ // Project settings are NOT merged here: with native trust behavior, Pi
316
+ // reads the project's settings itself.
317
+ const settings = { ...userSettings };
318
+ for (const kind of RESOURCE_DIR_KINDS) {
319
+ const resourceDir = path.join(agentDir, kind);
320
+ if (await exists(resourceDir)) {
321
+ const entries = Array.isArray(settings[kind]) ? (settings[kind] as unknown[]) : [];
322
+ settings[kind] = [...entries, resourceDir];
323
+ }
324
+ }
325
+ return settings;
326
+ }
327
+
328
+ // Selection plans: the trusted project's settings merge into the base
329
+ // per Pi's merge rules (project wins, nested objects merge), then the
330
+ // filtering encoding replaces the managed keys on top. With
331
+ // defaultProjectTrust: "never", Pi itself never reads project settings.
332
+ //
333
+ // The project's `packages` key is stripped: project packages install
334
+ // under the project's .pi/npm and are unreferenceable in generated
335
+ // global-scope settings — merging the key would make Pi install them
336
+ // into the (symlinked) global npm root as a launch side effect.
337
+ let base = { ...userSettings };
338
+ if (options.projectSettings !== undefined) {
339
+ const { packages: _stripped, ...mergeable } = options.projectSettings;
340
+ base = deepMergeSettings(base, mergeable);
341
+ }
342
+ return buildSelectionSettings(plan, base, agentDir, options.discovery ?? { skills: [], packages: [] }, runtimeDir);
343
+ }
344
+
345
+ /** Resolved name sets, carried in the launch plan for glob-delta reporting. */
346
+ export interface ResolvedNames {
347
+ skills: string[];
348
+ extensions: string[];
349
+ tools?: string[];
350
+ mcps?: string[];
351
+ }
352
+
353
+ /** Writes settings.json + pi-profile.json into an existing runtime dir and
354
+ * transitions the trust.json link to the plan's filter mode: linked for
355
+ * `default` (native trust behavior), absent for named profiles (a stored
356
+ * trust decision would beat the generated `defaultProjectTrust: "never"`
357
+ * inside Pi and re-enable unfiltered project auto-discovery). */
358
+ export async function writeRuntimeFiles(
359
+ runtimeDir: string,
360
+ plan: ActivationPlan,
361
+ options: RuntimeFileOptions,
362
+ ): Promise<void> {
363
+ const settings = await computeSettings(plan, options, runtimeDir);
364
+ await writeFile(path.join(runtimeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
365
+
366
+ // The launch plan feeds the in-pi extension: instructions injection,
367
+ // tool/model re-application after reload, MCP coordination, switching.
368
+ // agentDir is the REAL agent dir — the extension needs it for trust
369
+ // checks, state files, and catalog/registry reads (its own
370
+ // PI_CODING_AGENT_DIR points at this runtime dir).
371
+ await writeFile(
372
+ path.join(runtimeDir, "pi-profile.json"),
373
+ `${JSON.stringify(
374
+ {
375
+ profile: plan.profile,
376
+ source: plan.source,
377
+ agentDir: options.agentDir,
378
+ ...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}),
379
+ ...(plan.model !== undefined ? { model: plan.model } : {}),
380
+ ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
381
+ ...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
382
+ ...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
383
+ // The resolved sets feed /profile status (absolute paths) and the
384
+ // glob-delta diff against the previous activation.
385
+ resolved: {
386
+ skills: plan.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
387
+ extensions: plan.extensions,
388
+ },
389
+ // Zero-match glob references (ADR-0006) — surfaced by /profile status
390
+ // so a typo'd glob is visible instead of silently selecting nothing.
391
+ ...(plan.unmatched !== undefined ? { unmatched: plan.unmatched } : {}),
392
+ ...options.planExtras,
393
+ },
394
+ null,
395
+ 2,
396
+ )}\n`,
397
+ );
398
+
399
+ const trustLink = path.join(runtimeDir, "trust.json");
400
+ const trustTarget = path.join(options.agentDir, "trust.json");
401
+ if (plan.filter === "none") {
402
+ if ((await exists(trustTarget)) && !(await exists(trustLink))) {
403
+ await symlink(trustTarget, trustLink);
404
+ }
405
+ } else if (await exists(trustLink)) {
406
+ await rm(trustLink);
407
+ }
408
+
409
+ // MCP Servers generation (Ticket 04)
410
+ const mcpTarget = path.join(options.agentDir, "mcp.json");
411
+ const mcpInstancePath = path.join(runtimeDir, "mcp.json");
412
+ if (plan.mcps === undefined) {
413
+ // No restrictions, symlink
414
+ if (await exists(mcpTarget)) {
415
+ try { await rm(mcpInstancePath); } catch {}
416
+ await symlink(mcpTarget, mcpInstancePath);
417
+ }
418
+ } else {
419
+ // Filter MCP servers
420
+ try { await rm(mcpInstancePath); } catch {}
421
+ if (await exists(mcpTarget)) {
422
+ try {
423
+ const mcpContent = await readFile(mcpTarget, "utf8");
424
+ let mcpParsed = JSON.parse(mcpContent);
425
+ if (isRecord(mcpParsed) && isRecord(mcpParsed.mcpServers)) {
426
+ const filteredServers: Record<string, unknown> = {};
427
+ for (const serverName of plan.mcps) {
428
+ if (mcpParsed.mcpServers[serverName] !== undefined) {
429
+ filteredServers[serverName] = mcpParsed.mcpServers[serverName];
430
+ }
431
+ }
432
+ mcpParsed.mcpServers = filteredServers;
433
+ await writeFile(mcpInstancePath, JSON.stringify(mcpParsed, null, 2));
434
+ } else {
435
+ // Malformed or empty, write empty
436
+ await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
437
+ }
438
+ } catch {
439
+ await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
440
+ }
441
+ } else {
442
+ await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
443
+ }
444
+ }
445
+
446
+ // Instructions generation (Ticket 04)
447
+ const appendSystemPath = path.join(runtimeDir, "APPEND_SYSTEM.md");
448
+ if (plan.instructions !== undefined && plan.instructions.trim() !== "") {
449
+ await writeFile(appendSystemPath, plan.instructions);
450
+ } else {
451
+ try { await rm(appendSystemPath); } catch {}
452
+ }
453
+
454
+ // Full-fidelity symlink mirroring and dangling link cleanup (Ticket 02).
455
+ await syncAgentSymlinks(options.agentDir, runtimeDir);
456
+ }
457
+
458
+ /**
459
+ * Full-fidelity symlink mirroring of the user's real agentDir into runtimeDir (Ticket 02).
460
+ * - Excludes profile-managed files.
461
+ * - Mirrors both file and directory symlinks.
462
+ * - Detects and cleans up dangling or obsolete symlinks in runtimeDir.
463
+ * - Avoids recreating identical existing symlinks to minimize startup I/O.
464
+ */
465
+ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): Promise<void> {
466
+ if (!existsSync(agentDir)) return;
467
+ if (path.resolve(agentDir) === path.resolve(runtimeDir)) return;
468
+
469
+ // Ensure the real sessions directory exists so it is always mirrored
470
+ await mkdir(path.join(agentDir, "sessions"), { recursive: true });
471
+
472
+ // 1. Clean up dangling or obsolete symlinks in runtimeDir
473
+ try {
474
+ const runtimeEntries = await readdir(runtimeDir);
475
+ for (const name of runtimeEntries) {
476
+ if (MANAGED_INSTANCE_FILES.has(name)) continue;
477
+ const linkPath = path.join(runtimeDir, name);
478
+ const target = path.join(agentDir, name);
479
+ try {
480
+ const linkStat = await lstat(linkPath);
481
+ if (linkStat.isSymbolicLink()) {
482
+ if (!existsSync(target)) {
483
+ await rm(linkPath, { recursive: true, force: true });
484
+ }
485
+ }
486
+ } catch {
487
+ // Best-effort cleanup
488
+ }
489
+ }
490
+ } catch {}
491
+
492
+ // 2. Mirror files and directories from agentDir to runtimeDir
493
+ try {
494
+ const entries = await readdir(agentDir);
495
+ for (const name of entries) {
496
+ if (MANAGED_INSTANCE_FILES.has(name)) continue;
497
+
498
+ const target = path.join(agentDir, name);
499
+ const linkPath = path.join(runtimeDir, name);
500
+
501
+ try {
502
+ const linkStat = await lstat(linkPath).catch(() => null);
503
+ if (linkStat) {
504
+ if (linkStat.isSymbolicLink()) {
505
+ const currentTarget = await readlink(linkPath).catch(() => null);
506
+ if (currentTarget === target) {
507
+ continue;
508
+ }
509
+ }
510
+ await rm(linkPath, { recursive: true, force: true });
511
+ }
512
+
513
+ const info = await stat(target);
514
+ await symlink(target, linkPath, info.isDirectory() ? "dir" : "file");
515
+ } catch {
516
+ // Ignore broken source links or unreadable files
517
+ }
518
+ }
519
+ } catch {}
520
+ }
521
+
522
+ export async function generateRuntimeDir(
523
+ plan: ActivationPlan,
524
+ options: GenerateOptions,
525
+ ): Promise<GeneratedRuntime> {
526
+ const { agentDir } = options;
527
+ const runtimeDir = path.join(getInstancesRootDir(), plan.profile, "agent");
528
+ await mkdir(runtimeDir, { recursive: true });
529
+
530
+ await writeRuntimeFiles(runtimeDir, plan, options);
531
+
532
+ const flags: string[] = [];
533
+
534
+ return {
535
+ runtimeDir,
536
+ env: {
537
+ PI_CODING_AGENT_DIR: runtimeDir,
538
+ },
539
+ flags,
540
+ };
541
+ }