pi-profile-switch 0.5.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 +16 -9
- package/package.json +1 -1
- package/src/launcher/initial-profile.ts +10 -15
- package/src/launcher/runtime-cleanup.ts +210 -29
- package/src/launcher/spawn.ts +2 -1
- package/src/mcp-config.ts +13 -3
- package/src/project-trust.ts +12 -8
- package/src/settings-generator.ts +52 -68
- package/src/skill-registry.ts +7 -7
- package/src/switching/switch-profile.ts +0 -1
package/bin/pi-profile.ts
CHANGED
|
@@ -28,7 +28,7 @@ try {
|
|
|
28
28
|
const agentDir = getAgentDir();
|
|
29
29
|
// Fails before spawning when the profile is unknown or cannot activate.
|
|
30
30
|
// --approve/--no-approve are consumed here as a one-run trust input.
|
|
31
|
-
const { plan, discovery,
|
|
31
|
+
const { plan, discovery, projectDir, warnings } = await resolveInitialProfile(args.profile, {
|
|
32
32
|
agentDir,
|
|
33
33
|
cwd: process.cwd(),
|
|
34
34
|
trustOverride: args.trustOverride,
|
|
@@ -37,19 +37,26 @@ 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.
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
const generated = await generateRuntimeDir(plan, { agentDir, discovery,
|
|
52
|
+
const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectDir });
|
|
47
53
|
process.exitCode = await spawnPi({
|
|
48
54
|
generated,
|
|
49
55
|
piArgs: args.piArgs,
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
56
|
+
// Re-applied for every profile: the one-run trust input must decide both
|
|
57
|
+
// the resolver's project reads and Pi's project-scope visibility — two
|
|
58
|
+
// different answers in one launch would be a divergence, not a policy.
|
|
59
|
+
trustOverride: args.trustOverride,
|
|
53
60
|
});
|
|
54
61
|
} catch (error) {
|
|
55
62
|
// Launcher input/selection failures (unknown profile, unresolvable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
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": [
|
|
@@ -53,9 +53,6 @@ export interface InitialProfile {
|
|
|
53
53
|
/** Full discovery results for the settings generator. Undefined for the
|
|
54
54
|
* default profile (which applies no filtering). */
|
|
55
55
|
discovery?: LauncherDiscovery;
|
|
56
|
-
/** The trusted project's `.pi/settings.json` content, when trusted and
|
|
57
|
-
* present. The generator merges it into the base for selection plans. */
|
|
58
|
-
projectSettings?: Record<string, unknown>;
|
|
59
56
|
/** The trusted project directory, when trusted. */
|
|
60
57
|
projectDir?: string;
|
|
61
58
|
/** Non-fatal notices for the user (e.g. a dangling restored profile that
|
|
@@ -63,13 +60,12 @@ export interface InitialProfile {
|
|
|
63
60
|
warnings: string[];
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
/** Reads the real global `defaultProjectTrust` setting (a trust input) and
|
|
67
|
-
*
|
|
68
|
-
* session-side surfaces (selector/list/status) that need the same
|
|
69
|
-
* gate the launcher uses. */
|
|
63
|
+
/** Reads the real global `defaultProjectTrust` setting (a trust input) and
|
|
64
|
+
* resolves the project trust decision from Pi's own trust store. Exported
|
|
65
|
+
* for the session-side surfaces (selector/list/status) that need the same
|
|
66
|
+
* trust gate the launcher uses. */
|
|
70
67
|
export async function readTrustInputs(context: LauncherContext): Promise<{
|
|
71
68
|
projectTrusted: boolean;
|
|
72
|
-
projectSettings?: Record<string, unknown>;
|
|
73
69
|
}> {
|
|
74
70
|
const globalSettingsPath = path.join(context.agentDir, "settings.json");
|
|
75
71
|
const globalSettings = await readJsonFile(globalSettingsPath);
|
|
@@ -86,10 +82,9 @@ export async function readTrustInputs(context: LauncherContext): Promise<{
|
|
|
86
82
|
if (!projectTrusted) {
|
|
87
83
|
return { projectTrusted };
|
|
88
84
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return { projectTrusted, projectSettings };
|
|
85
|
+
// The project's own `.pi/settings.json` is Pi's to read (natively, for the
|
|
86
|
+
// same trust decision): pi-profile neither merges nor narrows it.
|
|
87
|
+
return { projectTrusted };
|
|
93
88
|
}
|
|
94
89
|
|
|
95
90
|
export async function resolveInitialProfile(
|
|
@@ -97,7 +92,7 @@ export async function resolveInitialProfile(
|
|
|
97
92
|
context: LauncherContext,
|
|
98
93
|
options?: { overlay?: RuntimeOverlay },
|
|
99
94
|
): Promise<InitialProfile> {
|
|
100
|
-
const { projectTrusted
|
|
95
|
+
const { projectTrusted } = await readTrustInputs(context);
|
|
101
96
|
const projectDir = projectTrusted ? context.cwd : undefined;
|
|
102
97
|
const catalog = await ProfileCatalog.load(context.agentDir, { projectDir });
|
|
103
98
|
|
|
@@ -156,7 +151,7 @@ export async function resolveInitialProfile(
|
|
|
156
151
|
overlay,
|
|
157
152
|
});
|
|
158
153
|
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
159
|
-
return { plan, discovery,
|
|
154
|
+
return { plan, discovery, projectDir, warnings };
|
|
160
155
|
}
|
|
161
156
|
|
|
162
157
|
const discovery = await discoverLauncherResources({ ...context, projectTrusted });
|
|
@@ -177,5 +172,5 @@ export async function resolveInitialProfile(
|
|
|
177
172
|
// silently do nothing or leak through unfiltered.
|
|
178
173
|
throw new MissingMcpAdapterError(plan.profile);
|
|
179
174
|
}
|
|
180
|
-
return { plan, discovery,
|
|
175
|
+
return { plan, discovery, projectDir, warnings };
|
|
181
176
|
}
|
|
@@ -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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
237
|
+
const nested = await unrecognizedNames(entryPath);
|
|
90
238
|
if (nested === undefined) {
|
|
91
|
-
|
|
239
|
+
decisions.push({ name: entry.name, disposition: "keep-warn" });
|
|
92
240
|
continue;
|
|
93
241
|
}
|
|
94
|
-
|
|
242
|
+
for (const name of nested) decisions.push({ name: path.join(entry.name, name), disposition: "keep-warn" });
|
|
95
243
|
}
|
|
96
244
|
}
|
|
97
|
-
return
|
|
245
|
+
return decisions;
|
|
98
246
|
}
|
|
99
247
|
|
|
100
|
-
/** Reclaims `dir` if it is both dead and free of unrecognized entries
|
|
101
|
-
*
|
|
102
|
-
|
|
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
|
|
120
|
-
if (
|
|
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
|
-
|
|
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 (${
|
|
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
|
|
139
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/launcher/spawn.ts
CHANGED
|
@@ -29,7 +29,8 @@ const EXTENSION_ENTRY = fileURLToPath(new URL("../../extensions/pi-profile/index
|
|
|
29
29
|
* trust re-application, then user args verbatim. */
|
|
30
30
|
export function buildPiArgs(options: SpawnPiOptions): string[] {
|
|
31
31
|
const args = ["-e", EXTENSION_ENTRY];
|
|
32
|
-
//
|
|
32
|
+
// Re-apply the recorded one-run trust input for every profile: the same input
|
|
33
|
+
// decided the launcher's project reads, so Pi must decide the same way.
|
|
33
34
|
if (options.trustOverride === true) args.push("--approve");
|
|
34
35
|
if (options.trustOverride === false) args.push("--no-approve");
|
|
35
36
|
args.push(...options.piArgs);
|
package/src/mcp-config.ts
CHANGED
|
@@ -40,6 +40,9 @@ export interface McpDiscoveryOptions {
|
|
|
40
40
|
export interface MergedMcpResult {
|
|
41
41
|
servers: Record<string, Record<string, unknown>>;
|
|
42
42
|
sharedServers: Set<string>;
|
|
43
|
+
/** Servers defined in a trusted project's own config (`.mcp.json`,
|
|
44
|
+
* `.pi/mcp.json`). They are not the profile's to narrow. */
|
|
45
|
+
projectServers: Set<string>;
|
|
43
46
|
baseConfig?: Record<string, unknown>;
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -47,6 +50,9 @@ export interface McpConfigSource {
|
|
|
47
50
|
path: string;
|
|
48
51
|
isShared: boolean;
|
|
49
52
|
isAgentDir?: boolean;
|
|
53
|
+
/** Project-scope source: only read for a trusted project, and its servers
|
|
54
|
+
* stay enabled regardless of a profile's `mcps` declaration. */
|
|
55
|
+
isProject?: boolean;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
/**
|
|
@@ -71,8 +77,8 @@ export function getStandardMcpConfigSources(
|
|
|
71
77
|
{ path: path.join(agentDir, "mcp.json"), isShared: false, isAgentDir: true },
|
|
72
78
|
];
|
|
73
79
|
if (projectDir !== undefined) {
|
|
74
|
-
sources.push({ path: path.join(projectDir, ".mcp.json"), isShared: true });
|
|
75
|
-
sources.push({ path: path.join(projectDir, ".pi", "mcp.json"), isShared: false });
|
|
80
|
+
sources.push({ path: path.join(projectDir, ".mcp.json"), isShared: true, isProject: true });
|
|
81
|
+
sources.push({ path: path.join(projectDir, ".pi", "mcp.json"), isShared: false, isProject: true });
|
|
76
82
|
}
|
|
77
83
|
return sources;
|
|
78
84
|
}
|
|
@@ -86,6 +92,7 @@ export async function loadMergedMcpServers(
|
|
|
86
92
|
const seenPaths = new Set<string>();
|
|
87
93
|
const servers: Record<string, Record<string, unknown>> = {};
|
|
88
94
|
const sharedServers = new Set<string>();
|
|
95
|
+
const projectServers = new Set<string>();
|
|
89
96
|
let baseConfig: Record<string, unknown> | undefined;
|
|
90
97
|
|
|
91
98
|
for (const source of sources) {
|
|
@@ -112,6 +119,9 @@ export async function loadMergedMcpServers(
|
|
|
112
119
|
if (source.isShared) {
|
|
113
120
|
sharedServers.add(name);
|
|
114
121
|
}
|
|
122
|
+
if (source.isProject === true) {
|
|
123
|
+
projectServers.add(name);
|
|
124
|
+
}
|
|
115
125
|
if (isRecord(def)) {
|
|
116
126
|
servers[name] = { ...(servers[name] ?? {}), ...def };
|
|
117
127
|
} else {
|
|
@@ -120,7 +130,7 @@ export async function loadMergedMcpServers(
|
|
|
120
130
|
}
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
return { servers, sharedServers, baseConfig };
|
|
133
|
+
return { servers, sharedServers, projectServers, baseConfig };
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
/** Server names the adapter would discover: standard global MCP configs,
|
package/src/project-trust.ts
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Project trust resolution for the launcher — pi-profile
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Project trust resolution for the launcher — pi-profile's gate for the
|
|
3
|
+
* project-scope content it reads itself: the project catalog, the project
|
|
4
|
+
* runtime state, and the project MCP config. Project-level resources
|
|
5
|
+
* (skills, extensions, prompts, themes, settings) are Pi's own business: its
|
|
6
|
+
* trust store decision, exposed through the linked `trust.json`, decides
|
|
7
|
+
* their visibility (see ADR-0011).
|
|
5
8
|
*
|
|
6
9
|
* Mirrors Pi's own trust decision order (`resolveProjectTrusted`), with
|
|
7
10
|
* pi-profile's catalog/state files added to the trust-requiring
|
|
8
11
|
* resource set (they are pi-profile's project attack surface; Pi doesn't
|
|
9
12
|
* know about them):
|
|
10
13
|
* 1. one-run `--approve` / `--no-approve` override (consumed by the
|
|
11
|
-
* launcher
|
|
14
|
+
* launcher and re-applied to the spawned pi for every profile)
|
|
12
15
|
* 2. a project with no trust-requiring resources at all is trusted —
|
|
13
16
|
* there is nothing project-scoped to gate
|
|
14
17
|
* 3. the stored decision in the real `trust.json` (nearest ancestor wins)
|
|
15
18
|
* 4. the user's `defaultProjectTrust` setting — but only "always" grants;
|
|
16
19
|
* "ask" cannot prompt here (the launcher has no trust UI), so it falls
|
|
17
20
|
* through to untrusted
|
|
18
|
-
* 5. otherwise untrusted: project catalogs,
|
|
19
|
-
*
|
|
21
|
+
* 5. otherwise untrusted: project catalogs, project state, and project MCP
|
|
22
|
+
* config are not read at all
|
|
20
23
|
*
|
|
21
24
|
* Known divergence from Pi: extension `project_trust` event handlers are not
|
|
22
25
|
* consulted — that would require executing extension code in the launcher,
|
|
23
|
-
* which pi-profile never does.
|
|
24
|
-
*
|
|
26
|
+
* which pi-profile never does. A spawned Pi still runs those handlers with
|
|
27
|
+
* its own settings, so an extension that answers yes/no can decide the
|
|
28
|
+
* project scope of that session.
|
|
25
29
|
*/
|
|
26
30
|
|
|
27
31
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -17,18 +17,20 @@
|
|
|
17
17
|
* the spawned pi behaves exactly like native `pi`.
|
|
18
18
|
*
|
|
19
19
|
* For named profiles the generated settings encode the profile's selection
|
|
20
|
-
*
|
|
20
|
+
* over user-scope resources only (see docs/architecture/overview.md):
|
|
21
21
|
* - agentDir-scope resources: additive allowlist paths (the discovery root
|
|
22
22
|
* moved, so nothing auto-discovered from the real agent dir)
|
|
23
23
|
* - `~/.agents` skills: always auto-discovered, so unselected ones are
|
|
24
24
|
* force-excluded with `-<path>` entries
|
|
25
25
|
* - packages: user-configured package entries rewritten to object form with
|
|
26
26
|
* per-type allowlists (unmanaged types keep the user's key or Pi's default)
|
|
27
|
-
* -
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* - project-scope resources (project `.pi/skills`, project `.pi/extensions`,
|
|
28
|
+
* ancestor `.agents/skills`) are never encoded: Pi discovers them natively
|
|
29
|
+
* whenever the project is trusted, and the profile neither adds nor
|
|
30
|
+
* excludes them
|
|
31
|
+
* - `defaultProjectTrust: "never"` only suppresses Pi's interactive trust
|
|
32
|
+
* prompt (a stored decision in the real trust.json still applies); the
|
|
33
|
+
* project's `.pi/settings.json` is not merged here — Pi reads it natively
|
|
32
34
|
* - unmanaged kinds (prompts, themes) pass through: the user's arrays are
|
|
33
35
|
* preserved and the real agent dir's prompts/themes dirs re-included
|
|
34
36
|
* - tools/model are written to generated settings (defaultTools,
|
|
@@ -74,11 +76,6 @@ export interface GenerateOptions {
|
|
|
74
76
|
projectDir?: string;
|
|
75
77
|
/** Required for selection plans; unused for the default profile. */
|
|
76
78
|
discovery?: DiscoveryContext;
|
|
77
|
-
/** The trusted project's `.pi/settings.json` content (already parsed).
|
|
78
|
-
* Only pass when the resolver's trust check passed; merged into the
|
|
79
|
-
* generated base per Pi's merge rules for selection plans. Ignored for
|
|
80
|
-
* the default profile (Pi reads project settings natively there). */
|
|
81
|
-
projectSettings?: Record<string, unknown>;
|
|
82
79
|
}
|
|
83
80
|
|
|
84
81
|
export interface GeneratedRuntime {
|
|
@@ -150,21 +147,6 @@ function toPosix(filePath: string): string {
|
|
|
150
147
|
return filePath.split(path.sep).join("/");
|
|
151
148
|
}
|
|
152
149
|
|
|
153
|
-
/** Mirrors Pi's own deepMergeSettings: plain objects merge recursively,
|
|
154
|
-
* everything else (arrays, primitives) is replaced by the override. */
|
|
155
|
-
function deepMergeSettings(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
|
|
156
|
-
const result: Record<string, unknown> = { ...base };
|
|
157
|
-
for (const [key, overrideValue] of Object.entries(overrides)) {
|
|
158
|
-
if (overrideValue === undefined) continue;
|
|
159
|
-
const baseValue = result[key];
|
|
160
|
-
result[key] =
|
|
161
|
-
isRecord(baseValue) && isRecord(overrideValue)
|
|
162
|
-
? deepMergeSettings(baseValue, overrideValue)
|
|
163
|
-
: overrideValue;
|
|
164
|
-
}
|
|
165
|
-
return result;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
150
|
function tryRealpath(p: string): string {
|
|
169
151
|
try {
|
|
170
152
|
return realpathSync(p);
|
|
@@ -194,27 +176,28 @@ function buildSelectionSettings(
|
|
|
194
176
|
agentDir: string,
|
|
195
177
|
discovery: DiscoveryContext,
|
|
196
178
|
runtimeDir: string,
|
|
179
|
+
projectDir?: string,
|
|
197
180
|
): Record<string, unknown> {
|
|
198
181
|
const settings = { ...userSettings };
|
|
199
182
|
|
|
200
183
|
// --- skills ---
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
const orderedSelectedSkills = [...plan.skills].sort((a, b) => {
|
|
205
|
-
const aProject = a.scope === "project" ? 0 : 1;
|
|
206
|
-
const bProject = b.scope === "project" ? 0 : 1;
|
|
207
|
-
return aProject - bProject;
|
|
208
|
-
});
|
|
184
|
+
// Only user-scope entries are encoded. Project-scope selections are
|
|
185
|
+
// skipped below: their visibility is Pi's, so the order they would have
|
|
186
|
+
// been emitted in carries no meaning.
|
|
209
187
|
const selectedPaths = new Set(plan.skills.map((skill) => skill.filePath));
|
|
210
188
|
const skillEntries: string[] = [];
|
|
211
|
-
for (const skill of
|
|
189
|
+
for (const skill of plan.skills) {
|
|
212
190
|
if (skill.origin === "package") continue; // encoded in the packages allowlist
|
|
191
|
+
// Project scope belongs to Pi: a trusted project's skills are discovered
|
|
192
|
+
// natively, so selecting one here would duplicate it and excluding one
|
|
193
|
+
// would contradict the profile's boundary.
|
|
194
|
+
if (skill.scope === "project") continue;
|
|
213
195
|
if (isUnderPath(skill.filePath, homeAgentsSkillsDir())) continue; // auto-discovered anyway
|
|
214
196
|
skillEntries.push(skill.filePath);
|
|
215
197
|
}
|
|
216
198
|
for (const skill of discovery.skills) {
|
|
217
199
|
if (skill.origin === "package") continue;
|
|
200
|
+
if (skill.scope === "project") continue;
|
|
218
201
|
if (selectedPaths.has(skill.filePath)) continue;
|
|
219
202
|
|
|
220
203
|
// If the skill is in the real agentDir, Pi will discover it via the symlink.
|
|
@@ -240,7 +223,13 @@ function buildSelectionSettings(
|
|
|
240
223
|
.map((pkg) => ({ ...pkg, root: pkg.root }));
|
|
241
224
|
const packageExtensions = new Map<string, string[]>();
|
|
242
225
|
const extensionEntries: string[] = [];
|
|
226
|
+
const projectExtensionsDir =
|
|
227
|
+
projectDir !== undefined ? path.join(projectDir, ".pi", "extensions") : undefined;
|
|
243
228
|
for (const extension of plan.extensions) {
|
|
229
|
+
// Loose project extensions are discovered natively by Pi; an explicit
|
|
230
|
+
// path reference inside the project (outside `.pi/extensions`) is the
|
|
231
|
+
// profile's own selection and stays.
|
|
232
|
+
if (projectExtensionsDir !== undefined && isUnderPath(extension.entry, projectExtensionsDir)) continue;
|
|
244
233
|
const owner = packageRoots.find((pkg) => isUnderPath(extension.entry, pkg.root));
|
|
245
234
|
if (owner === undefined) {
|
|
246
235
|
extensionEntries.push(extension.entry);
|
|
@@ -316,8 +305,6 @@ export interface RuntimeFileOptions {
|
|
|
316
305
|
projectDir?: string;
|
|
317
306
|
/** Required for selection plans; unused for the default profile. */
|
|
318
307
|
discovery?: DiscoveryContext;
|
|
319
|
-
/** The trusted project's `.pi/settings.json` content (already parsed). */
|
|
320
|
-
projectSettings?: Record<string, unknown>;
|
|
321
308
|
/** Extra launch-plan fields written by the in-session switch path:
|
|
322
309
|
* `switchedFrom` triggers the one-shot change summary; `persistSelection`
|
|
323
310
|
* tells the post-reload extension instance to save the selection;
|
|
@@ -363,21 +350,20 @@ async function computeSettings(
|
|
|
363
350
|
return settings;
|
|
364
351
|
}
|
|
365
352
|
|
|
366
|
-
// Selection plans:
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return buildSelectionSettings(plan, base, agentDir, options.discovery ?? { skills: [], packages: [] }, runtimeDir);
|
|
353
|
+
// Selection plans: only user-scope encoding is layered onto the user's own
|
|
354
|
+
// settings. The trusted project's `.pi/settings.json` is deliberately NOT
|
|
355
|
+
// merged here — Pi reads it natively for the same trust decision this
|
|
356
|
+
// process's Pi applies, and merging it would turn the project's `packages`
|
|
357
|
+
// into global-scope packages (installing them into the real agent dir's npm
|
|
358
|
+
// root as a launch side effect).
|
|
359
|
+
return buildSelectionSettings(
|
|
360
|
+
plan,
|
|
361
|
+
{ ...userSettings },
|
|
362
|
+
agentDir,
|
|
363
|
+
options.discovery ?? { skills: [], packages: [] },
|
|
364
|
+
runtimeDir,
|
|
365
|
+
options.projectDir,
|
|
366
|
+
);
|
|
381
367
|
}
|
|
382
368
|
|
|
383
369
|
/** Resolved name sets, carried in the launch plan for glob-delta reporting. */
|
|
@@ -389,10 +375,9 @@ export interface ResolvedNames {
|
|
|
389
375
|
}
|
|
390
376
|
|
|
391
377
|
/** Writes settings.json + pi-profile.json into an existing runtime dir and
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* trust
|
|
395
|
-
* inside Pi and re-enable unfiltered project auto-discovery). */
|
|
378
|
+
* keeps the trust.json link in place for every profile: Pi reads its
|
|
379
|
+
* project-scope decision from that path, and project-level resources belong
|
|
380
|
+
* to Pi's trust gate rather than to the profile. */
|
|
396
381
|
export async function writeRuntimeFiles(
|
|
397
382
|
runtimeDir: string,
|
|
398
383
|
plan: ActivationPlan,
|
|
@@ -433,18 +418,14 @@ export async function writeRuntimeFiles(
|
|
|
433
418
|
)}\n`,
|
|
434
419
|
);
|
|
435
420
|
|
|
421
|
+
// Every profile gets the link, dangling allowed: Pi's stored trust decision
|
|
422
|
+
// is what makes a trusted project's resources visible, and a decision Pi
|
|
423
|
+
// writes through the link must land in the real agent dir (same shape as the
|
|
424
|
+
// auth.json seed in ADR-0010). An entry that already exists is left alone —
|
|
425
|
+
// a real file Pi wrote during this session carries its own decision.
|
|
436
426
|
const trustLink = path.join(runtimeDir, "trust.json");
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if ((await exists(trustTarget)) && !(await existsLexical(trustLink))) {
|
|
440
|
-
await symlink(trustTarget, trustLink);
|
|
441
|
-
}
|
|
442
|
-
} else if (await existsLexical(trustLink)) {
|
|
443
|
-
// Lexical check: a dangling trust.json symlink (real trust.json deleted
|
|
444
|
-
// after the link was made) must still be removed — otherwise a later
|
|
445
|
-
// re-created real trust.json silently resurrects stored trust inside a
|
|
446
|
-
// named profile, defeating defaultProjectTrust: "never".
|
|
447
|
-
await rm(trustLink);
|
|
427
|
+
if (!(await existsLexical(trustLink))) {
|
|
428
|
+
await symlink(path.join(options.agentDir, "trust.json"), trustLink);
|
|
448
429
|
}
|
|
449
430
|
|
|
450
431
|
// MCP Servers generation (Ticket 04)
|
|
@@ -459,7 +440,7 @@ export async function writeRuntimeFiles(
|
|
|
459
440
|
} else {
|
|
460
441
|
// Filter MCP servers
|
|
461
442
|
try { await rm(mcpInstancePath); } catch {}
|
|
462
|
-
const { servers, sharedServers, baseConfig } = await loadMergedMcpServers(
|
|
443
|
+
const { servers, sharedServers, projectServers, baseConfig } = await loadMergedMcpServers(
|
|
463
444
|
options.agentDir,
|
|
464
445
|
options.projectDir,
|
|
465
446
|
options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
|
|
@@ -478,6 +459,9 @@ export async function writeRuntimeFiles(
|
|
|
478
459
|
|
|
479
460
|
for (const sharedName of sharedServers) {
|
|
480
461
|
if (!allowedSet.has(sharedName)) {
|
|
462
|
+
// Project-level servers are not the profile's to narrow (the same
|
|
463
|
+
// boundary as project skills and extensions).
|
|
464
|
+
if (projectServers.has(sharedName)) continue;
|
|
481
465
|
filteredServers[sharedName] = { disabled: true };
|
|
482
466
|
}
|
|
483
467
|
}
|
package/src/skill-registry.ts
CHANGED
|
@@ -44,9 +44,9 @@ export interface DiscoverSkillsOptions {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export async function discoverSkills(options: DiscoverSkillsOptions): Promise<SkillEntry[]> {
|
|
47
|
-
// Project trust comes from the caller's trust check
|
|
48
|
-
//
|
|
49
|
-
//
|
|
47
|
+
// Project trust comes from the caller's trust check: discovery is the only
|
|
48
|
+
// place project resources enter a plan's reference vocabulary (their
|
|
49
|
+
// visibility in the session is Pi's, not the plan's).
|
|
50
50
|
const settingsManager = SettingsManager.create(options.cwd, options.agentDir, {
|
|
51
51
|
projectTrusted: options.projectTrusted ?? false,
|
|
52
52
|
});
|
|
@@ -73,10 +73,10 @@ export async function discoverSkills(options: DiscoverSkillsOptions): Promise<Sk
|
|
|
73
73
|
else process.env.PI_OFFLINE = savedOffline;
|
|
74
74
|
}
|
|
75
75
|
return loader.getSkills().skills.flatMap((skill) => {
|
|
76
|
-
// Project-scoped package skills
|
|
77
|
-
// under the project's .pi/npm
|
|
78
|
-
//
|
|
79
|
-
//
|
|
76
|
+
// Project-scoped package skills stay out of the reference vocabulary:
|
|
77
|
+
// their packages live under the project's .pi/npm and Pi discovers their
|
|
78
|
+
// skills natively, so a profile reference would add nothing. Project
|
|
79
|
+
// .pi/skills and ancestor .agents/skills are referenceable.
|
|
80
80
|
if (skill.sourceInfo.origin === "package" && skill.sourceInfo.scope === "project") {
|
|
81
81
|
return [];
|
|
82
82
|
}
|
|
@@ -204,7 +204,6 @@ export async function switchProfile(
|
|
|
204
204
|
agentDir: deps.realAgentDir,
|
|
205
205
|
projectDir: resolved.projectDir,
|
|
206
206
|
discovery: resolved.discovery,
|
|
207
|
-
projectSettings: resolved.projectSettings,
|
|
208
207
|
planExtras: {
|
|
209
208
|
...(isSwitch && current.profile !== undefined ? { switchedFrom: current.profile } : {}),
|
|
210
209
|
// `/profile use` persists; `/profile reload` keeps the current
|