pi-profile-switch 0.4.9 → 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.9",
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,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(
@@ -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
  }