@danypops/pi-packed 0.5.3 → 0.6.1

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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * resource-config.ts — /packed config overlay: enable/disable individual
3
+ * extensions, skills, prompt templates, and themes declared by installed
4
+ * Pi packages, matching pi's own `pi config` interaction model (group by
5
+ * package, toggle with space/enter, Tab to switch global/project scope).
6
+ * Pi's own settings mutation lives in its internal SettingsManager, which
7
+ * extensions cannot reach -- this reads and writes the same documented
8
+ * settings.json filter arrays (docs/packages.md, "Enable and Disable
9
+ * Resources") entirely through Packed's authenticated daemon.
10
+ */
11
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
13
+ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
+ import type { PackageResources, ResourceField } from "./packed.js";
15
+ import type { Natives } from "./packed.js";
16
+ import { packagePermissionDecision } from "./permission.js";
17
+
18
+ type Scope = "global" | "project";
19
+ const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceField[];
20
+
21
+ interface FlatItem {
22
+ scope: Scope;
23
+ source: string;
24
+ packageName: string;
25
+ field: ResourceField;
26
+ path: string;
27
+ enabled: boolean;
28
+ }
29
+
30
+ export function flatten(groups: PackageResources[], scope: Scope): FlatItem[] {
31
+ const items: FlatItem[] = [];
32
+ for (const group of groups) {
33
+ for (const field of RESOURCE_FIELDS) {
34
+ for (const item of group[field]) items.push({ scope, source: group.source, packageName: group.name, field, path: item.path, enabled: item.enabled });
35
+ }
36
+ }
37
+ return items.sort((a, b) => a.packageName.localeCompare(b.packageName) || a.field.localeCompare(b.field) || a.path.localeCompare(b.path));
38
+ }
39
+
40
+ export function filterItems(items: FlatItem[], query: string): FlatItem[] {
41
+ const q = query.trim().toLowerCase();
42
+ if (!q) return items;
43
+ return items.filter((item) => `${item.packageName} ${item.field} ${item.path}`.toLowerCase().includes(q));
44
+ }
45
+
46
+ export type { FlatItem };
47
+
48
+ interface PanelAction {
49
+ type: "toggle" | "switch" | "refresh" | "close";
50
+ item?: FlatItem;
51
+ }
52
+
53
+ async function approveToggle(
54
+ natives: Pick<Natives, "security">,
55
+ ctx: Pick<ExtensionCommandContext, "hasUI" | "ui">,
56
+ item: FlatItem,
57
+ nextEnabled: boolean,
58
+ ): Promise<boolean> {
59
+ const settings = await natives.security();
60
+ if (!packagePermissionDecision(settings, "toggle").approvalRequired) return true;
61
+ if (!ctx.hasUI) return false;
62
+ const reloadNote = item.field === "extensions" ? " This will require a Pi reload (/reload) to take effect." : "";
63
+ return ctx.ui.confirm(
64
+ `${nextEnabled ? "Enable" : "Disable"} ${item.field.slice(0, -1)}`,
65
+ `${item.packageName} \u00b7 ${item.path}.${reloadNote} Continue?`,
66
+ );
67
+ }
68
+
69
+ export type ToggleOutcome = "toggled" | "cancelled" | "failed";
70
+
71
+ /** Warns inline (in the pre-toggle confirm dialog, not a footer note),
72
+ * requires the same approval every other settings-mutating operation
73
+ * requires, and reports -- never silently assumes -- whether the toggle
74
+ * actually reached the daemon. Extracted from the render loop so this
75
+ * approve-then-mutate decision is directly testable without faking a full
76
+ * ctx.ui.custom interaction. */
77
+ export async function applyResourceToggle(item: FlatItem, natives: Pick<Natives, "security" | "toggleResource">, ctx: ExtensionCommandContext): Promise<ToggleOutcome> {
78
+ const nextEnabled = !item.enabled;
79
+ const approved = await approveToggle(natives, ctx, item, nextEnabled);
80
+ if (!approved) { ctx.ui.notify("toggle cancelled", "warning"); return "cancelled"; }
81
+ try {
82
+ await natives.toggleResource(item.source, item.field, item.path, nextEnabled, item.scope === "project" ? ctx.cwd : undefined, true);
83
+ return "toggled";
84
+ } catch (error) {
85
+ ctx.ui.notify(`toggle failed: ${error instanceof Error ? error.message : error}`, "error");
86
+ return "failed";
87
+ }
88
+ }
89
+
90
+ /** `/packed config` dispatch, matching handleSetupCommand's own
91
+ * returns-true-if-handled shape so index.ts can chain both checks. */
92
+ export async function handleResourceConfigCommand(args: string, ctx: ExtensionCommandContext, natives: Natives): Promise<boolean> {
93
+ if (args.trim() !== "config") return false;
94
+ await showResourceConfig(ctx, natives);
95
+ return true;
96
+ }
97
+
98
+ export async function showResourceConfig(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
99
+ if (!ctx.hasUI) {
100
+ ctx.ui.notify("/packed config requires interactive mode", "warning");
101
+ return;
102
+ }
103
+ let data: { global: PackageResources[]; project: PackageResources[] };
104
+ try { data = await natives.listResources(ctx.cwd); }
105
+ catch (error) {
106
+ ctx.ui.notify(`packed unavailable: ${error instanceof Error ? error.message : error}`, "error");
107
+ return;
108
+ }
109
+
110
+ let scope: Scope = "global";
111
+ let pendingReload = false;
112
+
113
+ for (;;) {
114
+ const action = await renderConfig(ctx, data, scope);
115
+ if (action.type === "close") break;
116
+ if (action.type === "switch") { scope = scope === "global" ? "project" : "global"; continue; }
117
+ if (action.type === "refresh") {
118
+ try { data = await natives.listResources(ctx.cwd); }
119
+ catch (error) { ctx.ui.notify(`refresh failed: ${error instanceof Error ? error.message : error}`, "error"); }
120
+ continue;
121
+ }
122
+ const item = action.item;
123
+ if (!item) continue;
124
+ const outcome = await applyResourceToggle(item, natives, ctx);
125
+ if (outcome !== "toggled") continue;
126
+ const group = (item.scope === "global" ? data.global : data.project).find((candidate) => candidate.source === item.source);
127
+ const entry = group?.[item.field].find((candidate) => candidate.path === item.path);
128
+ if (entry) entry.enabled = !item.enabled;
129
+ if (item.field === "extensions") pendingReload = true;
130
+ }
131
+
132
+ if (!pendingReload) return;
133
+ const confirmed = await ctx.ui.confirm("Reload Pi now?", "Extension changes only take effect after a reload.");
134
+ if (confirmed) await ctx.reload();
135
+ else ctx.ui.notify("Extension changes pending -- run /reload when ready.", "warning");
136
+ }
137
+
138
+ function renderConfig(ctx: ExtensionCommandContext, data: { global: PackageResources[]; project: PackageResources[] }, scope: Scope): Promise<PanelAction> {
139
+ return ctx.ui.custom<PanelAction>((tui, theme, _kb, done) => {
140
+ const searchInput = new Input();
141
+ let searchActive = false;
142
+ const items = flatten(scope === "global" ? data.global : data.project, scope);
143
+ let filtered = items;
144
+ let selectedIndex = 0;
145
+ const maxVisible = 20;
146
+
147
+ function applyFilter(): void {
148
+ filtered = filterItems(items, searchInput.getValue());
149
+ selectedIndex = 0;
150
+ }
151
+
152
+ const header = {
153
+ invalidate() {},
154
+ render(width: number): string[] {
155
+ const title = theme.bold(scope === "project" ? "Project Resources" : "Global Resources");
156
+ const hint = searchActive
157
+ ? rawKeyHint("esc", "clear")
158
+ : rawKeyHint("space", "toggle") + theme.fg("muted", " \u00b7 ") + rawKeyHint("/", "filter") + theme.fg("muted", " \u00b7 ") + rawKeyHint("tab", "scope") + theme.fg("muted", " \u00b7 ") + rawKeyHint("esc", "close");
159
+ const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
160
+ const line1 = truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, "");
161
+ const line2 = truncateToWidth(theme.fg("muted", `${items.length} resource(s) \u00b7 extensions need /reload after a change`), width, "");
162
+ return [line1, line2];
163
+ },
164
+ };
165
+
166
+ const list = {
167
+ invalidate() {},
168
+ render(width: number): string[] {
169
+ const lines: string[] = [];
170
+ if (searchActive) lines.push(...searchInput.render(width));
171
+ lines.push("");
172
+ if (filtered.length === 0) { lines.push(theme.fg("muted", " No resources found")); return lines; }
173
+ const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible));
174
+ const end = Math.min(start + maxVisible, filtered.length);
175
+ for (let index = start; index < end; index++) {
176
+ const item = filtered[index]!;
177
+ const isSelected = index === selectedIndex;
178
+ const cursor = isSelected ? theme.fg("accent", "\u276f") : " ";
179
+ const box = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
180
+ const reloadMark = item.field === "extensions" ? theme.fg("warning", " \u27f3") : "";
181
+ const label = `${item.packageName} \u00b7 ${item.field} \u00b7 ${item.path}${reloadMark}`;
182
+ lines.push(truncateToWidth(`${cursor} ${box} ${isSelected ? theme.bold(label) : label}`, width, "..."));
183
+ }
184
+ const hasScroll = start > 0 || end < filtered.length;
185
+ if (hasScroll) lines.push(theme.fg("dim", ` ${selectedIndex + 1}/${filtered.length}`));
186
+ return lines;
187
+ },
188
+ };
189
+
190
+ const container = new Container();
191
+ container.addChild(new Spacer(1));
192
+ container.addChild(new DynamicBorder());
193
+ container.addChild(new Spacer(1));
194
+ container.addChild(header);
195
+ container.addChild(new Spacer(1));
196
+ container.addChild(list);
197
+ container.addChild(new Spacer(1));
198
+ container.addChild(new DynamicBorder());
199
+
200
+ return {
201
+ render: (width: number) => container.render(width),
202
+ invalidate: () => container.invalidate(),
203
+ handleInput(raw: string) {
204
+ if (searchActive) {
205
+ if (raw === "\x1b") { searchActive = false; searchInput.setValue?.(""); applyFilter(); }
206
+ else if (raw === "\r") searchActive = false;
207
+ else { searchInput.handleInput(raw); applyFilter(); }
208
+ tui.requestRender();
209
+ return;
210
+ }
211
+ switch (raw) {
212
+ case "\x1b[A": selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1); break;
213
+ case "\x1b[B": selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1); break;
214
+ case "\t": done({ type: "switch" }); return;
215
+ case "/": searchActive = true; break;
216
+ case "r": done({ type: "refresh" }); return;
217
+ case " ":
218
+ case "\r": {
219
+ const item = filtered[selectedIndex];
220
+ if (item) done({ type: "toggle", item });
221
+ return;
222
+ }
223
+ case "\x1b": done({ type: "close" }); return;
224
+ default: return;
225
+ }
226
+ tui.requestRender();
227
+ },
228
+ };
229
+ });
230
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import type { MutationApproval } from "../../src/security.ts";
2
+ import type { SecuritySettings } from "@danypops/packed/protocol";
3
+ type MutationApproval = SecuritySettings["mutationApproval"];
3
4
  import type { Natives } from "./packed.ts";
