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 +1 -1
- package/bin/pi-profile.ts +8 -5
- package/package.json +1 -1
- package/src/extension-discovery.ts +144 -89
- package/src/launcher/discovery.ts +4 -2
- package/src/launcher/initial-profile.ts +1 -1
- package/src/launcher/runtime-cleanup.ts +112 -32
- package/src/settings-generator.ts +63 -10
- package/src/switching/apply-plan.ts +1 -1
- package/src/switching/status.ts +1 -1
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
|
|
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 {
|
|
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
|
|
40
|
-
// window) are swept before this launch materializes its own.
|
|
41
|
-
//
|
|
42
|
-
|
|
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.
|
|
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-
|
|
3
|
+
* (ADR-0007/0008), so profiles can reference extensions without any
|
|
4
4
|
* registration step.
|
|
5
5
|
*
|
|
6
|
-
* Two implicit sources, both
|
|
7
|
-
* - Configured
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
256
|
-
* without a
|
|
257
|
-
*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
/**
|
|
283
|
-
*
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
281
|
+
return packageNameFromSource(source);
|
|
307
282
|
}
|
|
308
283
|
|
|
309
|
-
export
|
|
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
|
-
/**
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
338
|
-
agentDir
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
*
|
|
2
|
+
* InstanceCleanup: sweeps stale per-launch instance directories at startup.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* (ADR-
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
12
|
-
* (Naming the dir after the pid is impossible — the pid does not exist
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 →
|
|
17
|
-
* - no/unparsable pid file →
|
|
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
|
|
20
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 (
|
|
59
|
-
|
|
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
|
|
63
|
-
if (
|
|
64
|
-
|
|
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
|
|
69
|
-
|
|
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(
|
|
145
|
+
entries = await readdir(root);
|
|
73
146
|
} catch {
|
|
74
|
-
return; // No
|
|
147
|
+
return warnings; // No instances root yet: nothing to sweep.
|
|
75
148
|
}
|
|
149
|
+
|
|
76
150
|
for (const entry of entries) {
|
|
77
|
-
|
|
78
|
-
|
|
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())
|
|
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):
|
|
7
|
-
*
|
|
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-
|
|
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.
|
|
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
|
|
556
|
-
await mkdir(
|
|
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-
|
|
51
|
+
/** Glob references that matched nothing at resolution (ADR-0009). */
|
|
52
52
|
unmatched?: string[];
|
|
53
53
|
previousResolved?: {
|
|
54
54
|
skills: string[];
|
package/src/switching/status.ts
CHANGED
|
@@ -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-
|
|
39
|
+
/** Glob references that matched nothing at resolution (ADR-0009). */
|
|
40
40
|
unmatched?: string[];
|
|
41
41
|
conflicts: StatusConflict[];
|
|
42
42
|
}
|