@danypops/pi-packed 0.5.3 → 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 CHANGED
@@ -1,128 +1,20 @@
1
1
  # pi-packed
2
2
 
3
- Package service for the [Pi](https://github.com/earendil-works/pi) agent
4
- DNF-style package management that both **you** and the **agent** can use.
5
-
6
- The agent gets native tools (`pkg_search`, `pkg_info`, `pkg_install`, `pkg_update`, and `pkg_remove`); you get
7
- the `packed` CLI and an interactive `/packages` TUI. The extension is a thin,
8
- Node-compatible client. Registry access, SQLite, and package execution remain
9
- inside the supervised Bun daemon.
10
-
11
- ```text
12
- ┌─ Pi extension (Node-compatible) ──────────────┐
13
- │ pkg_search · pkg_info · pkg_install/update/remove│
14
- │ /packages · /packed permission settings │
15
- │ operation-aware approval · no Bun/SQLite access │
16
- └──────────────────┬────────────────────────────┘
17
- │ authenticated loopback HTTP
18
- ┌─ packed.service (Bun) ────────────────────────┐
19
- │ typed package client API · watcher · mirror │
20
- │ npm registry · SQLite WAL · pi install/remove │
21
- └───────────────────────────────────────────────┘
22
- ```
23
-
24
- ## Quickstart
3
+ Pi integration for `@danypops/packed`: package tools, `/packages`, `/packed`, setup apply/reload, and named profiles.
25
4
 
26
5
  ```bash
27
- bun test
28
- packed service > ~/.config/systemd/user/packed.service
29
- systemctl --user daemon-reload
30
- systemctl --user enable --now packed.service
31
-
32
- packed search lsp
33
- packed install npm:pi-lsp
34
- packed installed --json
35
-
36
- # In Pi after installing the package:
37
- /packages
38
- ```
39
-
40
- Packages execute arbitrary code and mutate Pi settings/install roots. One daemon-owned operation policy classifies every public package operation. Install, update, remove, and security-setting changes require explicit approval by default (`mutationApproval: always`); search, info, installed, catalog, and update-status reads are bounded reads, while mirror refresh is classified maintenance. Open `/packed` to retain the recommended approval policy or deliberately choose the unsafe **Never require mutation approval** opt-out. `/packages` uses the same policy for updates and removals.
41
-
42
- ## CLI
43
-
44
- | Command | What |
45
- |---|---|
46
- | `packed search <q> [--offline] [--limit N] [--json]` | Search npm or the local mirror, scoped to `keywords:pi-package` |
47
- | `packed info <name> [--json]` | Show version, repository, Pi manifest, size, and license |
48
- | `packed updates [--json]` | Show drift from the local mirror |
49
- | `packed update <source> [--approve] [--json]` | Update one configured source through `pi update --extension` |
50
- | `packed mirror [--json]` | Refresh the SQLite package index |
51
- | `packed installed [--json]` | Read Pi's installed package declarations |
52
- | `packed catalog [--json]` | Inspect the local package index |
53
- | `packed install <source> [--approve] [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
54
- | `packed remove <name> [--approve] [--json]` | Authenticated daemon removal by bare npm name |
55
- | `packed security [always\|never] [--approve] [--json]` | Read or set the package mutation approval policy |
56
- | `packed serve` | Run the loopback daemon |
57
- | `packed service` | Print the systemd user unit |
58
- | `packed version` | Print the package/service version |
59
-
60
- Guarded CLI mutations require `--approve` under the secure default. This is pi-packed mutation authorization, distinct from Pi's project-trust `--approve` semantics. Install/remove JSON results are stable objects:
61
-
62
- ```json
63
- {"ok":true,"source":"npm:pi-lsp","output":"Installed npm:pi-lsp"}
6
+ pi install npm:@danypops/pi-packed
64
7
  ```
65
8
 
66
- Failures use exit code 1 and `{ "ok": false, ... , "error": "..." }` with
67
- credential-safe diagnostics. Usage errors use exit code 2.
9
+ The extension connects to Packed's authenticated user daemon and starts the package-local `packed serve` process when no healthy daemon is available. It contains no SQLite, registry, package execution, or daemon implementation code.
68
10
 
69
- ## Native tool presentation
11
+ ## Installing the rest of the @danypops ecosystem
70
12
 
71
- Native tools keep three output contracts independent:
72
-
73
- - model-facing `content` is concise, credential-safe, and capped at 2,000 characters;
74
- - renderer-facing `details` uses a versioned bounded package DTO and never retains raw npm metadata or manifest values;
75
- - CLI human output and `--json` remain presenters over daemon DTOs and do not parse either native-tool channel.
76
-
77
- Calls and results have themed collapsed and expanded renderers. Missing or legacy details fall back to model content, while daemon and execution failures are thrown through Pi's native error channel. Package approval refusal remains a normal `cancelled` or `denied` outcome.
78
-
79
- ## Service API
80
-
81
- Every route requires the bearer token stored in the private state directory.
82
- The daemon listens on loopback only.
83
-
84
- | Method | Route |
85
- |---|---|
86
- | `GET` | `/health` |
87
- | `GET` | `/search?q=&limit=&offline=1` |
88
- | `GET` | `/info?name=` |
89
- | `GET` | `/installed` |
90
- | `GET` | `/security` |
91
- | `POST` | `/security` with `{ "mutationApproval": "always" | "never", "approved": true }` |
92
- | `GET` | `/updates` |
93
- | `GET` | `/catalog` |
94
- | `POST` | `/install` with `{ "source": "...", "approved": true }` |
95
- | `POST` | `/update` with `{ "source": "...", "approved": true }` |
96
- | `POST` | `/remove` with `{ "name": "...", "approved": true }` |
97
-
98
- State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
99
- `updates.json`, `security.json`, and `packed.db`. Relevant environment variables:
100
-
101
- - `PI_PACKED_HOME`
102
- - `PI_PACKED_PI_HOME`
103
- - `PI_PACKED_WATCH_SECS`
104
- - `PI_PACKED_CATALOG_SECS`
105
- - `PI_PACKED_IDLE_SECS`
106
- - `PI_PACKED_PI_BIN` / `PI_BIN`
107
-
108
- ## Architecture and safety
109
-
110
- - **Daemon-owned SQLite:** extensions never open the mirror directly.
111
- - **Runtime boundary:** extensions never call `Bun.spawn`; only the supervised
112
- Bun daemon owns the `ExecInstaller` adapter.
113
- - **Authenticated typed client:** extension and mutation CLI paths call the same
114
- loopback API and reconnect after daemon restarts.
115
- - **Ports and adapters:** registry and installer ports keep policy independent
116
- from npm, SQLite, subprocess, HTTP, and UI adapters.
117
- - **Operation-aware authorization:** one policy matrix classifies reads, maintenance, code execution, settings mutation, and security mutation; guarded daemon routes reject missing approval with stable `approval_required` errors.
118
- - **Allowlisted mutation input:** package sources and names reject shell
119
- metacharacters before reaching the installer.
120
- - **Bounded requests:** daemon calls use timeouts and return structured errors
121
- without tokens or credentials.
122
-
123
- ## Development
13
+ Once Packed is in, install everything else in one reviewable step -- no shell installer, no separate download, no per-package hunting for install commands:
124
14
 
125
15
  ```bash
126
- bun test
127
- bunx tsc --noEmit
16
+ packed setup plan --ecosystem # preview what would change, no mutation
17
+ packed setup apply --ecosystem --approve
128
18
  ```
19
+
20
+ `--ecosystem` resolves to a curated manifest bundled inside this same npm package (`node_modules/@danypops/pi-packed/node_modules/@danypops/packed/setup/danypops-ecosystem.pi-setup.json`) -- every entry is a real, pinned, integrity-checked npm version, the same schema and approval gate every other `packed setup` manifest goes through. Nothing is fetched from the network to decide what to install; only the already-installed npm package is trusted.
@@ -0,0 +1,8 @@
1
+ export const TOOL_MODEL_CONTENT_MAX_CHARACTERS = 2_000;
2
+ export const TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS = 32_000;
3
+ export const TOOL_DETAILS_MAX_PACKAGES = 50;
4
+ export const TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS = 240;
5
+ export const TOOL_DETAILS_MAX_OUTPUT_CHARACTERS = 1_000;
6
+ export const TOOL_DETAILS_MAX_KEYWORDS = 20;
7
+ export const TOOL_DETAILS_MAX_CAPABILITIES = 12;
8
+ export const TOOL_COLLAPSED_PACKAGE_PREVIEW = 3;
@@ -14,16 +14,22 @@ import { showPackages } from "./tui.js";
14
14
  import { createNatives } from "./packed.js";
15
15
  import { formatUpdateNotice } from "./model.js";
16
16
  import { showPackedSettings } from "./security-tui.js";
17
+ import { registerProfiles } from "./profile.js";
18
+ import { handleSetupCommand } from "./setup-command.js";
19
+ import { handleResourceConfigCommand } from "./resource-config.js";
17
20
 
18
21
  // Async factory (pi awaits it): the seam creates authenticated daemon
19
22
  // clients lazily. It never executes Bun-only adapters or opens SQLite.
20
23
  export default async function (pi: ExtensionAPI) {
24
+ registerProfiles(pi);
21
25
  const natives = await createNatives();
22
26
  registerTools(pi, natives);
23
27
 
24
28
  pi.registerCommand("packed", {
25
- description: "Configure pi-packed security settings",
26
- handler: async (_args, ctx) => {
29
+ description: "Configure pi-packed security settings, run setup plan/apply, or manage resources with config",
30
+ handler: async (args, ctx) => {
31
+ if (await handleSetupCommand(args, ctx, natives)) return;
32
+ if (await handleResourceConfigCommand(args, ctx, natives)) return;
27
33
  await showPackedSettings(ctx, natives);
28
34
  },
29
35
  });
@@ -10,18 +10,20 @@
10
10
  * one connected client instead of reconnecting on every single operation.
11
11
  */
12
12
  import { createRetryingClient } from "@danypops/daemon-kit/pi-client";
13
- import type { PackageDaemonPort as ClientPackageDaemonPort } from "../../src/client.ts";
14
- import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry, UpdateOutcome } from "../../src/ports.ts";
15
- import type { MutationApproval, SecuritySettings } from "../../src/security.ts";
13
+ import { ensurePackedClient, InstallServiceError, type PackedExtensionClient } from "@danypops/packed/client";
14
+ import type { InstalledPackage, PackageInfo, PackageResources, PackageSummary, ResourceField, SecuritySettings, ServiceSpecSummary, SetupApplyResult, SetupPlan, UpdateEntry, UpdateOutcome } from "@danypops/packed/protocol";
16
15
 
17
- export type { InstalledPkg, UpdateEntry, UpdateOutcome };
18
- export type PackageInfo = PkgInfo;
19
- export type PackageDaemonPort = ClientPackageDaemonPort;
16
+ export { InstallServiceError };
17
+
18
+ export type { PackageInfo, PackageResources, ResourceField, UpdateEntry, UpdateOutcome };
19
+ export type InstalledPkg = InstalledPackage;
20
+ export type PackageDaemonPort = PackedExtensionClient;
21
+ type MutationApproval = SecuritySettings["mutationApproval"];
20
22
 
21
23
  export interface SearchResponse {
22
24
  query: string;
23
25
  total: number;
24
- results: Pkg[];
26
+ results: PackageSummary[];
25
27
  }
26
28
 
27
29
  export interface Natives {
@@ -33,18 +35,20 @@ export interface Natives {
33
35
  security(): Promise<SecuritySettings>;
34
36
  setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
35
37
  install(source: string, approved?: boolean): Promise<string>;
38
+ /** Throws InstallServiceError; check .notADaemon before surfacing a failure -- most packages aren't daemons at all. */
39
+ installService(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSpecSummary }>;
36
40
  remove(name: string, approved?: boolean): Promise<string>;
37
41
  update(source: string, approved?: boolean): Promise<UpdateOutcome>;
42
+ setupPlan(manifestPath: string, prune?: boolean): Promise<SetupPlan>;
43
+ setupApply(manifestPath: string, approved?: boolean, prune?: boolean): Promise<SetupApplyResult>;
44
+ listResources(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>;
45
+ toggleResource(source: string, field: ResourceField, path: string, enabled: boolean, projectRoot?: string, approved?: boolean): Promise<string>;
38
46
  }
39
47
 
40
48
  export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
41
49
 
42
50
  async function connectDefaultDaemon(): Promise<PackageDaemonPort> {
43
- const [client, state] = await Promise.all([
44
- import("../../src/client.ts"),
45
- import("../../src/state.ts"),
46
- ]);
47
- return client.connectPackageDaemon(state.stateDir());
51
+ return ensurePackedClient();
48
52
  }
49
53
 
50
54
  export async function createNatives(connect: PackageDaemonConnector = connectDefaultDaemon): Promise<Natives> {
@@ -59,7 +63,12 @@ export async function createNatives(connect: PackageDaemonConnector = connectDef
59
63
  security: () => client.call((daemon) => daemon.security()),
60
64
  setMutationApproval: (value, approved) => client.call((daemon) => daemon.setMutationApproval(value, approved)),
61
65
  install: (source, approved) => client.call((daemon) => daemon.install(source, approved)),
66
+ installService: (source, approved) => client.call((daemon) => daemon.installService(source, approved)),
62
67
  remove: (name, approved) => client.call((daemon) => daemon.remove(name, approved)),
63
68
  update: (source, approved) => client.call((daemon) => daemon.update(source, approved)),
69
+ setupPlan: (manifestPath, prune) => client.call((daemon) => daemon.setupPlan(manifestPath, prune)),
70
+ setupApply: (manifestPath, approved, prune) => client.call((daemon) => daemon.setupApply(manifestPath, approved, prune)),
71
+ listResources: (projectRoot) => client.call((daemon) => daemon.listResources(projectRoot)),
72
+ toggleResource: (source, field, path, enabled, projectRoot, approved) => client.call((daemon) => daemon.toggleResource(source, field, path, enabled, projectRoot, approved)),
64
73
  };
65
74
  }
@@ -0,0 +1,9 @@
1
+ import type { SecuritySettings } from "@danypops/packed/protocol";
2
+
3
+ export type PackageOperation = "install" | "update" | "remove" | "toggle";
4
+
5
+ const GUARDED: readonly PackageOperation[] = ["install", "update", "remove", "toggle"];
6
+
7
+ export function packagePermissionDecision(settings: SecuritySettings, operation: PackageOperation): { approvalRequired: boolean } {
8
+ return { approvalRequired: settings.mutationApproval === "always" && GUARDED.includes(operation) };
9
+ }
@@ -0,0 +1,280 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { Key } from "@earendil-works/pi-tui";
6
+ export interface Profile {
7
+ provider?: string;
8
+ model?: string;
9
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
10
+ tools?: string[];
11
+ instructions?: string;
12
+ theme?: string;
13
+ allowedModels?: string[];
14
+ }
15
+ export type ProfilesConfig = Record<string, Profile>;
16
+ type ThinkingLevel = NonNullable<Profile["thinkingLevel"]>;
17
+ const PROFILE_FILE = "profiles.json";
18
+ const LAST_PROFILE_FILE = "profiles-last.json";
19
+ const MAX_PROFILE_BYTES = 256 * 1024;
20
+ const MAX_PROFILES = 100;
21
+ const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
22
+ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+
28
+ function boundedString(value: unknown, maximum: number): string | undefined {
29
+ return typeof value === "string" && value.length > 0 && value.length <= maximum ? value : undefined;
30
+ }
31
+
32
+ function secretLike(value: string): boolean {
33
+ return /-----BEGIN [A-Z ]*PRIVATE KEY-----/i.test(value)
34
+ || /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|secret)\s*[:=]\s*["']?[A-Za-z0-9_\-./+=]{12,}/i.test(value)
35
+ || /\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9_\-./+=]{12,}/i.test(value);
36
+ }
37
+
38
+ function stringArray(value: unknown, maximum: number): string[] | undefined {
39
+ if (value === undefined) return undefined;
40
+ if (!Array.isArray(value) || value.length > maximum || !value.every((item) => typeof item === "string" && item.length > 0 && item.length <= 256)) throw new Error("array must contain bounded strings");
41
+ return [...new Set(value)];
42
+ }
43
+
44
+ export function decodeProfiles(text: string): ProfilesConfig {
45
+ if (Buffer.byteLength(text) > MAX_PROFILE_BYTES) throw new Error("profile file exceeds 256 KiB");
46
+ const value = JSON.parse(text) as unknown;
47
+ if (!isRecord(value) || Object.keys(value).length > MAX_PROFILES) throw new Error("profile file must contain at most 100 named profiles");
48
+ const profiles: ProfilesConfig = {};
49
+ for (const [name, raw] of Object.entries(value)) {
50
+ if (!PROFILE_NAME.test(name) || !isRecord(raw)) throw new Error(`invalid profile: ${name}`);
51
+ const allowed = new Set(["provider", "model", "thinkingLevel", "tools", "instructions", "theme", "allowedModels"]);
52
+ if (Object.keys(raw).some((key) => !allowed.has(key))) throw new Error(`profile ${name} contains an unknown field`);
53
+ const profile: Profile = {};
54
+ for (const field of ["provider", "model", "theme"] as const) {
55
+ if (raw[field] !== undefined && !boundedString(raw[field], field === "model" ? 256 : 128)) throw new Error(`profile ${name}.${field} is invalid`);
56
+ const fieldValue = boundedString(raw[field], field === "model" ? 256 : 128);
57
+ if (fieldValue) profile[field] = fieldValue;
58
+ }
59
+ if (raw.instructions !== undefined && !boundedString(raw.instructions, 16_384)) throw new Error(`profile ${name}.instructions is invalid`);
60
+ if (typeof raw.instructions === "string") {
61
+ if (secretLike(raw.instructions)) throw new Error(`profile ${name}.instructions contains secret-like material`);
62
+ profile.instructions = raw.instructions;
63
+ }
64
+ if (raw.thinkingLevel !== undefined && (typeof raw.thinkingLevel !== "string" || !THINKING_LEVELS.has(raw.thinkingLevel))) throw new Error(`profile ${name}.thinkingLevel is invalid`);
65
+ if (raw.thinkingLevel) profile.thinkingLevel = raw.thinkingLevel as ThinkingLevel;
66
+ profile.tools = stringArray(raw.tools, 100);
67
+ profile.allowedModels = stringArray(raw.allowedModels, 100);
68
+ if (!profile.tools) delete profile.tools;
69
+ if (!profile.allowedModels) delete profile.allowedModels;
70
+ profiles[name] = profile;
71
+ }
72
+ return profiles;
73
+ }
74
+
75
+ function readProfileFile(path: string): ProfilesConfig {
76
+ if (!existsSync(path)) return {};
77
+ const stat = lstatSync(path);
78
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_PROFILE_BYTES) throw new Error(`${path} is not a safe bounded profile file`);
79
+ return decodeProfiles(readFileSync(path, "utf8"));
80
+ }
81
+
82
+ export function loadProfiles(agentDir: string, cwd: string, projectTrusted: boolean): ProfilesConfig {
83
+ const globalProfiles = readProfileFile(join(agentDir, PROFILE_FILE));
84
+ if (!projectTrusted) return globalProfiles;
85
+ return { ...globalProfiles, ...readProfileFile(join(cwd, CONFIG_DIR_NAME, PROFILE_FILE)) };
86
+ }
87
+
88
+ function loadDefaultProfile(cwd: string, projectTrusted: boolean): string | undefined {
89
+ if (!projectTrusted) return undefined;
90
+ try {
91
+ const path = join(cwd, "pi-setup.json");
92
+ if (!existsSync(path)) return undefined;
93
+ const stat = lstatSync(path);
94
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) return undefined;
95
+ const value = JSON.parse(readFileSync(path, "utf8")) as { schemaVersion?: unknown; defaultProfile?: unknown };
96
+ return value.schemaVersion === 1 && typeof value.defaultProfile === "string" && PROFILE_NAME.test(value.defaultProfile) ? value.defaultProfile : undefined;
97
+ } catch { return undefined; }
98
+ }
99
+
100
+ function loadLastProfile(agentDir: string): string | undefined {
101
+ try {
102
+ const path = join(agentDir, LAST_PROFILE_FILE);
103
+ if (!existsSync(path)) return undefined;
104
+ const stat = lstatSync(path);
105
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4_096) return undefined;
106
+ const value = JSON.parse(readFileSync(path, "utf8")) as { name?: unknown };
107
+ return typeof value.name === "string" && PROFILE_NAME.test(value.name) ? value.name : undefined;
108
+ } catch { return undefined; }
109
+ }
110
+
111
+ function saveLastProfile(agentDir: string, name: string | undefined): void {
112
+ mkdirSync(agentDir, { recursive: true, mode: 0o700 });
113
+ const path = join(agentDir, LAST_PROFILE_FILE);
114
+ if (!name) { rmSync(path, { force: true }); return; }
115
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error("last-profile state is a symlink");
116
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
117
+ try { writeFileSync(temporary, `${JSON.stringify({ name })}\n`, { flag: "wx", mode: 0o600 }); renameSync(temporary, path); }
118
+ finally { rmSync(temporary, { force: true }); }
119
+ }
120
+
121
+ function matchesPattern(value: string, pattern: string): boolean {
122
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
123
+ try { return new RegExp(`^${escaped}$`).test(value); } catch { return false; }
124
+ }
125
+
126
+ interface OriginalState {
127
+ model: ExtensionContext["model"];
128
+ thinkingLevel: ThinkingLevel;
129
+ tools: string[];
130
+ themeName?: string;
131
+ }
132
+
133
+ export function registerProfiles(pi: ExtensionAPI): void {
134
+ const agentDir = getAgentDir();
135
+ let profiles: ProfilesConfig = {};
136
+ let activeName: string | undefined;
137
+ let activeProfile: Profile | undefined;
138
+ let original: OriginalState | undefined;
139
+ let applyingModel = false;
140
+
141
+ function setStatus(ctx: ExtensionContext): void {
142
+ ctx.ui.setStatus("packed-profile", activeName ? ctx.ui.theme.fg("accent", `profile:${activeName}`) : undefined);
143
+ }
144
+
145
+ function persistState(name: string | undefined): void {
146
+ pi.appendEntry("profile-state", name ? { name } : { name: null });
147
+ }
148
+
149
+ async function apply(name: string, profile: Profile, ctx: ExtensionContext, persist = true): Promise<void> {
150
+ if (!original) original = { model: ctx.model, thinkingLevel: pi.getThinkingLevel(), tools: pi.getActiveTools(), themeName: ctx.ui.theme.name };
151
+ applyingModel = true;
152
+ try {
153
+ if (profile.provider && profile.model) {
154
+ const model = ctx.modelRegistry.find(profile.provider, profile.model);
155
+ if (!model) ctx.ui.notify(`Profile "${name}": model ${profile.provider}/${profile.model} not found`, "warning");
156
+ else if (!await pi.setModel(model)) ctx.ui.notify(`Profile "${name}": credentials unavailable for ${profile.provider}/${profile.model}`, "warning");
157
+ }
158
+ } finally { applyingModel = false; }
159
+ if (profile.thinkingLevel) pi.setThinkingLevel(profile.thinkingLevel);
160
+ if (profile.tools?.length) {
161
+ const available = new Set(pi.getAllTools().map((tool) => tool.name));
162
+ const valid = profile.tools.filter((tool) => available.has(tool));
163
+ const missing = profile.tools.filter((tool) => !available.has(tool));
164
+ if (missing.length) ctx.ui.notify(`Profile "${name}": unknown tools: ${missing.join(", ")}`, "warning");
165
+ if (valid.length) pi.setActiveTools(valid);
166
+ }
167
+ if (profile.theme) {
168
+ const result = ctx.ui.setTheme(profile.theme);
169
+ if (!result.success) ctx.ui.notify(`Profile "${name}": ${result.error}`, "warning");
170
+ }
171
+ activeName = name;
172
+ activeProfile = profile;
173
+ if (persist) {
174
+ try { saveLastProfile(agentDir, name); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); }
175
+ persistState(name);
176
+ }
177
+ setStatus(ctx);
178
+ }
179
+
180
+ async function clear(ctx: ExtensionContext): Promise<void> {
181
+ activeName = undefined;
182
+ activeProfile = undefined;
183
+ if (original?.model) await pi.setModel(original.model);
184
+ if (original) {
185
+ pi.setThinkingLevel(original.thinkingLevel);
186
+ pi.setActiveTools(original.tools);
187
+ if (original.themeName) {
188
+ const result = ctx.ui.setTheme(original.themeName);
189
+ if (!result.success) ctx.ui.notify(result.error ?? "theme restore failed", "warning");
190
+ }
191
+ }
192
+ original = undefined;
193
+ try { saveLastProfile(agentDir, undefined); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); }
194
+ persistState(undefined);
195
+ setStatus(ctx);
196
+ }
197
+
198
+ async function choose(ctx: ExtensionContext): Promise<void> {
199
+ const names = Object.keys(profiles).sort();
200
+ if (names.length === 0) { ctx.ui.notify("No profiles defined", "warning"); return; }
201
+ const none = "(none)";
202
+ const selected = await ctx.ui.select("Select profile", [none, ...names]);
203
+ if (!selected) return;
204
+ if (selected === none) { await clear(ctx); ctx.ui.notify("Profile cleared", "info"); return; }
205
+ await apply(selected, profiles[selected]!, ctx);
206
+ ctx.ui.notify(`Profile "${selected}" activated`, "info");
207
+ }
208
+
209
+ async function cycle(ctx: ExtensionContext): Promise<void> {
210
+ if (Object.keys(profiles).length === 0) { ctx.ui.notify("No profiles defined", "warning"); return; }
211
+ const values = [undefined, ...Object.keys(profiles).sort()];
212
+ const index = values.indexOf(activeName);
213
+ const next = values[(index + 1) % values.length];
214
+ if (!next) { await clear(ctx); ctx.ui.notify("Profile cleared", "info"); return; }
215
+ await apply(next, profiles[next]!, ctx);
216
+ ctx.ui.notify(`Profile "${next}" activated`, "info");
217
+ }
218
+
219
+ pi.registerFlag("profile", { description: "Packed profile to activate", type: "string" });
220
+ pi.registerCommand("profile", {
221
+ description: "Switch Packed profile",
222
+ getArgumentCompletions(prefix) {
223
+ const matches = Object.keys(profiles).sort().filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name }));
224
+ return matches.length ? matches : null;
225
+ },
226
+ async handler(args, ctx) {
227
+ const name = args.trim();
228
+ if (!name) { await choose(ctx); return; }
229
+ const profile = profiles[name];
230
+ if (!profile) { ctx.ui.notify(`Unknown profile "${name}"`, "error"); return; }
231
+ await apply(name, profile, ctx);
232
+ ctx.ui.notify(`Profile "${name}" activated`, "info");
233
+ },
234
+ });
235
+ pi.registerShortcut(Key.ctrlShift("u"), { description: "Cycle Packed profiles", handler: cycle });
236
+
237
+ pi.on("model_select", (event, ctx) => {
238
+ if (applyingModel || event.source === "restore" || !activeProfile?.allowedModels?.length) return;
239
+ const selected = `${event.model.provider}/${event.model.id}`;
240
+ if (!activeProfile.allowedModels.some((pattern) => matchesPattern(selected, pattern))) ctx.ui.notify(`${selected} is outside profile "${activeName}" allowedModels`, "warning");
241
+ });
242
+ pi.on("before_agent_start", (event) => activeProfile?.instructions ? { systemPrompt: `${event.systemPrompt}\n\n${activeProfile.instructions}` } : undefined);
243
+ pi.on("session_start", async (event, ctx) => {
244
+ try { profiles = loadProfiles(agentDir, ctx.cwd, ctx.isProjectTrusted()); }
245
+ catch (error) { profiles = {}; ctx.ui.notify(`Profile configuration rejected: ${error instanceof Error ? error.message : String(error)}`, "error"); }
246
+ const flag = pi.getFlag("profile");
247
+ if (typeof flag === "string" && flag) {
248
+ if (!profiles[flag]) { ctx.ui.notify(`Unknown profile "${flag}"`, "warning"); setStatus(ctx); return; }
249
+ await apply(flag, profiles[flag]!, ctx);
250
+ ctx.ui.notify(`Profile "${flag}" activated`, "info");
251
+ return;
252
+ }
253
+ const defaultProfile = loadDefaultProfile(ctx.cwd, ctx.isProjectTrusted());
254
+ if ((event.reason === "reload" || event.reason === "new") && defaultProfile) {
255
+ if (profiles[defaultProfile]) await apply(defaultProfile, profiles[defaultProfile]!, ctx, false);
256
+ else { ctx.ui.notify(`Default profile "${defaultProfile}" is not defined`, "warning"); setStatus(ctx); }
257
+ return;
258
+ }
259
+ const state = [...ctx.sessionManager.getEntries()].reverse().find((entry: { type: string; customType?: string }) => entry.type === "custom" && (entry.customType === "profile-state" || entry.customType === "packed-profile-state")) as { data?: { name?: unknown } } | undefined;
260
+ if (state) {
261
+ if (typeof state.data?.name === "string" && profiles[state.data.name]) {
262
+ activeName = state.data.name;
263
+ activeProfile = profiles[state.data.name];
264
+ }
265
+ setStatus(ctx);
266
+ return;
267
+ }
268
+ if (defaultProfile) {
269
+ if (profiles[defaultProfile]) await apply(defaultProfile, profiles[defaultProfile]!, ctx, false);
270
+ else { ctx.ui.notify(`Default profile "${defaultProfile}" is not defined`, "warning"); setStatus(ctx); }
271
+ return;
272
+ }
273
+ const last = loadLastProfile(agentDir);
274
+ if (last && profiles[last]) { await apply(last, profiles[last]!, ctx, false); ctx.ui.notify(`Profile "${last}" restored`, "info"); }
275
+ else {
276
+ if (last) ctx.ui.notify(`Last active profile "${last}" no longer exists`, "warning");
277
+ setStatus(ctx);
278
+ }
279
+ });
280
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * reload.ts — one shared decision for whether a Pi package mutation needs a
3
+ * reload to take effect, and the exact wording every mutation surface
4
+ * (native tools, the /packages panel, and the resource overlay) uses to
5
+ * warn about it. Keeps three independent implementations from drifting
6
+ * apart on when and how they say a reload is coming.
7
+ */
8
+ import type { PackageOperation } from "./permission.js";
9
+
10
+ /** Inline warning appended to a mutation's pre-confirmation dialog, before
11
+ * the operation runs -- the moment the user is actually deciding. */
12
+ export function reloadWarning(operation: PackageOperation): string {
13
+ return operation === "remove"
14
+ ? "This will require a Pi reload (/reload) to deactivate it."
15
+ : "This will likely require a Pi reload (/reload) to activate its resources.";
16
+ }