pi-updater 0.3.1 → 0.3.3

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/CHANGELOG.md CHANGED
@@ -1,13 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.3.3 - 2026-05-21
6
+
7
+ - Honor the update service `packageName` and install the explicit advertised npm package/version.
8
+ - Avoid native `pi update --self` so pi-updater can update through stale native self-update behavior.
9
+ - Keep loading under the legacy `@mariozechner/pi-coding-agent` runtime so package-name migrations can run.
10
+ - Migrate the global pi binary when the update service advertises a new package name by force-installing the advertised package after an engine-strict dry run.
11
+ - Treat package-name-only migrations as updates only when the advertised version is unchanged or newer, while staying on the current package if `packageName` is absent.
12
+ - Respect npm engine requirements during pi installs so updates fail safely when Node.js is too old.
13
+ - Switch extension imports and optional peer dependency to `@earendil-works/pi-coding-agent` so installing pi-updater no longer pulls the old `@mariozechner` pi package.
14
+
15
+ ## 0.3.2 - 2026-05-02
16
+
17
+ - Use pi's native `pi update --self` installer on pi 0.70.3+ and keep npm install as the fallback for older pi versions.
18
+ - Use pi's `https://pi.dev/api/latest-version` update endpoint with a `pi/<version>` user agent.
19
+ - Keep pi-updater's interactive startup prompt on pi 0.70.3+ while avoiding pi's duplicate built-in version notice.
20
+
3
21
  ## 0.3.1 - 2026-04-04
4
22
 
5
23
  - Compatibility with pi 0.65+: use `session_start` instead of legacy `session_switch` for automatic checks. See [pi-mono v0.65.0](https://github.com/badlogic/pi-mono/releases/tag/v0.65.0).
6
24
  - Store cache and dismissed-version state in pi's configured agent directory.
7
25
  - Preserve `--no-session` mode when restarting after an update and show the correct manual restart hint.
8
26
 
9
- ## Unreleased
10
-
11
27
  ## 0.3.0 - 2026-03-23
12
28
 
13
29
  - Auto-restart pi after a successful update. Asks to restart, then seamlessly relaunches on the current session.
package/README.md CHANGED
@@ -5,29 +5,27 @@ A lightweight, Codex-style auto-updater for pi with fast, cache-first startup ch
5
5
  - npm: https://www.npmjs.com/package/pi-updater
6
6
  - repo: https://github.com/tonze/pi-updater
7
7
 
8
- > **Note:** Automatic installation currently supports npm-based pi installs only.
8
+ > **Note:** pi-updater installs the exact package/version returned by pi's update service with npm. This handles pi package-name migrations and avoids stale native self-update behavior while still keeping the interactive prompt/restart flow.
9
9
 
10
10
  <img width="800" height="482" alt="Screenshot 2026-02-28 at 09 01 37" src="https://github.com/user-attachments/assets/89df2dad-8d91-464b-b3cb-dfd15bce1c06" />
11
11
 
12
12
  ## What it does
13
13
 
14
- **On startup:** if a newer version is available, shows a prompt:
14
+ If a newer version is available, pi-updater shows a startup prompt:
15
15
  - **Update now** — install with npm, then auto-restart pi on the current session
16
16
  - **Skip** — dismiss until next session
17
17
  - **Skip this version** — don't ask again until a newer version appears
18
18
 
19
19
  After a successful update, pi-updater asks whether to restart immediately. If confirmed, pi relaunches seamlessly on the current session. In non-interactive modes or if auto-restart fails, it falls back to a manual restart message. Ephemeral `--no-session` runs stay ephemeral on restart.
20
20
 
21
- **In the background (once per run):** performs one live npm check and can show the prompt in the same session when a new release is detected.
22
-
23
- **`/update`:** manually check for updates (always fetches fresh from npm, unless `PI_OFFLINE` is set).
21
+ **`/update`:** manually check for updates (always fetches fresh from pi's update service, unless `PI_OFFLINE` is set). It installs the exact npm package/version advertised by pi's update service and respects npm engine requirements, so upgrade Node.js first if the new pi release requires it.
24
22
 
25
23
  ## How version checks work
26
24
 
27
25
  pi-updater uses a cache-first approach to keep startup fast:
28
26
 
29
27
  1. On startup, cached version data is checked instantly.
30
- 2. One background live fetch refreshes the cache.
28
+ 2. One background live fetch refreshes the cache from pi's update service.
31
29
  3. If the background fetch finds a newer version, pi-updater can prompt in the same session.
32
30
  4. Automatic checks are skipped when `PI_SKIP_VERSION_CHECK` or `PI_OFFLINE` is set.
33
31
 
package/index.ts CHANGED
@@ -1,128 +1,387 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
- } from "@mariozechner/pi-coding-agent";
5
- import { VERSION, BorderedLoader, getAgentDir } from "@mariozechner/pi-coding-agent";
4
+ } from "@earendil-works/pi-coding-agent";
6
5
  import { spawnSync } from "node:child_process";
