pi-profile-switch 0.6.0 → 0.7.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/bin/pi-profile.ts CHANGED
@@ -37,10 +37,16 @@ try {
37
37
  console.error(`pi-profile: warning: ${warning}`);
38
38
  }
39
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()) {
40
+ // window) are swept before this launch materializes its own. Unrecognized
41
+ // entries are dispositioned per content scan: kept + reported when they
42
+ // reference the instance path or cannot be cleared, adopted into the real
43
+ // agent dir, or deleted on a name conflict (real wins) — all with a stderr
44
+ // notice (ADR-0012). Best-effort: sweep errors never block the launch.
45
+ const sweep = await sweepStaleInstances(agentDir);
46
+ for (const notice of sweep.notices) {
47
+ console.error(`pi-profile: notice: ${notice}`);
48
+ }
49
+ for (const warning of sweep.warnings) {
44
50
  console.error(`pi-profile: warning: ${warning}`);
45
51
  }
46
52
  const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectDir });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": [
@@ -21,19 +21,21 @@
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.
24
+ * A candidate is reclaimed only when every entry in it is pi-profile's own
25
+ * or has been dispositioned away. Unrecognized entries (state an extension
26
+ * created at runtime) get one of three dispositions per entry (ADR-0012):
27
+ * content that references the instance dir's own path, exceeds the scan
28
+ * budget, or is not a regular file/dir is kept and reported; position-
29
+ * independent content is renamed into the real agent dir (adopted), or
30
+ * deleted when the real agent dir already holds that name (real wins — the
31
+ * conflict window means the instance copy is never the authority). The sweep
32
+ * never destroys data it cannot attribute.
31
33
  *
32
34
  * Everything is best-effort: sweep errors never block a launch.
33
35
  */
34
36
 
35
37
  import type { Dirent } from "node:fs";
36
- import { lstat, readdir, readFile, rm, stat } from "node:fs/promises";
38
+ import { lstat, readdir, readFile, rename, rm, stat } from "node:fs/promises";
37
39
  import path from "node:path";
38
40
 
39
41
  import { MANAGED_INSTANCE_FILES } from "../settings-generator.ts";
