pi-profile-switch 0.4.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [中文](README.zh-CN.md)
4
4
 
5
- Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile is a named capability bundle you define: skills, extensions, MCP servers, tools (including tools exposed by MCP servers and extensions), model defaults, and extra system-prompt instructions. Switch bundles inside a running Pi session — no restart.
5
+ Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile is a named set of resources you define: skills, extensions, MCP servers, tools (including tools exposed by MCP servers and extensions), model defaults, and extra system-prompt instructions. Switch profiles inside a running Pi session — no restart.
6
6
 
7
7
  ## Install
8
8
 
package/bin/pi-profile.ts CHANGED
@@ -16,7 +16,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
16
  import { ExtensionError } from "../src/extension-discovery.ts";
17
17
  import { parseLauncherArgs } from "../src/launcher/args.ts";
18
18
  import { UnknownProfileError, resolveInitialProfile } from "../src/launcher/initial-profile.ts";
19
- import { sweepStaleRuntimeDirs } from "../src/launcher/runtime-cleanup.ts";
19
+ import { sweepStaleInstances } from "../src/launcher/runtime-cleanup.ts";
20
20
  import { spawnPi } from "../src/launcher/spawn.ts";
21
21
  import { McpConfigError, MissingMcpAdapterError } from "../src/mcp-config.ts";
22
22
  import { CatalogError } from "../src/profile-catalog.ts";
