pi-extmgr 0.1.16 → 0.1.18

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
@@ -142,7 +142,6 @@ Examples:
142
142
  - **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
143
143
  - **Invalid JSON is handled safely**: malformed `auto-update.json` / metadata cache files are backed up and reset; invalid `.pi/settings.json` is not overwritten during package-extension toggles.
144
144
  - **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.
145
- - **Remove requires restart**: After removing a package, you need to fully restart Pi (not just a reload) for it to be completely unloaded.
146
145
 
147
146
  ## License
148
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -237,6 +237,29 @@ export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
237
237
  return parseInstalledPackagesOutputInternal(text, { dedupeBySource: true });
238
238
  }
239
239
 
240
+ /**
241
+ * Check if a specific source is installed by running `pi list` and checking output.
242
+ * Returns true if found, false if not found.
243
+ */
244
+ export async function isSourceInstalled(
245
+ source: string,
246
+ ctx: ExtensionCommandContext | ExtensionContext,
247
+ pi: ExtensionAPI
248
+ ): Promise<boolean> {
249
+ try {
250
+ const res = await pi.exec("pi", ["list"], { timeout: TIMEOUTS.listPackages, cwd: ctx.cwd });
251
+ if (res.code !== 0) return false;
252
+
253
+ const normalized = source.toLowerCase().replace(/\\/g, "/");
254
+ const stdout = res.stdout || "";
255
+
256
+ // Check if the source appears in the output (case-insensitive, normalized paths)
257
+ return stdout.toLowerCase().replace(/\\/g, "/").includes(normalized);
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
240
263
  export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
241
264
  return parseInstalledPackagesOutputInternal(text, { dedupeBySource: false });
242
265
  }
@@ -6,7 +6,8 @@ import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
8
8
  import { normalizePackageSource } from "../utils/format.js";
9
- import { clearSearchCache } from "./discovery.js";
9
+ import { clearSearchCache, isSourceInstalled } from "./discovery.js";
10
+ import { waitForCondition } from "../utils/retry.js";
10
11
  import { logPackageInstall } from "../utils/history.js";
11
12
  import { clearUpdatesAvailable } from "../utils/settings.js";
12
13
  import { notify, error as notifyError, success } from "../utils/notify.js";
@@ -129,8 +130,28 @@ export async function installPackage(
129
130
  success(ctx, `Installed ${normalized} (${scope})`);
130
131
  clearUpdatesAvailable(pi, ctx);
131
132
 
132
- void updateExtmgrStatus(ctx, pi);
133
- await confirmReload(ctx, "Package installed.");
133
+ // Wait for the extension to be discoverable before reloading.
134
+ // This prevents a race condition where ctx.reload() runs before
135
+ // settings.json or extension files are fully flushed to disk.
136
+ notify(ctx, "Waiting for extension to be ready...", "info");
137
+ const isReady = await waitForCondition(() => isSourceInstalled(normalized, ctx, pi), {
138
+ maxAttempts: 10,
139
+ delayMs: 100,
140
+ backoff: "exponential",
141
+ });
142
+
143
+ if (!isReady) {
144
+ notify(
145
+ ctx,
146
+ "Extension may not be immediately available. Reload pi manually if needed.",
147
+ "warning"
148
+ );
149
+ }
150
+
151
+ const reloaded = await confirmReload(ctx, "Package installed.");
152
+ if (!reloaded) {
153
+ void updateExtmgrStatus(ctx, pi);
154
+ }
134
155
  }
135
156
 
136
157
  export async function installFromUrl(
@@ -189,8 +210,27 @@ export async function installFromUrl(
189
210
  logPackageInstall(pi, url, name, undefined, scope, true);
190
211
  success(ctx, `Installed ${name} to:\n${destPath}`);
191
212
  clearUpdatesAvailable(pi, ctx);
192
- void updateExtmgrStatus(ctx, pi);
193
- await confirmReload(ctx, "Extension installed.");
213
+
214
+ // Wait for the extension file to be fully written and discoverable
215
+ notify(ctx, "Waiting for extension to be ready...", "info");
216
+ const isReady = await waitForCondition(() => isSourceInstalled(name, ctx, pi), {
217
+ maxAttempts: 10,
218
+ delayMs: 100,
219
+ backoff: "exponential",
220
+ });
221
+
222
+ if (!isReady) {
223
+ notify(
224
+ ctx,
225
+ "Extension may not be immediately available. Reload pi manually if needed.",
226
+ "warning"
227
+ );
228
+ }
229
+
230
+ const reloaded = await confirmReload(ctx, "Extension installed.");
231
+ if (!reloaded) {
232
+ void updateExtmgrStatus(ctx, pi);
233
+ }
194
234
  }
195
235
 
196
236
  /**
@@ -405,6 +445,25 @@ export async function installPackageLocally(
405
445
  logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true);
406
446
  success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}/index.ts`);
407
447
  clearUpdatesAvailable(pi, ctx);
408
- void updateExtmgrStatus(ctx, pi);
409
- await confirmReload(ctx, "Extension installed.");
448
+
449
+ // Wait for the extension to be discoverable before reloading
450
+ notify(ctx, "Waiting for extension to be ready...", "info");
451
+ const isReady = await waitForCondition(() => isSourceInstalled(`npm:${packageName}`, ctx, pi), {
452
+ maxAttempts: 10,
453
+ delayMs: 100,
454
+ backoff: "exponential",
455
+ });
456
+
457
+ if (!isReady) {
458
+ notify(
459
+ ctx,
460
+ "Extension may not be immediately available. Reload pi manually if needed.",
461
+ "warning"
462
+ );
463
+ }
464
+
465
+ const reloaded = await confirmReload(ctx, "Extension installed.");
466
+ if (!reloaded) {
467
+ void updateExtmgrStatus(ctx, pi);
468
+ }
410
469
  }
@@ -7,7 +7,9 @@ import {
7
7
  getInstalledPackages,
8
8
  clearSearchCache,
9
9
  parseInstalledPackagesOutputAllScopes,
10
+ isSourceInstalled,
10
11
  } from "./discovery.js";
12
+ import { waitForCondition } from "../utils/retry.js";
11
13
  import { formatInstalledPackageLabel, formatBytes, parseNpmSource } from "../utils/format.js";
12
14
  import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
13
15
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
@@ -16,7 +18,6 @@ import { notify, error as notifyError, success } from "../utils/notify.js";
16
18
  import {
17
19
  confirmAction,
18
20
  confirmReload,
19
- confirmRestart,
20
21
  showProgress,
21
22
  formatListOutput,
22
23
  } from "../utils/ui-helpers.js";
@@ -26,12 +27,10 @@ import { TIMEOUTS, UI } from "../constants.js";
26
27
 
27
28
  export interface PackageMutationOutcome {
28
29
  reloaded: boolean;
29
- restartRequested: boolean;
30
30
  }
31
31
 
32
32
  const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = {
33
33
  reloaded: false,
34
- restartRequested: false,
35
34
  };
36
35
 
37
36
  function packageMutationOutcome(
@@ -71,9 +70,11 @@ async function updatePackageInternal(
71
70
  logPackageUpdate(pi, source, source, undefined, true);
72
71
  success(ctx, `Updated ${source}`);
73
72
  clearUpdatesAvailable(pi, ctx);
74
- void updateExtmgrStatus(ctx, pi);
75
73
 
76
74
  const reloaded = await confirmReload(ctx, "Package updated.");
75
+ if (!reloaded) {
76
+ void updateExtmgrStatus(ctx, pi);
77
+ }
77
78
  return packageMutationOutcome({ reloaded });
78
79
  }
79
80
 
@@ -100,9 +101,11 @@ async function updatePackagesInternal(
100
101
 
101
102
  success(ctx, "Packages updated");
102
103
  clearUpdatesAvailable(pi, ctx);
103
- void updateExtmgrStatus(ctx, pi);
104
104
 
105
105
  const reloaded = await confirmReload(ctx, "Packages updated.");
106
+ if (!reloaded) {
107
+ void updateExtmgrStatus(ctx, pi);
108
+ }
106
109
  return packageMutationOutcome({ reloaded });
107
110
  }
108
111
 
@@ -334,14 +337,33 @@ async function removePackageInternal(
334
337
  if (failures.length === 0) {
335
338
  clearUpdatesAvailable(pi, ctx);
336
339
  }
337
- void updateExtmgrStatus(ctx, pi);
338
340
 
339
- const restartRequested = await confirmRestart(
340
- ctx,
341
- `Removal complete.\n\n⚠️ Extensions/prompts/skills/themes from removed packages are fully unloaded after restarting pi.`
342
- );
341
+ // Wait for the extension to be fully removed from pi list before restarting.
342
+ // This prevents a race condition where the removal hasn't flushed to disk yet.
343
+ if (failures.length === 0 && targets.length > 0) {
344
+ notify(ctx, "Waiting for removal to complete...", "info");
345
+ const mainTarget = targets[0];
346
+ if (mainTarget) {
347
+ const isRemoved = await waitForCondition(
348
+ async () => {
349
+ const stillInstalled = await isSourceInstalled(mainTarget.source, ctx, pi);
350
+ return !stillInstalled;
351
+ },
352
+ { maxAttempts: 10, delayMs: 100, backoff: "exponential" }
353
+ );
343
354
 
344
- return packageMutationOutcome({ restartRequested });
355
+ if (!isRemoved) {
356
+ notify(ctx, "Extension may still be active. Restart pi manually if needed.", "warning");
357
+ }
358
+ }
359
+ }
360
+
361
+ const reloaded = await confirmReload(ctx, "Removal complete.");
362
+ if (!reloaded) {
363
+ void updateExtmgrStatus(ctx, pi);
364
+ }
365
+
366
+ return packageMutationOutcome({ reloaded });
345
367
  }
346
368
 
347
369
  export async function removePackage(
@@ -419,11 +441,11 @@ export async function showPackageActions(
419
441
  switch (action) {
420
442
  case "remove": {
421
443
  const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
422
- return outcome.reloaded || outcome.restartRequested;
444
+ return outcome.reloaded;
423
445
  }
424
446
  case "update": {
425
447
  const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
426
- return outcome.reloaded || outcome.restartRequested;
448
+ return outcome.reloaded;
427
449
  }
428
450
  case "details": {
429
451
  const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
package/src/ui/unified.ts CHANGED
@@ -650,7 +650,7 @@ async function navigateWithPendingGuard(
650
650
  return "done";
651
651
  case "update-all": {
652
652
  const outcome = await updatePackagesWithOutcome(ctx, pi);
653
- return outcome.reloaded || outcome.restartRequested ? "exit" : "done";
653
+ return outcome.reloaded ? "exit" : "done";
654
654
  }
655
655
  case "auto-update":
656
656
  await promptAutoUpdateWizard(pi, ctx, (packages) => {
@@ -819,11 +819,11 @@ async function handleUnifiedAction(
819
819
  switch (result.action) {
820
820
  case "update": {
821
821
  const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
822
- return outcome.reloaded || outcome.restartRequested;
822
+ return outcome.reloaded;
823
823
  }
824
824
  case "remove": {
825
825
  const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
826
- return outcome.reloaded || outcome.restartRequested;
826
+ return outcome.reloaded;
827
827
  }
828
828
  case "details": {
829
829
  const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Retry utilities for async operations
3
+ */
4
+
5
+ export interface RetryOptions {
6
+ maxAttempts?: number;
7
+ delayMs?: number;
8
+ backoff?: "fixed" | "linear" | "exponential";
9
+ }
10
+
11
+ export async function retryWithBackoff<T>(
12
+ operation: () => Promise<T | undefined>,
13
+ options: RetryOptions = {}
14
+ ): Promise<T | undefined> {
15
+ const { maxAttempts = 5, delayMs = 100, backoff = "exponential" } = options;
16
+
17
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
18
+ const result = await operation();
19
+ if (result !== undefined) {
20
+ return result;
21
+ }
22
+
23
+ if (attempt < maxAttempts) {
24
+ const delay =
25
+ backoff === "exponential"
26
+ ? delayMs * Math.pow(2, attempt - 1)
27
+ : backoff === "linear"
28
+ ? delayMs * attempt
29
+ : delayMs;
30
+ await new Promise((resolve) => setTimeout(resolve, delay));
31
+ }
32
+ }
33
+
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * Wait for a condition to be true with timeout
39
+ */
40
+ export async function waitForCondition(
41
+ condition: () => Promise<boolean> | boolean,
42
+ options: RetryOptions = {}
43
+ ): Promise<boolean> {
44
+ const result = await retryWithBackoff(async () => {
45
+ const value = await condition();
46
+ return value ? true : undefined;
47
+ }, options);
48
+ return result === true;
49
+ }