4
5
 
5
6
  const OPTIONS: Array<{ value: MutationApproval; label: string }> = [
@@ -0,0 +1,50 @@
1
+ import { resolve } from "node:path";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import type { Natives } from "./packed.js";
4
+
5
+ interface SetupCommandArgs { action: "plan" | "apply"; path: string; prune: boolean; }
6
+
7
+ function parseSetupCommand(args: string, cwd: string): SetupCommandArgs | undefined {
8
+ const parts = args.trim().split(/\s+/).filter(Boolean);
9
+ if (parts[0] !== "setup" || (parts[1] !== "plan" && parts[1] !== "apply")) return undefined;
10
+ const prune = parts.includes("--prune");
11
+ const path = parts.slice(2).find((part) => !part.startsWith("--")) ?? "pi-setup.json";
12
+ return { action: parts[1], path: resolve(cwd, path), prune };
13
+ }
14
+
15
+ export async function handleSetupCommand(args: string, ctx: ExtensionCommandContext, natives: Pick<Natives, "setupPlan" | "setupApply">): Promise<boolean> {
16
+ const parsed = parseSetupCommand(args, ctx.cwd);
17
+ if (!parsed) return false;
18
+ if (!ctx.hasUI) return true;
19
+ const plan = await natives.setupPlan(parsed.path, parsed.prune);
20
+ if (!plan.ok) {
21
+ ctx.ui.notify(plan.diagnostics[0]?.message ?? "Setup manifest is invalid", "error");
22
+ return true;
23
+ }
24
+ if (parsed.action === "plan") {
25
+ const suffix = parsed.prune ? " with prune" : "";
26
+ ctx.ui.notify(`${plan.operations.length} setup operation(s)${suffix}`, "info");
27
+ return true;
28
+ }
29
+ if (plan.operations.length === 0) {
30
+ ctx.ui.notify("Setup already matches", "info");
31
+ return true;
32
+ }
33
+ const packageChanges = plan.operations.filter((operation) => operation.kind.includes("package")).length;
34
+ const confirmed = await ctx.ui.confirm(
35
+ "Apply Pi setup?",
36
+ `Apply ${plan.operations.length} operation(s)${parsed.prune ? ", including prune removals" : ""}. ${packageChanges} package operation(s) may execute package code.`,
37
+ );
38
+ if (!confirmed) return true;
39
+ const result = await natives.setupApply(parsed.path, true, parsed.prune);
40
+ if (!result.ok) {
41
+ ctx.ui.notify(result.diagnostics[0]?.message ?? "Setup apply failed", "error");
42
+ return true;
43
+ }
44
+ if (result.reloadRequired) {
45
+ await ctx.reload();
46
+ return true;
47
+ }
48
+ ctx.ui.notify(`Applied ${result.operations.length} setup operation(s)`, "info");
49
+ return true;
50
+ }
@@ -9,17 +9,21 @@ import {
9
9
  TOOL_DETAILS_MAX_PACKAGES,
10
10
  TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS,
11
11
  TOOL_MODEL_CONTENT_MAX_CHARACTERS,
12
- } from "../../src/constants.ts";
13
- import type { Pkg, PkgInfo } from "../../src/ports.ts";
12
+ } from "./constants.js";
13
+ import type { PackageInfo as PkgInfo, PackageSummary as Pkg } from "@danypops/packed/protocol";
14
14
 
