pi-profile-switch 0.4.8 → 0.5.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";
@@ -36,10 +36,13 @@ 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);
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
+ }
43
46
  const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectSettings, projectDir });
44
47
  process.exitCode = await spawnPi({
45
48
  generated,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.4.8",
3
+ "version": "0.5.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,27 +1,34 @@
1
1
  /**
2
2
  * ExtensionDiscovery: implicit, read-only discovery of selectable extensions
3
- * (ADR-0006), 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
- * Two implicit sources, both Pi-native and side-effect free:
7
- * - Configured user packages: each package's `package.json#pi.extensions`
8
- * declares its extension entry files; the package name (or an alias like
9
- * the `npm:` source string) is the profile-facing reference.
10
- * - Loose extension files: `<agentDir>/extensions/*.{ts,js}` and, for
11
- * trusted projects, `<projectDir>/.pi/extensions/*.{ts,js}`, referenced by
12
- * filename stem.
6
+ * Two implicit sources, both resolved by Pi itself:
7
+ * - Configured packages: `DefaultPackageManager.resolve()` expands each
8
+ * package's `package.json#pi.extensions` (files, directories, globs, the
9
+ * package's own `+`/`-`/`!` filters, `.gitignore` rules) exactly like the
10
+ * spawned pi does at startup. The package name — or an alias such as the
11
+ * `npm:` source string — is the profile-facing reference.
12
+ * - Loose extension files: `<agentDir>/extensions/**` and, for trusted
13
+ * projects, `<projectDir>/.pi/extensions/**`, referenced by a path-derived
14
+ * ID ("conventions", "guards/review").
13
15
  *
14
- * Discovery never executes extension code and never installs anything: a
15
- * package contributes entries only for declared files that exist on disk.
16
+ * This module owns naming, merging, selection, and glob expansion only. Entry
17
+ * enumeration is Pi's own, so a new Pi convention (or a fix to one) cannot
18
+ * silently diverge from what the spawned pi actually loads.
19
+ *
20
+ * Discovery stays side-effect free: `resolve()` is called with
21
+ * `onMissing: "skip"`, which never installs and never touches the network,
22
+ * and extension code is never imported.
16
23
  */
17
24
 
18
- import { readdir, readFile, stat } from "node:fs/promises";
25
+ import { readFile, stat } from "node:fs/promises";
19
26
  import path from "node:path";
20
27
 
28
+ import { DefaultPackageManager, SettingsManager, type PackageManager } from "@earendil-works/pi-coding-agent";
21
29
  import { minimatch } from "minimatch";
22
30
 
23
31
  import { isRecord } from "./json-file.ts";
24
- import type { ConfiguredPackageRoot } from "./settings-generator.ts";
25
32
 
26
33
  export interface DiscoveredPackage {
27
34
  /** Selectable package name: package.json "name", or the source minus its
@@ -32,12 +39,14 @@ export interface DiscoveredPackage {
32
39
  source: string;
33
40
  /** Absolute install/local root of the package. */
34
41
  root: string;
35
- /** Absolute paths of declared `pi.extensions` entries that exist on disk. */
42
+ /** Absolute paths of the package's enabled extension entries. */
36
43
  entries: string[];
37
44
  }
38
45
 
39
46
  export interface DiscoveredLocalExtension {
40
- /** Selectable ID: the filename stem ("conventions" for conventions.ts). */
47
+ /** Selectable ID: the path under the extensions dir, without the file
48
+ * extension and with a trailing "/index" collapsed ("conventions",
49
+ * "guards/review"). */
41
50
  id: string;
42
51
  entry: string;
43
52
  }
@@ -72,7 +81,7 @@ function toPosix(filePath: string): string {
72
81
  return filePath.split(path.sep).join("/");
73
82
  }
74
83
 
75
- async function exists(filePath: string): Promise<boolean> {
84
+ async function isFile(filePath: string): Promise<boolean> {
76
85
  try {
77
86
  return (await stat(filePath)).isFile();
78
87
  } catch {
@@ -205,7 +214,7 @@ export class DiscoveredExtensions {
205
214
  const pkgEntries = this.#packageEntries(pkg);
206
215
  if (pkgEntries.length === 0) {
207
216
  throw new ExtensionError(
208
- `package "${reference}" declares no extension entries (its pi.extensions files are missing or shadowed by local files)`,
217
+ `package "${reference}" declares no extension entries (missing on disk, filtered out by its settings package entry, or shadowed by local files)`,
209
218
  );
210
219
  }
211
220
  for (const pkgEntry of pkgEntries) add(pkgEntry);
@@ -220,7 +229,7 @@ export class DiscoveredExtensions {
220
229
  const resolved = reference.startsWith("~/")
221
230
  ? path.join(process.env.HOME ?? "", reference.slice(1))
222
231
  : path.resolve(reference);
223
- if (!(await exists(resolved))) {
232
+ if (!(await isFile(resolved))) {
224
233
  throw new ExtensionError(`extension path not found: ${resolved}`);
225
234
  }
226
235
  add({ id: resolved, entry: resolved, origin: "path" });
@@ -233,8 +242,6 @@ export class DiscoveredExtensions {
233
242
  }
234
243
  }
235
244
 
236
- const LOOSE_FILE_PATTERN = /\.(ts|js)$/;
237
-
238
245
  /** Derives a package name from its source string when package.json is
239
246
  * unreadable: strips the npm:/git:/github: prefix and any version spec
240
247
  * (scoped names keep their "@scope/" prefix). */
@@ -252,93 +259,141 @@ export function packageNameFromSource(source: string): string {
252
259
  return name;
253
260
  }
254
261
 
255
- /** Reads one installed package's declared extension entries. Packages
256
- * without a readable package.json or without `pi.extensions` contribute
257
- * nothing (skills-only packages are the common case). Declared entries
258
- * missing on disk are skipped — the spawned pi reports load errors itself. */
259
- async function readPackageExtensions(pkg: ConfiguredPackageRoot): Promise<DiscoveredPackage | undefined> {
260
- if (pkg.root === undefined) return undefined;
261
- let manifest: unknown;
262
- try {
263
- manifest = JSON.parse(await readFile(path.join(pkg.root, "package.json"), "utf8"));
264
- } catch {
265
- return undefined; // not installed yet or unreadable — nothing selectable
266
- }
267
- if (!isRecord(manifest)) return undefined;
268
- const pi = manifest.pi;
269
- const declared =
270
- isRecord(pi) && Array.isArray(pi.extensions) ? pi.extensions.filter((e): e is string => typeof e === "string") : [];
271
- if (declared.length === 0) return undefined;
272
- const entries: string[] = [];
273
- for (const rel of declared) {
274
- const entry = path.resolve(pkg.root, rel);
275
- if (await exists(entry)) entries.push(entry);
276
- }
277
- if (entries.length === 0) return undefined;
278
- const name = typeof manifest.name === "string" && manifest.name.length > 0 ? manifest.name : packageNameFromSource(pkg.source);
279
- return { name, source: pkg.source, root: pkg.root, entries };
262
+ /** Selectable ID for a loose extension file: its path under the extensions
263
+ * dir without the file extension, with a trailing "/index" collapsed so a
264
+ * directory-style extension is referenced by its directory name. */
265
+ function looseId(filePath: string, extensionsDir: string): string {
266
+ const rel = toPosix(path.relative(extensionsDir, filePath));
267
+ const collapsed = rel.replace(/(?:^|\/)index\.(ts|js)$/, "");
268
+ const base = collapsed === "" ? rel : collapsed;
269
+ return base.replace(/\.(ts|js)$/, "");
280
270
  }
281
271
 
282
- /** Lists loose extension files in one directory; missing dir → empty.
283
- * A `.ts`/`.js` stem pair resolves to the `.ts` file (Pi's convention:
284
- * TypeScript sources are the canonical form) and is reported, not silent. */
285
- async function scanLooseDir(dir: string, warnings: string[]): Promise<DiscoveredLocalExtension[]> {
286
- let files: string[];
272
+ /** Reads a package's display name from its manifest, falling back to the
273
+ * source string when the manifest is missing or has no name. */
274
+ async function readPackageName(root: string, source: string): Promise<string> {
287
275
  try {
288
- files = (await readdir(dir)).filter((name) => LOOSE_FILE_PATTERN.test(name));
276
+ const manifest: unknown = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
277
+ if (isRecord(manifest) && typeof manifest.name === "string" && manifest.name.length > 0) return manifest.name;
289
278
  } catch {
290
- return [];
291
- }
292
- const byStem = new Map<string, string>();
293
- for (const name of files.sort()) {
294
- const stem = name.replace(LOOSE_FILE_PATTERN, "");
295
- const full = path.join(dir, name);
296
- const existing = byStem.get(stem);
297
- if (existing !== undefined) {
298
- if (!existing.endsWith(".ts") && name.endsWith(".ts")) {
299
- warnings.push(`extension "${stem}" exists as both .ts and .js in ${dir}; the .ts file is used`);
300
- byStem.set(stem, full);
301
- }
302
- continue;
303
- }
304
- byStem.set(stem, full);
279
+ // Not installed (a skipped source) or unreadable — derive from source.
305
280
  }
306
- return [...byStem.entries()].map(([id, entry]) => ({ id, entry }));
281
+ return packageNameFromSource(source);
307
282
  }
308
283
 
309
- export async function discoverImplicitExtensions(options: {
284
+ export interface DiscoverExtensionsOptions {
285
+ /** Project working directory (Pi's cwd). */
286
+ cwd: string;
287
+ /** The user's real agent dir (e.g. ~/.pi/agent). */
310
288
  agentDir: string;
311
- /** Configured user-scope packages with resolved roots (launcher discovery). */
312
- packages: ConfiguredPackageRoot[];
313
- /** Trusted project dir; untrusted projects are never scanned. */
314
- projectDir?: string;
315
- }): Promise<ImplicitExtensionDiscovery> {
289
+ /**
290
+ * Whether the project at `cwd` is trusted (the launcher's trust check).
291
+ * Untrusted projects contribute nothing: no project settings packages, no
292
+ * `.pi/extensions` files. Defaults to false.
293
+ */
294
+ projectTrusted?: boolean;
295
+ }
296
+
297
+ /** Builds the Pi package manager that owns discovery for one cwd/agentDir. */
298
+ function createPackageManager(options: DiscoverExtensionsOptions): PackageManager {
299
+ const settingsManager = SettingsManager.create(options.cwd, options.agentDir, {
300
+ projectTrusted: options.projectTrusted ?? false,
301
+ });
302
+ return new DefaultPackageManager({
303
+ cwd: options.cwd,
304
+ agentDir: options.agentDir,
305
+ settingsManager,
306
+ });
307
+ }
308
+
309
+ interface PackageGroup {
310
+ source: string;
311
+ root: string;
312
+ entries: string[];
313
+ /** Some declared entry exists on disk, even if all are disabled — keeps
314
+ * "package is filtered out" distinguishable from "package has no
315
+ * extensions". */
316
+ known: boolean;
317
+ }
318
+
319
+ /** Runs Pi's own resolution once and classifies the result into pi-profile's
320
+ * selectable model. */
321
+ async function resolveImplicit(packageManager: PackageManager, agentDir: string): Promise<ImplicitExtensionDiscovery> {
316
322
  const warnings: string[] = [];
323
+ // `skip` is the API's read-only mode: missing sources are reported as
324
+ // absent instead of triggering an install (no network, no mutation).
325
+ const resolved = await packageManager.resolve(async () => "skip");
326
+ // Sort for deterministic IDs and ordering: Pi preserves directory read
327
+ // order, which varies by filesystem.
328
+ const resources = [...resolved.extensions].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
329
+
330
+ const groups = new Map<string, PackageGroup>();
331
+ const userLoose = new Map<string, DiscoveredLocalExtension>();
332
+ const projectLoose = new Map<string, DiscoveredLocalExtension>();
333
+
334
+ for (const resource of resources) {
335
+ const { source, scope, origin, baseDir } = resource.metadata;
336
+ if (origin === "package") {
337
+ // Project-scope packages install under the project's .pi/npm, which
338
+ // generated global-scope settings cannot reference.
339
+ if (scope === "project") continue;
340
+ if (baseDir === undefined) continue;
341
+ const group = groups.get(source) ?? { source, root: baseDir, entries: [], known: false };
342
+ // A local source with no manifest and no convention dir resolves to
343
+ // the directory itself; the loader imports paths verbatim, so it is
344
+ // not selectable and must not be advertised.
345
+ if (await isFile(resource.path)) {
346
+ group.known = true;
347
+ if (resource.enabled && !group.entries.includes(resource.path)) group.entries.push(resource.path);
348
+ }
349
+ groups.set(source, group);
350
+ continue;
351
+ }
352
+ // Settings-declared paths (`extensions: [...]`) are already concrete
353
+ // files, so profiles reference them directly instead of through a
354
+ // derived ID; only auto-discovered directory contents become IDs.
355
+ if (!resource.enabled || source !== "auto") continue;
356
+ const extensionsDir = path.join(baseDir ?? agentDir, "extensions");
357
+ const id = looseId(resource.path, extensionsDir);
358
+ const target = scope === "project" ? projectLoose : userLoose;
359
+ const existing = target.get(id);
360
+ if (existing === undefined) {
361
+ target.set(id, { id, entry: resource.path });
362
+ continue;
363
+ }
364
+ // Pi reports a `.ts`/`.js` stem pair as two entries; keep its
365
+ // TypeScript-first convention, but say so instead of silently
366
+ // shadowing the file the loader would pick.
367
+ if (!existing.entry.endsWith(".ts") && resource.path.endsWith(".ts")) {
368
+ warnings.push(`extension "${id}" exists as both .ts and .js in ${extensionsDir}; the .ts file is used`);
369
+ target.set(id, { id, entry: resource.path });
370
+ }
371
+ }
317
372
 
318
373
  const packages: DiscoveredPackage[] = [];
319
- for (const pkg of options.packages) {
320
- const discovered = await readPackageExtensions(pkg);
321
- if (discovered !== undefined) packages.push(discovered);
374
+ for (const group of groups.values()) {
375
+ if (!group.known) continue;
376
+ packages.push({
377
+ name: await readPackageName(group.root, group.source),
378
+ source: group.source,
379
+ root: group.root,
380
+ entries: group.entries,
381
+ });
322
382
  }
323
383
 
324
- const globalLocal = await scanLooseDir(path.join(options.agentDir, "extensions"), warnings);
325
- const merged = new Map<string, DiscoveredLocalExtension>(globalLocal.map((entry) => [entry.id, entry]));
326
- if (options.projectDir !== undefined) {
327
- // Project loose files override same-ID global ones, mirroring the
328
- // project-over-global catalog override convention.
329
- for (const entry of await scanLooseDir(path.join(options.projectDir, ".pi", "extensions"), warnings)) {
330
- merged.set(entry.id, entry);
331
- }
332
- }
384
+ // Project loose files override same-ID global ones, mirroring the
385
+ // project-over-global catalog override convention.
386
+ const merged = new Map(userLoose);
387
+ for (const [id, entry] of projectLoose) merged.set(id, entry);
333
388
 
334
389
  return { packages, local: [...merged.values()], warnings };
335
390
  }
336
391
 
337
- export async function discoverExtensions(options: {
338
- agentDir: string;
339
- packages: ConfiguredPackageRoot[];
340
- projectDir?: string;
341
- }): Promise<DiscoveredExtensions> {
392
+ export async function discoverImplicitExtensions(options: DiscoverExtensionsOptions): Promise<ImplicitExtensionDiscovery> {
393
+ return await resolveImplicit(createPackageManager(options), options.agentDir);
394
+ }
395
+
396
+ export async function discoverExtensions(options: DiscoverExtensionsOptions): Promise<DiscoveredExtensions> {
342
397
  const raw = await discoverImplicitExtensions(options);
343
398
  return new DiscoveredExtensions(raw.packages, raw.local, raw.warnings);
344
399
  }
@@ -54,10 +54,12 @@ export async function discoverLauncherResources(
54
54
  .map((pkg) => ({ source: pkg.source, root: resolvePackageRoot(pkg.source, pkg.installedPath, context.agentDir) }));
55
55
  const [skills, extensions] = await Promise.all([
56
56
  discoverSkills(context),
57
+ // Extension discovery resolves through Pi's own package manager, so it
58
+ // only needs the same cwd/agentDir/trust inputs as skill discovery.
57
59
  discoverExtensions({
60
+ cwd: context.cwd,
58
61
  agentDir: context.agentDir,
59
- packages: configured,
60
- projectDir: context.projectTrusted ? context.cwd : undefined,
62
+ projectTrusted: context.projectTrusted,
61
63
  }),
62
64
  ]);
63
65
  return { skills, packages: configured, extensions };
@@ -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(
@@ -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
  }
@@ -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
@@ -99,6 +99,24 @@ export const MANAGED_INSTANCE_FILES = new Set([
99
99
  "extensions",
100
100
  ]);
101
101
 
102
+ /** State directories that Pi and its extensions resolve under the agent dir,
103
+ * and which therefore appear at runtime rather than at install time. They are
104
+ * seeded in the REAL agent dir before mirroring, so the instance gets a
105
+ * symlink instead of a private real directory: runtime-created state then
106
+ * lands where native Pi puts it, and third-party records never embed an
107
+ * instance path (ADR-0010). Adding a name here needs observed evidence that a
108
+ * package creates that directory under the agent dir; anything unlisted shows
109
+ * up as an unrecognized entry in the sweep (src/launcher/runtime-cleanup.ts). */
110
+ const SEEDED_STATE_DIRS = ["sessions", "missions"] as const;
111
+
112
+ /** State FILES Pi creates at runtime (same evidence rule as the dirs). They
113
+ * cannot be created up front — the content is Pi's, not pi-profile's — so the
114
+ * instance gets a symlink into the real agent dir that is deliberately allowed
115
+ * to dangle: Pi sees no file, writes through the link, and the real agent dir
116
+ * gets the file. A real file left here instead would be unrecognized state and
117
+ * would strand credentials in a directory the sweep refuses to delete. */
118
+ const SEEDED_STATE_FILES = ["auth.json", "models-store.json"] as const;
119
+
102
120
  /** Resource dirs rooted at the real agent dir, re-included for the default
103
121
  * profile because PI_CODING_AGENT_DIR moves the discovery root. */
104
122
  const RESOURCE_DIR_KINDS = ["skills", "extensions", "prompts", "themes"] as const;
@@ -405,7 +423,7 @@ export async function writeRuntimeFiles(
405
423
  skills: plan.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
406
424
  extensions: plan.extensions,
407
425
  },
408
- // Zero-match glob references (ADR-0006) — surfaced by /profile status
426
+ // Zero-match glob references (ADR-0009) — surfaced by /profile status
409
427
  // so a typo'd glob is visible instead of silently selecting nothing.
410
428
  ...(plan.unmatched !== undefined ? { unmatched: plan.unmatched } : {}),
411
429
  ...options.planExtras,
@@ -494,9 +512,6 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
494
512
  if (!existsSync(agentDir)) return;
495
513
  if (path.resolve(agentDir) === path.resolve(runtimeDir)) return;
496
514
 
497
- // Ensure the real sessions directory exists so it is always mirrored
498
- await mkdir(path.join(agentDir, "sessions"), { recursive: true });
499
-
500
515
  // 1. Clean up dangling or obsolete symlinks in runtimeDir
501
516
  try {
502
517
  const runtimeEntries = await readdir(runtimeDir);
@@ -517,7 +532,13 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
517
532
  }
518
533
  } catch {}
519
534
 
520
- // 2. Mirror files and directories from agentDir to runtimeDir
535
+ // 2. Seed the state paths this process's Pi will create at runtime, so their
536
+ // writes land in the real agent dir instead of an instance-local copy
537
+ // (ADR-0010). Runs after the cleanup above, which would otherwise remove the
538
+ // deliberately dangling file links.
539
+ await seedRuntimeState(agentDir, runtimeDir);
540
+
541
+ // 3. Mirror files and directories from agentDir to runtimeDir
521
542
  try {
522
543
  const entries = await readdir(agentDir);
523
544
  for (const name of entries) {
@@ -547,13 +568,45 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
547
568
  } catch {}
548
569
  }
549
570
 
571
+ /** Ensures the runtime state paths exist (or are linked) in the real agent dir
572
+ * and the instance. Best-effort: a failure here leaves the path unseeded, and
573
+ * the sweep's unrecognized-entry warning names it later. */
574
+ async function seedRuntimeState(agentDir: string, runtimeDir: string): Promise<void> {
575
+ for (const name of SEEDED_STATE_DIRS) {
576
+ try {
577
+ await mkdir(path.join(agentDir, name), { recursive: true });
578
+ } catch {
579
+ // Best-effort: the mirror then simply links nothing for this name.
580
+ }
581
+ }
582
+
583
+ for (const name of SEEDED_STATE_FILES) {
584
+ const linkPath = path.join(runtimeDir, name);
585
+ // A real file here belongs to an earlier run of a different layout, and a
586
+ // link may already point somewhere else: leave both alone rather than
587
+ // replacing state pi-profile cannot attribute.
588
+ if (await existsLexical(linkPath)) continue;
589
+ try {
590
+ await symlink(path.join(agentDir, name), linkPath);
591
+ } catch {
592
+ // Best-effort: Pi then creates the file inside the instance, and the
593
+ // sweep keeps that directory instead of deleting it silently.
594
+ }
595
+ }
596
+ }
597
+
550
598
  export async function generateRuntimeDir(
551
599
  plan: ActivationPlan,
552
600
  options: GenerateOptions,
553
601
  ): Promise<GeneratedRuntime> {
554
602
  const { agentDir } = options;
555
- const runtimeDir = path.join(getInstancesRootDir(), plan.profile, "agent");
556
- await mkdir(runtimeDir, { recursive: true });
603
+ const runtimeRoot = getInstancesRootDir();
604
+ await mkdir(runtimeRoot, { recursive: true });
605
+ // One instance per launch, never reused: PI_CODING_AGENT_DIR is frozen for
606
+ // the life of the spawned process, so a stable path cannot follow an
607
+ // in-session switch, and a shared path would make concurrent launches (and
608
+ // their switches) rewrite each other's files (ADR-0010).
609
+ const runtimeDir = await mkdtemp(path.join(runtimeRoot, "launch-"));
557
610
 
558
611
  await writeRuntimeFiles(runtimeDir, plan, options);
559
612
 
@@ -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
  }