pi-profile-switch 0.4.9 → 0.6.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 +14 -10
- package/package.json +1 -1
- package/src/extension-discovery.ts +1 -1
- package/src/launcher/initial-profile.ts +11 -16
- package/src/launcher/runtime-cleanup.ts +112 -32
- 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 +115 -78
- package/src/skill-registry.ts +7 -7
- package/src/switching/apply-plan.ts +1 -1
- package/src/switching/status.ts +1 -1
- package/src/switching/switch-profile.ts +0 -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";
|
|
@@ -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,
|
|
@@ -36,17 +36,21 @@ 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
|
-
|
|
43
|
-
const
|
|
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
|
+
}
|
|
46
|
+
const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectDir });
|
|
44
47
|
process.exitCode = await spawnPi({
|
|
45
48
|
generated,
|
|
46
49
|
piArgs: args.piArgs,
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
+
// Re-applied for every profile: the one-run trust input must decide both
|
|
51
|
+
// the resolver's project reads and Pi's project-scope visibility — two
|
|
52
|
+
// different answers in one launch would be a divergence, not a policy.
|
|
53
|
+
trustOverride: args.trustOverride,
|
|
50
54
|
});
|
|
51
55
|
} catch (error) {
|
|
52
56
|
// 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.6.0",
|
|
4
4
|
"description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ExtensionDiscovery: implicit, read-only discovery of selectable extensions
|
|
3
|
-
* (ADR-
|
|
3
|
+
* (ADR-0007/0008), so profiles can reference extensions without any
|
|
4
4
|
* registration step.
|
|
5
5
|
*
|
|
6
6
|
* Two implicit sources, both resolved by Pi itself:
|
|
@@ -31,7 +31,7 @@ export class UnknownProfileError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Zero-match glob references surface as launch warnings (ADR-
|
|
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(
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
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";
|
|
@@ -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
|
|
@@ -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 {
|
|
@@ -99,6 +96,24 @@ export const MANAGED_INSTANCE_FILES = new Set([
|
|
|
99
96
|
"extensions",
|
|
100
97
|
]);
|
|
101
98
|
|
|
99
|
+
/** State directories that Pi and its extensions resolve under the agent dir,
|
|
100
|
+
* and which therefore appear at runtime rather than at install time. They are
|
|
101
|
+
* seeded in the REAL agent dir before mirroring, so the instance gets a
|
|
102
|
+
* symlink instead of a private real directory: runtime-created state then
|
|
103
|
+
* lands where native Pi puts it, and third-party records never embed an
|
|
104
|
+
* instance path (ADR-0010). Adding a name here needs observed evidence that a
|
|
105
|
+
* package creates that directory under the agent dir; anything unlisted shows
|
|
106
|
+
* up as an unrecognized entry in the sweep (src/launcher/runtime-cleanup.ts). */
|
|
107
|
+
const SEEDED_STATE_DIRS = ["sessions", "missions"] as const;
|
|
108
|
+
|
|
109
|
+
/** State FILES Pi creates at runtime (same evidence rule as the dirs). They
|
|
110
|
+
* cannot be created up front — the content is Pi's, not pi-profile's — so the
|
|
111
|
+
* instance gets a symlink into the real agent dir that is deliberately allowed
|
|
112
|
+
* to dangle: Pi sees no file, writes through the link, and the real agent dir
|
|
113
|
+
* gets the file. A real file left here instead would be unrecognized state and
|
|
114
|
+
* would strand credentials in a directory the sweep refuses to delete. */
|
|
115
|
+
const SEEDED_STATE_FILES = ["auth.json", "models-store.json"] as const;
|
|
116
|
+
|
|
102
117
|
/** Resource dirs rooted at the real agent dir, re-included for the default
|
|
103
118
|
* profile because PI_CODING_AGENT_DIR moves the discovery root. */
|
|
104
119
|
const RESOURCE_DIR_KINDS = ["skills", "extensions", "prompts", "themes"] as const;
|
|
@@ -132,21 +147,6 @@ function toPosix(filePath: string): string {
|
|
|
132
147
|
return filePath.split(path.sep).join("/");
|
|
133
148
|
}
|
|
134
149
|
|
|
135
|
-
/** Mirrors Pi's own deepMergeSettings: plain objects merge recursively,
|
|
136
|
-
* everything else (arrays, primitives) is replaced by the override. */
|
|
137
|
-
function deepMergeSettings(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
|
|
138
|
-
const result: Record<string, unknown> = { ...base };
|
|
139
|
-
for (const [key, overrideValue] of Object.entries(overrides)) {
|
|
140
|
-
if (overrideValue === undefined) continue;
|
|
141
|
-
const baseValue = result[key];
|
|
142
|
-
result[key] =
|
|
143
|
-
isRecord(baseValue) && isRecord(overrideValue)
|
|
144
|
-
? deepMergeSettings(baseValue, overrideValue)
|
|
145
|
-
: overrideValue;
|
|
146
|
-
}
|
|
147
|
-
return result;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
150
|
function tryRealpath(p: string): string {
|
|
151
151
|
try {
|
|
152
152
|
return realpathSync(p);
|
|
@@ -176,27 +176,28 @@ function buildSelectionSettings(
|
|
|
176
176
|
agentDir: string,
|
|
177
177
|
discovery: DiscoveryContext,
|
|
178
178
|
runtimeDir: string,
|
|
179
|
+
projectDir?: string,
|
|
179
180
|
): Record<string, unknown> {
|
|
180
181
|
const settings = { ...userSettings };
|
|
181
182
|
|
|
182
183
|
// --- skills ---
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
const orderedSelectedSkills = [...plan.skills].sort((a, b) => {
|
|
187
|
-
const aProject = a.scope === "project" ? 0 : 1;
|
|
188
|
-
const bProject = b.scope === "project" ? 0 : 1;
|
|
189
|
-
return aProject - bProject;
|
|
190
|
-
});
|
|
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.
|
|
191
187
|
const selectedPaths = new Set(plan.skills.map((skill) => skill.filePath));
|
|
192
188
|
const skillEntries: string[] = [];
|
|
193
|
-
for (const skill of
|
|
189
|
+
for (const skill of plan.skills) {
|
|
194
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;
|
|
195
195
|
if (isUnderPath(skill.filePath, homeAgentsSkillsDir())) continue; // auto-discovered anyway
|
|
196
196
|
skillEntries.push(skill.filePath);
|
|
197
197
|
}
|
|
198
198
|
for (const skill of discovery.skills) {
|
|
199
199
|
if (skill.origin === "package") continue;
|
|
200
|
+
if (skill.scope === "project") continue;
|
|
200
201
|
if (selectedPaths.has(skill.filePath)) continue;
|
|
201
202
|
|
|
202
203
|
// If the skill is in the real agentDir, Pi will discover it via the symlink.
|
|
@@ -222,7 +223,13 @@ function buildSelectionSettings(
|
|
|
222
223
|
.map((pkg) => ({ ...pkg, root: pkg.root }));
|
|
223
224
|
const packageExtensions = new Map<string, string[]>();
|
|
224
225
|
const extensionEntries: string[] = [];
|
|
226
|
+
const projectExtensionsDir =
|
|
227
|
+
projectDir !== undefined ? path.join(projectDir, ".pi", "extensions") : undefined;
|
|
225
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;
|
|
226
233
|
const owner = packageRoots.find((pkg) => isUnderPath(extension.entry, pkg.root));
|
|
227
234
|
if (owner === undefined) {
|
|
228
235
|
extensionEntries.push(extension.entry);
|
|
@@ -298,8 +305,6 @@ export interface RuntimeFileOptions {
|
|
|
298
305
|
projectDir?: string;
|
|
299
306
|
/** Required for selection plans; unused for the default profile. */
|
|
300
307
|
discovery?: DiscoveryContext;
|
|
301
|
-
/** The trusted project's `.pi/settings.json` content (already parsed). */
|
|
302
|
-
projectSettings?: Record<string, unknown>;
|
|
303
308
|
/** Extra launch-plan fields written by the in-session switch path:
|
|
304
309
|
* `switchedFrom` triggers the one-shot change summary; `persistSelection`
|
|
305
310
|
* tells the post-reload extension instance to save the selection;
|
|
@@ -345,21 +350,20 @@ async function computeSettings(
|
|
|
345
350
|
return settings;
|
|
346
351
|
}
|
|
347
352
|
|
|
348
|
-
// Selection plans:
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
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
|
+
);
|
|
363
367
|
}
|
|
364
368
|
|
|
365
369
|
/** Resolved name sets, carried in the launch plan for glob-delta reporting. */
|
|
@@ -371,10 +375,9 @@ export interface ResolvedNames {
|
|
|
371
375
|
}
|
|
372
376
|
|
|
373
377
|
/** Writes settings.json + pi-profile.json into an existing runtime dir and
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* trust
|
|
377
|
-
* 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. */
|
|
378
381
|
export async function writeRuntimeFiles(
|
|
379
382
|
runtimeDir: string,
|
|
380
383
|
plan: ActivationPlan,
|
|
@@ -405,7 +408,7 @@ export async function writeRuntimeFiles(
|
|
|
405
408
|
skills: plan.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
|
|
406
409
|
extensions: plan.extensions,
|
|
407
410
|
},
|
|
408
|
-
// Zero-match glob references (ADR-
|
|
411
|
+
// Zero-match glob references (ADR-0009) — surfaced by /profile status
|
|
409
412
|
// so a typo'd glob is visible instead of silently selecting nothing.
|
|
410
413
|
...(plan.unmatched !== undefined ? { unmatched: plan.unmatched } : {}),
|
|
411
414
|
...options.planExtras,
|
|
@@ -415,18 +418,14 @@ export async function writeRuntimeFiles(
|
|
|
415
418
|
)}\n`,
|
|
416
419
|
);
|
|
417
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.
|
|
418
426
|
const trustLink = path.join(runtimeDir, "trust.json");
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if ((await exists(trustTarget)) && !(await existsLexical(trustLink))) {
|
|
422
|
-
await symlink(trustTarget, trustLink);
|
|
423
|
-
}
|
|
424
|
-
} else if (await existsLexical(trustLink)) {
|
|
425
|
-
// Lexical check: a dangling trust.json symlink (real trust.json deleted
|
|
426
|
-
// after the link was made) must still be removed — otherwise a later
|
|
427
|
-
// re-created real trust.json silently resurrects stored trust inside a
|
|
428
|
-
// named profile, defeating defaultProjectTrust: "never".
|
|
429
|
-
await rm(trustLink);
|
|
427
|
+
if (!(await existsLexical(trustLink))) {
|
|
428
|
+
await symlink(path.join(options.agentDir, "trust.json"), trustLink);
|
|
430
429
|
}
|
|
431
430
|
|
|
432
431
|
// MCP Servers generation (Ticket 04)
|
|
@@ -441,7 +440,7 @@ export async function writeRuntimeFiles(
|
|
|
441
440
|
} else {
|
|
442
441
|
// Filter MCP servers
|
|
443
442
|
try { await rm(mcpInstancePath); } catch {}
|
|
444
|
-
const { servers, sharedServers, baseConfig } = await loadMergedMcpServers(
|
|
443
|
+
const { servers, sharedServers, projectServers, baseConfig } = await loadMergedMcpServers(
|
|
445
444
|
options.agentDir,
|
|
446
445
|
options.projectDir,
|
|
447
446
|
options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
|
|
@@ -460,6 +459,9 @@ export async function writeRuntimeFiles(
|
|
|
460
459
|
|
|
461
460
|
for (const sharedName of sharedServers) {
|
|
462
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;
|
|
463
465
|
filteredServers[sharedName] = { disabled: true };
|
|
464
466
|
}
|
|
465
467
|
}
|
|
@@ -494,9 +496,6 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
|
|
|
494
496
|
if (!existsSync(agentDir)) return;
|
|
495
497
|
if (path.resolve(agentDir) === path.resolve(runtimeDir)) return;
|
|
496
498
|
|
|
497
|
-
// Ensure the real sessions directory exists so it is always mirrored
|
|
498
|
-
await mkdir(path.join(agentDir, "sessions"), { recursive: true });
|
|
499
|
-
|
|
500
499
|
// 1. Clean up dangling or obsolete symlinks in runtimeDir
|
|
501
500
|
try {
|
|
502
501
|
const runtimeEntries = await readdir(runtimeDir);
|
|
@@ -517,7 +516,13 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
|
|
|
517
516
|
}
|
|
518
517
|
} catch {}
|
|
519
518
|
|
|
520
|
-
// 2.
|
|
519
|
+
// 2. Seed the state paths this process's Pi will create at runtime, so their
|
|
520
|
+
// writes land in the real agent dir instead of an instance-local copy
|
|
521
|
+
// (ADR-0010). Runs after the cleanup above, which would otherwise remove the
|
|
522
|
+
// deliberately dangling file links.
|
|
523
|
+
await seedRuntimeState(agentDir, runtimeDir);
|
|
524
|
+
|
|
525
|
+
// 3. Mirror files and directories from agentDir to runtimeDir
|
|
521
526
|
try {
|
|
522
527
|
const entries = await readdir(agentDir);
|
|
523
528
|
for (const name of entries) {
|
|
@@ -547,13 +552,45 @@ export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): P
|
|
|
547
552
|
} catch {}
|
|
548
553
|
}
|
|
549
554
|
|
|
555
|
+
/** Ensures the runtime state paths exist (or are linked) in the real agent dir
|
|
556
|
+
* and the instance. Best-effort: a failure here leaves the path unseeded, and
|
|
557
|
+
* the sweep's unrecognized-entry warning names it later. */
|
|
558
|
+
async function seedRuntimeState(agentDir: string, runtimeDir: string): Promise<void> {
|
|
559
|
+
for (const name of SEEDED_STATE_DIRS) {
|
|
560
|
+
try {
|
|
561
|
+
await mkdir(path.join(agentDir, name), { recursive: true });
|
|
562
|
+
} catch {
|
|
563
|
+
// Best-effort: the mirror then simply links nothing for this name.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
for (const name of SEEDED_STATE_FILES) {
|
|
568
|
+
const linkPath = path.join(runtimeDir, name);
|
|
569
|
+
// A real file here belongs to an earlier run of a different layout, and a
|
|
570
|
+
// link may already point somewhere else: leave both alone rather than
|
|
571
|
+
// replacing state pi-profile cannot attribute.
|
|
572
|
+
if (await existsLexical(linkPath)) continue;
|
|
573
|
+
try {
|
|
574
|
+
await symlink(path.join(agentDir, name), linkPath);
|
|
575
|
+
} catch {
|
|
576
|
+
// Best-effort: Pi then creates the file inside the instance, and the
|
|
577
|
+
// sweep keeps that directory instead of deleting it silently.
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
550
582
|
export async function generateRuntimeDir(
|
|
551
583
|
plan: ActivationPlan,
|
|
552
584
|
options: GenerateOptions,
|
|
553
585
|
): Promise<GeneratedRuntime> {
|
|
554
586
|
const { agentDir } = options;
|
|
555
|
-
const
|
|
556
|
-
await mkdir(
|
|
587
|
+
const runtimeRoot = getInstancesRootDir();
|
|
588
|
+
await mkdir(runtimeRoot, { recursive: true });
|
|
589
|
+
// One instance per launch, never reused: PI_CODING_AGENT_DIR is frozen for
|
|
590
|
+
// the life of the spawned process, so a stable path cannot follow an
|
|
591
|
+
// in-session switch, and a shared path would make concurrent launches (and
|
|
592
|
+
// their switches) rewrite each other's files (ADR-0010).
|
|
593
|
+
const runtimeDir = await mkdtemp(path.join(runtimeRoot, "launch-"));
|
|
557
594
|
|
|
558
595
|
await writeRuntimeFiles(runtimeDir, plan, options);
|
|
559
596
|
|
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
|
}
|
|
@@ -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
|
}
|
|
@@ -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
|