pi-profile-switch 0.3.1 → 0.4.2
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 +31 -36
- package/README.zh-CN.md +31 -36
- package/bin/pi-profile.js +11 -0
- package/bin/pi-profile.ts +65 -0
- package/bin/postinstall.d.ts +13 -0
- package/bin/postinstall.js +88 -0
- package/defaults/profiles.json +18 -0
- package/examples/profiles.json +31 -5
- package/extensions/pi-profile/index.ts +451 -0
- package/package.json +10 -13
- package/schemas/profiles.schema.json +22 -24
- package/src/extension-discovery.ts +347 -0
- package/src/json-file.ts +1 -21
- package/src/launcher/args.ts +57 -0
- package/src/launcher/discovery.ts +64 -0
- package/src/launcher/initial-profile.ts +179 -0
- package/src/launcher/model-check.ts +52 -0
- package/src/launcher/runtime-cleanup.ts +85 -0
- package/src/launcher/spawn.ts +82 -0
- package/src/mcp-config.ts +37 -153
- package/src/mcp-coordination.ts +29 -10
- package/src/profile-catalog-store.ts +31 -12
- package/src/profile-catalog.ts +45 -93
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -41
- package/src/settings-generator.ts +541 -0
- package/src/skill-registry.ts +94 -0
- package/src/switching/apply-plan.ts +197 -0
- package/src/switching/customize.ts +62 -33
- package/src/switching/list-profiles.ts +9 -6
- package/src/switching/mcp-toggle.ts +14 -26
- package/src/switching/profile-crud.ts +31 -24
- package/src/switching/profile-wizard.ts +21 -49
- package/src/switching/status.ts +142 -72
- package/src/switching/switch-profile.ts +219 -0
- package/src/switching/tool-references.ts +40 -0
- package/src/workspace.ts +57 -0
- package/LICENSE +0 -21
- package/examples/profiles.example.json +0 -74
- package/extensions/pi-profile-switch/index.ts +0 -778
- package/src/adapter-presence.ts +0 -75
- package/src/default-profiles.ts +0 -59
- package/src/mcp-overlay-file.ts +0 -35
- package/src/mcp-overlay.ts +0 -122
- package/src/model-selection.ts +0 -64
- package/src/name-matching.ts +0 -50
- package/src/profile-badge.ts +0 -142
- package/src/profile-presets.ts +0 -61
- package/src/skill-selection.ts +0 -81
- package/src/startup-mcp-scope.ts +0 -271
- package/src/startup-selection.ts +0 -201
- package/src/switching/activate-profile.ts +0 -144
- package/src/switching/apply-profile.ts +0 -131
|
@@ -8,14 +8,9 @@
|
|
|
8
8
|
* keeps the current value (prefill via placeholder); there is no
|
|
9
9
|
* field-clearing gesture (delete + create instead). Any cancelled step
|
|
10
10
|
* aborts the wizard — nothing is written.
|
|
11
|
-
*
|
|
12
|
-
* Create can start from a shipped preset (src/profile-presets.ts). A preset is
|
|
13
|
-
* prefilled form state only: the wizard copies its complete definition into the
|
|
14
|
-
* chosen catalog, and the new profile belongs to the user from then on.
|
|
15
11
|
*/
|
|
16
12
|
|
|
17
13
|
import type { ProfileDefinition } from "../profile-catalog.ts";
|
|
18
|
-
import { PROFILE_PRESETS, type ProfilePreset } from "../profile-presets.ts";
|
|
19
14
|
import type { CatalogScope } from "./profile-crud.ts";
|
|
20
15
|
|
|
21
16
|
export interface ProfileWizardUi {
|
|
@@ -27,8 +22,6 @@ export interface ProfileWizardResult {
|
|
|
27
22
|
scope: CatalogScope;
|
|
28
23
|
name: string;
|
|
29
24
|
definition: ProfileDefinition;
|
|
30
|
-
/** Name of the preset this definition was copied from, when any. */
|
|
31
|
-
preset?: string;
|
|
32
25
|
}
|
|
33
26
|
|
|
34
27
|
interface ExistingProfile {
|
|
@@ -45,7 +38,7 @@ function parseList(raw: string): string[] {
|
|
|
45
38
|
}
|
|
46
39
|
|
|
47
40
|
/** "provider/id[/thinkingLevel]" → ProfileModel; empty/undefined → none. */
|
|
48
|
-
function parseModel(raw: string):
|
|
41
|
+
function parseModel(raw: string): { defaultProvider: string; defaultModel: string; defaultThinkingLevel?: string } | undefined {
|
|
49
42
|
const trimmed = raw.trim();
|
|
50
43
|
if (trimmed.length === 0) return undefined;
|
|
51
44
|
const [provider, id, thinkingLevel] = trimmed.split("/").map((part) => part.trim());
|
|
@@ -53,8 +46,8 @@ function parseModel(raw: string): ProfileDefinition["model"] | undefined {
|
|
|
53
46
|
return undefined;
|
|
54
47
|
}
|
|
55
48
|
return thinkingLevel !== undefined && thinkingLevel.length > 0
|
|
56
|
-
? { provider, id, thinkingLevel }
|
|
57
|
-
: { provider, id };
|
|
49
|
+
? { defaultProvider: provider, defaultModel: id, defaultThinkingLevel: thinkingLevel }
|
|
50
|
+
: { defaultProvider: provider, defaultModel: id };
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
async function captureDefinition(
|
|
@@ -74,8 +67,9 @@ async function captureDefinition(
|
|
|
74
67
|
else if (existing?.description !== undefined) definition.description = existing.description;
|
|
75
68
|
|
|
76
69
|
const listFields = [
|
|
77
|
-
["skills", "skills (comma-separated names or globs, empty =
|
|
78
|
-
["
|
|
70
|
+
["skills", "skills (comma-separated names or globs, empty = none)"],
|
|
71
|
+
["extensions", "extensions (names or globs, empty = none)"],
|
|
72
|
+
["mcps", "mcp servers (names or globs, empty = none)"],
|
|
79
73
|
["tools", "tools (names or globs, empty = pi default set)"],
|
|
80
74
|
] as const;
|
|
81
75
|
for (const [field, prompt] of listFields) {
|
|
@@ -100,64 +94,42 @@ async function captureDefinition(
|
|
|
100
94
|
else if (existing?.instructions !== undefined) definition.instructions = existing.instructions;
|
|
101
95
|
|
|
102
96
|
const currentModel =
|
|
103
|
-
existing?.
|
|
104
|
-
? `${existing.
|
|
97
|
+
existing?.defaultProvider !== undefined && existing?.defaultModel !== undefined
|
|
98
|
+
? `${existing.defaultProvider}/${existing.defaultModel}${existing.defaultThinkingLevel !== undefined ? `/${existing.defaultThinkingLevel}` : ""}`
|
|
105
99
|
: undefined;
|
|
106
100
|
const modelRaw = await ui.input("model provider/id[/thinking] (empty = none)", currentModel);
|
|
107
101
|
if (modelRaw === undefined) return undefined;
|
|
108
102
|
const model = parseModel(modelRaw);
|
|
109
103
|
if (model !== undefined) {
|
|
110
|
-
definition.
|
|
111
|
-
|
|
112
|
-
definition.
|
|
104
|
+
definition.defaultProvider = model.defaultProvider;
|
|
105
|
+
definition.defaultModel = model.defaultModel;
|
|
106
|
+
if (model.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = model.defaultThinkingLevel;
|
|
107
|
+
} else if (existing?.defaultProvider !== undefined && modelRaw.trim().length === 0) {
|
|
108
|
+
definition.defaultProvider = existing.defaultProvider;
|
|
109
|
+
definition.defaultModel = existing.defaultModel;
|
|
110
|
+
if (existing.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = existing.defaultThinkingLevel;
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
return definition;
|
|
116
114
|
}
|
|
117
115
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
/** One preset row. The caller identifies the preset by the option's INDEX, so
|
|
121
|
-
* a preset named like another row's text cannot be misread. */
|
|
122
|
-
function presetOption(preset: ProfilePreset): string {
|
|
123
|
-
const hint = preset.definition.description ?? preset.definition.label;
|
|
124
|
-
return hint !== undefined ? `${preset.name} — ${hint}` : preset.name;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Create: scope, optional preset, name, then fields. */
|
|
116
|
+
/** Create: scope first (project only when trusted), then name, then fields. */
|
|
128
117
|
export async function runProfileCreateWizard(
|
|
129
118
|
ui: ProfileWizardUi,
|
|
130
|
-
input: { projectTrusted: boolean
|
|
119
|
+
input: { projectTrusted: boolean },
|
|
131
120
|
): Promise<ProfileWizardResult | undefined> {
|
|
132
121
|
const scopeOptions = input.projectTrusted ? ["global", "project"] : ["global"];
|
|
133
122
|
// A single available scope needs no dialog.
|
|
134
123
|
const scope = scopeOptions.length === 1 ? scopeOptions[0] : await ui.select("write to which catalog?", scopeOptions);
|
|
135
124
|
if (scope === undefined) return undefined;
|
|
136
125
|
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
if (presets.length > 0) {
|
|
140
|
-
const options = [BLANK_OPTION, ...presets.map(presetOption)];
|
|
141
|
-
const chosen = await ui.select("start from which preset?", options);
|
|
142
|
-
if (chosen === undefined) return undefined;
|
|
143
|
-
preset = presets[options.indexOf(chosen) - 1];
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// An empty answer takes the preset's name, so Enter accepts the preset.
|
|
147
|
-
const name = await ui.input("profile name", preset?.name);
|
|
148
|
-
if (name === undefined) return undefined;
|
|
149
|
-
const resolvedName = name.trim().length > 0 ? name.trim() : preset?.name;
|
|
150
|
-
if (resolvedName === undefined) return undefined;
|
|
126
|
+
const name = await ui.input("profile name");
|
|
127
|
+
if (name === undefined || name.trim().length === 0) return undefined;
|
|
151
128
|
|
|
152
|
-
const definition = await captureDefinition(ui
|
|
129
|
+
const definition = await captureDefinition(ui);
|
|
153
130
|
if (definition === undefined) return undefined;
|
|
154
131
|
|
|
155
|
-
return {
|
|
156
|
-
scope: scope as CatalogScope,
|
|
157
|
-
name: resolvedName,
|
|
158
|
-
definition,
|
|
159
|
-
...(preset !== undefined ? { preset: preset.name } : {}),
|
|
160
|
-
};
|
|
132
|
+
return { scope: scope as CatalogScope, name: name.trim(), definition };
|
|
161
133
|
}
|
|
162
134
|
|
|
163
135
|
/** Edit: fields prefilled from the existing complete definition. */
|
package/src/switching/status.ts
CHANGED
|
@@ -1,74 +1,139 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StatusReport: the `/profile status` observability surface.
|
|
2
|
+
* StatusReport: the `/profile status` observability surface (ticket 07).
|
|
3
3
|
*
|
|
4
|
-
* Pure report builder: combines the ACTIVE
|
|
5
|
-
* resolved to), the stored overlay,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Pure report builder: combines the ACTIVE launch plan (what the runtime
|
|
5
|
+
* was resolved to), the stored overlay, fresh MCP adapter discovery, and
|
|
6
|
+
* Pi's actual command registrations (the winner evidence for same-name
|
|
7
|
+
* conflicts). Markdown formatting is the only presentation; the extension
|
|
8
|
+
* ships it via `pi.sendMessage`.
|
|
9
|
+
*
|
|
10
|
+
* Conflict semantics: Pi's load order is first-wins by scope/file order,
|
|
11
|
+
* so the registered command IS the winner. A conflict is reported when the
|
|
12
|
+
* plan resolved a skill whose command name is registered from a DIFFERENT
|
|
13
|
+
* path (shadowed) or is absent (failed to load) — never blocked, always
|
|
14
|
+
* visible.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import type { ProfileSource } from "../profile-catalog.ts";
|
|
11
|
-
import type { ResolvedSelection, UnresolvedRef } from "../profile-resolver.ts";
|
|
12
17
|
import type { RuntimeOverlay } from "../runtime-state-store.ts";
|
|
13
|
-
import {
|
|
18
|
+
import type { LaunchPlanFile } from "./apply-plan.ts";
|
|
19
|
+
|
|
20
|
+
export interface StatusConflict {
|
|
21
|
+
/** Command name as registered (e.g. `skill:review`). */
|
|
22
|
+
name: string;
|
|
23
|
+
/** The path the active plan resolved. */
|
|
24
|
+
expectedPath: string;
|
|
25
|
+
/** The path Pi actually registered (the winner), or "not loaded". */
|
|
26
|
+
winnerPath: string;
|
|
27
|
+
}
|
|
14
28
|
|
|
15
29
|
export interface StatusReport {
|
|
16
30
|
profile: string;
|
|
17
|
-
source:
|
|
31
|
+
source: string;
|
|
18
32
|
overlay?: RuntimeOverlay;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
skills: Array<{ name: string; filePath: string }>;
|
|
34
|
+
extensions: Array<{ id: string; entry: string; origin?: string }>;
|
|
35
|
+
tools?: string[];
|
|
36
|
+
mcp: { enabled: string[]; disabled: string[]; missing: string[] };
|
|
37
|
+
/** Glob delta versus the previous activation (prefixed names). */
|
|
38
|
+
delta?: { added: string[]; removed: string[] };
|
|
39
|
+
/** Glob references that matched nothing at resolution (ADR-0006). */
|
|
40
|
+
unmatched?: string[];
|
|
41
|
+
conflicts: StatusConflict[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RegisteredCommand {
|
|
45
|
+
name: string;
|
|
46
|
+
sourceInfo?: { path: string };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RegisteredTool {
|
|
50
|
+
name: string;
|
|
51
|
+
sourceInfo?: { path: string; source: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function currentNames(plan: LaunchPlanFile): string[] {
|
|
55
|
+
const names = [
|
|
56
|
+
...(plan.resolved?.skills ?? []).map((skill) => `skill:${skill.name}`),
|
|
57
|
+
...(plan.resolved?.extensions ?? []).map((entry) => `extension:${entry.id}`),
|
|
58
|
+
...(plan.tools ?? []).map((tool) => `tool:${tool}`),
|
|
59
|
+
...(plan.mcps ?? []).map((server) => `mcp:${server}`),
|
|
60
|
+
];
|
|
61
|
+
return names.sort();
|
|
31
62
|
}
|
|
32
63
|
|
|
33
64
|
export function buildStatusReport(input: {
|
|
34
|
-
|
|
35
|
-
|
|
65
|
+
plan: LaunchPlanFile;
|
|
66
|
+
overlay?: RuntimeOverlay;
|
|
36
67
|
discoveredMcpServers: string[];
|
|
37
|
-
|
|
68
|
+
commands: RegisteredCommand[];
|
|
69
|
+
tools: RegisteredTool[];
|
|
38
70
|
}): StatusReport {
|
|
39
|
-
const {
|
|
40
|
-
const visibleNames = visibleSkillNames(input.allSkills, selection.skills);
|
|
41
|
-
const visible =
|
|
42
|
-
visibleNames === undefined
|
|
43
|
-
? input.allSkills
|
|
44
|
-
: input.allSkills.filter((skill) => visibleNames.includes(skill.name));
|
|
71
|
+
const { plan } = input;
|
|
45
72
|
|
|
46
|
-
const enabled =
|
|
73
|
+
const enabled = plan.mcps ?? [];
|
|
47
74
|
const discovered = new Set(input.discoveredMcpServers);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
const mcp = {
|
|
76
|
+
enabled,
|
|
77
|
+
disabled: input.discoveredMcpServers.filter((name) => !enabled.includes(name)),
|
|
78
|
+
missing: enabled.filter((name) => !discovered.has(name)),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let delta: StatusReport["delta"];
|
|
82
|
+
if (plan.previousResolved !== undefined) {
|
|
83
|
+
const before = new Set(
|
|
84
|
+
[
|
|
85
|
+
...plan.previousResolved.skills.map((name) => `skill:${name}`),
|
|
86
|
+
...plan.previousResolved.extensions.map((id) => `extension:${id}`),
|
|
87
|
+
...(plan.previousResolved.tools ?? []).map((name) => `tool:${name}`),
|
|
88
|
+
...(plan.previousResolved.mcps ?? []).map((name) => `mcp:${name}`),
|
|
89
|
+
].sort(),
|
|
90
|
+
);
|
|
91
|
+
const after = new Set(currentNames(plan));
|
|
92
|
+
const added = [...after].filter((name) => !before.has(name));
|
|
93
|
+
const removed = [...before].filter((name) => !after.has(name));
|
|
94
|
+
if (added.length > 0 || removed.length > 0) {
|
|
95
|
+
delta = { added, removed };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const conflicts: StatusConflict[] = [];
|
|
100
|
+
for (const skill of plan.resolved?.skills ?? []) {
|
|
101
|
+
const command = input.commands.find((entry) => entry.name === `skill:${skill.name}`);
|
|
102
|
+
const winnerPath = command?.sourceInfo?.path;
|
|
103
|
+
if (winnerPath === undefined) {
|
|
104
|
+
conflicts.push({ name: `skill:${skill.name}`, expectedPath: skill.filePath, winnerPath: "not loaded" });
|
|
105
|
+
} else if (winnerPath !== skill.filePath) {
|
|
106
|
+
conflicts.push({ name: `skill:${skill.name}`, expectedPath: skill.filePath, winnerPath });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Tool conflicts: a plan tool whose registered winner is neither a pi
|
|
111
|
+
// builtin nor a tool from one of the plan's selected extensions was
|
|
112
|
+
// shadowed by (or shadows) an unexpected source.
|
|
113
|
+
const extensionDirs = (plan.resolved?.extensions ?? []).map((entry) =>
|
|
114
|
+
entry.entry.endsWith(".ts") ? entry.entry.slice(0, entry.entry.lastIndexOf("/")) : entry.entry,
|
|
115
|
+
);
|
|
116
|
+
for (const toolName of plan.tools ?? []) {
|
|
117
|
+
const winner = input.tools.find((entry) => entry.name === toolName);
|
|
118
|
+
const info = winner?.sourceInfo;
|
|
119
|
+
if (info === undefined) continue; // unknown names are dropped by pi.setActiveTools
|
|
120
|
+
const expected = info.source === "builtin" || extensionDirs.some((dir) => info.path.startsWith(dir));
|
|
121
|
+
if (!expected) {
|
|
122
|
+
conflicts.push({ name: `tool:${toolName}`, expectedPath: "builtin or selected extension", winnerPath: info.path });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
53
125
|
|
|
54
126
|
return {
|
|
55
|
-
profile:
|
|
56
|
-
source:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
},
|
|
63
|
-
...(
|
|
64
|
-
|
|
65
|
-
: {}),
|
|
66
|
-
mcp: {
|
|
67
|
-
enabled,
|
|
68
|
-
discovered: input.discoveredMcpServers,
|
|
69
|
-
missing: enabled.filter((name) => !discovered.has(name)),
|
|
70
|
-
},
|
|
71
|
-
unresolved: { skills: selection.warnings.skillsUnresolved, unmatched },
|
|
127
|
+
profile: plan.profile,
|
|
128
|
+
source: plan.source,
|
|
129
|
+
overlay: input.overlay,
|
|
130
|
+
skills: plan.resolved?.skills ?? [],
|
|
131
|
+
extensions: plan.resolved?.extensions ?? [],
|
|
132
|
+
...(plan.tools !== undefined ? { tools: plan.tools } : {}),
|
|
133
|
+
mcp,
|
|
134
|
+
...(delta !== undefined ? { delta } : {}),
|
|
135
|
+
...(plan.unmatched !== undefined && plan.unmatched.length > 0 ? { unmatched: plan.unmatched } : {}),
|
|
136
|
+
conflicts,
|
|
72
137
|
};
|
|
73
138
|
}
|
|
74
139
|
|
|
@@ -78,36 +143,41 @@ export function formatStatusMarkdown(report: StatusReport): string {
|
|
|
78
143
|
if (report.overlay !== undefined) {
|
|
79
144
|
const parts = [
|
|
80
145
|
...(report.overlay.disabledSkills ?? []).map((name) => `-skill:${name}`),
|
|
81
|
-
...(report.overlay.
|
|
146
|
+
...(report.overlay.disabledExtensions ?? []).map((id) => `-extension:${id}`),
|
|
147
|
+
...(report.overlay.disabledMcps ?? []).map((name) => `-mcp:${name}`),
|
|
82
148
|
...(report.overlay.tools !== undefined ? [`tools=[${report.overlay.tools.join(", ")}]`] : []),
|
|
83
149
|
];
|
|
84
150
|
lines.push(`overlay: ${parts.length > 0 ? parts.join(" ") : "(empty)"}`);
|
|
85
151
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
lines.push(`skills filter: not applied (${report.skills.filterOutcome})`);
|
|
152
|
+
if (report.skills.length > 0) {
|
|
153
|
+
lines.push("skills:");
|
|
154
|
+
for (const skill of report.skills) {
|
|
155
|
+
lines.push(` ${skill.name} → ${skill.filePath}`);
|
|
156
|
+
}
|
|
92
157
|
}
|
|
93
|
-
|
|
94
|
-
lines.push(
|
|
158
|
+
if (report.extensions.length > 0) {
|
|
159
|
+
lines.push("extensions:");
|
|
160
|
+
for (const extension of report.extensions) {
|
|
161
|
+
const originTag = extension.origin !== undefined ? ` [${extension.origin}]` : "";
|
|
162
|
+
lines.push(` ${extension.id}${originTag} → ${extension.entry}`);
|
|
163
|
+
}
|
|
95
164
|
}
|
|
96
165
|
if (report.tools !== undefined) {
|
|
97
|
-
lines.push(`tools: [${report.tools.
|
|
98
|
-
if (report.tools.pending.length > 0) {
|
|
99
|
-
lines.push(`tools pending (not registered yet): [${report.tools.pending.join(", ")}]`);
|
|
100
|
-
}
|
|
166
|
+
lines.push(`tools: [${report.tools.join(", ")}]`);
|
|
101
167
|
}
|
|
102
168
|
lines.push(
|
|
103
|
-
`mcp: enabled=[${report.mcp.enabled.join(", ")}]
|
|
169
|
+
`mcp: enabled=[${report.mcp.enabled.join(", ")}] disabled=[${report.mcp.disabled.join(", ")}] missing=[${report.mcp.missing.join(", ")}]`,
|
|
104
170
|
);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
171
|
+
if (report.delta !== undefined) {
|
|
172
|
+
lines.push(`delta: +[${report.delta.added.join(", ")}] -[${report.delta.removed.join(", ")}]`);
|
|
173
|
+
}
|
|
174
|
+
if (report.unmatched !== undefined) {
|
|
175
|
+
lines.push(`unmatched (zero-match globs this resolution): [${report.unmatched.join(", ")}]`);
|
|
108
176
|
}
|
|
109
|
-
|
|
110
|
-
lines.push(
|
|
177
|
+
for (const conflict of report.conflicts) {
|
|
178
|
+
lines.push(
|
|
179
|
+
`conflict: ${conflict.name} — plan resolved ${conflict.expectedPath}, Pi registered ${conflict.winnerPath} (Pi first-wins load order)`,
|
|
180
|
+
);
|
|
111
181
|
}
|
|
112
182
|
return lines.join("\n");
|
|
113
183
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwitchProfile: the in-session switching orchestrator (ticket 05).
|
|
3
|
+
*
|
|
4
|
+
* Runs inside Pi (the extension's `/profile use` / `/profile reload`), but
|
|
5
|
+
* is written dependency-injected so unit tests never need a real Pi.
|
|
6
|
+
*
|
|
7
|
+
* Flow (`/profile use <name>`):
|
|
8
|
+
* 1. wait for the agent to be idle (Pi's native `ctx.waitForIdle()`) — a
|
|
9
|
+
* running turn is never torn down
|
|
10
|
+
* 2. snapshot the runtime dir's settings.json + pi-profile.json in memory
|
|
11
|
+
* 3. re-resolve through the full launcher path (trust check, catalogs,
|
|
12
|
+
* discovery, registry, model/MCP validation) against the REAL agent
|
|
13
|
+
* dir — any failure here leaves the runtime untouched
|
|
14
|
+
* 4. rewrite the runtime files in place (the running process's
|
|
15
|
+
* PI_CODING_AGENT_DIR cannot move) and mark the plan
|
|
16
|
+
* `persistSelection` so the post-reload extension instance saves the
|
|
17
|
+
* selection + rollback anchor
|
|
18
|
+
* 5. `ctx.reload()` — Pi re-reads settings from disk, re-executes
|
|
19
|
+
* extensions, preserves the session
|
|
20
|
+
* 6. VERIFY the reload ran: interactive Pi swallows reload refusals and
|
|
21
|
+
* errors (showError) instead of rejecting, so a resolved promise is
|
|
22
|
+
* not proof. A real reload invalidates this extension context — the
|
|
23
|
+
* `assertStale` probe throws iff that happened. A silent skip rolls
|
|
24
|
+
* back exactly like a rejection: restore the snapshot, reload again.
|
|
25
|
+
* The runtime never sits half-switched.
|
|
26
|
+
*
|
|
27
|
+
* `/profile reload` is the same path minus the `switchedFrom` marker (no
|
|
28
|
+
* change summary) and preserving however the current profile became active
|
|
29
|
+
* (transient launch selections stay transient).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
|
|
35
|
+
import { resolveInitialProfile } from "../launcher/initial-profile.ts";
|
|
36
|
+
import { RuntimeStateStore, type RuntimeOverlay } from "../runtime-state-store.ts";
|
|
37
|
+
import { getGlobalStateDir } from "../workspace.ts";
|
|
38
|
+
import { writeRuntimeFiles } from "../settings-generator.ts";
|
|
39
|
+
import { readLaunchPlanFile } from "./apply-plan.ts";
|
|
40
|
+
|
|
41
|
+
export class SwitchError extends Error {
|
|
42
|
+
constructor(message: string) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "SwitchError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SwitchDeps {
|
|
49
|
+
/** The active generated runtime dir (the running Pi's agent dir). */
|
|
50
|
+
runtimeDir: string;
|
|
51
|
+
/** The user's real agent dir (from the launch plan; trust, catalogs,
|
|
52
|
+
* registries, and state all live there). */
|
|
53
|
+
realAgentDir: string;
|
|
54
|
+
/** The project working directory. */
|
|
55
|
+
cwd: string;
|
|
56
|
+
/** Pi's native idle wait (ctx.waitForIdle): resolves when the current
|
|
57
|
+
* turn/compaction finishes. */
|
|
58
|
+
waitForIdle(): Promise<void>;
|
|
59
|
+
reload(): Promise<void>;
|
|
60
|
+
/** Throws iff this extension context has been invalidated — proof the
|
|
61
|
+
* reload actually re-executed extensions. Interactive Pi swallows reload
|
|
62
|
+
* refusals/failures instead of rejecting, so without this probe a
|
|
63
|
+
* skipped reload would be misreported as a successful switch. Optional
|
|
64
|
+
* for tests; always provided by the extension. */
|
|
65
|
+
assertStale?(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SwitchResult {
|
|
69
|
+
profile: string;
|
|
70
|
+
warnings: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface RuntimeSnapshot {
|
|
74
|
+
settings?: string;
|
|
75
|
+
plan?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readIfExists(filePath: string): Promise<string | undefined> {
|
|
79
|
+
try {
|
|
80
|
+
return await readFile(filePath, "utf8");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Absence is expected (first launch); anything else (permissions,
|
|
83
|
+
// unreadable dir) must not silently disable rollback protection.
|
|
84
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function snapshotRuntimeFiles(runtimeDir: string): Promise<RuntimeSnapshot> {
|
|
90
|
+
return {
|
|
91
|
+
settings: await readIfExists(path.join(runtimeDir, "settings.json")),
|
|
92
|
+
plan: await readIfExists(path.join(runtimeDir, "pi-profile.json")),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function restoreRuntimeFiles(runtimeDir: string, snapshot: RuntimeSnapshot): Promise<void> {
|
|
97
|
+
if (snapshot.settings !== undefined) {
|
|
98
|
+
await writeFile(path.join(runtimeDir, "settings.json"), snapshot.settings);
|
|
99
|
+
}
|
|
100
|
+
if (snapshot.plan !== undefined) {
|
|
101
|
+
await writeFile(path.join(runtimeDir, "pi-profile.json"), snapshot.plan);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Waits are delegated to Pi's native `ctx.waitForIdle()` (see SwitchDeps);
|
|
106
|
+
* no polling loop lives here. */
|
|
107
|
+
|
|
108
|
+
/** Reads the current plan file for `switchedFrom`/persistence. A missing or
|
|
109
|
+
* malformed plan means the session is not profile-managed: switching still
|
|
110
|
+
* works, with no prior name to report. */
|
|
111
|
+
async function readCurrentPlan(runtimeDir: string): Promise<{ profile?: string; persistSelection: boolean }> {
|
|
112
|
+
const plan = await readLaunchPlanFile(runtimeDir);
|
|
113
|
+
return { profile: plan?.profile, persistSelection: plan?.persistSelection === true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function switchProfile(
|
|
117
|
+
name: string | undefined,
|
|
118
|
+
deps: SwitchDeps,
|
|
119
|
+
options?: { reloadCurrent?: boolean; overlay?: RuntimeOverlay | null; clearOverlay?: boolean },
|
|
120
|
+
): Promise<SwitchResult> {
|
|
121
|
+
const current = await readCurrentPlan(deps.runtimeDir);
|
|
122
|
+
const target = options?.reloadCurrent === true ? (current.profile ?? name) : name;
|
|
123
|
+
if (target === undefined) {
|
|
124
|
+
throw new SwitchError("no active profile to reload");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Overlay resolution: explicit `overlay` wins (customize), explicit
|
|
128
|
+
// `null` suppresses (reset/switch), and a plain `/profile reload`
|
|
129
|
+
// re-applies the stored overlay so runtime and state never diverge.
|
|
130
|
+
let overlay = options?.overlay;
|
|
131
|
+
if (overlay === undefined && options?.reloadCurrent === true && current.profile !== undefined) {
|
|
132
|
+
const currentPlan = await readLaunchPlanFile(deps.runtimeDir);
|
|
133
|
+
if (currentPlan?.agentDir !== undefined) {
|
|
134
|
+
const stateDir = currentPlan.source === "project" ? path.join(deps.cwd, ".pi") : getGlobalStateDir(currentPlan.agentDir);
|
|
135
|
+
overlay = (await new RuntimeStateStore(stateDir).read()).overlay ?? null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await deps.waitForIdle();
|
|
140
|
+
|
|
141
|
+
// Snapshot before resolving so the rollback target always exists.
|
|
142
|
+
const snapshot = await snapshotRuntimeFiles(deps.runtimeDir);
|
|
143
|
+
|
|
144
|
+
// Full launcher resolution: trust gate, catalogs, discovery, dependency
|
|
145
|
+
// closure, model + MCP validation. Failures here leave the runtime
|
|
146
|
+
// untouched — nothing was written yet.
|
|
147
|
+
const resolved = await resolveInitialProfile(
|
|
148
|
+
target,
|
|
149
|
+
{ agentDir: deps.realAgentDir, cwd: deps.cwd },
|
|
150
|
+
{ overlay: overlay ?? undefined },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const isSwitch = !options?.reloadCurrent && target !== current.profile;
|
|
154
|
+
// Carry the pre-switch resolved sets into the new plan for status deltas.
|
|
155
|
+
const previousPlan = await readLaunchPlanFile(deps.runtimeDir);
|
|
156
|
+
const previousResolved =
|
|
157
|
+
previousPlan?.resolved !== undefined
|
|
158
|
+
? {
|
|
159
|
+
skills: previousPlan.resolved.skills.map((skill) => skill.name),
|
|
160
|
+
extensions: previousPlan.resolved.extensions.map((entry) => entry.id),
|
|
161
|
+
...(previousPlan.tools !== undefined ? { tools: previousPlan.tools } : {}),
|
|
162
|
+
...(previousPlan.mcps !== undefined ? { mcps: previousPlan.mcps } : {}),
|
|
163
|
+
}
|
|
164
|
+
: undefined;
|
|
165
|
+
await writeRuntimeFiles(deps.runtimeDir, resolved.plan, {
|
|
166
|
+
agentDir: deps.realAgentDir,
|
|
167
|
+
discovery: resolved.discovery,
|
|
168
|
+
projectSettings: resolved.projectSettings,
|
|
169
|
+
planExtras: {
|
|
170
|
+
...(isSwitch && current.profile !== undefined ? { switchedFrom: current.profile } : {}),
|
|
171
|
+
// `/profile use` persists; `/profile reload` keeps the current
|
|
172
|
+
// profile's existing persistence (launch selections stay transient).
|
|
173
|
+
persistSelection: options?.reloadCurrent === true ? current.persistSelection : true,
|
|
174
|
+
// A switch discards the previous profile's overlay; the post-reload
|
|
175
|
+
// instance drops it from the state file. Customize/reset manage the
|
|
176
|
+
// overlay directly and never set this.
|
|
177
|
+
...(options?.clearOverlay === true ? { clearOverlay: true } : {}),
|
|
178
|
+
...(previousResolved !== undefined ? { previousResolved } : {}),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const rollback = async (cause: string): Promise<never> => {
|
|
183
|
+
// Restore the verified snapshot and reload again — the runtime must
|
|
184
|
+
// never sit half-switched. State files were not written yet (the
|
|
185
|
+
// post-reload extension instance owns them), so nothing else moved.
|
|
186
|
+
await restoreRuntimeFiles(deps.runtimeDir, snapshot);
|
|
187
|
+
try {
|
|
188
|
+
await deps.reload();
|
|
189
|
+
} catch {
|
|
190
|
+
// The restore reload failing too is reported through the original error.
|
|
191
|
+
}
|
|
192
|
+
throw new SwitchError(
|
|
193
|
+
`activation of profile "${target}" failed; restored the previous settings. Cause: ${cause}`,
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await deps.reload();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
await rollback(error instanceof Error ? error.message : String(error));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Interactive Pi reports reload refusals/failures via the UI instead of
|
|
204
|
+
// rejecting — verify the reload actually re-executed extensions (which
|
|
205
|
+
// invalidates this context) before calling the switch a success.
|
|
206
|
+
if (deps.assertStale !== undefined) {
|
|
207
|
+
let stale = false;
|
|
208
|
+
try {
|
|
209
|
+
deps.assertStale();
|
|
210
|
+
} catch {
|
|
211
|
+
stale = true;
|
|
212
|
+
}
|
|
213
|
+
if (!stale) {
|
|
214
|
+
await rollback("Pi did not run the reload (refused or failed silently)");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { profile: resolved.plan.profile, warnings: resolved.warnings };
|
|
219
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool reference expansion against Pi's LIVE tool registry.
|
|
3
|
+
*
|
|
4
|
+
* Pre-spawn, the resolver expands tool globs against built-in names only
|
|
5
|
+
* (extension tools are unknowable before extension code runs). Post session
|
|
6
|
+
* start, the extension re-expands the raw references against
|
|
7
|
+
* `pi.getAllTools()`, which includes extension- and MCP-provided names.
|
|
8
|
+
* Literal references that match nothing are reported, not silently dropped
|
|
9
|
+
* (Pi's setActiveTools ignores unknown names).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { minimatch } from "minimatch";
|
|
13
|
+
|
|
14
|
+
export interface ToolExpansion {
|
|
15
|
+
expanded: string[];
|
|
16
|
+
/** Literal references no live tool provides. */
|
|
17
|
+
droppedLiterals: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function expandToolReferences(references: string[], liveToolNames: string[]): ToolExpansion {
|
|
21
|
+
const live = new Set(liveToolNames);
|
|
22
|
+
const expanded = new Set<string>();
|
|
23
|
+
const droppedLiterals: string[] = [];
|
|
24
|
+
for (const reference of references) {
|
|
25
|
+
if (reference.includes("*") || reference.includes("?")) {
|
|
26
|
+
for (const name of liveToolNames) {
|
|
27
|
+
if (minimatch(name, reference)) {
|
|
28
|
+
expanded.add(name);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (live.has(reference)) {
|
|
34
|
+
expanded.add(reference);
|
|
35
|
+
} else {
|
|
36
|
+
droppedLiterals.push(reference);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { expanded: [...expanded], droppedLiterals };
|
|
40
|
+
}
|