7
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, realpathSync, writeFileSync, mkdirSync } from "node:fs";
8
7
  import { join, dirname } from "node:path";
9
8
 
10
- const PACKAGE_NAME = "@mariozechner/pi-coding-agent";
11
- const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
12
- const CACHE_FILE = join(getAgentDir(), "update-cache.json");
9
+ const PACKAGE_NAME = "@earendil-works/pi-coding-agent";
10
+ const LEGACY_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
11
+ const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
12
+ const NATIVE_VERSION_NOTICE_MIN_VERSION = "0.70.3";
13
13
 
14
14
  const ENV_SKIP_VERSION_CHECK = "PI_SKIP_VERSION_CHECK";
15
15
  const ENV_OFFLINE = "PI_OFFLINE";
16
+ const ENV_INTERNAL_SKIP = "PI_UPDATER_SUPPRESSED_NATIVE_VERSION_CHECK";
17
+
18
+ interface LatestRelease {
19
+ version: string;
20
+ packageName?: string;
21
+ }
16
22
 
17
23
  interface VersionCache {
18
24
  latestVersion: string;
25
+ latestPackageName?: string;
19
26
  dismissedVersion?: string;
27
+ dismissedPackageName?: string;
20
28
  checkedAt?: string;
21
29
  }
22
30
 