@@ -28,7 +28,7 @@ try {
28
28
  const agentDir = getAgentDir();
29
29
  // Fails before spawning when the profile is unknown or cannot activate.
30
30
  // --approve/--no-approve are consumed here as a one-run trust input.
31
- const { plan, discovery, projectSettings, projectDir, warnings } = await resolveInitialProfile(args.profile, {
31
+ const { plan, discovery, projectDir, warnings } = await resolveInitialProfile(args.profile, {
32
32
  agentDir,
33
33
  cwd: process.cwd(),
34
34
  trustOverride: args.trustOverride,
@@ -36,17 +36,21 @@ try {
36
36
  for (const warning of warnings) {
37
37
  console.error(`pi-profile: warning: ${warning}`);
38
38
  }
39
- // Stale per-launch runtime dirs (dead pid, or no pid past the grace
40
- // window) are swept before this launch materializes its own. Best-effort:
41
- // sweep errors never block the launch.
42
- await sweepStaleRuntimeDirs(agentDir);
43
- const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectSettings, projectDir });
39
+ // Stale per-launch instance dirs (dead pid, or no pid past the grace
40
+ // window) are swept before this launch materializes its own. Directories
41
+ // holding state pi-profile did not generate are kept and reported instead of
42
+ // deleted (ADR-0010). Best-effort: sweep errors never block the launch.
43
+ for (const warning of await sweepStaleInstances()) {
44
+ console.error(`pi-profile: warning: ${warning}`);
45
+ }
46
+ const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectDir });
44
47
  process.exitCode = await spawnPi({
45
48
  generated,
46
49
  piArgs: args.piArgs,
47
- // Only the default profile keeps trust behavior native (flag re-applied);
48
- // named profiles never forward it — the resolver is the trust gatekeeper.
49
- trustOverride: plan.filter === "none" ? args.trustOverride : undefined,
50
+ // Re-applied for every profile: the one-run trust input must decide both
51
+ // the resolver's project reads and Pi's project-scope visibility — two
52
+ // different answers in one launch would be a divergence, not a policy.
53
+ trustOverride: args.trustOverride,
50
54
  });
51
55
  } catch (error) {
52
56
  // Launcher input/selection failures (unknown profile, unresolvable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.4.9",
3
+ "version": "0.6.0",
4
4
  "description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * ExtensionDiscovery: implicit, read-only discovery of selectable extensions
3
- * (ADR-0006/0007), so profiles can reference extensions without any
3
+ * (ADR-0007/0008), so profiles can reference extensions without any
4
4
  * registration step.
5
5
  *
6
6
  * Two implicit sources, both resolved by Pi itself:
@@ -31,7 +31,7 @@ export class UnknownProfileError extends Error {
31
31
  }
32
32
  }
33
33
 
34
- /** Zero-match glob references surface as launch warnings (ADR-0006):
34
+ /** Zero-match glob references surface as launch warnings (ADR-0009):
35
35
  * visible, but never blocking — globs re-expand on every resolution. */
36
36
  function unmatchedWarnings(plan: ActivationPlan): string[] {
37
37
  return (plan.unmatched ?? []).map(
@@ -53,9 +53,6 @@ export interface InitialProfile {
53
53
  /** Full discovery results for the settings generator. Undefined for the
54
54
  * default profile (which applies no filtering). */
55
55
  discovery?: LauncherDiscovery;
56
- /** The trusted project's `.pi/settings.json` content, when trusted and
57
- * present. The generator merges it into the base for selection plans. */
58
- projectSettings?: Record<string, unknown>;
59
56
  /** The trusted project directory, when trusted. */
60
57
  projectDir?: string;
61
58
  /** Non-fatal notices for the user (e.g. a dangling restored profile that
@@ -63,13 +60,12 @@ export interface InitialProfile {
63
60
  warnings: string[];
64
61
  }
65
62
 
66
- /** Reads the real global `defaultProjectTrust` setting (a trust input) and,
67
- * when trusted, the project's `.pi/settings.json`. Exported for the
68
- * session-side surfaces (selector/list/status) that need the same trust
69
- * gate the launcher uses. */
63
+ /** Reads the real global `defaultProjectTrust` setting (a trust input) and
64
+ * resolves the project trust decision from Pi's own trust store. Exported
65
+ * for the session-side surfaces (selector/list/status) that need the same
66
+ * trust gate the launcher uses. */
70
67
  export async function readTrustInputs(context: LauncherContext): Promise<{
71
68
  projectTrusted: boolean;
72
- projectSettings?: Record<string, unknown>;
73
69
  }> {
74
70
  const globalSettingsPath = path.join(context.agentDir, "settings.json");
75
71
  const globalSettings = await readJsonFile(globalSettingsPath);
@@ -86,10 +82,9 @@ export async function readTrustInputs(context: LauncherContext): Promise<{
86
82
  if (!projectTrusted) {
87
83
  return { projectTrusted };
88
84
  }
89
- const projectSettingsResult = await readJsonFile(path.join(context.cwd, ".pi", "settings.json"));
90
- const projectSettings =
91
- projectSettingsResult.ok && isRecord(projectSettingsResult.value) ? projectSettingsResult.value : undefined;
92
- return { projectTrusted, projectSettings };
85
+ // The project's own `.pi/settings.json` is Pi's to read (natively, for the
86
+ // same trust decision): pi-profile neither merges nor narrows it.
87
+ return { projectTrusted };
93
88
  }
94
89
 
95
90
  export async function resolveInitialProfile(
@@ -97,7 +92,7 @@ export async function resolveInitialProfile(
97
92
  context: LauncherContext,
98
93
  options?: { overlay?: RuntimeOverlay },
99
94
  ): Promise<InitialProfile> {
100
- const { projectTrusted, projectSettings } = await readTrustInputs(context);
95
+ const { projectTrusted } = await readTrustInputs(context);
101
96
  const projectDir = projectTrusted ? context.cwd : undefined;
102
97
  const catalog = await ProfileCatalog.load(context.agentDir, { projectDir });
103
98
 
@@ -156,7 +151,7 @@ export async function resolveInitialProfile(
156
151
  overlay,
157
152
  });
158
153
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
159
- return { plan, discovery, projectSettings, projectDir, warnings };
154
+ return { plan, discovery, projectDir, warnings };
160
155
  }
161
156
 
162
157
  const discovery = await discoverLauncherResources({ ...context, projectTrusted });
@@ -177,5 +172,5 @@ export async function resolveInitialProfile(
177
172
  // silently do nothing or leak through unfiltered.
178
173
  throw new MissingMcpAdapterError(plan.profile);
179
174
  }
180
- return { plan, discovery, projectSettings, projectDir, warnings };
175
+ return { plan, discovery, projectDir, warnings };
181
176
  }
@@ -1,35 +1,54 @@
1
1
  /**
2
- * RuntimeCleanup: sweeps stale per-launch runtime directories at startup.
2
+ * InstanceCleanup: sweeps stale per-launch instance directories at startup.
3
3
  *
4
- * The generated agent dirs under `<agentDir>/pi-profile/runtime/launch-*`
5
- * (ADR-0005) would otherwise accumulate forever. Startup sweep is the ONLY
6
- * cleanup mechanism by design: any exit — graceful, signal, SIGKILL, power
7
- * loss — kills the child pid, so the next launch's sweep converges. There is
8
- * no exit-time deletion; it would only buy immediacy at the cost of deletion
9
- * logic on the signal path.
4
+ * Every launch materializes its own instance dir under
5
+ * `<PI_PROFILE_SWITCH_DIR>/instances/launch-*` (ADR-0010), which would
6
+ * otherwise accumulate forever. Startup sweep is the ONLY cleanup mechanism by
7
+ * design: any exit — graceful, signal, SIGKILL, power loss — kills the child
8
+ * pid, so the next launch's sweep converges. There is no exit-time deletion; it
9
+ * would only buy immediacy at the cost of deletion logic on the signal path.
10
10
  *
11
- * Liveness token: a `pid` file written by spawnPi into the runtime dir.
12
- * (Naming the dir after the pid is impossible — the pid does not exist
13
- * before spawn, and the running process's PI_CODING_AGENT_DIR path is
14
- * frozen.) Rules per launch-* dir:
11
+ * Liveness token: a `pid` file written by spawnPi into the instance dir.
12
+ * (Naming the dir after the pid is impossible — the pid does not exist before
13
+ * spawn, and the running process's PI_CODING_AGENT_DIR path is frozen.) Rules
14
+ * per instance dir:
15
15
  * - pid file parses and the process is alive (or EPERM) → keep;
16
- * ESRCH → delete.
17
- * - no/unparsable pid file → delete only when the dir mtime is older than
16
+ * ESRCH → candidate.
17
+ * - no/unparsable pid file → candidate only when the dir mtime is older than
18
18
  * NO_PID_GRACE_MS. The grace window guards the concurrent-launch race (a
19
- * second launcher between mkdtemp and its pid write must not be reaped);
20
- * it also covers pre-feature dirs and post-mkdtemp crashes.
19
+ * second launcher between mkdir and its pid write must not be reaped); it
20
+ * also covers post-mkdir crashes.
21
21
  * PID reuse needs no /proc check: a wrong keep only delays cleanup and
22
22
  * self-heals once the reused pid dies.
23
23
  *
24
+ * A candidate is deleted only when every entry in it is pi-profile's own: a
25
+ * symlink (the mirror's link into the real agent dir) or a managed generated
26
+ * file. Anything else is state an extension created at runtime, so the
27
+ * directory is kept and reported — the sweep never destroys data it cannot
28
+ * attribute. The check deliberately does NOT consult the real agent dir: a
29
+ * same-named entry there (a seeded `missions`, say) must not turn an
30
+ * instance-local real directory into a deletable one.
31
+ *
24
32
  * Everything is best-effort: sweep errors never block a launch.
25
33
  */
26
34
 
27
- import { readdir, readFile, rm, stat } from "node:fs/promises";
35
+ import type { Dirent } from "node:fs";
36
+ import { lstat, readdir, readFile, rm, stat } from "node:fs/promises";
28
37
  import path from "node:path";
29
38
 
30
- /** Grace period for launch dirs without a (parseable) pid file. */
39
+ import { MANAGED_INSTANCE_FILES } from "../settings-generator.ts";
40
+ import { getInstancesRootDir } from "../workspace.ts";
41
+
42
+ /** Prefix of the directories this module owns under the instances root. */
43
+ const INSTANCE_DIR_PREFIX = "launch-";
44
+
45
+ /** Grace period for instance dirs without a (parseable) pid file. */
31
46
  export const NO_PID_GRACE_MS = 10 * 60 * 1000;
32
47
 
48
+ /** Managed directories whose contents are not necessarily generated: a package
49
+ * may write its own files inside them (e.g. an extension's own config). */
50
+ const MANAGED_DIRS_WITH_RUNTIME_CONTENT = new Set(["extensions"]);
51
+
33
52
  function isProcessAlive(pid: number): boolean {
34
53
  try {
35
54
  process.kill(pid, 0);
@@ -40,11 +59,47 @@ function isProcessAlive(pid: number): boolean {
40
59
  }
41
60
  }
42
61
 
43
- function runtimeRootOf(agentDir: string): string {
44
- return path.join(agentDir, "pi-profile", "runtime");
62
+ /** Paths inside `dir` that pi-profile did not generate, relative to `dir`.
63
+ * `undefined` means the directory could not be inspected — the caller must
64
+ * treat that as unrecognized rather than as empty. */
65
+ async function unrecognizedEntries(dir: string): Promise<string[] | undefined> {
66
+ let entries: Dirent<string>[];
67
+ try {
68
+ entries = await readdir(dir, { withFileTypes: true });
69
+ } catch {
70
+ return undefined;
71
+ }
72
+
73
+ const found: string[] = [];
74
+ for (const entry of entries) {
75
+ const entryPath = path.join(dir, entry.name);
76
+ let linkStat;
77
+ try {
78
+ linkStat = await lstat(entryPath);
79
+ } catch {
80
+ continue; // Vanished under us: nothing to protect.
81
+ }
82
+ // Symlinks only point at the real agent dir and hold no data of their own.
83
+ if (linkStat.isSymbolicLink()) continue;
84
+ if (!MANAGED_INSTANCE_FILES.has(entry.name)) {
85
+ found.push(entry.name);
86
+ continue;
87
+ }
88
+ if (MANAGED_DIRS_WITH_RUNTIME_CONTENT.has(entry.name) && linkStat.isDirectory()) {
89
+ const nested = await unrecognizedEntries(entryPath);
90
+ if (nested === undefined) {
91
+ found.push(entry.name);
92
+ continue;
93
+ }
94
+ found.push(...nested.map((name) => path.join(entry.name, name)));
95
+ }
96
+ }
97
+ return found;
45
98
  }
46
99
 
47
- async function sweepEntry(dir: string): Promise<void> {
100
+ /** Reclaims `dir` if it is both dead and free of unrecognized entries.
101
+ * Returns the warnings for the cases where it was intentionally kept. */
102
+ async function sweepEntry(dir: string): Promise<string[]> {
48
103
  let pid: number | undefined;
49
104
  try {
50
105
  const raw = await readFile(path.join(dir, "pid"), "utf8");
@@ -55,31 +110,56 @@ async function sweepEntry(dir: string): Promise<void> {
55
110
  }
56
111
 
57
112
  if (pid !== undefined) {
58
- if (!isProcessAlive(pid)) await rm(dir, { recursive: true, force: true });
59
- return;
113
+ if (isProcessAlive(pid)) return [];
114
+ } else {
115
+ const info = await stat(dir);
116
+ if (Date.now() - info.mtimeMs <= NO_PID_GRACE_MS) return [];
60
117
  }
61
118
 
62
- const info = await stat(dir);
63
- if (Date.now() - info.mtimeMs > NO_PID_GRACE_MS) {
64
- await rm(dir, { recursive: true, force: true });
119
+ const unrecognized = await unrecognizedEntries(dir);
120
+ if (unrecognized === undefined) {
121
+ return [
122
+ `${dir} was not reclaimed: it could not be inspected (permissions?). Check its contents and delete it manually.`,
123
+ ];
65
124
  }
125
+ if (unrecognized.length > 0) {
126
+ return [
127
+ `${dir} was not reclaimed: it holds state pi-profile did not generate (${unrecognized.join(", ")}). ` +
128
+ `Move that state into the real agent dir (it is mirrored on the next launch), or point the extension that ` +
129
+ `created it at a fixed path via that extension's own configuration, then delete ${dir}.`,
130
+ ];
131
+ }
132
+
133
+ await rm(dir, { recursive: true, force: true });
134
+ return [];
66
135
  }
67
136
 
68
- /** Deletes stale launch dirs under the agent dir's runtime root. Never throws. */
69
- export async function sweepStaleRuntimeDirs(agentDir: string): Promise<void> {
137
+ /** Deletes stale instance dirs under the instances root and returns the
138
+ * warnings for directories it deliberately kept. Never throws. */
139
+ export async function sweepStaleInstances(): Promise<string[]> {
140
+ const warnings: string[] = [];
141
+ const root = getInstancesRootDir();
142
+
70
143
  let entries: string[];
71
144
  try {
72
- entries = await readdir(runtimeRootOf(agentDir));
145
+ entries = await readdir(root);
73
146
  } catch {
74
- return; // No runtime root yet: nothing to sweep.
147
+ return warnings; // No instances root yet: nothing to sweep.
75
148
  }
149
+
76
150
  for (const entry of entries) {
77
- if (!entry.startsWith("launch-")) continue;
78
- const dir = path.join(runtimeRootOf(agentDir), entry);
151
+ // Only directories this module generated are candidates. Anything else
152
+ // under the root — 0.4.x's per-profile dirs included — is not ours to
153
+ // delete, and is left alone without a warning.
154
+ if (!entry.startsWith(INSTANCE_DIR_PREFIX)) continue;
155
+ const dir = path.join(root, entry);
79
156
  try {
80
- if ((await stat(dir)).isDirectory()) await sweepEntry(dir);
157
+ if (!(await stat(dir)).isDirectory()) continue;
158
+ warnings.push(...(await sweepEntry(dir)));
81
159
  } catch {
82
160
  // Best-effort: one bad entry must not stop the sweep or the launch.
83
161
  }
84
162
  }
163
+
164
+ return warnings;
85
165
  }
@@ -29,7 +29,8 @@ const EXTENSION_ENTRY = fileURLToPath(new URL("../../extensions/pi-profile/index
29
29
  * trust re-application, then user args verbatim. */
30
30
  export function buildPiArgs(options: SpawnPiOptions): string[] {
31
31
  const args = ["-e", EXTENSION_ENTRY];
32
- // default profile keeps trust behavior native: re-apply the recorded flag.
32
+ // Re-apply the recorded one-run trust input for every profile: the same input
33
+ // decided the launcher's project reads, so Pi must decide the same way.
33
34
  if (options.trustOverride === true) args.push("--approve");
34
35
  if (options.trustOverride === false) args.push("--no-approve");
35
36
  args.push(...options.piArgs);
package/src/mcp-config.ts CHANGED
@@ -40,6 +40,9 @@ export interface McpDiscoveryOptions {
40
40
  export interface MergedMcpResult {
41
41
  servers: Record<string, Record<string, unknown>>;
42
42
  sharedServers: Set<string>;
43
+ /** Servers defined in a trusted project's own config (`.mcp.json`,
44
+ * `.pi/mcp.json`). They are not the profile's to narrow. */
45
+ projectServers: Set<string>;
43
46
  baseConfig?: Record<string, unknown>;
44
47
  }
45
48
 
@@ -47,6 +50,9 @@ export interface McpConfigSource {
47
50
  path: string;
48
51
  isShared: boolean;
49
52
  isAgentDir?: boolean;
53
+ /** Project-scope source: only read for a trusted project, and its servers
54
+ * stay enabled regardless of a profile's `mcps` declaration. */
55
+ isProject?: boolean;
50
56
  }
51
57
 
52
58
  /**
@@ -71,8 +77,8 @@ export function getStandardMcpConfigSources(
71
77
  { path: path.join(agentDir, "mcp.json"), isShared: false, isAgentDir: true },
72
78
  ];
73
79
  if (projectDir !== undefined) {
74
- sources.push({ path: path.join(projectDir, ".mcp.json"), isShared: true });
75
- sources.push({ path: path.join(projectDir, ".pi", "mcp.json"), isShared: false });
80
+ sources.push({ path: path.join(projectDir, ".mcp.json"), isShared: true, isProject: true });
81
+ sources.push({ path: path.join(projectDir, ".pi", "mcp.json"), isShared: false, isProject: true });
76
82
  }
77
83
  return sources;
78
84
  }
@@ -86,6 +92,7 @@ export async function loadMergedMcpServers(
86
92
  const seenPaths = new Set<string>();
87
93
  const servers: Record<string, Record<string, unknown>> = {};
88
94
  const sharedServers = new Set<string>();
95
+ const projectServers = new Set<string>();
89
96
  let baseConfig: Record<string, unknown> | undefined;
90
97
 
91
98
  for (const source of sources) {
@@ -112,6 +119,9 @@ export async function loadMergedMcpServers(
112
119
  if (source.isShared) {
113
120
  sharedServers.add(name);
114
121
  }
122
+ if (source.isProject === true) {
123
+ projectServers.add(name);
124
+ }
115
125
  if (isRecord(def)) {
116
126
  servers[name] = { ...(servers[name] ?? {}), ...def };
117
127
  } else {
@@ -120,7 +130,7 @@ export async function loadMergedMcpServers(
120
130
  }
121
131
  }
122
132
 
123
- return { servers, sharedServers, baseConfig };
133
+ return { servers, sharedServers, projectServers, baseConfig };
124
134
  }
125
135
 
126
136
  /** Server names the adapter would discover: standard global MCP configs,
@@ -1,27 +1,31 @@
1
1
  /**
2
- * Project trust resolution for the launcher — pi-profile is the sole
3
- * gatekeeper for project resources, because generated settings carry
4
- * `defaultProjectTrust: "never"` and Pi therefore never auto-discovers them.
2
+ * Project trust resolution for the launcher — pi-profile's gate for the
3
+ * project-scope content it reads itself: the project catalog, the project
4
+ * runtime state, and the project MCP config. Project-level resources
5
+ * (skills, extensions, prompts, themes, settings) are Pi's own business: its
6
+ * trust store decision, exposed through the linked `trust.json`, decides
7
+ * their visibility (see ADR-0011).
5
8
  *
6
9
  * Mirrors Pi's own trust decision order (`resolveProjectTrusted`), with
7
10
  * pi-profile's catalog/state files added to the trust-requiring
8
11
  * resource set (they are pi-profile's project attack surface; Pi doesn't
9
12
  * know about them):
10
13
  * 1. one-run `--approve` / `--no-approve` override (consumed by the
11
- * launcher, never forwarded for named profiles)
14
+ * launcher and re-applied to the spawned pi for every profile)
12
15
  * 2. a project with no trust-requiring resources at all is trusted —
13
16
  * there is nothing project-scoped to gate
14
17
  * 3. the stored decision in the real `trust.json` (nearest ancestor wins)
15
18
  * 4. the user's `defaultProjectTrust` setting — but only "always" grants;
16
19
  * "ask" cannot prompt here (the launcher has no trust UI), so it falls
17
20
  * through to untrusted
18
- * 5. otherwise untrusted: project catalogs, registries, resources, and
19
- * state files are not read at all
21
+ * 5. otherwise untrusted: project catalogs, project state, and project MCP
22
+ * config are not read at all
20
23
  *
21
24
  * Known divergence from Pi: extension `project_trust` event handlers are not
22
25
  * consulted — that would require executing extension code in the launcher,
23
- * which pi-profile never does. In named-profile sessions the event is moot
24
- * anyway (generated settings carry `defaultProjectTrust: "never"`).
26
+ * which pi-profile never does. A spawned Pi still runs those handlers with
27
+ * its own settings, so an extension that answers yes/no can decide the
28
+ * project scope of that session.
25
29
  */
26
30
 
27
31
  import { existsSync, readFileSync } from "node:fs";
@@ -3,9 +3,9 @@
3
3
  * runtime directory (ADR-0005).
4
4
  *
5
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.
6
+ * - `generateRuntimeDir` (launcher): create a fresh per-launch runtime dir
7
+ * under the workspace instances root, write the files, mirror the real
8
+ * agent dir as symlinks (trust.json only for 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
@@ -17,18 +17,20 @@
17
17
  * the spawned pi behaves exactly like native `pi`.
18
18
  *
19
19
  * For named profiles the generated settings encode the profile's selection
20
- * per the filtering model (see docs/architecture/overview.md):
20
+ * over user-scope resources only (see docs/architecture/overview.md):
21
21
  * - agentDir-scope resources: additive allowlist paths (the discovery root
22
22
  * moved, so nothing auto-discovered from the real agent dir)
23
23
  * - `~/.agents` skills: always auto-discovered, so unselected ones are
24
24
  * force-excluded with `-<path>` entries
25
25
  * - packages: user-configured package entries rewritten to object form with
26
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)
27
+ * - project-scope resources (project `.pi/skills`, project `.pi/extensions`,
28
+ * ancestor `.agents/skills`) are never encoded: Pi discovers them natively
29
+ * whenever the project is trusted, and the profile neither adds nor
30
+ * excludes them
31
+ * - `defaultProjectTrust: "never"` only suppresses Pi's interactive trust
32
+ * prompt (a stored decision in the real trust.json still applies); the
33
+ * project's `.pi/settings.json` is not merged here — Pi reads it natively
32
34
  * - unmanaged kinds (prompts, themes) pass through: the user's arrays are
33
35
  * preserved and the real agent dir's prompts/themes dirs re-included
34
36
  * - tools/model are written to generated settings (defaultTools,
@@ -74,11 +76,6 @@ export interface GenerateOptions {
74
76
  projectDir?: string;
75
77
  /** Required for selection plans; unused for the default profile. */
76
78
  discovery?: DiscoveryContext;
77
- /** The trusted project's `.pi/settings.json` content (already parsed).
78
- * Only pass when the resolver's trust check passed; merged into the
79
- * generated base per Pi's merge rules for selection plans. Ignored for
80
- * the default profile (Pi reads project settings natively there). */
81
- projectSettings?: Record<string, unknown>;
82
79
  }
83
80
 
84
81
  export interface GeneratedRuntime {
@@ -99,6 +96,24 @@ export const MANAGED_INSTANCE_FILES = new Set([
99
96
  "extensions",
100
97
  ]);
101
98
 
99
+ /** State directories that Pi and its extensions resolve under the agent dir,
100
+ * and which therefore appear at runtime rather than at install time. They are
101
+ * seeded in the REAL agent dir before mirroring, so the instance gets a
102
+ * symlink instead of a private real directory: runtime-created state then
103
+ * lands where native Pi puts it, and third-party records never embed an
104
+ * instance path (ADR-0010). Adding a name here needs observed evidence that a
105
+ * package creates that directory under the agent dir; anything unlisted shows
106
+ * up as an unrecognized entry in the sweep (src/launcher/runtime-cleanup.ts). */
107
+ const SEEDED_STATE_DIRS = ["sessions", "missions"] as const;
108
+
109
+ /** State FILES Pi creates at runtime (same evidence rule as the dirs). They
110
+ * cannot be created up front — the content is Pi's, not pi-profile's — so the
111
+ * instance gets a symlink into the real agent dir that is deliberately allowed
112
+ * to dangle: Pi sees no file, writes through the link, and the real agent dir
113
+ * gets the file. A real file left here instead would be unrecognized state and
114
+ * would strand credentials in a directory the sweep refuses to delete. */
115
+ const SEEDED_STATE_FILES = ["auth.json", "models-store.json"] as const;
116
+
102
117
  /** Resource dirs rooted at the real agent dir, re-included for the default
103
118
  * profile because PI_CODING_AGENT_DIR moves the discovery root. */
104
119
  const RESOURCE_DIR_KINDS = ["skills", "extensions", "prompts", "themes"] as const;
@@ -132,21 +147,6 @@ function toPosix(filePath: string): string {
132
147
  return filePath.split(path.sep).join("/");
133
148
  }
134
149
 
135
- /** Mirrors Pi's own deepMergeSettings: plain objects merge recursively,
136
- * everything else (arrays, primitives) is replaced by the override. */
137
- function deepMergeSettings(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
138
- const result: Record<string, unknown> = { ...base };
139
- for (const [key, overrideValue] of Object.entries(overrides)) {
140
- if (overrideValue === undefined) continue;
141
- const baseValue = result[key];
142
- result[key] =
143
- isRecord(baseValue) && isRecord(overrideValue)
144
- ? deepMergeSettings(baseValue, overrideValue)
145
- : overrideValue;
146
- }
147
- return result;
148
- }
149
-
150
150
  function tryRealpath(p: string): string {
151
151
  try {
152
152
  return realpathSync(p);
@@ -176,27 +176,28 @@ function buildSelectionSettings(
176
176
  agentDir: string,
177
177
  discovery: DiscoveryContext,
178
178
  runtimeDir: string,
179
+ projectDir?: string,
179
180
  ): Record<string, unknown> {
180
181
  const settings = { ...userSettings };
181
182
 
182
183
  // --- skills ---
183
- // Project-scope selections are emitted before user-scope ones: Pi's
184
- // same-name collision rule is first-wins, and project resources must keep
185
- // their native priority (ticket 03).
186
- const orderedSelectedSkills = [...plan.skills].sort((a, b) => {
187
- const aProject = a.scope === "project" ? 0 : 1;
188
- const bProject = b.scope === "project" ? 0 : 1;
189
- return aProject - bProject;
190
- });
184
+ // Only user-scope entries are encoded. Project-scope selections are
185
+ // skipped below: their visibility is Pi's, so the order they would have
186
+ // been emitted in carries no meaning.
191
187
  const selectedPaths = new Set(plan.skills.map((skill) => skill.filePath));
192
188
  const skillEntries: string[] = [];
193
- for (const skill of orderedSelectedSkills) {
189
+ for (const skill of plan.skills) {
194
190
  if (skill.origin === "package") continue; // encoded in the packages allowlist
191
+ // Project scope belongs to Pi: a trusted project's skills are discovered
192
+ // natively, so selecting one here would duplicate it and excluding one
193
+ // would contradict the profile's boundary.
194
+ if (skill.scope === "project") continue;
195
195
  if (isUnderPath(skill.filePath, homeAgentsSkillsDir())) continue; // auto-discovered anyway
196
196
  skillEntries.push(skill.filePath);
197
197
  }
198
198
  for (const skill of discovery.skills) {
199
199
  if (skill.origin === "package") continue;
200
+ if (skill.scope === "project") continue;
200
201
  if (selectedPaths.has(skill.filePath)) continue;
201
202
 
202
203
  // If the skill is in the real agentDir, Pi will discover it via the symlink.
@@ -222,7 +223,13 @@ function buildSelectionSettings(
222
223
  .map((pkg) => ({ ...pkg, root: pkg.root }));
223
224
  const packageExtensions = new Map<string, string[]>();
224
225
  const extensionEntries: string[] = [];
226
+ const projectExtensionsDir =
227
+ projectDir !== undefined ? path.join(projectDir, ".pi", "extensions") : undefined;
225
228
  for (const extension of plan.extensions) {
229
+ // Loose project extensions are discovered natively by Pi; an explicit
230
+ // path reference inside the project (outside `.pi/extensions`) is the
231
+ // profile's own selection and stays.
232
+ if (projectExtensionsDir !== undefined && isUnderPath(extension.entry, projectExtensionsDir)) continue;
226
233
  const owner = packageRoots.find((pkg) => isUnderPath(extension.entry, pkg.root));
227
234
  if (owner === undefined) {
228
235
  extensionEntries.push(extension.entry);
@@ -298,8 +305,6 @@ export interface RuntimeFileOptions {
298
305
  projectDir?: string;
299
306
  /** Required for selection plans; unused for the default profile. */
300
307
  discovery?: DiscoveryContext;
301
- /** The trusted project's `.pi/settings.json` content (already parsed). */
302
- projectSettings?: Record<string, unknown>;
303
308
  /** Extra launch-plan fields written by the in-session switch path:
304
309
  * `switchedFrom` triggers the one-shot change summary; `persistSelection`
305
310
  * tells the post-reload extension instance to save the selection;
@@ -345,21 +350,20 @@ async function computeSettings(
345
350
  return settings;
346
351
  }
347
352
 
348
- // Selection plans: the trusted project's settings merge into the base
349
- // per Pi's merge rules (project wins, nested objects merge), then the
350
- // filtering encoding replaces the managed keys on top. With
351
- // defaultProjectTrust: "never", Pi itself never reads project settings.
352
- //
353
- // The project's `packages` key is stripped: project packages install
354
- // under the project's .pi/npm and are unreferenceable in generated
355
- // global-scope settings — merging the key would make Pi install them
356
- // into the (symlinked) global npm root as a launch side effect.
357
- let base = { ...userSettings };
358
- if (options.projectSettings !== undefined) {
359
- const { packages: _stripped, ...mergeable } = options.projectSettings;
360
- base = deepMergeSettings(base, mergeable);
361
- }
362
- return buildSelectionSettings(plan, base, agentDir, options.discovery ?? { skills: [], packages: [] }, runtimeDir);
353
+ // Selection plans: only user-scope encoding is layered onto the user's own
354
+ // settings. The trusted project's `.pi/settings.json` is deliberately NOT
355
+ // merged here — Pi reads it natively for the same trust decision this
356
+ // process's Pi applies, and merging it would turn the project's `packages`
357
+ // into global-scope packages (installing them into the real agent dir's npm
358
+ // root as a launch side effect).
359
+ return buildSelectionSettings(
360
+ plan,
361
+ { ...userSettings },
362
+ agentDir,
363
+ options.discovery ?? { skills: [], packages: [] },
364
+ runtimeDir,
365
+ options.projectDir,
366
+ );
363
367
  }
364
368
 
365
369
  /** Resolved name sets, carried in the launch plan for glob-delta reporting. */
@@ -371,10 +375,9 @@ export interface ResolvedNames {
371
375
  }
372
376
 
373
377
  /** Writes settings.json + pi-profile.json into an existing runtime dir and
374
- * transitions the trust.json link to the plan's filter mode: linked for
375
- * `default` (native trust behavior), absent for named profiles (a stored
376
- * trust decision would beat the generated `defaultProjectTrust: "never"`
377
- * inside Pi and re-enable unfiltered project auto-discovery). */
378
+ * keeps the trust.json link in place for every profile: Pi reads its
379
+ * project-scope decision from that path, and project-level resources belong
380
+ * to Pi's trust gate rather than to the profile. */
378
381
  export async function writeRuntimeFiles(
379
382
  runtimeDir: string,
380
383
  plan: ActivationPlan,
@@ -405,7 +408,7 @@ export async function writeRuntimeFiles(
405
408
  skills: plan.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
406
409
  extensions: plan.extensions,
407
410
  },
408
- // Zero-match glob references (ADR-0006) — surfaced by /profile status
411
+ // Zero-match glob references (ADR-0009) — surfaced by /profile status
409
412
  // so a typo'd glob is visible instead of silently selecting nothing.
410
413
  ...(plan.unmatched !== undefined ? { unmatched: plan.unmatched } : {}),
411
414
  ...options.planExtras,
@@ -415,18 +418,14 @@ export async function writeRuntimeFiles(
415
418
  )}\n`,
416
419
  );
417
420
 
421
+ // Every profile gets the link, dangling allowed: Pi's stored trust decision
422
+ // is what makes a trusted project's resources visible, and a decision Pi
423
+ // writes through the link must land in the real agent dir (same shape as the
424
+ // auth.json seed in ADR-0010). An entry that already exists is left alone —
425
+ // a real file Pi wrote during this session carries its own decision.
418
426
  const trustLink = path.join(runtimeDir, "trust.json");
419
- const trustTarget = path.join(options.agentDir, "trust.json");
420
- if (plan.filter === "none") {
421
- if ((await exists(trustTarget)) && !(await existsLexical(trustLink))) {
422
- await symlink(trustTarget, trustLink);
423
- }
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".
429
- await rm(trustLink);
427
+ if (!(await existsLexical(trustLink))) {
428
+ await symlink(path.join(options.agentDir, "trust.json"), trustLink);
430
429
  }
431
430
 
432
431
  // MCP Servers generation (Ticket 04)
@@ -441,7 +440,7 @@ export async function writeRuntimeFiles(
441
440
  } else {
442
441
  // Filter MCP servers
443
442
  try { await rm(mcpInstancePath); } catch {}
444
- const { servers, sharedServers, baseConfig } = await loadMergedMcpServers(
443
+ const { servers, sharedServers, projectServers, baseConfig } = await loadMergedMcpServers(
445
444
  options.agentDir,
446
445
  options.projectDir,
447
446
  options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
@@ -460,6 +459,9 @@ export async function writeRuntimeFiles(
460
459
 
461
460
  for (const sharedName of sharedServers) {
462
461
  if (!allowedSet.has(sharedName)) {
462
+ // Project-level servers are not the profile's to narrow (the same
463
+ // boundary as project skills and extensions).
464
+ if (projectServers.has(sharedName)) continue;
463
465
  filteredServers[sharedName] = { disabled: true };
464
466
  }
465
467
  }
@@ -494,9 +496,6 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
494
496
  if (!existsSync(agentDir)) return;
495
497
  if (path.resolve(agentDir) === path.resolve(runtimeDir)) return;
496
498
 
497
- // Ensure the real sessions directory exists so it is always mirrored
498
- await mkdir(path.join(agentDir, "sessions"), { recursive: true });
499
-
500
499
  // 1. Clean up dangling or obsolete symlinks in runtimeDir
501
500
  try {
502
501
  const runtimeEntries = await readdir(runtimeDir);
@@ -517,7 +516,13 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
517
516
  }
518
517
  } catch {}
519
518
 
520
- // 2. Mirror files and directories from agentDir to runtimeDir
519
+ // 2. Seed the state paths this process's Pi will create at runtime, so their
520
+ // writes land in the real agent dir instead of an instance-local copy
521
+ // (ADR-0010). Runs after the cleanup above, which would otherwise remove the
522
+ // deliberately dangling file links.
523
+ await seedRuntimeState(agentDir, runtimeDir);
524
+
525
+ // 3. Mirror files and directories from agentDir to runtimeDir
521
526
  try {
522
527
  const entries = await readdir(agentDir);
523
528
  for (const name of entries) {
@@ -547,13 +552,45 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
547
552
  } catch {}
548
553
  }
549
554
 
555
+ /** Ensures the runtime state paths exist (or are linked) in the real agent dir
556
+ * and the instance. Best-effort: a failure here leaves the path unseeded, and
557
+ * the sweep's unrecognized-entry warning names it later. */
558
+ async function seedRuntimeState(agentDir: string, runtimeDir: string): Promise<void> {
559
+ for (const name of SEEDED_STATE_DIRS) {
560
+ try {
561
+ await mkdir(path.join(agentDir, name), { recursive: true });
562
+ } catch {
563
+ // Best-effort: the mirror then simply links nothing for this name.
564
+ }
565
+ }
566
+
567
+ for (const name of SEEDED_STATE_FILES) {
568
+ const linkPath = path.join(runtimeDir, name);
569
+ // A real file here belongs to an earlier run of a different layout, and a
570
+ // link may already point somewhere else: leave both alone rather than
571
+ // replacing state pi-profile cannot attribute.
572
+ if (await existsLexical(linkPath)) continue;
573
+ try {
574
+ await symlink(path.join(agentDir, name), linkPath);
575
+ } catch {
576
+ // Best-effort: Pi then creates the file inside the instance, and the
577
+ // sweep keeps that directory instead of deleting it silently.
578
+ }
579
+ }
580
+ }
581
+
550
582
  export async function generateRuntimeDir(
551
583
  plan: ActivationPlan,
552
584
  options: GenerateOptions,
553
585
  ): Promise<GeneratedRuntime> {
554
586
  const { agentDir } = options;
555
- const runtimeDir = path.join(getInstancesRootDir(), plan.profile, "agent");
556
- await mkdir(runtimeDir, { recursive: true });
587
+ const runtimeRoot = getInstancesRootDir();
588
+ await mkdir(runtimeRoot, { recursive: true });
589
+ // One instance per launch, never reused: PI_CODING_AGENT_DIR is frozen for
590
+ // the life of the spawned process, so a stable path cannot follow an
591
+ // in-session switch, and a shared path would make concurrent launches (and
592
+ // their switches) rewrite each other's files (ADR-0010).
593
+ const runtimeDir = await mkdtemp(path.join(runtimeRoot, "launch-"));
557
594
 
558
595
  await writeRuntimeFiles(runtimeDir, plan, options);
559
596
 
@@ -44,9 +44,9 @@ export interface DiscoverSkillsOptions {
44
44
  }
45
45
 
46
46
  export async function discoverSkills(options: DiscoverSkillsOptions): Promise<SkillEntry[]> {
47
- // Project trust comes from the caller's trust check; generated settings
48
- // carry defaultProjectTrust: "never", so discovery is the only place
49
- // project resources can enter a plan.
47
+ // Project trust comes from the caller's trust check: discovery is the only
48
+ // place project resources enter a plan's reference vocabulary (their
49
+ // visibility in the session is Pi's, not the plan's).
50
50
  const settingsManager = SettingsManager.create(options.cwd, options.agentDir, {
51
51
  projectTrusted: options.projectTrusted ?? false,
52
52
  });
@@ -73,10 +73,10 @@ export async function discoverSkills(options: DiscoverSkillsOptions): Promise<Sk
73
73
  else process.env.PI_OFFLINE = savedOffline;
74
74
  }
75
75
  return loader.getSkills().skills.flatMap((skill) => {
76
- // Project-scoped package skills are excluded: their packages install
77
- // under the project's .pi/npm, which generated global-scope settings
78
- // cannot reference. Project .pi/skills and ancestor .agents/skills are
79
- // unaffected. (Limitation documented in ticket 03's comments.)
76
+ // Project-scoped package skills stay out of the reference vocabulary:
77
+ // their packages live under the project's .pi/npm and Pi discovers their
78
+ // skills natively, so a profile reference would add nothing. Project
79
+ // .pi/skills and ancestor .agents/skills are referenceable.
80
80
  if (skill.sourceInfo.origin === "package" && skill.sourceInfo.scope === "project") {
81
81
  return [];
82
82
  }
@@ -48,7 +48,7 @@ export interface LaunchPlanFile {
48
48
  skills: Array<{ name: string; filePath: string }>;
49
49
  extensions: Array<{ id: string; entry: string }>;
50
50
  };
51
- /** Glob references that matched nothing at resolution (ADR-0006). */
51
+ /** Glob references that matched nothing at resolution (ADR-0009). */
52
52
  unmatched?: string[];
53
53
  previousResolved?: {
54
54
  skills: string[];
@@ -36,7 +36,7 @@ export interface StatusReport {
36
36
  mcp: { enabled: string[]; disabled: string[]; missing: string[] };
37
37
  /** Glob delta versus the previous activation (prefixed names). */
38
38
  delta?: { added: string[]; removed: string[] };
39
- /** Glob references that matched nothing at resolution (ADR-0006). */
39
+ /** Glob references that matched nothing at resolution (ADR-0009). */
40
40
  unmatched?: string[];
41
41
  conflicts: StatusConflict[];
42
42
  }
@@ -204,7 +204,6 @@ export async function switchProfile(
204
204
  agentDir: deps.realAgentDir,
205
205
  projectDir: resolved.projectDir,
206
206
  discovery: resolved.discovery,
207
- projectSettings: resolved.projectSettings,
208
207
  planExtras: {
209
208
  ...(isSwitch && current.profile !== undefined ? { switchedFrom: current.profile } : {}),
210
209
  // `/profile use` persists; `/profile reload` keeps the current