15
- const DETAILS_VERSION = 1 as const;
15
+ const DETAILS_VERSION = 2 as const;
16
16
  const MUTATION_OPERATIONS = new Set(["install", "update", "remove"]);
17
17
  const MUTATION_STATUSES = new Set(["succeeded", "cancelled", "denied"]);
18
+ const PACKAGE_SHAPES = new Set(["keyword-only", "manifest", "conventional"]);
19
+ const TRUSTED_PUBLISHER_STATES = new Set(["verified", "not-verified", "unknown"]);
18
20
 
19
21
  export interface PackageSummaryDetails {
20
22
  name: string;
21
23
  version: string;
22
24
  description: string;
25
+ shape: "keyword-only" | "manifest" | "conventional";
26
+ verified: boolean;
23
27
  }
24
28
 
25
29
  export interface SearchToolDetails {
@@ -42,6 +46,8 @@ export interface InfoToolDetails {
42
46
  license?: string;
43
47
  keywords: string[];
44
48
  capabilities: string[];
49
+ provenance?: string;
50
+ trustedPublisher: "verified" | "not-verified" | "unknown";
45
51
  };
46
52
  }
47
53
 
@@ -116,6 +122,8 @@ function packageSummary(pkg: Pkg): PackageSummaryDetails {
116
122
  name: bounded(pkg.name, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS),
117
123
  version: bounded(pkg.version, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS),
118
124
  description: bounded(pkg.description, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS),
125
+ shape: pkg.packageEvidence?.shape ?? "keyword-only",
126
+ verified: pkg.packageEvidence?.verified === true,
119
127
  };