23
- function readCache(): VersionCache | undefined {
31
+ type BorderedLoaderConstructor = new (...args: any[]) => any;
32
+
33
+ interface PiRuntime {
34
+ VERSION: string;
35
+ BorderedLoader: BorderedLoaderConstructor;
36
+ getAgentDir: () => string;
37
+ packageName: string;
38
+ }
39
+
40
+ let VERSION = "0.0.0";
41
+ let BorderedLoader: BorderedLoaderConstructor;
42
+ let getAgentDir: () => string;
43
+
44
+ function packageNameFromNodeModulesPath(path: string): string | undefined {
45
+ const normalized = path.replace(/\\/g, "/");
46
+ const marker = "/node_modules/";
47
+ const index = normalized.lastIndexOf(marker);
48
+ if (index === -1) return undefined;
49
+
50
+ const parts = normalized.slice(index + marker.length).split("/");
51
+ if (!parts[0]) return undefined;
52
+ if (parts[0].startsWith("@")) {
53
+ if (!parts[1]) return undefined;
54
+ return `${parts[0]}/${parts[1]}`;
55
+ }
56
+ return parts[0];
57
+ }
58
+
59
+ async function findOwningPiPackageName(pi: ExtensionAPI): Promise<string | undefined> {
60
+ try {
61
+ const cmd = process.platform === "win32" ? "where" : "which";
62
+ const result = await pi.exec(cmd, ["pi"]);
63
+ const binary = result.code === 0 ? result.stdout?.trim().split(/\r?\n/)[0] : undefined;
64
+ if (!binary) return undefined;
65
+
66
+ try {
67
+ return packageNameFromNodeModulesPath(realpathSync(binary));
68
+ } catch {
69
+ return packageNameFromNodeModulesPath(binary);
70
+ }
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ async function loadPiRuntime(preferredPackageName?: string): Promise<PiRuntime> {
77
+ const packageNames = [
78
+ preferredPackageName,
79
+ PACKAGE_NAME,
80
+ LEGACY_PACKAGE_NAME,
81
+ ].filter((packageName): packageName is string => !!packageName);
82
+
83
+ for (const packageName of new Set(packageNames)) {
84
+ try {
85
+ const runtime = await import(packageName);
86
+ if (
87
+ typeof runtime.VERSION === "string" &&
88
+ typeof runtime.BorderedLoader === "function" &&
89
+ typeof runtime.getAgentDir === "function"
90
+ ) {
91
+ return {
92
+ VERSION: runtime.VERSION,
93
+ BorderedLoader: runtime.BorderedLoader,
94
+ getAgentDir: runtime.getAgentDir,
95
+ packageName,
96
+ };
97
+ }
98
+ } catch {}
99
+ }
100
+
101
+ throw new Error(`Could not load ${PACKAGE_NAME} or ${LEGACY_PACKAGE_NAME}`);
102
+ }
103
+
104
+ function readCache(cacheFile: string): VersionCache | undefined {
24
105
  try {
25
- return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
106
+ return JSON.parse(readFileSync(cacheFile, "utf-8"));
26
107
  } catch {
27
108
  return undefined;
28
109
  }
29
110
  }
30
111
 
31
- function writeCache(cache: VersionCache) {
112
+ function writeCache(cacheFile: string, cache: VersionCache) {
32
113
  try {
33
- mkdirSync(dirname(CACHE_FILE), { recursive: true });
34
- writeFileSync(CACHE_FILE, JSON.stringify(cache) + "\n");
114
+ mkdirSync(dirname(cacheFile), { recursive: true });
115
+ writeFileSync(cacheFile, JSON.stringify(cache) + "\n");
35
116
  } catch {}
36
117
  }
37
118
 
38
- function parseVersion(v: string): [number, number, number] | undefined {
39
- const parts = v.trim().split(".");
40
- if (parts.length !== 3) return undefined;
41
- const nums = parts.map(Number);
42
- if (nums.some(isNaN)) return undefined;
43
- return nums as [number, number, number];
119
+ interface ParsedVersion {
120
+ major: number;
121
+ minor: number;
122
+ patch: number;
123
+ prerelease?: string;
124
+ }
125
+
126
+ function parseVersion(version: string): ParsedVersion | undefined {
127
+ const match = version
128
+ .trim()
129
+ .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
130
+ if (!match) return undefined;
131
+ return {
132
+ major: Number.parseInt(match[1], 10),
133
+ minor: Number.parseInt(match[2], 10),
134
+ patch: Number.parseInt(match[3], 10),
135
+ prerelease: match[4],
136
+ };
137
+ }
138
+
139
+ function compareVersions(leftVersion: string, rightVersion: string): number | undefined {
140
+ const left = parseVersion(leftVersion);
141
+ const right = parseVersion(rightVersion);
142
+ if (!left || !right) return undefined;
143
+ if (left.major !== right.major) return left.major - right.major;
144
+ if (left.minor !== right.minor) return left.minor - right.minor;
145
+ if (left.patch !== right.patch) return left.patch - right.patch;
146
+ if (left.prerelease === right.prerelease) return 0;
147
+ if (!left.prerelease) return 1;
148
+ if (!right.prerelease) return -1;
149
+ return left.prerelease.localeCompare(right.prerelease);
44
150
  }
45
151
 
46
- function isNewer(latest: string, current: string): boolean {
47
- const l = parseVersion(latest);
48
- const c = parseVersion(current);
49
- if (!l || !c) return false;
50
- if (l[0] !== c[0]) return l[0] > c[0];
51
- if (l[1] !== c[1]) return l[1] > c[1];
52
- return l[2] > c[2];
152
+ function isAtLeast(version: string, minimum: string): boolean {
153
+ const comparison = compareVersions(version, minimum);
154
+ return comparison !== undefined && comparison >= 0;
53
155
  }
54
156
 
55
157
  function isEnvSet(name: string): boolean {
56
158
  return Boolean(process.env[name]);
57
159
  }
58
160
 
161
+ const userSkippedVersionCheck =
162
+ isEnvSet(ENV_SKIP_VERSION_CHECK) && !isEnvSet(ENV_INTERNAL_SKIP);
163
+
59
164
  function shouldSkipAutoChecks(): boolean {
60
- return isEnvSet(ENV_SKIP_VERSION_CHECK) || isEnvSet(ENV_OFFLINE);
165
+ return userSkippedVersionCheck || isEnvSet(ENV_OFFLINE);
61
166
  }
62
167
 
63
168
  function isOffline(): boolean {
64
169
  return isEnvSet(ENV_OFFLINE);
65
170
  }
66
171
 
67
- function saveLatestToCache(latest: string) {
68
- const prev = readCache();
69
- writeCache({
70
- latestVersion: latest,
172
+ function piUserAgent(): string {
173
+ const runtime = process.versions.bun
174
+ ? `bun/${process.versions.bun}`
175
+ : `node/${process.version}`;
176
+ return `pi/${VERSION} (${process.platform}; ${runtime}; ${process.arch})`;
177
+ }
178
+
179
+ function hasNativeVersionNotice(): boolean {
180
+ return isAtLeast(VERSION, NATIVE_VERSION_NOTICE_MIN_VERSION);
181
+ }
182
+
183
+ function targetPackageName(release: LatestRelease, currentPackageName: string): string {
184
+ return release.packageName ?? currentPackageName;
185
+ }
186
+
187
+ function releaseKey(release: LatestRelease, currentPackageName: string): string {
188
+ return `${targetPackageName(release, currentPackageName)}@${release.version}`;
189
+ }
190
+
191
+ function isUpdateAvailable(release: LatestRelease, currentPackageName: string): boolean {
192
+ const comparison = compareVersions(release.version, VERSION);
193
+ if (comparison === undefined) return false;
194
+ return comparison > 0 || (comparison === 0 && targetPackageName(release, currentPackageName) !== currentPackageName);
195
+ }
196
+
197
+ function isDismissed(
198
+ cache: VersionCache,
199
+ release: LatestRelease,
200
+ currentPackageName: string,
201
+ ): boolean {
202
+ if (cache.dismissedVersion !== release.version) return false;
203
+ if (!cache.dismissedPackageName) return !release.packageName;
204
+ return cache.dismissedPackageName === targetPackageName(release, currentPackageName);
205
+ }
206
+
207
+ function saveLatestToCache(cacheFile: string, latest: LatestRelease) {
208
+ const prev = readCache(cacheFile);
209
+ writeCache(cacheFile, {
210
+ latestVersion: latest.version,
211
+ latestPackageName: latest.packageName,
71
212
  dismissedVersion: prev?.dismissedVersion,
213
+ dismissedPackageName: prev?.dismissedPackageName,
72
214
  checkedAt: new Date().toISOString(),
73
215
  });
74
216
  }
75
217
 
76
- async function fetchLatestVersion(): Promise<string | undefined> {
218
+ async function fetchLatestRelease(): Promise<LatestRelease | undefined> {
77
219
  try {
78
- const res = await fetch(REGISTRY_URL, {
220
+ const res = await fetch(LATEST_VERSION_URL, {
221
+ headers: {
222
+ "User-Agent": piUserAgent(),
223
+ accept: "application/json",
224
+ },
79
225
  signal: AbortSignal.timeout(10_000),
80
226
  });
81
227
  if (!res.ok) return undefined;
82
- return ((await res.json()) as { version?: string }).version;
228
+ const data = (await res.json()) as { version?: string; packageName?: string };
229
+ if (typeof data.version !== "string" || !data.version.trim()) return undefined;
230
+ const packageName =
231
+ typeof data.packageName === "string" && data.packageName.trim()
232
+ ? data.packageName.trim()
233
+ : undefined;
234
+ return { version: data.version.trim(), packageName };
83
235
  } catch {
84
236
  return undefined;
85
237
  }
86
238
  }
87
239
 
88
240
  /** Returns a cached upgrade if available and not dismissed. */
89
- function getCachedUpgradeVersion(): string | undefined {
90
- const cache = readCache();
241
+ function getCachedUpgradeRelease(
242
+ cacheFile: string,
243
+ currentPackageName: string,
244
+ ): LatestRelease | undefined {
245
+ const cache = readCache(cacheFile);
91
246
  if (!cache) return undefined;
92
- if (!isNewer(cache.latestVersion, VERSION)) return undefined;
93
- if (cache.dismissedVersion === cache.latestVersion) return undefined;
94
- return cache.latestVersion;
247
+ const release = {
248
+ version: cache.latestVersion,
249
+ packageName: cache.latestPackageName,
250
+ };
251
+ if (!isUpdateAvailable(release, currentPackageName)) return undefined;
252
+ if (isDismissed(cache, release, currentPackageName)) return undefined;
253
+ return release;
95
254
  }
96
255
 
97
- /** Fetch latest from npm and refresh cache. */
98
- async function refreshLatestVersionInCache(): Promise<string | undefined> {
99
- const latest = await fetchLatestVersion();
256
+ /** Fetch latest from Pi's update endpoint and refresh cache. */
257
+ async function refreshLatestReleaseInCache(cacheFile: string): Promise<LatestRelease | undefined> {
258
+ const latest = await fetchLatestRelease();
100
259
  if (!latest) return undefined;
101
- saveLatestToCache(latest);
260
+ saveLatestToCache(cacheFile, latest);
102
261
  return latest;
103
262
  }
104
263
 
105
- function dismissVersion(version: string) {
106
- const cache = readCache();
107
- writeCache({
108
- latestVersion: cache?.latestVersion ?? version,
109
- dismissedVersion: version,
264
+ function dismissRelease(
265
+ cacheFile: string,
266
+ release: LatestRelease,
267
+ currentPackageName: string,
268
+ ) {
269
+ const cache = readCache(cacheFile);
270
+ writeCache(cacheFile, {
271
+ latestVersion: cache?.latestVersion ?? release.version,
272
+ latestPackageName: cache?.latestPackageName ?? release.packageName,
273
+ dismissedVersion: release.version,
274
+ dismissedPackageName: targetPackageName(release, currentPackageName),
110
275
  checkedAt: cache?.checkedAt,
111
276
  });
112
277
  }
113
278
 
114
- function getInstallCommand(version: string): { program: string; args: string[] } {
279
+ interface InstallStep {
280
+ program: string;
281
+ args: string[];
282
+ display: string;
283
+ }
284
+
285
+ interface InstallCommand {
286
+ steps: InstallStep[];
287
+ display: string;
288
+ targetVersion: string;
289
+ targetPackageName: string;
290
+ }
291
+
292
+ interface InstallFailure {
293
+ step: InstallStep;
294
+ code: number;
295
+ output: string;
296
+ }
297
+
298
+ function npmInstallStep(packageSpec: string, args: string[] = []): InstallStep {
299
+ const stepArgs = ["install", "-g", packageSpec, ...args];
115
300
  return {
116
301
  program: "npm",
117
- args: ["install", "-g", `${PACKAGE_NAME}@${version}`],
302
+ args: stepArgs,
303
+ display: ["npm", ...stepArgs].join(" "),
118
304
  };
119
305
  }
120
306
 
121
- function fmtCmd(cmd: { program: string; args: string[] }): string {
122
- return `${cmd.program} ${cmd.args.join(" ")}`;
307
+ function getInstallCommand(
308
+ release: LatestRelease,
309
+ currentPackageName: string,
310
+ ): InstallCommand {
311
+ const updatePackageName = targetPackageName(release, currentPackageName);
312
+ const targetVersion = release.version;
313
+ const packageSpec = `${updatePackageName}@${targetVersion}`;
314
+ const packageChanged = updatePackageName !== currentPackageName;
315
+ const installStep = npmInstallStep(packageSpec, ["--engine-strict=true"]);
316
+
317
+ if (!packageChanged) {
318
+ return {
319
+ steps: [installStep],
320
+ display: installStep.display,
321
+ targetVersion,
322
+ targetPackageName: updatePackageName,
323
+ };
324
+ }
325
+
326
+ return {
327
+ steps: [
328
+ npmInstallStep(packageSpec, ["--dry-run", "--engine-strict=true"]),
329
+ npmInstallStep(packageSpec, ["--force"]),
330
+ ],
331
+ display: `migrate ${currentPackageName} → ${packageSpec}`,
332
+ targetVersion,
333
+ targetPackageName: updatePackageName,
334
+ };
123
335
  }
124
336
 
125
- export default function (pi: ExtensionAPI) {
337
+ function extractRequiredNodeVersion(output: string): string | undefined {
338
+ return (
339
+ output.match(/required:\s*\{\s*node:\s*['"]([^'"]+)['"]/i)?.[1] ??
340
+ output.match(/Required:\s*\{[^}]*"node":"([^"]+)"/i)?.[1]
341
+ );
342
+ }
343
+
344
+ function formatInstallFailure(failure: InstallFailure, cmd: InstallCommand): string {
345
+ if (/EBADENGINE|Unsupported engine|not compatible with your version of node/i.test(failure.output)) {
346
+ const requiredNode = extractRequiredNodeVersion(failure.output);
347
+ const requirement = requiredNode ? ` Requires Node.js ${requiredNode}.` : "";
348
+ return `Update blocked: pi ${cmd.targetVersion} is incompatible with current Node.js ${process.version}.${requirement} Upgrade Node.js, restart pi, then run /update again.`;
349
+ }
350
+
351
+ return `Update failed while running \`${failure.step.display}\` (exit ${failure.code})${failure.output ? `: ${failure.output}` : ""}`;
352
+ }
353
+
354
+ async function runInstallCommand(
355
+ pi: ExtensionAPI,
356
+ cmd: InstallCommand,
357
+ ): Promise<InstallFailure | undefined> {
358
+ for (const step of cmd.steps) {
359
+ const result = await pi.exec(step.program, step.args, { timeout: 120_000 });
360
+ if (result.code !== 0) {
361
+ return {
362
+ step,
363
+ code: result.code,
364
+ output: [result.stderr, result.stdout].filter(Boolean).join("\n").trim(),
365
+ };
366
+ }
367
+ }
368
+ }
369
+
370
+ export default async function (pi: ExtensionAPI) {
371
+ const owningPackageName = await findOwningPiPackageName(pi);
372
+ const runtime = await loadPiRuntime(owningPackageName);
373
+ VERSION = runtime.VERSION;
374
+ BorderedLoader = runtime.BorderedLoader;
375
+ getAgentDir = runtime.getAgentDir;
376
+ const currentPackageName = owningPackageName ?? runtime.packageName;
377
+
378
+ const cacheFile = join(getAgentDir(), "update-cache.json");
379
+ const suppressNativeCheck = hasNativeVersionNotice() && !userSkippedVersionCheck;
380
+ if (suppressNativeCheck) {
381
+ process.env[ENV_SKIP_VERSION_CHECK] = "1";
382
+ process.env[ENV_INTERNAL_SKIP] = "1";
383
+ }
384
+
126
385
  let promptOpen = false;
127
386
  const promptedVersions = new Set<string>();
128
387
  let liveCheckStarted = false;
@@ -144,12 +403,17 @@ export default function (pi: ExtensionAPI) {
144
403
  const piBinary = await findPiBinary();
145
404
  const sessionFile = ctx.sessionManager.getSessionFile();
146
405
  const restartArgs = sessionFile ? ["--session", sessionFile] : ["--no-session"];
406
+ const env = { ...process.env };
407
+ if (suppressNativeCheck) {
408
+ delete env[ENV_SKIP_VERSION_CHECK];
409
+ delete env[ENV_INTERNAL_SKIP];
410
+ }
147
411
 
148
412
  return ctx.ui.custom<boolean>((tui, _theme, _kb, done) => {
149
413
  tui.stop();
150
414
  const result = spawnSync(piBinary, restartArgs, {
151
415
  cwd: ctx.cwd,
152
- env: process.env,
416
+ env,
153
417
  stdio: "inherit",
154
418
  shell: process.platform === "win32",
155
419
  windowsHide: false,
@@ -164,25 +428,28 @@ export default function (pi: ExtensionAPI) {
164
428
  async function doInstall(
165
429
  ctx: ExtensionContext,
166
430
  latest: string,
167
- cmd: { program: string; args: string[] },
431
+ cmd: InstallCommand,
168
432
  ) {
169
433
  const success = await ctx.ui.custom<boolean>((tui, theme, _kb, done) => {
170
- const loader = new BorderedLoader(tui, theme, `Installing ${latest}...`);
434
+ const loader = new BorderedLoader(tui, theme, `Running ${cmd.display}...`);
171
435
  loader.onAbort = () => done(false);
172
436
 
173
- pi.exec(cmd.program, cmd.args, { timeout: 120_000 })
174
- .then((result) => {
175
- if (result.code !== 0) {
176
- ctx.ui.notify(
177
- `Update failed (exit ${result.code}): ${result.stderr || result.stdout}`,
178
- "error",
179
- );
437
+ runInstallCommand(pi, cmd)
438
+ .then((failure) => {
439
+ if (failure) {
440
+ ctx.ui.notify(formatInstallFailure(failure, cmd), "error");
180
441
  done(false);
181
442
  } else {
182
443
  done(true);
183
444
  }
184
445
  })
185
- .catch(() => done(false));
446
+ .catch((error) => {
447
+ ctx.ui.notify(
448
+ `Update failed: ${error instanceof Error ? error.message : String(error)}`,
449
+ "error",
450
+ );
451
+ done(false);
452
+ });
186
453
 
187
454
  return loader;
188
455
  });
@@ -220,36 +487,39 @@ export default function (pi: ExtensionAPI) {
220
487
  );
221
488
  }
222
489
 
223
- async function showUpdatePrompt(ctx: ExtensionContext, latest: string) {
224
- const cmd = getInstallCommand(latest);
225
- const choice = await ctx.ui.select(`Update ${VERSION} → ${latest}`, [
226
- `Update now (${fmtCmd(cmd)})`,
490
+ async function showUpdatePrompt(ctx: ExtensionContext, latest: LatestRelease) {
491
+ const cmd = getInstallCommand(latest, currentPackageName);
492
+ const currentLabel = `${currentPackageName}@${VERSION}`;
493
+ const targetLabel = `${cmd.targetPackageName}@${cmd.targetVersion}`;
494
+ const choice = await ctx.ui.select(`Update ${currentLabel} → ${targetLabel}`, [
495
+ `Update now (${cmd.display})`,
227
496
  "Skip",
228
497
  "Skip this version",
229
498
  ]);
230
499
 
231
500
  if (!choice || choice === "Skip") return;
232
501
  if (choice === "Skip this version") {
233
- dismissVersion(latest);
502
+ dismissRelease(cacheFile, latest, currentPackageName);
234
503
  return;
235
504
  }
236
- await doInstall(ctx, latest, cmd);
505
+ await doInstall(ctx, targetLabel, cmd);
237
506
  }
238
507
 
239
- function canAutoPromptVersion(latest: string): boolean {
240
- if (!isNewer(latest, VERSION)) return false;
241
- if (promptedVersions.has(latest)) return false;
242
- if (readCache()?.dismissedVersion === latest) return false;
508
+ function canAutoPromptVersion(latest: LatestRelease): boolean {
509
+ if (!isUpdateAvailable(latest, currentPackageName)) return false;
510
+ if (promptedVersions.has(releaseKey(latest, currentPackageName))) return false;
511
+ const cache = readCache(cacheFile);
512
+ if (cache && isDismissed(cache, latest, currentPackageName)) return false;
243
513
  return true;
244
514
  }
245
515
 
246
- async function maybeShowAutoPrompt(ctx: ExtensionContext, latest: string) {
516
+ async function maybeShowAutoPrompt(ctx: ExtensionContext, latest: LatestRelease) {
247
517
  if (!ctx.hasUI) return;
248
518
  if (promptOpen) return;
249
519
  if (!canAutoPromptVersion(latest)) return;
250
520
 
251
521
  promptOpen = true;
252
- promptedVersions.add(latest);
522
+ promptedVersions.add(releaseKey(latest, currentPackageName));
253
523
  try {
254
524
  await showUpdatePrompt(ctx, latest);
255
525
  } finally {
@@ -261,13 +531,13 @@ export default function (pi: ExtensionAPI) {
261
531
  if (!ctx.hasUI) return;
262
532
  if (shouldSkipAutoChecks()) return;
263
533
 
264
- const cached = getCachedUpgradeVersion();
534
+ const cached = getCachedUpgradeRelease(cacheFile, currentPackageName);
265
535
  if (cached) void maybeShowAutoPrompt(ctx, cached);
266
536
 
267
537
  if (liveCheckStarted) return;
268
538
  liveCheckStarted = true;
269
539
 
270
- void refreshLatestVersionInCache()
540
+ void refreshLatestReleaseInCache(cacheFile)
271
541
  .then((latest) => {
272
542
  if (!latest) return;
273
543
  void maybeShowAutoPrompt(ctx, latest);
@@ -281,21 +551,21 @@ export default function (pi: ExtensionAPI) {
281
551
  });
282
552
 
283
553
  pi.registerCommand("update", {
284
- description: "Check for pi updates and install",
554
+ description: "Check for pi updates and install with npm",
285
555
  handler: async (rawArgs, ctx) => {
286
556
  // /update --test — simulate the full UI flow without a real install
287
557
  if (rawArgs?.trim() === "--test") {
288
558
  const fakeLatest = "99.0.0";
289
- const cmd = getInstallCommand(fakeLatest);
290
- const choice = await ctx.ui.select(`Update ${VERSION} → ${fakeLatest}`, [
291
- `Update now (${fmtCmd(cmd)})`,
559
+ const cmd = getInstallCommand({ version: fakeLatest }, currentPackageName);
560
+ const choice = await ctx.ui.select(`Update ${currentPackageName}@${VERSION} → ${cmd.targetPackageName}@${fakeLatest}`, [
561
+ `Update now (${cmd.display})`,
292
562
  "Skip",
293
563
  "Skip this version",
294
564
  ]);
295
565
  if (!choice || choice === "Skip" || choice === "Skip this version") return;
296
566
 
297
567
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
298
- const loader = new BorderedLoader(tui, theme, `Installing ${fakeLatest}...`);
568
+ const loader = new BorderedLoader(tui, theme, `Running ${cmd.display}...`);
299
569
  loader.onAbort = () => done();
300
570
  setTimeout(() => done(), 1500);
301
571
  return loader;
@@ -323,7 +593,7 @@ export default function (pi: ExtensionAPI) {
323
593
  return;
324
594
  }
325
595
 
326
- const latest = await ctx.ui.custom<string | null>(
596
+ const latest = await ctx.ui.custom<LatestRelease | null>(
327
597
  (tui, theme, _kb, done) => {
328
598
  const loader = new BorderedLoader(
329
599
  tui,
@@ -331,7 +601,7 @@ export default function (pi: ExtensionAPI) {
331
601
  "Checking for updates...",
332
602
  );
333
603
  loader.onAbort = () => done(null);
334
- fetchLatestVersion()
604
+ fetchLatestRelease()
335
605
  .then((v) => done(v ?? null))
336
606
  .catch(() => done(null));
337
607
  return loader;
@@ -339,18 +609,18 @@ export default function (pi: ExtensionAPI) {
339
609
  );
340
610
 
341
611
  if (!latest) {
342
- ctx.ui.notify("Could not reach npm registry.", "error");
612
+ ctx.ui.notify("Could not reach Pi update service.", "error");
343
613
  return;
344
614
  }
345
615
 
346
- saveLatestToCache(latest);
616
+ saveLatestToCache(cacheFile, latest);
347
617
 
348
- if (!isNewer(latest, VERSION)) {
349
- ctx.ui.notify(`Already on latest version (${VERSION}).`, "info");
618
+ if (!isUpdateAvailable(latest, currentPackageName)) {
619
+ ctx.ui.notify(`Already on latest version (${currentPackageName}@${VERSION}).`, "info");
350
620
  return;
351
621
  }
352
622
 
353
- promptedVersions.add(latest);
623
+ promptedVersions.add(releaseKey(latest, currentPackageName));
354
624
  await showUpdatePrompt(ctx, latest);
355
625
  },
356
626
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-updater",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Codex-style auto-updater for pi. Checks for new versions on startup and prompts to install.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,10 +26,15 @@
26
26
  "CHANGELOG.md"
27
27
  ],
28
28
  "peerDependencies": {
29
- "@mariozechner/pi-coding-agent": "*"
29
+ "@earendil-works/pi-coding-agent": "*"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "@earendil-works/pi-coding-agent": {
33
+ "optional": true
34
+ }
30
35
  },
31
36
  "devDependencies": {
32
- "@mariozechner/pi-coding-agent": "^0.65.0",
37
+ "@earendil-works/pi-coding-agent": "^0.74.1",
33
38
  "@types/node": "^25.3.2",
34
39
  "typescript": "^5.9.3"
35
40
  }