@@ -49,6 +51,29 @@ export const NO_PID_GRACE_MS = 10 * 60 * 1000;
49
51
  * may write its own files inside them (e.g. an extension's own config). */
50
52
  const MANAGED_DIRS_WITH_RUNTIME_CONTENT = new Set(["extensions"]);
51
53
 
54
+ /** Content-scan budget for deciding whether an unrecognized entry is safe to
55
+ * adopt (ADR-0012). Over budget → undecidable → keep and warn. The values
56
+ * are implementation constants: the contract only fixes "over budget means
57
+ * keep". Exported for the tests that pin the over-budget behavior. */
58
+ export const SCAN_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
59
+ export const SCAN_MAX_FILES = 1024;
60
+
61
+ /** What the sweep does with one unrecognized entry in a candidate dir. */
62
+ type Disposition = "adopt" | "delete" | "keep-warn";
63
+
64
+ interface EntryDecision {
65
+ /** Entry name relative to the instance dir. */
66
+ name: string;
67
+ disposition: Disposition;
68
+ }
69
+
70
+ export interface SweepResult {
71
+ /** Warnings for directories intentionally kept. */
72
+ warnings: string[];
73
+ /** One-line notices for entries adopted into or deleted from a candidate. */
74
+ notices: string[];
75
+ }
76
+
52
77
  function isProcessAlive(pid: number): boolean {
53
78
  try {
54
79
  process.kill(pid, 0);
@@ -59,10 +84,98 @@ function isProcessAlive(pid: number): boolean {
59
84
  }
60
85
  }
61
86
 
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> {
87
+ /** Scan budget shared across one entry's recursive content scan. */
88
+ interface ScanBudget {
89
+ bytes: number;
90
+ files: number;
91
+ /** Set when a limit is hit or a member cannot be read: undecidable. */
92
+ exceeded: boolean;
93
+ /** Set when a member's bytes contain the instance dir's absolute path. */
94
+ found: boolean;
95
+ }
96
+
97
+ async function scanContent(entryPath: string, instanceDir: string, budget: ScanBudget): Promise<void> {
98
+ if (budget.exceeded || budget.found) return;
99
+ let info;
100
+ try {
101
+ info = await lstat(entryPath);
102
+ } catch {
103
+ budget.exceeded = true; // Vanished or unreadable mid-scan: undecidable.
104
+ return;
105
+ }
106
+ if (info.isDirectory()) {
107
+ let members: string[];
108
+ try {
109
+ members = await readdir(entryPath);
110
+ } catch {
111
+ budget.exceeded = true;
112
+ return;
113
+ }
114
+ for (const member of members) {
115
+ await scanContent(path.join(entryPath, member), instanceDir, budget);
116
+ if (budget.exceeded || budget.found) return;
117
+ }
118
+ return;
119
+ }
120
+ if (!info.isFile()) {
121
+ // A socket/fifo/device member cannot be read: undecidable.
122
+ budget.exceeded = true;
123
+ return;
124
+ }
125
+ budget.files += 1;
126
+ budget.bytes += info.size;
127
+ if (budget.files > SCAN_MAX_FILES || budget.bytes > SCAN_MAX_TOTAL_BYTES) {
128
+ budget.exceeded = true;
129
+ return;
130
+ }
131
+ let content: Buffer;
132
+ try {
133
+ content = await readFile(entryPath);
134
+ } catch {
135
+ budget.exceeded = true;
136
+ return;
137
+ }
138
+ if (content.includes(instanceDir)) budget.found = true;
139
+ }
140
+
141
+ /** Whether an unrecognized entry must be kept because its content scan says
142
+ * so: the content references the instance dir's own path (records like
143
+ * pi-subagents ledgers would break if moved), the scan could not finish
144
+ * within the budget, or the entry is not a regular file/dir. Only a clean
145
+ * negative scan makes the entry eligible for adoption (ADR-0012). */
146
+ export async function mustKeepByContentScan(entryPath: string, instanceDir: string): Promise<boolean> {
147
+ let info;
148
+ try {
149
+ info = await lstat(entryPath);
150
+ } catch {
151
+ return true; // Vanished or unreadable: nothing to adopt.
152
+ }
153
+ if (!info.isFile() && !info.isDirectory()) return true; // socket/fifo etc.
154
+ const budget: ScanBudget = { bytes: 0, files: 0, exceeded: false, found: false };
155
+ await scanContent(entryPath, instanceDir, budget);
156
+ // Keep unless the scan finished within budget and never saw the path.
157
+ return budget.exceeded || budget.found;
158
+ }
159
+
160
+ /** Decides one top-level unrecognized entry: keep when the content scan is
161
+ * not a clean negative; otherwise real-wins on a name conflict, adopt when
162
+ * the real agent dir is free. A name lookup that fails for reasons other
163
+ * than absence is undecidable → keep. */
164
+ async function decideEntry(name: string, entryPath: string, instanceDir: string, agentDir: string): Promise<EntryDecision> {
165
+ if (await mustKeepByContentScan(entryPath, instanceDir)) return { name, disposition: "keep-warn" };
166
+ try {
167
+ await lstat(path.join(agentDir, name));
168
+ return { name, disposition: "delete" };
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { name, disposition: "adopt" };
171
+ return { name, disposition: "keep-warn" };
172
+ }
173
+ }
174
+
175
+ /** Unrecognized entry names inside a managed runtime-content directory
176
+ * (`extensions/`): reported at pi-profile's boundary, never adopted or
177
+ * deleted. `undefined` means the directory could not be inspected. */
178
+ async function unrecognizedNames(dir: string): Promise<string[] | undefined> {
66
179
  let entries: Dirent<string>[];
67
180
  try {
68
181
  entries = await readdir(dir, { withFileTypes: true });
@@ -71,6 +184,41 @@ async function unrecognizedEntries(dir: string): Promise<string[] | undefined> {
71
184
  }
72
185
 
73
186
  const found: string[] = [];
187
+ for (const entry of entries) {
188
+ const entryPath = path.join(dir, entry.name);
189
+ let linkStat;
190
+ try {
191
+ linkStat = await lstat(entryPath);
192
+ } catch {
193
+ continue; // Vanished under us: nothing to protect.
194
+ }
195
+ if (linkStat.isSymbolicLink()) continue;
196
+ if (MANAGED_DIRS_WITH_RUNTIME_CONTENT.has(entry.name) && linkStat.isDirectory()) {
197
+ const nested = await unrecognizedNames(entryPath);
198
+ if (nested === undefined) {
199
+ found.push(entry.name);
200
+ continue;
201
+ }
202
+ found.push(...nested.map((name) => path.join(entry.name, name)));
203
+ continue;
204
+ }
205
+ found.push(entry.name);
206
+ }
207
+ return found;
208
+ }
209
+
210
+ /** Decides every unrecognized entry in `dir`. `undefined` means the directory
211
+ * could not be inspected — the caller must treat that as unrecognized rather
212
+ * than as empty. */
213
+ async function classifyUnrecognized(dir: string, agentDir: string): Promise<EntryDecision[] | undefined> {
214
+ let entries: Dirent<string>[];
215
+ try {
216
+ entries = await readdir(dir, { withFileTypes: true });
217
+ } catch {
218
+ return undefined;
219
+ }
220
+
221
+ const decisions: EntryDecision[] = [];
74
222
  for (const entry of entries) {
75
223
  const entryPath = path.join(dir, entry.name);
76
224
  let linkStat;
@@ -82,24 +230,26 @@ async function unrecognizedEntries(dir: string): Promise<string[] | undefined> {
82
230
  // Symlinks only point at the real agent dir and hold no data of their own.
83
231
  if (linkStat.isSymbolicLink()) continue;
84
232
  if (!MANAGED_INSTANCE_FILES.has(entry.name)) {
85
- found.push(entry.name);
233
+ decisions.push(await decideEntry(entry.name, entryPath, dir, agentDir));
86
234
  continue;
87
235
  }
88
236
  if (MANAGED_DIRS_WITH_RUNTIME_CONTENT.has(entry.name) && linkStat.isDirectory()) {
89
- const nested = await unrecognizedEntries(entryPath);
237
+ const nested = await unrecognizedNames(entryPath);
90
238
  if (nested === undefined) {
91
- found.push(entry.name);
239
+ decisions.push({ name: entry.name, disposition: "keep-warn" });
92
240
  continue;
93
241
  }
94
- found.push(...nested.map((name) => path.join(entry.name, name)));
242
+ for (const name of nested) decisions.push({ name: path.join(entry.name, name), disposition: "keep-warn" });
95
243
  }
96
244
  }
97
- return found;
245
+ return decisions;
98
246
  }
99
247
 
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[]> {
248
+ /** Reclaims `dir` if it is both dead and free of unrecognized entries, after
249
+ * dispositioning those entries (ADR-0012). Appends notices for adopted and
250
+ * deleted entries to `notices`; returns the warnings for the cases where the
251
+ * directory was intentionally kept. */
252
+ async function sweepEntry(dir: string, agentDir: string, notices: string[]): Promise<string[]> {
103
253
  let pid: number | undefined;
104
254
  try {
105
255
  const raw = await readFile(path.join(dir, "pid"), "utf8");
@@ -116,15 +266,43 @@ async function sweepEntry(dir: string): Promise<string[]> {
116
266
  if (Date.now() - info.mtimeMs <= NO_PID_GRACE_MS) return [];
117
267
  }
118
268
 
119
- const unrecognized = await unrecognizedEntries(dir);
120
- if (unrecognized === undefined) {
269
+ const decisions = await classifyUnrecognized(dir, agentDir);
270
+ if (decisions === undefined) {
121
271
  return [
122
272
  `${dir} was not reclaimed: it could not be inspected (permissions?). Check its contents and delete it manually.`,
123
273
  ];
124
274
  }
125
- if (unrecognized.length > 0) {
275
+
276
+ const kept: string[] = [];
277
+ for (const decision of decisions) {
278
+ if (decision.disposition === "keep-warn") {
279
+ kept.push(decision.name);
280
+ continue;
281
+ }
282
+ const entryPath = path.join(dir, decision.name);
283
+ if (decision.disposition === "adopt") {
284
+ try {
285
+ await rename(entryPath, path.join(agentDir, decision.name));
286
+ notices.push(`adopted ${decision.name} from ${dir} into the real agent dir (${agentDir}); it is mirrored on the next launch`);
287
+ continue;
288
+ } catch {
289
+ kept.push(decision.name); // Best-effort: keep rather than destroy.
290
+ continue;
291
+ }
292
+ }
293
+ // delete: the real agent dir already holds this name (real wins, no
294
+ // content comparison — ADR-0012).
295
+ try {
296
+ await rm(entryPath, { recursive: true, force: true });
297
+ notices.push(`deleted ${decision.name} from ${dir}: the real agent dir already holds that name`);
298
+ } catch {
299
+ kept.push(decision.name);
300
+ }
301
+ }
302
+
303
+ if (kept.length > 0) {
126
304
  return [
127
- `${dir} was not reclaimed: it holds state pi-profile did not generate (${unrecognized.join(", ")}). ` +
305
+ `${dir} was not reclaimed: it holds state pi-profile did not generate (${kept.join(", ")}). ` +
128
306
  `Move that state into the real agent dir (it is mirrored on the next launch), or point the extension that ` +
129
307
  `created it at a fixed path via that extension's own configuration, then delete ${dir}.`,
130
308
  ];
@@ -135,16 +313,19 @@ async function sweepEntry(dir: string): Promise<string[]> {
135
313
  }
136
314
 
137
315
  /** 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[]> {
316
+ * warnings for directories it deliberately kept plus the notices for entries
317
+ * it adopted or deleted. `agentDir` is the real agent dir that adopted
318
+ * entries move into and name conflicts resolve against. Never throws. */
319
+ export async function sweepStaleInstances(agentDir: string): Promise<SweepResult> {
140
320
  const warnings: string[] = [];
321
+ const notices: string[] = [];
141
322
  const root = getInstancesRootDir();
142
323
 
143
324
  let entries: string[];
144
325
  try {
145
326
  entries = await readdir(root);
146
327
  } catch {
147
- return warnings; // No instances root yet: nothing to sweep.
328
+ entries = []; // No instances root yet: nothing to sweep.
148
329
  }
149
330
 
150
331
  for (const entry of entries) {
@@ -155,11 +336,11 @@ export async function sweepStaleInstances(): Promise<string[]> {
155
336
  const dir = path.join(root, entry);
156
337
  try {
157
338
  if (!(await stat(dir)).isDirectory()) continue;
158
- warnings.push(...(await sweepEntry(dir)));
339
+ warnings.push(...(await sweepEntry(dir, agentDir, notices)));
159
340
  } catch {
160
341
  // Best-effort: one bad entry must not stop the sweep or the launch.
161
342
  }
162
343
  }
163
344
 
164
- return warnings;
345
+ return { warnings, notices };
165
346
  }