120
128
  }
121
129
 
@@ -143,6 +151,8 @@ export function createInfoDetails(info: PkgInfo): InfoToolDetails {
143
151
  ...(info.license ? { license: bounded(info.license, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS) } : {}),
144
152
  keywords: (info.keywords ?? []).slice(0, TOOL_DETAILS_MAX_KEYWORDS).map((keyword) => bounded(keyword, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)),
145
153
  capabilities: Object.keys(info.pi ?? {}).slice(0, TOOL_DETAILS_MAX_CAPABILITIES).map((name) => bounded(name, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)),
154
+ ...(info.publication?.provenanceUrl ? { provenance: safePackageTarget(info.publication.provenanceUrl) } : {}),
155
+ trustedPublisher: info.publication?.trustedPublisher ?? "unknown",
146
156
  },
147
157
  };
148
158
  }
@@ -191,9 +201,10 @@ export function parsePackageToolDetails(value: unknown): PackageToolDetails | un
191
201
  const pkg = candidate.package as Record<string, unknown>;
192
202
  if (!isPackageSummary(pkg) || !Array.isArray(pkg.keywords) || pkg.keywords.length > TOOL_DETAILS_MAX_KEYWORDS || !pkg.keywords.every((item) => isShortString(item, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS))) return undefined;
