@danypops/pi-packed 0.19.5 → 0.19.7
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/extension/src/discover.ts +10 -3
- package/extension/src/index.ts +6 -5
- package/extension/src/packed.ts +44 -12
- package/extension/src/profile.ts +123 -43
- package/extension/src/resource-config.ts +55 -17
- package/extension/src/security-tui.ts +9 -2
- package/extension/src/setup-command.ts +10 -2
- package/extension/src/tool-output.ts +50 -18
- package/extension/src/tools.ts +46 -31
- package/extension/src/tui.ts +240 -183
- package/package.json +4 -4
|
@@ -16,8 +16,8 @@ import type { Component } from "malevich-tui-components";
|
|
|
16
16
|
import { shouldSearch } from "./discover-model.js";
|
|
17
17
|
import type { Natives, PackageSummary } from "./packed.js";
|
|
18
18
|
import { InstallServiceError } from "./packed.js";
|
|
19
|
-
import { approvePackageOperation } from "./tools.js";
|
|
20
19
|
import type { TabHost } from "./tab-host.js";
|
|
20
|
+
import { approvePackageOperation } from "./tools.js";
|
|
21
21
|
|
|
22
22
|
const SEARCH_LIMIT = 20;
|
|
23
23
|
|
|
@@ -52,7 +52,10 @@ export async function applyInstall(result: PackageSummary, natives: Natives, ctx
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
interface Theme {
|
|
55
|
+
interface Theme {
|
|
56
|
+
fg(color: string, s: string): string;
|
|
57
|
+
bold(s: string): string;
|
|
58
|
+
}
|
|
56
59
|
|
|
57
60
|
/** /packed's Find tab -- a real Component. Its query box always captures
|
|
58
61
|
* free text (isCapturingInput() is unconditionally true), unlike
|
|
@@ -71,6 +74,7 @@ export class FindTab implements Component {
|
|
|
71
74
|
constructor(
|
|
72
75
|
private readonly natives: Natives,
|
|
73
76
|
private readonly host: TabHost,
|
|
77
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: read via `const { theme } = this` in render(), which Biome's usage check doesn't trace
|
|
74
78
|
private readonly theme: Theme,
|
|
75
79
|
) {}
|
|
76
80
|
|
|
@@ -169,7 +173,10 @@ export class FindTab implements Component {
|
|
|
169
173
|
this.busy = true;
|
|
170
174
|
this.host.requestRender();
|
|
171
175
|
try {
|
|
172
|
-
const confirmed = await this.host.inlineCtx.ui.confirm(
|
|
176
|
+
const confirmed = await this.host.inlineCtx.ui.confirm(
|
|
177
|
+
`Install ${result.name}@${result.version}`,
|
|
178
|
+
"Search itself never mutates -- this is the first of two confirmations before anything installs.",
|
|
179
|
+
);
|
|
173
180
|
if (!confirmed) return;
|
|
174
181
|
const outcome = await applyInstall(result, this.natives, this.host.inlineCtx);
|
|
175
182
|
if (outcome === "installed") this.host.onSessionReplaced();
|
package/extension/src/index.ts
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
* Install: pi install git:github.com/DanyPops/pi-packed
|
|
10
10
|
*/
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { registerTools } from "./tools.js";
|
|
13
|
-
import { showPackedPanel } from "./tui.js";
|
|
14
|
-
import { createNatives } from "./packed.js";
|
|
15
12
|
import { formatUpdateNotice } from "./model.js";
|
|
13
|
+
import { createNatives } from "./packed.js";
|
|
16
14
|
import { registerProfiles } from "./profile.js";
|
|
17
|
-
import { handleSetupCommand } from "./setup-command.js";
|
|
18
15
|
import { handleResourceConfigCommand } from "./resource-config.js";
|
|
16
|
+
import { handleSetupCommand } from "./setup-command.js";
|
|
17
|
+
import { registerTools } from "./tools.js";
|
|
18
|
+
import { showPackedPanel } from "./tui.js";
|
|
19
19
|
|
|
20
20
|
// Async factory (pi awaits it): the seam creates authenticated daemon
|
|
21
21
|
// clients lazily. It never executes Bun-only adapters or opens SQLite.
|
|
@@ -25,7 +25,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
25
25
|
registerTools(pi, natives);
|
|
26
26
|
|
|
27
27
|
pi.registerCommand("packed", {
|
|
28
|
-
description:
|
|
28
|
+
description:
|
|
29
|
+
"Browse and manage installed Pi packages -- u/U update, x remove, d disable, c config, f find, s settings -- or run setup plan/apply or config directly",
|
|
29
30
|
handler: async (args, ctx) => {
|
|
30
31
|
if (await handleSetupCommand(args, ctx, natives)) return;
|
|
31
32
|
if (await handleResourceConfigCommand(args, ctx, natives, showPackedPanel)) return;
|
package/extension/src/packed.ts
CHANGED
|
@@ -8,14 +8,35 @@
|
|
|
8
8
|
* the rest of the Pi session -- vehicle-client's createRetryingClient detects
|
|
9
9
|
* that on the failing call itself and reconnects, so the happy path reuses
|
|
10
10
|
* one connected client instead of reconnecting on every single operation.
|
|
11
|
+
*
|
|
12
|
+
* Reads/idempotent lookups use call() (transparent retry once on a stale
|
|
13
|
+
* connection); install/installService/remove/update/setMutationApproval/
|
|
14
|
+
* setupApply/toggleResource use callOnce() instead -- a stale connection
|
|
15
|
+
* during any of those must surface its one real failure rather than risk a
|
|
16
|
+
* silently duplicated mutation (installing/removing/updating a package
|
|
17
|
+
* twice) if the retried call actually reached the daemon the first time.
|
|
11
18
|
*/
|
|
12
|
-
import { createRetryingClient } from "@danypops/vehicle-client/daemon-client";
|
|
13
|
-
import { ensurePackedClient, InstallServiceError, type PackedExtensionClient } from "@danypops/packed/client";
|
|
14
|
-
import { PI_COMMAND_NAME, type InstalledPackage, type PackageInfo, type PackageResources, type PackageSummary, type PiStatus, type ResourceField, type SecuritySettings, type ServiceSpecSummary, type SetupApplyResult, type SetupPlan, type UpdateEntry, type UpdateOutcome } from "@danypops/packed/protocol";
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
import { ensurePackedClient, InstallServiceError, type PackedExtensionClient } from "@danypops/packed/client";
|
|
21
|
+
import {
|
|
22
|
+
type InstalledPackage,
|
|
23
|
+
type PackageInfo,
|
|
24
|
+
type PackageResources,
|
|
25
|
+
type PackageSummary,
|
|
26
|
+
PI_COMMAND_NAME,
|
|
27
|
+
type PiStatus,
|
|
28
|
+
type ResourceField,
|
|
29
|
+
type SecuritySettings,
|
|
30
|
+
type ServiceSpecSummary,
|
|
31
|
+
type SetupApplyResult,
|
|
32
|
+
type SetupPlan,
|
|
33
|
+
type UpdateEntry,
|
|
34
|
+
type UpdateOutcome,
|
|
35
|
+
} from "@danypops/packed/protocol";
|
|
36
|
+
import { createRetryingClient } from "@danypops/vehicle-client/daemon-client";
|
|
17
37
|
|
|
18
38
|
export type { PackageInfo, PackageResources, PackageSummary, PiStatus, ResourceField, UpdateEntry, UpdateOutcome };
|
|
39
|
+
export { InstallServiceError, PI_COMMAND_NAME };
|
|
19
40
|
export type InstalledPkg = InstalledPackage;
|
|
20
41
|
export type PackageDaemonPort = PackedExtensionClient;
|
|
21
42
|
type MutationApproval = SecuritySettings["mutationApproval"];
|
|
@@ -42,7 +63,14 @@ export interface Natives {
|
|
|
42
63
|
setupPlan(manifestPath: string, prune?: boolean): Promise<SetupPlan>;
|
|
43
64
|
setupApply(manifestPath: string, approved?: boolean, prune?: boolean): Promise<SetupApplyResult>;
|
|
44
65
|
listResources(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>;
|
|
45
|
-
toggleResource(
|
|
66
|
+
toggleResource(
|
|
67
|
+
source: string,
|
|
68
|
+
field: ResourceField,
|
|
69
|
+
path: string,
|
|
70
|
+
enabled: boolean,
|
|
71
|
+
projectRoot?: string,
|
|
72
|
+
approved?: boolean,
|
|
73
|
+
): Promise<string>;
|
|
46
74
|
piStatus(): Promise<PiStatus>;
|
|
47
75
|
}
|
|
48
76
|
|
|
@@ -56,21 +84,25 @@ export async function createNatives(connect: PackageDaemonConnector = connectDef
|
|
|
56
84
|
const client = createRetryingClient(connect, { label: "pi-packed" });
|
|
57
85
|
|
|
58
86
|
return {
|
|
87
|
+
// Reads/idempotent lookups: transparently retried once on a stale connection.
|
|
59
88
|
search: (query, limit) => client.call((daemon) => daemon.search(query, limit)),
|
|
60
89
|
searchOffline: (query, limit) => client.call((daemon) => daemon.search(query, limit, true)),
|
|
61
90
|
info: (name) => client.call((daemon) => daemon.info(name)),
|
|
62
91
|
installed: () => client.call((daemon) => daemon.installed()),
|
|
63
92
|
updates: () => client.call((daemon) => daemon.updates()),
|
|
64
93
|
security: () => client.call((daemon) => daemon.security()),
|
|
65
|
-
setMutationApproval: (value, approved) => client.call((daemon) => daemon.setMutationApproval(value, approved)),
|
|
66
|
-
install: (source, approved) => client.call((daemon) => daemon.install(source, approved)),
|
|
67
|
-
installService: (source, approved) => client.call((daemon) => daemon.installService(source, approved)),
|
|
68
|
-
remove: (name, approved) => client.call((daemon) => daemon.remove(name, approved)),
|
|
69
|
-
update: (source, approved) => client.call((daemon) => daemon.update(source, approved)),
|
|
70
94
|
setupPlan: (manifestPath, prune) => client.call((daemon) => daemon.setupPlan(manifestPath, prune)),
|
|
71
|
-
setupApply: (manifestPath, approved, prune) => client.call((daemon) => daemon.setupApply(manifestPath, approved, prune)),
|
|
72
95
|
listResources: (projectRoot) => client.call((daemon) => daemon.listResources(projectRoot)),
|
|
73
|
-
toggleResource: (source, field, path, enabled, projectRoot, approved) => client.call((daemon) => daemon.toggleResource(source, field, path, enabled, projectRoot, approved)),
|
|
74
96
|
piStatus: () => client.call((daemon) => daemon.piStatus()),
|
|
97
|
+
// Mutations: never transparently retried -- a stale connection surfaces its one
|
|
98
|
+
// real failure instead of risking a silently duplicated install/remove/update.
|
|
99
|
+
setMutationApproval: (value, approved) => client.callOnce((daemon) => daemon.setMutationApproval(value, approved)),
|
|
100
|
+
install: (source, approved) => client.callOnce((daemon) => daemon.install(source, approved)),
|
|
101
|
+
installService: (source, approved) => client.callOnce((daemon) => daemon.installService(source, approved)),
|
|
102
|
+
remove: (name, approved) => client.callOnce((daemon) => daemon.remove(name, approved)),
|
|
103
|
+
update: (source, approved) => client.callOnce((daemon) => daemon.update(source, approved)),
|
|
104
|
+
setupApply: (manifestPath, approved, prune) => client.callOnce((daemon) => daemon.setupApply(manifestPath, approved, prune)),
|
|
105
|
+
toggleResource: (source, field, path, enabled, projectRoot, approved) =>
|
|
106
|
+
client.callOnce((daemon) => daemon.toggleResource(source, field, path, enabled, projectRoot, approved)),
|
|
75
107
|
};
|
|
76
108
|
}
|
package/extension/src/profile.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync,
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
3
|
+
import { writeJsonAtomic } from "@danypops/packed/atomic-json";
|
|
4
|
+
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Key } from "@earendil-works/pi-tui";
|
|
6
6
|
export interface Profile {
|
|
7
7
|
provider?: string;
|
|
@@ -30,14 +30,21 @@ function boundedString(value: unknown, maximum: number): string | undefined {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function secretLike(value: string): boolean {
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
return (
|
|
34
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/i.test(value) ||
|
|
35
|
+
/\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|secret)\s*[:=]\s*["']?[A-Za-z0-9_\-./+=]{12,}/i.test(value) ||
|
|
36
|
+
/\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9_\-./+=]{12,}/i.test(value)
|
|
37
|
+
);
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
function stringArray(value: unknown, maximum: number): string[] | undefined {
|
|
39
41
|
if (value === undefined) return undefined;
|
|
40
|
-
if (
|
|
42
|
+
if (
|
|
43
|
+
!Array.isArray(value) ||
|
|
44
|
+
value.length > maximum ||
|
|
45
|
+
!value.every((item) => typeof item === "string" && item.length > 0 && item.length <= 256)
|
|
46
|
+
)
|
|
47
|
+
throw new Error("array must contain bounded strings");
|
|
41
48
|
return [...new Set(value)];
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -52,16 +59,19 @@ export function decodeProfiles(text: string): ProfilesConfig {
|
|
|
52
59
|
if (Object.keys(raw).some((key) => !allowed.has(key))) throw new Error(`profile ${name} contains an unknown field`);
|
|
53
60
|
const profile: Profile = {};
|
|
54
61
|
for (const field of ["provider", "model", "theme"] as const) {
|
|
55
|
-
if (raw[field] !== undefined && !boundedString(raw[field], field === "model" ? 256 : 128))
|
|
62
|
+
if (raw[field] !== undefined && !boundedString(raw[field], field === "model" ? 256 : 128))
|
|
63
|
+
throw new Error(`profile ${name}.${field} is invalid`);
|
|
56
64
|
const fieldValue = boundedString(raw[field], field === "model" ? 256 : 128);
|
|
57
65
|
if (fieldValue) profile[field] = fieldValue;
|
|
58
66
|
}
|
|
59
|
-
if (raw.instructions !== undefined && !boundedString(raw.instructions, 16_384))
|
|
67
|
+
if (raw.instructions !== undefined && !boundedString(raw.instructions, 16_384))
|
|
68
|
+
throw new Error(`profile ${name}.instructions is invalid`);
|
|
60
69
|
if (typeof raw.instructions === "string") {
|
|
61
70
|
if (secretLike(raw.instructions)) throw new Error(`profile ${name}.instructions contains secret-like material`);
|
|
62
71
|
profile.instructions = raw.instructions;
|
|
63
72
|
}
|
|
64
|
-
if (raw.thinkingLevel !== undefined && (typeof raw.thinkingLevel !== "string" || !THINKING_LEVELS.has(raw.thinkingLevel)))
|
|
73
|
+
if (raw.thinkingLevel !== undefined && (typeof raw.thinkingLevel !== "string" || !THINKING_LEVELS.has(raw.thinkingLevel)))
|
|
74
|
+
throw new Error(`profile ${name}.thinkingLevel is invalid`);
|
|
65
75
|
if (raw.thinkingLevel) profile.thinkingLevel = raw.thinkingLevel as ThinkingLevel;
|
|
66
76
|
profile.tools = stringArray(raw.tools, 100);
|
|
67
77
|
profile.allowedModels = stringArray(raw.allowedModels, 100);
|
|
@@ -75,7 +85,8 @@ export function decodeProfiles(text: string): ProfilesConfig {
|
|
|
75
85
|
function readProfileFile(path: string): ProfilesConfig {
|
|
76
86
|
if (!existsSync(path)) return {};
|
|
77
87
|
const stat = lstatSync(path);
|
|
78
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_PROFILE_BYTES)
|
|
88
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_PROFILE_BYTES)
|
|
89
|
+
throw new Error(`${path} is not a safe bounded profile file`);
|
|
79
90
|
return decodeProfiles(readFileSync(path, "utf8"));
|
|
80
91
|
}
|
|
81
92
|
|
|
@@ -93,8 +104,12 @@ function loadDefaultProfile(cwd: string, projectTrusted: boolean): string | unde
|
|
|
93
104
|
const stat = lstatSync(path);
|
|
94
105
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) return undefined;
|
|
95
106
|
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)
|
|
97
|
-
|
|
107
|
+
return value.schemaVersion === 1 && typeof value.defaultProfile === "string" && PROFILE_NAME.test(value.defaultProfile)
|
|
108
|
+
? value.defaultProfile
|
|
109
|
+
: undefined;
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
function loadLastProfile(agentDir: string): string | undefined {
|
|
@@ -105,22 +120,28 @@ function loadLastProfile(agentDir: string): string | undefined {
|
|
|
105
120
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4_096) return undefined;
|
|
106
121
|
const value = JSON.parse(readFileSync(path, "utf8")) as { name?: unknown };
|
|
107
122
|
return typeof value.name === "string" && PROFILE_NAME.test(value.name) ? value.name : undefined;
|
|
108
|
-
} catch {
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
109
126
|
}
|
|
110
127
|
|
|
111
|
-
function saveLastProfile(agentDir: string, name: string | undefined): void {
|
|
128
|
+
async function saveLastProfile(agentDir: string, name: string | undefined): Promise<void> {
|
|
112
129
|
mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
|
113
130
|
const path = join(agentDir, LAST_PROFILE_FILE);
|
|
114
|
-
if (!name) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
if (!name) {
|
|
132
|
+
rmSync(path, { force: true });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
await writeJsonAtomic(path, { name }, { mode: 0o600 });
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
function matchesPattern(value: string, pattern: string): boolean {
|
|
122
139
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
|
|
123
|
-
try {
|
|
140
|
+
try {
|
|
141
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
interface OriginalState {
|
|
@@ -147,15 +168,19 @@ export function registerProfiles(pi: ExtensionAPI): void {
|
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
async function apply(name: string, profile: Profile, ctx: ExtensionContext, persist = true): Promise<void> {
|
|
150
|
-
if (!original)
|
|
171
|
+
if (!original)
|
|
172
|
+
original = { model: ctx.model, thinkingLevel: pi.getThinkingLevel(), tools: pi.getActiveTools(), themeName: ctx.ui.theme.name };
|
|
151
173
|
applyingModel = true;
|
|
152
174
|
try {
|
|
153
175
|
if (profile.provider && profile.model) {
|
|
154
176
|
const model = ctx.modelRegistry.find(profile.provider, profile.model);
|
|
155
177
|
if (!model) ctx.ui.notify(`Profile "${name}": model ${profile.provider}/${profile.model} not found`, "warning");
|
|
156
|
-
else if (!await pi.setModel(model))
|
|
178
|
+
else if (!(await pi.setModel(model)))
|
|
179
|
+
ctx.ui.notify(`Profile "${name}": credentials unavailable for ${profile.provider}/${profile.model}`, "warning");
|
|
157
180
|
}
|
|
158
|
-
} finally {
|
|
181
|
+
} finally {
|
|
182
|
+
applyingModel = false;
|
|
183
|
+
}
|
|
159
184
|
if (profile.thinkingLevel) pi.setThinkingLevel(profile.thinkingLevel);
|
|
160
185
|
if (profile.tools?.length) {
|
|
161
186
|
const available = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
@@ -171,7 +196,11 @@ export function registerProfiles(pi: ExtensionAPI): void {
|
|
|
171
196
|
activeName = name;
|
|
172
197
|
activeProfile = profile;
|
|
173
198
|
if (persist) {
|
|
174
|
-
try {
|
|
199
|
+
try {
|
|
200
|
+
await saveLastProfile(agentDir, name);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
203
|
+
}
|
|
175
204
|
persistState(name);
|
|
176
205
|
}
|
|
177
206
|
setStatus(ctx);
|
|
@@ -190,28 +219,46 @@ export function registerProfiles(pi: ExtensionAPI): void {
|
|
|
190
219
|
}
|
|
191
220
|
}
|
|
192
221
|
original = undefined;
|
|
193
|
-
try {
|
|
222
|
+
try {
|
|
223
|
+
await saveLastProfile(agentDir, undefined);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
226
|
+
}
|
|
194
227
|
persistState(undefined);
|
|
195
228
|
setStatus(ctx);
|
|
196
229
|
}
|
|
197
230
|
|
|
198
231
|
async function choose(ctx: ExtensionContext): Promise<void> {
|
|
199
232
|
const names = Object.keys(profiles).sort();
|
|
200
|
-
if (names.length === 0) {
|
|
233
|
+
if (names.length === 0) {
|
|
234
|
+
ctx.ui.notify("No profiles defined", "warning");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
201
237
|
const none = "(none)";
|
|
202
238
|
const selected = await ctx.ui.select("Select profile", [none, ...names]);
|
|
203
239
|
if (!selected) return;
|
|
204
|
-
if (selected === none) {
|
|
240
|
+
if (selected === none) {
|
|
241
|
+
await clear(ctx);
|
|
242
|
+
ctx.ui.notify("Profile cleared", "info");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
205
245
|
await apply(selected, profiles[selected]!, ctx);
|
|
206
246
|
ctx.ui.notify(`Profile "${selected}" activated`, "info");
|
|
207
247
|
}
|
|
208
248
|
|
|
209
249
|
async function cycle(ctx: ExtensionContext): Promise<void> {
|
|
210
|
-
if (Object.keys(profiles).length === 0) {
|
|
250
|
+
if (Object.keys(profiles).length === 0) {
|
|
251
|
+
ctx.ui.notify("No profiles defined", "warning");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
211
254
|
const values = [undefined, ...Object.keys(profiles).sort()];
|
|
212
255
|
const index = values.indexOf(activeName);
|
|
213
256
|
const next = values[(index + 1) % values.length];
|
|
214
|
-
if (!next) {
|
|
257
|
+
if (!next) {
|
|
258
|
+
await clear(ctx);
|
|
259
|
+
ctx.ui.notify("Profile cleared", "info");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
215
262
|
await apply(next, profiles[next]!, ctx);
|
|
216
263
|
ctx.ui.notify(`Profile "${next}" activated`, "info");
|
|
217
264
|
}
|
|
@@ -220,14 +267,23 @@ export function registerProfiles(pi: ExtensionAPI): void {
|
|
|
220
267
|
pi.registerCommand("profile", {
|
|
221
268
|
description: "Switch Packed profile",
|
|
222
269
|
getArgumentCompletions(prefix) {
|
|
223
|
-
const matches = Object.keys(profiles)
|
|
270
|
+
const matches = Object.keys(profiles)
|
|
271
|
+
.sort()
|
|
272
|
+
.filter((name) => name.startsWith(prefix))
|
|
273
|
+
.map((name) => ({ value: name, label: name }));
|
|
224
274
|
return matches.length ? matches : null;
|
|
225
275
|
},
|
|
226
276
|
async handler(args, ctx) {
|
|
227
277
|
const name = args.trim();
|
|
228
|
-
if (!name) {
|
|
278
|
+
if (!name) {
|
|
279
|
+
await choose(ctx);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
229
282
|
const profile = profiles[name];
|
|
230
|
-
if (!profile) {
|
|
283
|
+
if (!profile) {
|
|
284
|
+
ctx.ui.notify(`Unknown profile "${name}"`, "error");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
231
287
|
await apply(name, profile, ctx);
|
|
232
288
|
ctx.ui.notify(`Profile "${name}" activated`, "info");
|
|
233
289
|
},
|
|
@@ -237,15 +293,26 @@ export function registerProfiles(pi: ExtensionAPI): void {
|
|
|
237
293
|
pi.on("model_select", (event, ctx) => {
|
|
238
294
|
if (applyingModel || event.source === "restore" || !activeProfile?.allowedModels?.length) return;
|
|
239
295
|
const selected = `${event.model.provider}/${event.model.id}`;
|
|
240
|
-
if (!activeProfile.allowedModels.some((pattern) => matchesPattern(selected, pattern)))
|
|
296
|
+
if (!activeProfile.allowedModels.some((pattern) => matchesPattern(selected, pattern)))
|
|
297
|
+
ctx.ui.notify(`${selected} is outside profile "${activeName}" allowedModels`, "warning");
|
|
241
298
|
});
|
|
242
|
-
pi.on("before_agent_start", (event) =>
|
|
299
|
+
pi.on("before_agent_start", (event) =>
|
|
300
|
+
activeProfile?.instructions ? { systemPrompt: `${event.systemPrompt}\n\n${activeProfile.instructions}` } : undefined,
|
|
301
|
+
);
|
|
243
302
|
pi.on("session_start", async (event, ctx) => {
|
|
244
|
-
try {
|
|
245
|
-
|
|
303
|
+
try {
|
|
304
|
+
profiles = loadProfiles(agentDir, ctx.cwd, ctx.isProjectTrusted());
|
|
305
|
+
} catch (error) {
|
|
306
|
+
profiles = {};
|
|
307
|
+
ctx.ui.notify(`Profile configuration rejected: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
308
|
+
}
|
|
246
309
|
const flag = pi.getFlag("profile");
|
|
247
310
|
if (typeof flag === "string" && flag) {
|
|
248
|
-
if (!profiles[flag]) {
|
|
311
|
+
if (!profiles[flag]) {
|
|
312
|
+
ctx.ui.notify(`Unknown profile "${flag}"`, "warning");
|
|
313
|
+
setStatus(ctx);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
249
316
|
await apply(flag, profiles[flag]!, ctx);
|
|
250
317
|
ctx.ui.notify(`Profile "${flag}" activated`, "info");
|
|
251
318
|
return;
|
|
@@ -253,10 +320,18 @@ export function registerProfiles(pi: ExtensionAPI): void {
|
|
|
253
320
|
const defaultProfile = loadDefaultProfile(ctx.cwd, ctx.isProjectTrusted());
|
|
254
321
|
if ((event.reason === "reload" || event.reason === "new") && defaultProfile) {
|
|
255
322
|
if (profiles[defaultProfile]) await apply(defaultProfile, profiles[defaultProfile]!, ctx, false);
|
|
256
|
-
else {
|
|
323
|
+
else {
|
|
324
|
+
ctx.ui.notify(`Default profile "${defaultProfile}" is not defined`, "warning");
|
|
325
|
+
setStatus(ctx);
|
|
326
|
+
}
|
|
257
327
|
return;
|
|
258
328
|
}
|
|
259
|
-
const state = [...ctx.sessionManager.getEntries()]
|
|
329
|
+
const state = [...ctx.sessionManager.getEntries()]
|
|
330
|
+
.reverse()
|
|
331
|
+
.find(
|
|
332
|
+
(entry: { type: string; customType?: string }) =>
|
|
333
|
+
entry.type === "custom" && (entry.customType === "profile-state" || entry.customType === "packed-profile-state"),
|
|
334
|
+
) as { data?: { name?: unknown } } | undefined;
|
|
260
335
|
if (state) {
|
|
261
336
|
if (typeof state.data?.name === "string" && profiles[state.data.name]) {
|
|
262
337
|
activeName = state.data.name;
|
|
@@ -267,12 +342,17 @@ export function registerProfiles(pi: ExtensionAPI): void {
|
|
|
267
342
|
}
|
|
268
343
|
if (defaultProfile) {
|
|
269
344
|
if (profiles[defaultProfile]) await apply(defaultProfile, profiles[defaultProfile]!, ctx, false);
|
|
270
|
-
else {
|
|
345
|
+
else {
|
|
346
|
+
ctx.ui.notify(`Default profile "${defaultProfile}" is not defined`, "warning");
|
|
347
|
+
setStatus(ctx);
|
|
348
|
+
}
|
|
271
349
|
return;
|
|
272
350
|
}
|
|
273
351
|
const last = loadLastProfile(agentDir);
|
|
274
|
-
if (last && profiles[last]) {
|
|
275
|
-
|
|
352
|
+
if (last && profiles[last]) {
|
|
353
|
+
await apply(last, profiles[last]!, ctx, false);
|
|
354
|
+
ctx.ui.notify(`Profile "${last}" restored`, "info");
|
|
355
|
+
} else {
|
|
276
356
|
if (last) ctx.ui.notify(`Last active profile "${last}" no longer exists`, "warning");
|
|
277
357
|
setStatus(ctx);
|
|
278
358
|
}
|
|
@@ -17,8 +17,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
import { rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
19
19
|
import type { Component } from "malevich-tui-components";
|
|
20
|
-
import type { PackageResources, ResourceField } from "./packed.js";
|
|
21
|
-
import type { Natives } from "./packed.js";
|
|
20
|
+
import type { Natives, PackageResources, ResourceField } from "./packed.js";
|
|
22
21
|
import { packagePermissionDecision } from "./permission.js";
|
|
23
22
|
import { confirmReload } from "./reload.js";
|
|
24
23
|
import type { TabHost } from "./tab-host.js";
|
|
@@ -39,7 +38,8 @@ export function flatten(groups: PackageResources[], scope: Scope): FlatItem[] {
|
|
|
39
38
|
const items: FlatItem[] = [];
|
|
40
39
|
for (const group of groups) {
|
|
41
40
|
for (const field of RESOURCE_FIELDS) {
|
|
42
|
-
for (const item of group[field])
|
|
41
|
+
for (const item of group[field])
|
|
42
|
+
items.push({ scope, source: group.source, packageName: group.name, field, path: item.path, enabled: item.enabled });
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
return items.sort((a, b) => a.packageName.localeCompare(b.packageName) || a.field.localeCompare(b.field) || a.path.localeCompare(b.path));
|
|
@@ -77,10 +77,17 @@ export type ToggleOutcome = "toggled" | "cancelled" | "failed";
|
|
|
77
77
|
* actually reached the daemon. Extracted from the render loop so this
|
|
78
78
|
* approve-then-mutate decision is directly testable without faking a full
|
|
79
79
|
* ctx.ui.custom interaction. */
|
|
80
|
-
export async function applyResourceToggle(
|
|
80
|
+
export async function applyResourceToggle(
|
|
81
|
+
item: FlatItem,
|
|
82
|
+
natives: Pick<Natives, "security" | "toggleResource">,
|
|
83
|
+
ctx: ExtensionCommandContext,
|
|
84
|
+
): Promise<ToggleOutcome> {
|
|
81
85
|
const nextEnabled = !item.enabled;
|
|
82
86
|
const approved = await approveToggle(natives, ctx, item, nextEnabled);
|
|
83
|
-
if (!approved) {
|
|
87
|
+
if (!approved) {
|
|
88
|
+
ctx.ui.notify("toggle cancelled", "warning");
|
|
89
|
+
return "cancelled";
|
|
90
|
+
}
|
|
84
91
|
try {
|
|
85
92
|
await natives.toggleResource(item.source, item.field, item.path, nextEnabled, item.scope === "project" ? ctx.cwd : undefined, true);
|
|
86
93
|
return "toggled";
|
|
@@ -106,7 +113,10 @@ export async function handleResourceConfigCommand(
|
|
|
106
113
|
return true;
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
interface Theme {
|
|
116
|
+
interface Theme {
|
|
117
|
+
fg(color: string, s: string): string;
|
|
118
|
+
bold(s: string): string;
|
|
119
|
+
}
|
|
110
120
|
|
|
111
121
|
/** /packed's Config tab -- a real Component, not its own ctx.ui.custom
|
|
112
122
|
* overlay. Each toggle's reload decision (confirmReload) fires
|
|
@@ -129,6 +139,7 @@ export class ConfigTab implements Component {
|
|
|
129
139
|
constructor(
|
|
130
140
|
private readonly natives: Pick<Natives, "listResources" | "security" | "toggleResource">,
|
|
131
141
|
private readonly host: TabHost,
|
|
142
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: read via `const { theme } = this` in render(), which Biome's usage check doesn't trace
|
|
132
143
|
private readonly theme: Theme,
|
|
133
144
|
initialFilter?: string,
|
|
134
145
|
) {
|
|
@@ -167,10 +178,20 @@ export class ConfigTab implements Component {
|
|
|
167
178
|
const scopeLabel = theme.bold(this.scope === "project" ? "Project Resources" : "Global Resources");
|
|
168
179
|
const hint = this.searchActive
|
|
169
180
|
? rawKeyHint("esc", "clear")
|
|
170
|
-
: rawKeyHint("space", "toggle") +
|
|
181
|
+
: rawKeyHint("space", "toggle") +
|
|
182
|
+
theme.fg("muted", " · ") +
|
|
183
|
+
rawKeyHint("/", "filter") +
|
|
184
|
+
theme.fg("muted", " · ") +
|
|
185
|
+
rawKeyHint("v", "scope") +
|
|
186
|
+
theme.fg("muted", " · ") +
|
|
187
|
+
rawKeyHint("r", "refresh");
|
|
171
188
|
const spacing = Math.max(1, width - visibleWidth(scopeLabel) - visibleWidth(hint));
|
|
172
189
|
const line1 = truncateToWidth(`${scopeLabel}${" ".repeat(spacing)}${hint}`, width, "");
|
|
173
|
-
const line2 = truncateToWidth(
|
|
190
|
+
const line2 = truncateToWidth(
|
|
191
|
+
theme.fg("muted", `${this.items.length} resource(s) \u00b7 extensions need a reload after a change`),
|
|
192
|
+
width,
|
|
193
|
+
"",
|
|
194
|
+
);
|
|
174
195
|
const lines = [line1, line2];
|
|
175
196
|
if (this.searchActive) lines.push(...this.searchInput.render(width));
|
|
176
197
|
lines.push("");
|
|
@@ -197,25 +218,42 @@ export class ConfigTab implements Component {
|
|
|
197
218
|
handleInput(raw: string): void {
|
|
198
219
|
if (this.busy) return;
|
|
199
220
|
if (this.searchActive) {
|
|
200
|
-
if (raw === "\x1b") {
|
|
201
|
-
|
|
202
|
-
|
|
221
|
+
if (raw === "\x1b") {
|
|
222
|
+
this.searchActive = false;
|
|
223
|
+
this.searchInput.setValue?.("");
|
|
224
|
+
this.applyFilter();
|
|
225
|
+
} else if (raw === "\r") this.searchActive = false;
|
|
226
|
+
else {
|
|
227
|
+
this.searchInput.handleInput(raw);
|
|
228
|
+
this.applyFilter();
|
|
229
|
+
}
|
|
203
230
|
this.host.requestRender();
|
|
204
231
|
return;
|
|
205
232
|
}
|
|
206
233
|
switch (raw) {
|
|
207
|
-
case "\x1b[A":
|
|
208
|
-
|
|
234
|
+
case "\x1b[A":
|
|
235
|
+
this.selectedIndex = (this.selectedIndex - 1 + this.filtered.length) % Math.max(this.filtered.length, 1);
|
|
236
|
+
break;
|
|
237
|
+
case "\x1b[B":
|
|
238
|
+
this.selectedIndex = (this.selectedIndex + 1) % Math.max(this.filtered.length, 1);
|
|
239
|
+
break;
|
|
209
240
|
case "v": // Tab is now reserved globally for sweeping between menus (TabbedContainer)
|
|
210
241
|
this.scope = this.scope === "global" ? "project" : "global";
|
|
211
242
|
this.rebuildItems();
|
|
212
243
|
this.applyFilter();
|
|
213
244
|
break;
|
|
214
|
-
case "/":
|
|
215
|
-
|
|
245
|
+
case "/":
|
|
246
|
+
this.searchActive = true;
|
|
247
|
+
break;
|
|
248
|
+
case "r":
|
|
249
|
+
void this.refresh();
|
|
250
|
+
return;
|
|
216
251
|
case " ":
|
|
217
|
-
case "\r":
|
|
218
|
-
|
|
252
|
+
case "\r":
|
|
253
|
+
void this.toggleSelected();
|
|
254
|
+
return;
|
|
255
|
+
default:
|
|
256
|
+
return;
|
|
219
257
|
}
|
|
220
258
|
this.host.requestRender();
|
|
221
259
|
}
|
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
* dialog from any custom overlay, confirmed live as the same "opens a
|
|
8
8
|
* different TUI" complaint the other three tabs had.
|
|
9
9
|
*/
|
|
10
|
+
|
|
11
|
+
import type { SecuritySettings } from "@danypops/packed/protocol";
|
|
10
12
|
import { rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
11
13
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
12
14
|
import type { Component } from "malevich-tui-components";
|
|
13
|
-
|
|
15
|
+
|
|
14
16
|
type MutationApproval = SecuritySettings["mutationApproval"];
|
|
17
|
+
|
|
15
18
|
import type { Natives } from "./packed.ts";
|
|
16
19
|
import type { TabHost } from "./tab-host.ts";
|
|
17
20
|
|
|
@@ -20,7 +23,10 @@ const OPTIONS: Array<{ value: MutationApproval; label: string }> = [
|
|
|
20
23
|
{ value: "never", label: "Never require mutation approval (unsafe opt-out)" },
|
|
21
24
|
];
|
|
22
25
|
|
|
23
|
-
interface Theme {
|
|
26
|
+
interface Theme {
|
|
27
|
+
fg(color: string, s: string): string;
|
|
28
|
+
bold(s: string): string;
|
|
29
|
+
}
|
|
24
30
|
|
|
25
31
|
export class SettingsTab implements Component {
|
|
26
32
|
private current: MutationApproval = "always";
|
|
@@ -30,6 +36,7 @@ export class SettingsTab implements Component {
|
|
|
30
36
|
constructor(
|
|
31
37
|
private readonly natives: Pick<Natives, "security" | "setMutationApproval">,
|
|
32
38
|
private readonly host: TabHost,
|
|
39
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: read via `const { theme } = this` in render(), which Biome's usage check doesn't trace
|
|
33
40
|
private readonly theme: Theme,
|
|
34
41
|
) {}
|
|
35
42
|
|