193
203
  if (!Array.isArray(pkg.capabilities) || pkg.capabilities.length > TOOL_DETAILS_MAX_CAPABILITIES || !pkg.capabilities.every((item) => isShortString(item, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS))) return undefined;
194
- for (const field of ["homepage", "repository", "license"] as const) {
204
+ for (const field of ["homepage", "repository", "license", "provenance"] as const) {
195
205
  if (pkg[field] !== undefined && !isShortString(pkg[field])) return undefined;
196
206
  }
207
+ if (typeof pkg.trustedPublisher !== "string" || !TRUSTED_PUBLISHER_STATES.has(pkg.trustedPublisher)) return undefined;
197
208
  return value as InfoToolDetails;
198
209
  }
199
210
  if (candidate.kind === "mutation") {
@@ -213,7 +224,9 @@ function isPackageSummary(value: unknown): boolean {
213
224
  const item = value as Record<string, unknown>;
214
225
  return isShortString(item.name, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)
215
226
  && isShortString(item.version, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)
216
- && isShortString(item.description, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS);
227
+ && isShortString(item.description, TOOL_DETAILS_MAX_DESCRIPTION_CHARACTERS)
228
+ && typeof item.shape === "string" && PACKAGE_SHAPES.has(item.shape)
229
+ && typeof item.verified === "boolean";
217
230
  }
218
231
 
219
232
  function contentFallback(result: AgentToolResult<unknown>): string {
@@ -240,7 +253,10 @@ export function renderPackageToolResult(
240
253
  if (details.kind === "search") {
241
254
  const shown = options.expanded ? details.items : details.items.slice(0, TOOL_COLLAPSED_PACKAGE_PREVIEW);
242
255
  const heading = `${theme.bold(String(details.total))} packages · showing ${details.items.length}${details.truncated ? " · bounded" : ""}`;
243
- const rows = shown.map((pkg) => truncateToWidth(`${theme.fg("accent", `${pkg.name}@${pkg.version}`)}${options.expanded && pkg.description ? ` — ${pkg.description}` : ""}`, safeWidth));
256
+ const rows = shown.map((pkg) => {
257
+ const evidence = pkg.verified ? ` [verified ${pkg.shape}]` : " [keyword candidate]";
258
+ return truncateToWidth(`${theme.fg("accent", `${pkg.name}@${pkg.version}`)}${theme.fg("muted", evidence)}${options.expanded && pkg.description ? ` — ${pkg.description}` : ""}`, safeWidth);
259
+ });
244
260
  if (!options.expanded && details.items.length > shown.length) rows.push(theme.fg("muted", `… ${details.items.length - shown.length} more`));
245
261
  return [truncateToWidth(heading, safeWidth), ...rows];
246
262
  }
@@ -251,6 +267,9 @@ export function renderPackageToolResult(
251
267
  if (pkg.license) lines.push(`license: ${pkg.license}`);
252
268
  if (pkg.repository) lines.push(`repository: ${pkg.repository}`);
253
269
  if (pkg.homepage) lines.push(`homepage: ${pkg.homepage}`);
270
+ lines.push(`shape: ${pkg.shape}${pkg.verified ? " (tarball verified)" : " (metadata only)"}`);
271
+ if (pkg.provenance) lines.push(`provenance: ${pkg.provenance}`);
272
+ lines.push(`trusted publisher: ${pkg.trustedPublisher}`);
254
273
  if (pkg.capabilities.length) lines.push(`provides: ${pkg.capabilities.join(", ")}`);
255
274
  if (pkg.keywords.length) lines.push(`keywords: ${pkg.keywords.join(", ")}`);
256
275
  }
@@ -1,8 +1,9 @@
1
1
  /** Agent-facing package tools over the authenticated daemon. */
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- import { packagePermissionDecision, type PackageOperation } from "../../src/security.ts";
5
- import type { Natives } from "./packed.js";
4
+ import { packagePermissionDecision, type PackageOperation } from "./permission.js";
5
+ import { InstallServiceError, PI_COMMAND_NAME, type Natives } from "./packed.js";
6
+ import { reloadWarning } from "./reload.js";
6
7
  import {
7
8
  createInfoDetails,
8
9
  createModelContent,
@@ -41,14 +42,14 @@ export async function approvePackageOperation(
41
42
  }
42
43
  const approved = await ctx.ui.confirm(
43
44
  `${operation[0]!.toUpperCase()}${operation.slice(1)} Pi package`,
44
- `Run: ${command}\n\nThis operation can execute package code or mutate Pi settings/install roots. Continue?`,
45
+ `Run: ${command}\n\nThis operation can execute package code or mutate Pi settings/install roots. ${reloadWarning(operation)} Continue?`,
45
46
  );
46
47
  return approved ? { allowed: true, approved: true } : { allowed: false, approved: false, reason: "cancelled", message: `${operation} cancelled by user.` };
47
48
  }
48
49
 
49
50
  export async function installPackageWithPolicy(
50
51
  source: string,
51
- natives: Pick<Natives, "security" | "install">,
52
+ natives: Pick<Natives, "security" | "install" | "installService">,
52
53
  ctx: ApprovalContext,
53
54
  ) {
54
55
  const approval = await approvePackageOperation("install", `pi install ${source}`, natives, ctx);
@@ -56,7 +57,23 @@ export async function installPackageWithPolicy(
56
57
  const output = approval.message ?? "install denied";
57
58
  return text(output, createMutationDetails("install", source, approval.reason ?? "denied", output));
58
59
  }
59
- const output = await natives.install(source, approval.approved) || `Installed ${source}. Reload with /reload to activate.`;
60
+ let output = await natives.install(source, approval.approved) || `Installed ${source}. Reload with /reload to activate.`;
61
+ // Piggybacks on the same approval already granted for install -- both are
62
+ // the same code-execution mutation tier, not a new consent surface. Silent
63
+ // for the overwhelmingly common case (most Pi packages aren't daemons at
64
+ // all); a genuine failure is reported without failing the install itself,
65
+ // which already succeeded.
66
+ if (source.startsWith("npm:")) {
67
+ try {
68
+ const svc = await natives.installService(source, approval.approved);
69
+ output += `\n${svc.output}`;
70
+ } catch (e) {
71
+ if (!(e instanceof InstallServiceError) || !e.notADaemon) {
72
+ const message = e instanceof Error ? e.message : String(e);
73
+ output += `\nnote: detected a persistent-service daemon but could not register it: ${message}`;
74
+ }
75
+ }
76
+ }
60
77
  return text(output, createMutationDetails("install", source, "succeeded", output));
61
78
  }
62
79
 
@@ -125,7 +142,7 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
125
142
  pi.registerTool({
126
143
  name: "pkg_info",
127
144
  label: "Pi Package Info",
128
- description: "Show bounded metadata and declared Pi resources for one package.",
145
+ description: `Show bounded metadata and declared Pi resources for one package. Special case: querying ${PI_COMMAND_NAME} (Pi itself) also reports the locally running version against the latest published release -- a different question from, and in addition to, the generic npm registry metadata every other package gets.`,
129
146
  parameters: Type.Object({ name: Type.String({ description: "npm package name" }) }),
130
147
  renderCall(args, theme) { return renderPackageToolCall("Package info", args, theme); },
131
148
  renderResult(result, options, theme, context) { return renderPackageToolResult(result, options, theme, context); },
@@ -141,6 +158,21 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
141
158
  info.unpackedSize ? `size: ${(info.unpackedSize / 1024).toFixed(0)} KB` : "",
142
159
  info.modified ? `modified: ${info.modified}` : "",
143
160
  ].filter(Boolean);
161
+ // Special-cased by package identity, not by any change in risk
162
+ // profile -- still a pure read, just answering a second, genuinely
163
+ // different question (what's actually running here, not what npm
164
+ // last published) that the generic registry lookup above cannot.
165
+ if (params.name === PI_COMMAND_NAME) {
166
+ const status = await natives.piStatus().catch(() => undefined);
167
+ if (status?.current) {
168
+ const comparison = status.latest === undefined
169
+ ? "latest release unknown"
170
+ : status.upToDate === false
171
+ ? `behind -- latest is ${status.latest}, run pi update --self`
172
+ : `up to date with the latest ${status.latest}`;
173
+ lines.push(`--- Pi runtime (not npm registry data) ---`, `currently running: ${status.current}`, comparison);
174
+ }
175
+ }
144
176
  return text(lines.join("\n"), createInfoDetails(info));
145
177
  } catch (error) {
146
178
  throw new Error(`pkg_info failed: ${error instanceof Error ? error.message : error}`);
@@ -17,6 +17,63 @@ interface PanelAction {
17
17
  row?: Row;
18
18
  }
19
19
 
20
+ /** Outcome of a confirmed row action, resolved after any real mutation and
21
+ * reload decision -- "changed" means the daemon state changed and Pi has
22
+ * already been reloaded (the caller should stop showing the stale panel);
23
+ * "unchanged"/"cancelled" mean the panel keeps running as-is. */
24
+ export type PackageChoiceOutcome = "changed" | "unchanged" | "cancelled";
25
+
26
+ export async function applyPackageChoice(
27
+ choice: string | undefined,
28
+ row: Row,
29
+ natives: Natives,
30
+ ctx: ExtensionCommandContext,
31
+ ): Promise<PackageChoiceOutcome> {
32
+ if (choice?.startsWith("Update")) {
33
+ try {
34
+ const approval = await approvePackageOperation("update", `pi update --extension npm:${row.name}`, natives, ctx);
35
+ if (!approval.allowed) {
36
+ ctx.ui.notify(approval.message ?? "update denied", "warning");
37
+ return "cancelled";
38
+ }
39
+ ctx.ui.notify(`Updating ${row.name}…`, "info");
40
+ const outcome = await natives.update(`npm:${row.name}`, approval.approved);
41
+ if (!outcome.reloadRequired) {
42
+ const version = outcome.currentVersion ?? outcome.previousVersion;
43
+ const reason = outcome.pinned
44
+ ? `pinned to ${version ?? "an exact version"} -- pi update intentionally leaves pinned packages unchanged`
45
+ : `already up to date${version ? ` at ${version}` : ""}`;
46
+ ctx.ui.notify(`${row.name} is ${reason}.`, "info");
47
+ return "unchanged";
48
+ }
49
+ const transition = outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
50
+ ctx.ui.notify(`Updated ${row.name}${transition}; reloading Pi resources.`, "info");
51
+ await ctx.reload();
52
+ return "changed";
53
+ } catch (e) {
54
+ ctx.ui.notify(`update failed: ${e instanceof Error ? e.message : e}`, "error");
55
+ return "cancelled";
56
+ }
57
+ }
58
+ if (choice === "Remove") {
59
+ try {
60
+ const approval = await approvePackageOperation("remove", `pi remove npm:${row.name}`, natives, ctx);
61
+ if (!approval.allowed) {
62
+ ctx.ui.notify(approval.message ?? "remove denied", "warning");
63
+ return "cancelled";
64
+ }
65
+ await natives.remove(row.name, approval.approved);
66
+ ctx.ui.notify(`Removed ${row.name}; reloading Pi resources.`, "info");
67
+ await ctx.reload();
68
+ return "changed";
69
+ } catch (e) {
70
+ ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
71
+ return "cancelled";
72
+ }
73
+ }
74
+ return "cancelled";
75
+ }
76
+
20
77
  async function loadRows(natives: Natives): Promise<{ rows: Row[]; error?: string }> {
21
78
  try {
22
79
  const [installed, updates] = await Promise.all([
@@ -64,35 +121,8 @@ export async function showPackages(ctx: ExtensionCommandContext, natives: Native
64
121
  ],
65
122
  );
66
123
 
67
- if (choice?.startsWith("Update")) {
68
- try {
69
- const approval = await approvePackageOperation("update", `pi update --extension npm:${row.name}`, natives, ctx);
70
- if (!approval.allowed) {
71
- ctx.ui.notify(approval.message ?? "update denied", "warning");
72
- continue;
73
- }
74
- ctx.ui.notify(`Updating ${row.name}…`, "info");
75
- await natives.update(`npm:${row.name}`, approval.approved);
76
- ctx.ui.notify(`Updated ${row.name} to ${row.latest}; reloading Pi resources`, "info");
77
- await ctx.reload();
78
- return;
79
- } catch (e) {
80
- ctx.ui.notify(`update failed: ${e instanceof Error ? e.message : e}`, "error");
81
- }
82
- } else if (choice === "Remove") {
83
- try {
84
- const approval = await approvePackageOperation("remove", `pi remove npm:${row.name}`, natives, ctx);
85
- if (approval.allowed) {
86
- await natives.remove(row.name, approval.approved);
87
- ctx.ui.notify(`Removed ${row.name}`, "info");
88
- rows = rows.filter((r) => r.name !== row.name);
89
- } else {
90
- ctx.ui.notify(approval.message ?? "remove denied", "warning");
91
- }
92
- } catch (e) {
93
- ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
94
- }
95
- }
124
+ const outcome = await applyPackageChoice(choice, row, natives, ctx);
125
+ if (outcome === "changed") return; // ctx.reload() already replaced the session
96
126
  }
97
127
  }
98
128
 
package/package.json CHANGED
@@ -1,43 +1,30 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.5.3",
4
- "description": "Package service for the Pi agent: search/info/install/updates + /packages TUI, backed by a long-running Bun service",
3
+ "version": "0.6.1",
4
+ "description": "Pi package tools, commands, profiles, and TUI for the Packed daemon",
5
5
  "type": "module",
6
- "bin": {
7
- "packed": "src/cli.ts"
6
+ "keywords": ["pi-package"],
7
+ "pi": {
8
+ "extensions": ["extension/src/index.ts"]
8
9
  },
9
- "keywords": [
10
- "pi-package"
11
- ],
12
10
  "scripts": {
13
- "test": "bun test",
14
- "typecheck": "bun x tsc --noEmit",
15
- "cli": "bun src/cli.ts",
16
- "serve": "bun src/cli.ts serve"
17
- },
18
- "pi": {
19
- "extensions": [
20
- "extension/src/index.ts"
21
- ]
11
+ "test": "bun test test",
12
+ "typecheck": "bunx tsc --noEmit"
22
13
  },
23
14
  "dependencies": {
24
- "@danypops/daemon-kit": "^0.4.0"
15
+ "@danypops/packed": "^0.2.0",
16
+ "@danypops/vehicle-client": "^0.1.1",
17
+ "@danypops/vehicle-client-pi": "^0.1.5"
25
18
  },
26
19
  "peerDependencies": {
27
20
  "@earendil-works/pi-coding-agent": "*",
28
- "@earendil-works/pi-tui": "*",
29
- "typebox": "*"
30
- },
31
- "devDependencies": {
32
- "bun-types": "latest"
21
+ "typebox": "*",
22
+ "@earendil-works/pi-tui": "*"
33
23
  },
34
24
  "repository": {
35
25
  "type": "git",
36
- "url": "git+https://github.com/DanyPops/pi-packed.git"
26
+ "url": "git+https://github.com/DanyPops/packed.git",
27
+ "directory": "packages/pi-packed"
37
28
  },
38
- "files": [
39
- "src",
40
- "extension",
41
- "README.md"
42
- ]
29
+ "files": ["extension", "setup", "README.md"]
43
30
  }
package/src/cache.ts DELETED
@@ -1,21 +0,0 @@
1
- /** TTLCache — the smart-proxy concern, nothing more. */
2
- import { CACHE_TTL_MS } from "./constants.ts";
3
-
4
- export class TTLCache {
5
- private m = new Map<string, { body: string; expires: number }>();
6
- constructor(private ttlMs = CACHE_TTL_MS) {}
7
-
8
- get(key: string): string | undefined {
9
- const e = this.m.get(key);
10
- if (!e) return undefined;
11
- if (Date.now() > e.expires) {
12
- this.m.delete(key);
13
- return undefined;
14
- }
15
- return e.body;
16
- }
17
-
18
- set(key: string, body: string): void {
19
- this.m.set(key, { body, expires: Date.now() + this.ttlMs });
20
- }
21
- }