pi-updater 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +87 -37
  3. package/index.ts +273 -164
  4. package/package.json +9 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-07-05
4
+
5
+ - Detect outdated extension packages (same check as pi's "Package Updates Available" banner) and offer to update them interactively.
6
+ - Combined prompt when both pi and extensions are outdated: "Update all" (`pi update --self --extensions`), pi only, or extensions only.
7
+ - Extensions-only prompt when pi is current but packages are outdated: runs `pi update --extensions`, then hot-reloads via `ctx.reload()` (from `/update`) or restarts into the same session (from the startup prompt, which cannot trigger a reload).
8
+ - Selecting an update option is the only interaction: no separate restart confirmation afterwards, and the restarted pi skips its startup check once so partial updates don't immediately re-prompt.
9
+ - Startup shows one consolidated prompt after both checks resolve (never a partial offer); the cached pi version is now only a fallback for failed fetches.
10
+ - `/update` now checks both pi and extension packages.
11
+ - Raise install timeout to 5 minutes to accommodate multi-package updates.
12
+ - Treat killed (timed-out) update processes as failures so a partial install never reloads or restarts as success.
13
+ - Use `pi update --self --extensions` instead of `--all` for compatibility with pi < 0.80.
14
+ - Version dismissal is now "Ignore <version>" and only offered in the pi-only prompt.
15
+
16
+ ## 0.3.4 - 2026-07-05 (not published to npm; included in 0.4.0)
17
+
18
+ - Delegate installs to pi's native `pi update --self` command so npm, pnpm, yarn, bun, and standalone installs use pi's own update logic.
19
+ - Remove the npm-only package migration installer now that the legacy package migration window is closed.
20
+ - Remove all legacy `@mariozechner` support: static `@earendil-works/pi-coding-agent` import, no owning-package detection, no package-name tracking in the cache. Requires pi 0.74.0+ (`@earendil-works` scope); older installs can pin `pi-updater@0.3.3`.
21
+ - Keep pi-updater focused on the interactive prompt, cache-first update checks, and auto-restart flow.
22
+
23
+ ## 0.3.3 - 2026-05-21
24
+
25
+ - Honor the update service `packageName` and install the explicit advertised npm package/version.
26
+ - Avoid native `pi update --self` so pi-updater can update through stale native self-update behavior.
27
+ - Keep loading under the legacy `@mariozechner/pi-coding-agent` runtime so package-name migrations can run.
28
+ - 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.
29
+ - 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.
30
+ - Respect npm engine requirements during pi installs so updates fail safely when Node.js is too old.
31
+ - 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.
32
+
3
33
  ## 0.3.2 - 2026-05-02
4
34
 
5
35
  - Use pi's native `pi update --self` installer on pi 0.70.3+ and keep npm install as the fallback for older pi versions.
package/README.md CHANGED
@@ -1,72 +1,122 @@
1
1
  # pi-updater
2
2
 
3
- A lightweight, Codex-style auto-updater for pi with fast, cache-first startup checks.
3
+ pi-updater is a [pi](https://pi.dev) extension that turns pi's update notices
4
+ into an interactive flow: it prompts you when a new pi version or extension
5
+ package updates are available, installs them without leaving your session,
6
+ and puts you right back where you were.
4
7
 
5
8
  - npm: https://www.npmjs.com/package/pi-updater
6
9
  - repo: https://github.com/tonze/pi-updater
7
10
 
8
- > **Note:** On pi 0.70.3+, pi-updater delegates installation to pi's native `pi update --self` command. Older pi versions fall back to npm-based installation.
9
-
10
11
  <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
12
 
12
- ## What it does
13
+ ## Why does this exist?
13
14
 
14
- **On pi 0.70.3+:** pi-updater is native-aware. It keeps the interactive startup update prompt, checks pi's update service, installs with `pi update --self`, then offers to restart the current session. Pi's built-in updater only shows a notice with a command to run; pi-updater provides the clickable update/restart flow.
15
+ pi already checks for updates on startup for itself and for installed
16
+ extension packages — but all it does is print a notice telling you which
17
+ command to run. The built-in flow is: see the notice, finish what you're
18
+ doing, quit pi, run `pi update`, start pi again, run `pi -c` to get your
19
+ session back. That's five steps for something that should be one keypress.
15
20
 
16
- **On older pi versions:** if a newer version is available, pi-updater shows a startup prompt:
17
- - **Update now** install with npm, then auto-restart pi on the current session
18
- - **Skip** dismiss until next session
19
- - **Skip this version** — don't ask again until a newer version appears
21
+ pi-updater collapses this into a prompt. Choose an update option and it
22
+ installs the new versions and puts you back in your current session. You can
23
+ also skip once, or skip a specific pi version so it stops asking until the
24
+ next release.
20
25
 
21
- 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.
26
+ The actual installation is delegated to pi's native `pi update` command. pi
27
+ knows how it was installed (npm, pnpm, yarn, bun, or a standalone binary)
28
+ and what extension packages you have configured; pi-updater deliberately
29
+ does not reimplement any of that. This extension owns the interactive
30
+ experience, nothing more.
22
31
 
23
- **`/update`:** manually check for updates (always fetches fresh from pi's update service, unless `PI_OFFLINE` is set). On pi 0.70.3+ it installs with `pi update --self`; on older pi it falls back to npm.
32
+ ## Installation
24
33
 
25
- ## How version checks work
34
+ ```bash
35
+ pi install npm:pi-updater
36
+ ```
26
37
 
27
- pi-updater uses a cache-first approach to keep startup fast:
38
+ Requires pi 0.74.0 or later (the `@earendil-works` package scope). On older
39
+ installs the extension fails to load harmlessly; if you need it there, pin
40
+ `pi-updater@0.3.3`.
28
41
 
29
- 1. On startup, cached version data is checked instantly.
30
- 2. One background live fetch refreshes the cache from pi's update service.
31
- 3. If the background fetch finds a newer version, pi-updater can prompt in the same session.
32
- 4. Automatic checks are skipped when `PI_SKIP_VERSION_CHECK` or `PI_OFFLINE` is set.
42
+ ## Usage
33
43
 
34
- ## Install
44
+ There is nothing to configure. On startup, pi-updater checks both pi itself
45
+ and your installed extension packages (the same check behind pi's "Package
46
+ Updates Available" banner).
35
47
 
36
- ```bash
37
- pi install npm:pi-updater
38
- ```
48
+ If only pi is outdated:
39
49
 
40
- Or from git:
50
+ - **Update now** — run `pi update --self`, then restart pi on the current session
51
+ - **Skip** — ask again next session
52
+ - **Ignore \<version\>** — don't ask again until a newer version appears
41
53
 
42
- ```bash
43
- pi install git:github.com/tonze/pi-updater
44
- ```
54
+ If both pi and extensions are outdated, a combined prompt appears:
45
55
 
46
- ## Usage
56
+ - **Update all** — run `pi update --self --extensions`, then restart
57
+ - **Skip** — ask again next session
58
+ - **Update pi only** — run `pi update --self`, then restart
59
+ - **Update extensions only** — run `pi update --extensions`, then reload
47
60
 
48
- Use `/update` inside pi to manually check for updates and install them.
61
+ Version dismissal ("Ignore") is only offered in the pi-only prompt; a
62
+ dismissed pi version degrades the combined prompt to extensions-only.
49
63
 
50
- Cache and dismissed-version state are stored in pi's configured agent directory and respect `PI_CODING_AGENT_DIR`.
64
+ If only extensions are outdated, you're offered `pi update --extensions`.
51
65
 
52
- ## Environment flags
66
+ Choosing an update option is the only interaction: anything involving pi
67
+ core restarts straight back into your current session, and extension-only
68
+ updates are hot-reloaded in place (from the startup prompt, where extensions
69
+ cannot trigger a reload, pi restarts into the session instead — same
70
+ result). Either way you keep working where you left off.
53
71
 
54
- Disable automatic version checks:
72
+ You can also check manually at any time with `/update`.
55
73
 
56
- ```bash
57
- export PI_SKIP_VERSION_CHECK=1
58
- ```
74
+ Extension updates have no per-version skip; choosing Skip simply asks again
75
+ next session. Pinned (`@version` / `#ref`) and local packages are excluded,
76
+ matching pi's own update check.
77
+
78
+ In non-interactive modes, or if the restart fails, pi-updater falls back to
79
+ a message telling you how to restart yourself. Ephemeral `--no-session` runs
80
+ stay ephemeral across the restart.
81
+
82
+ ### How version checks work
83
+
84
+ Startup is never blocked. Both checks run in the background — pi's version
85
+ against pi's update service, extension packages against their npm/git
86
+ sources — and one consolidated prompt appears when they resolve, so you are
87
+ never offered a partial update. If the version fetch fails, a previously
88
+ cached result is used as fallback. After an update restarts pi, the startup
89
+ check is skipped once so you're not immediately re-prompted for anything you
90
+ just declined.
59
91
 
60
- Or run in offline mode (also disables automatic checks):
92
+ `/update` always fetches fresh. Cache and dismissed-version state live in pi's
93
+ agent directory and respect `PI_CODING_AGENT_DIR`.
94
+
95
+ ### Disabling checks
96
+
97
+ pi's standard environment variables are respected:
61
98
 
62
99
  ```bash
63
- export PI_OFFLINE=1
100
+ export PI_SKIP_VERSION_CHECK=1 # disable automatic checks
101
+ export PI_OFFLINE=1 # offline mode, also disables checks
64
102
  ```
65
103
 
66
- ## Updating this package
104
+ While pi-updater is active it suppresses pi's built-in update notice so you
105
+ don't get prompted twice for the same release. pi's "Package Updates
106
+ Available" banner cannot be suppressed the same way, so it may still appear
107
+ alongside pi-updater's extension prompt.
108
+
109
+ ## Caveats
110
+
111
+ Because installation is delegated to pi, pi's limitations apply: standalone
112
+ binary installs get download instructions instead of an automatic install, and
113
+ Windows self-update covers npm and pnpm installs only. In those cases you'll
114
+ see pi's own message explaining what to do.
115
+
116
+ ## Updating pi-updater itself
67
117
 
68
118
  ```bash
69
- pi update
119
+ pi update npm:pi-updater
70
120
  ```
71
121
 
72
122
  ## License
package/index.ts CHANGED
@@ -1,20 +1,35 @@
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";
5
+ import { VERSION, BorderedLoader, getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
8
  import { join, dirname } from "node:path";
9
9
 
10
- const PACKAGE_NAME = "@mariozechner/pi-coding-agent";
11
10
  const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
12
- const NATIVE_SELF_UPDATE_MIN_VERSION = "0.70.3";
13
11
  const CACHE_FILE = join(getAgentDir(), "update-cache.json");
12
+ const UPDATE_COMMANDS = {
13
+ self: { args: ["update", "--self"], display: "pi update --self" },
14
+ extensions: { args: ["update", "--extensions"], display: "pi update --extensions" },
15
+ // --self --extensions rather than --all: pi < 0.80 doesn't parse --all,
16
+ // but the flag pair resolves to the "all" target on every supported version.
17
+ all: { args: ["update", "--self", "--extensions"], display: "pi update --self --extensions" },
18
+ } as const;
19
+
20
+ type UpdateTarget = keyof typeof UPDATE_COMMANDS;
21
+
22
+ interface UpdateOffer {
23
+ piLatest?: string;
24
+ extensions: string[];
25
+ }
14
26
 
15
27
  const ENV_SKIP_VERSION_CHECK = "PI_SKIP_VERSION_CHECK";
16
28
  const ENV_OFFLINE = "PI_OFFLINE";
17
29
  const ENV_INTERNAL_SKIP = "PI_UPDATER_SUPPRESSED_NATIVE_VERSION_CHECK";
30
+ // One-shot: set on the pi process we restart into after an update, so the
31
+ // user is not immediately re-prompted for updates they just declined.
32
+ const ENV_SUPPRESS_STARTUP_CHECK = "PI_UPDATER_SUPPRESS_STARTUP_CHECK";
18
33
 
19
34
  interface VersionCache {
20
35
  latestVersion: string;
@@ -57,27 +72,17 @@ function parseVersion(version: string): ParsedVersion | undefined {
57
72
  };
58
73
  }
59
74
 
60
- function compareVersions(leftVersion: string, rightVersion: string): number | undefined {
61
- const left = parseVersion(leftVersion);
62
- const right = parseVersion(rightVersion);
63
- if (!left || !right) return undefined;
64
- if (left.major !== right.major) return left.major - right.major;
65
- if (left.minor !== right.minor) return left.minor - right.minor;
66
- if (left.patch !== right.patch) return left.patch - right.patch;
67
- if (left.prerelease === right.prerelease) return 0;
68
- if (!left.prerelease) return 1;
69
- if (!right.prerelease) return -1;
70
- return left.prerelease.localeCompare(right.prerelease);
71
- }
72
-
73
- function isNewer(latest: string, current: string): boolean {
74
- const comparison = compareVersions(latest, current);
75
- return comparison !== undefined && comparison > 0;
76
- }
77
-
78
- function isAtLeast(version: string, minimum: string): boolean {
79
- const comparison = compareVersions(version, minimum);
80
- return comparison !== undefined && comparison >= 0;
75
+ function isNewer(candidate: string, current: string): boolean {
76
+ const left = parseVersion(candidate);
77
+ const right = parseVersion(current);
78
+ if (!left || !right) return false;
79
+ if (left.major !== right.major) return left.major > right.major;
80
+ if (left.minor !== right.minor) return left.minor > right.minor;
81
+ if (left.patch !== right.patch) return left.patch > right.patch;
82
+ if (left.prerelease === right.prerelease) return false;
83
+ if (!left.prerelease) return true;
84
+ if (!right.prerelease) return false;
85
+ return left.prerelease.localeCompare(right.prerelease) > 0;
81
86
  }
82
87
 
83
88
  function isEnvSet(name: string): boolean {
@@ -102,19 +107,6 @@ function piUserAgent(): string {
102
107
  return `pi/${VERSION} (${process.platform}; ${runtime}; ${process.arch})`;
103
108
  }
104
109
 
105
- function hasNativeSelfUpdate(): boolean {
106
- return isAtLeast(VERSION, NATIVE_SELF_UPDATE_MIN_VERSION);
107
- }
108
-
109
- function saveLatestToCache(latest: string) {
110
- const prev = readCache();
111
- writeCache({
112
- latestVersion: latest,
113
- dismissedVersion: prev?.dismissedVersion,
114
- checkedAt: new Date().toISOString(),
115
- });
116
- }
117
-
118
110
  async function fetchLatestVersion(): Promise<string | undefined> {
119
111
  try {
120
112
  const res = await fetch(LATEST_VERSION_URL, {
@@ -125,16 +117,46 @@ async function fetchLatestVersion(): Promise<string | undefined> {
125
117
  signal: AbortSignal.timeout(10_000),
126
118
  });
127
119
  if (!res.ok) return undefined;
128
- const version = ((await res.json()) as { version?: string }).version;
129
- return typeof version === "string" && version.trim()
130
- ? version.trim()
131
- : undefined;
120
+ const data = (await res.json()) as { version?: string };
121
+ if (typeof data.version !== "string" || !data.version.trim()) return undefined;
122
+ return data.version.trim();
132
123
  } catch {
133
124
  return undefined;
134
125
  }
135
126
  }
136
127
 
137
- /** Returns a cached upgrade if available and not dismissed. */
128
+ /**
129
+ * Check for outdated extension packages using pi's own package manager —
130
+ * the same code path behind pi's startup "Package Updates Available" banner.
131
+ * These exports are not documented extension API, so degrade gracefully.
132
+ */
133
+ async function checkForExtensionUpdates(cwd: string): Promise<string[]> {
134
+ try {
135
+ const mod = (await import("@earendil-works/pi-coding-agent")) as Record<string, any>;
136
+ const { DefaultPackageManager, SettingsManager } = mod;
137
+ if (
138
+ typeof DefaultPackageManager !== "function" ||
139
+ typeof SettingsManager?.create !== "function"
140
+ ) {
141
+ return [];
142
+ }
143
+ const agentDir = getAgentDir();
144
+ const packageManager = new DefaultPackageManager({
145
+ cwd,
146
+ agentDir,
147
+ settingsManager: SettingsManager.create(cwd, agentDir),
148
+ });
149
+ const updates = await packageManager.checkForAvailableUpdates();
150
+ if (!Array.isArray(updates)) return [];
151
+ return updates
152
+ .map((update) => String(update?.displayName ?? update?.source ?? ""))
153
+ .filter(Boolean);
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+
159
+ /** Returns a cached upgrade version if available and not dismissed. */
138
160
  function getCachedUpgradeVersion(): string | undefined {
139
161
  const cache = readCache();
140
162
  if (!cache) return undefined;
@@ -143,6 +165,15 @@ function getCachedUpgradeVersion(): string | undefined {
143
165
  return cache.latestVersion;
144
166
  }
145
167
 
168
+ function saveLatestToCache(latest: string) {
169
+ const prev = readCache();
170
+ writeCache({
171
+ latestVersion: latest,
172
+ dismissedVersion: prev?.dismissedVersion,
173
+ checkedAt: new Date().toISOString(),
174
+ });
175
+ }
176
+
146
177
  /** Fetch latest from Pi's update endpoint and refresh cache. */
147
178
  async function refreshLatestVersionInCache(): Promise<string | undefined> {
148
179
  const latest = await fetchLatestVersion();
@@ -160,34 +191,65 @@ function dismissVersion(version: string) {
160
191
  });
161
192
  }
162
193
 
163
- interface InstallCommand {
164
- program: string;
165
- args: string[];
166
- display: string;
194
+ function isBunFsPath(path: string): boolean {
195
+ return path.includes("$bunfs") || path.includes("~BUN") || path.includes("%7EBUN");
167
196
  }
168
197
 
169
- function getInstallCommand(version: string): InstallCommand {
170
- if (hasNativeSelfUpdate()) {
171
- return {
172
- program: "pi",
173
- args: ["update", "--self"],
174
- display: "pi update --self",
175
- };
198
+ /**
199
+ * Command that re-invokes the currently running pi, regardless of what
200
+ * `pi` on PATH points to. For Node installs this is `node <entrypoint>`;
201
+ * for Bun standalone binaries the executable itself is pi.
202
+ */
203
+ function currentPiCommand(args: string[]): { program: string; args: string[] } {
204
+ const entry = process.argv[1];
205
+ if (entry && !isBunFsPath(entry)) {
206
+ return { program: process.execPath, args: [entry, ...args] };
176
207
  }
208
+ return { program: process.execPath, args };
209
+ }
177
210
 
178
- return {
179
- program: "npm",
180
- args: ["install", "-g", `${PACKAGE_NAME}@${version}`],
181
- display: `npm install -g ${PACKAGE_NAME}@${version}`,
182
- };
211
+ interface InstallFailure {
212
+ code: number;
213
+ output: string;
183
214
  }
184
215
 
185
- function fmtCmd(cmd: InstallCommand): string {
186
- return cmd.display;
216
+ function formatInstallFailure(failure: InstallFailure, display: string): string {
217
+ return `Update failed while running \`${display}\` (exit ${failure.code})${failure.output ? `: ${failure.output}` : ""}`;
218
+ }
219
+
220
+ async function runNativeUpdate(
221
+ pi: ExtensionAPI,
222
+ target: UpdateTarget,
223
+ ): Promise<InstallFailure | undefined> {
224
+ const previousSkip = process.env[ENV_SKIP_VERSION_CHECK];
225
+ const previousInternalSkip = process.env[ENV_INTERNAL_SKIP];
226
+ delete process.env[ENV_SKIP_VERSION_CHECK];
227
+ delete process.env[ENV_INTERNAL_SKIP];
228
+
229
+ try {
230
+ const cmd = currentPiCommand([...UPDATE_COMMANDS[target].args]);
231
+ const result = await pi.exec(cmd.program, cmd.args, { timeout: 300_000 });
232
+ // A timed-out process is killed and can report exit code 0; treat it as failure.
233
+ if (result.killed || result.code !== 0) {
234
+ const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
235
+ return {
236
+ code: result.code,
237
+ output: result.killed ? ["Update timed out after 5 minutes.", output].filter(Boolean).join("\n") : output,
238
+ };
239
+ }
240
+ } finally {
241
+ if (previousSkip === undefined) delete process.env[ENV_SKIP_VERSION_CHECK];
242
+ else process.env[ENV_SKIP_VERSION_CHECK] = previousSkip;
243
+
244
+ if (previousInternalSkip === undefined) delete process.env[ENV_INTERNAL_SKIP];
245
+ else process.env[ENV_INTERNAL_SKIP] = previousInternalSkip;
246
+ }
187
247
  }
188
248
 
189
249
  export default function (pi: ExtensionAPI) {
190
- const suppressNativeCheck = hasNativeSelfUpdate() && !userSkippedVersionCheck;
250
+ // Take over pi's built-in version notice with our interactive prompt,
251
+ // unless the user disabled version checks themselves.
252
+ const suppressNativeCheck = !userSkippedVersionCheck;
191
253
  if (suppressNativeCheck) {
192
254
  process.env[ENV_SKIP_VERSION_CHECK] = "1";
193
255
  process.env[ENV_INTERNAL_SKIP] = "1";
@@ -197,36 +259,33 @@ export default function (pi: ExtensionAPI) {
197
259
  const promptedVersions = new Set<string>();
198
260
  let liveCheckStarted = false;
199
261
 
200
- async function findPiBinary(): Promise<string> {
201
- const cmd = process.platform === "win32" ? "where" : "which";
202
- const result = await pi.exec(cmd, ["pi"]);
203
- if (result.code === 0 && result.stdout?.trim()) {
204
- return result.stdout.trim().split(/\r?\n/)[0];
205
- }
206
- return "pi";
207
- }
262
+ // Consume the one-shot suppression from a post-update restart. Deleting it
263
+ // keeps it from propagating into any further restarts from this session.
264
+ const suppressStartupCheck = isEnvSet(ENV_SUPPRESS_STARTUP_CHECK);
265
+ delete process.env[ENV_SUPPRESS_STARTUP_CHECK];
208
266
 
209
267
  function canAutoRestart(ctx: ExtensionContext): boolean {
210
268
  return ctx.hasUI && !!process.stdin.isTTY && !!process.stdout.isTTY;
211
269
  }
212
270
 
213
271
  async function restartPi(ctx: ExtensionContext): Promise<boolean> {
214
- const piBinary = await findPiBinary();
215
272
  const sessionFile = ctx.sessionManager.getSessionFile();
216
273
  const restartArgs = sessionFile ? ["--session", sessionFile] : ["--no-session"];
274
+ const cmd = currentPiCommand(restartArgs);
217
275
  const env = { ...process.env };
218
276
  if (suppressNativeCheck) {
219
277
  delete env[ENV_SKIP_VERSION_CHECK];
220
278
  delete env[ENV_INTERNAL_SKIP];
221
279
  }
280
+ // The user just acted on an update prompt; don't greet them with another.
281
+ env[ENV_SUPPRESS_STARTUP_CHECK] = "1";
222
282
 
223
283
  return ctx.ui.custom<boolean>((tui, _theme, _kb, done) => {
224
284
  tui.stop();
225
- const result = spawnSync(piBinary, restartArgs, {
285
+ const result = spawnSync(cmd.program, cmd.args, {
226
286
  cwd: ctx.cwd,
227
287
  env,
228
288
  stdio: "inherit",
229
- shell: process.platform === "win32",
230
289
  windowsHide: false,
231
290
  });
232
291
  tui.start();
@@ -236,40 +295,37 @@ export default function (pi: ExtensionAPI) {
236
295
  });
237
296
  }
238
297
 
239
- async function doInstall(
240
- ctx: ExtensionContext,
241
- latest: string,
242
- cmd: InstallCommand,
243
- ) {
298
+ async function reloadExtensions(ctx: ExtensionContext) {
299
+ // Command contexts (/update) can hot-reload in place — the best path.
300
+ const maybeReload = (ctx as { reload?: () => Promise<void> }).reload;
301
+ if (typeof maybeReload === "function") {
302
+ ctx.ui.notify("Extensions updated. Reloading...", "info");
303
+ await maybeReload.call(ctx);
304
+ return;
305
+ }
306
+ // Event contexts (startup prompt) can't reload programmatically.
307
+ // Restart into the same session instead — at startup this is cheap
308
+ // and the user already consented by choosing "Update now".
309
+ if (canAutoRestart(ctx)) {
310
+ const ok = await restartPi(ctx);
311
+ if (ok) {
312
+ ctx.shutdown();
313
+ return;
314
+ }
315
+ }
316
+ ctx.ui.notify("Extensions updated. Run /reload to load them.", "info");
317
+ }
318
+
319
+ async function doInstall(ctx: ExtensionContext, target: UpdateTarget, piLatest?: string) {
320
+ const command = UPDATE_COMMANDS[target];
244
321
  const success = await ctx.ui.custom<boolean>((tui, theme, _kb, done) => {
245
- const loader = new BorderedLoader(tui, theme, `Running ${cmd.display}...`);
322
+ const loader = new BorderedLoader(tui, theme, `Running ${command.display}...`);
246
323
  loader.onAbort = () => done(false);
247
324
 
248
- const runUpdateCommand = async () => {
249
- if (suppressNativeCheck && cmd.program === "pi") {
250
- delete process.env[ENV_SKIP_VERSION_CHECK];
251
- delete process.env[ENV_INTERNAL_SKIP];
252
- try {
253
- return await pi.exec(cmd.program, cmd.args, { timeout: 120_000 });
254
- } finally {
255
- process.env[ENV_SKIP_VERSION_CHECK] = "1";
256
- process.env[ENV_INTERNAL_SKIP] = "1";
257
- }
258
- }
259
- return pi.exec(cmd.program, cmd.args, { timeout: 120_000 });
260
- };
261
-
262
- runUpdateCommand()
263
- .then((result) => {
264
- if (result.code !== 0) {
265
- const output = [result.stderr, result.stdout]
266
- .filter(Boolean)
267
- .join("\n")
268
- .trim();
269
- ctx.ui.notify(
270
- `Update failed (exit ${result.code})${output ? `: ${output}` : ""}`,
271
- "error",
272
- );
325
+ runNativeUpdate(pi, target)
326
+ .then((failure) => {
327
+ if (failure) {
328
+ ctx.ui.notify(formatInstallFailure(failure, command.display), "error");
273
329
  done(false);
274
330
  } else {
275
331
  done(true);
@@ -288,6 +344,12 @@ export default function (pi: ExtensionAPI) {
288
344
 
289
345
  if (!success) return;
290
346
 
347
+ if (target === "extensions") {
348
+ await reloadExtensions(ctx);
349
+ return;
350
+ }
351
+
352
+ const latest = piLatest ?? "latest";
291
353
  const restartTip = ctx.sessionManager.getSessionFile()
292
354
  ? "Tip: run `pi -c` to continue this session."
293
355
  : "Tip: run `pi --no-session` to continue without a saved session.";
@@ -300,13 +362,8 @@ export default function (pi: ExtensionAPI) {
300
362
  return;
301
363
  }
302
364
 
303
- const restart = await ctx.ui.confirm(
304
- `Updated to ${latest}!`,
305
- "Restart now?",
306
- );
307
-
308
- if (!restart) return;
309
-
365
+ // No confirmation: choosing the update option was the consent, and the
366
+ // restart relaunches straight into the current session.
310
367
  const ok = await restartPi(ctx);
311
368
  if (ok) {
312
369
  ctx.shutdown();
@@ -319,20 +376,55 @@ export default function (pi: ExtensionAPI) {
319
376
  );
320
377
  }
321
378
 
322
- async function showUpdatePrompt(ctx: ExtensionContext, latest: string) {
323
- const cmd = getInstallCommand(latest);
324
- const choice = await ctx.ui.select(`Update ${VERSION} → ${latest}`, [
325
- `Update now (${fmtCmd(cmd)})`,
326
- "Skip",
327
- "Skip this version",
328
- ]);
329
-
330
- if (!choice || choice === "Skip") return;
331
- if (choice === "Skip this version") {
332
- dismissVersion(latest);
379
+ async function showUpdatePrompt(ctx: ExtensionContext, offer: UpdateOffer) {
380
+ const { piLatest, extensions } = offer;
381
+ const extList = extensions.join(", ");
382
+
383
+ if (piLatest && extensions.length > 0) {
384
+ const updateAll = "Update all";
385
+ const updatePi = "Update pi only";
386
+ const updateExtensions = "Update extensions only";
387
+ // No "Ignore pi X" here: dismissing a version while also deciding about
388
+ // extensions is ambiguous. Version dismissal lives in the pi-only prompt.
389
+ const choice = await ctx.ui.select(
390
+ `Update pi ${VERSION} → ${piLatest} · extensions: ${extList}`,
391
+ [updateAll, "Skip", updatePi, updateExtensions],
392
+ );
393
+
394
+ if (!choice || choice === "Skip") return;
395
+ if (choice === updateAll) return doInstall(ctx, "all", piLatest);
396
+ if (choice === updatePi) return doInstall(ctx, "self", piLatest);
397
+ if (choice === updateExtensions) return doInstall(ctx, "extensions");
333
398
  return;
334
399
  }
335
- await doInstall(ctx, latest, cmd);
400
+
401
+ if (piLatest) {
402
+ const updateAction = "Update now";
403
+ const ignorePiVersion = `Ignore ${piLatest}`;
404
+ const choice = await ctx.ui.select(`Update ${VERSION} → ${piLatest}`, [
405
+ updateAction,
406
+ "Skip",
407
+ ignorePiVersion,
408
+ ]);
409
+
410
+ if (!choice || choice === "Skip") return;
411
+ if (choice === ignorePiVersion) {
412
+ dismissVersion(piLatest);
413
+ return;
414
+ }
415
+ if (choice !== updateAction) return;
416
+ await doInstall(ctx, "self", piLatest);
417
+ return;
418
+ }
419
+
420
+ if (extensions.length > 0) {
421
+ const updateAction = "Update now";
422
+ const choice = await ctx.ui.select(`Extension updates available: ${extList}`, [
423
+ updateAction,
424
+ "Skip",
425
+ ]);
426
+ if (choice === updateAction) await doInstall(ctx, "extensions");
427
+ }
336
428
  }
337
429
 
338
430
  function canAutoPromptVersion(latest: string): boolean {
@@ -342,15 +434,21 @@ export default function (pi: ExtensionAPI) {
342
434
  return true;
343
435
  }
344
436
 
345
- async function maybeShowAutoPrompt(ctx: ExtensionContext, latest: string) {
437
+ async function maybeShowAutoPrompt(
438
+ ctx: ExtensionContext,
439
+ latest: string | undefined,
440
+ extensions: string[],
441
+ ) {
346
442
  if (!ctx.hasUI) return;
347
443
  if (promptOpen) return;
348
- if (!canAutoPromptVersion(latest)) return;
444
+
445
+ const piLatest = latest && canAutoPromptVersion(latest) ? latest : undefined;
446
+ if (!piLatest && extensions.length === 0) return;
349
447
 
350
448
  promptOpen = true;
351
- promptedVersions.add(latest);
449
+ if (piLatest) promptedVersions.add(piLatest);
352
450
  try {
353
- await showUpdatePrompt(ctx, latest);
451
+ await showUpdatePrompt(ctx, { piLatest, extensions });
354
452
  } finally {
355
453
  promptOpen = false;
356
454
  }
@@ -359,42 +457,46 @@ export default function (pi: ExtensionAPI) {
359
457
  function runAutoChecks(ctx: ExtensionContext) {
360
458
  if (!ctx.hasUI) return;
361
459
  if (shouldSkipAutoChecks()) return;
362
-
363
- const cached = getCachedUpgradeVersion();
364
- if (cached) void maybeShowAutoPrompt(ctx, cached);
365
-
366
460
  if (liveCheckStarted) return;
367
461
  liveCheckStarted = true;
368
462
 
369
- void refreshLatestVersionInCache()
370
- .then((latest) => {
371
- if (!latest) return;
372
- void maybeShowAutoPrompt(ctx, latest);
463
+ // Wait for both checks and show a single consolidated prompt. Startup is
464
+ // never blocked; the prompt simply appears when the checks resolve.
465
+ void Promise.all([
466
+ refreshLatestVersionInCache().catch(() => undefined),
467
+ checkForExtensionUpdates(ctx.cwd).catch(() => [] as string[]),
468
+ ])
469
+ .then(([latest, extensions]) => {
470
+ // Fall back to a previously cached pi version if the live fetch failed.
471
+ void maybeShowAutoPrompt(ctx, latest ?? getCachedUpgradeVersion(), extensions);
373
472
  })
374
473
  .catch(() => {});
375
474
  }
376
475
 
377
476
  pi.on("session_start", async (event, ctx) => {
378
477
  if (event.reason === "reload" || event.reason === "fork") return;
478
+ // One-shot suppression applies only to the startup right after a
479
+ // post-update restart, never to later new/resume session starts.
480
+ if (suppressStartupCheck && event.reason === "startup") return;
379
481
  runAutoChecks(ctx);
380
482
  });
381
483
 
382
484
  pi.registerCommand("update", {
383
- description: "Check for pi updates and install via native updater when available",
485
+ description: "Check for pi updates and install with pi's native updater",
384
486
  handler: async (rawArgs, ctx) => {
385
487
  // /update --test — simulate the full UI flow without a real install
386
488
  if (rawArgs?.trim() === "--test") {
387
489
  const fakeLatest = "99.0.0";
388
- const cmd = getInstallCommand(fakeLatest);
490
+ const updateAction = "Update now";
389
491
  const choice = await ctx.ui.select(`Update ${VERSION} → ${fakeLatest}`, [
390
- `Update now (${fmtCmd(cmd)})`,
492
+ updateAction,
391
493
  "Skip",
392
- "Skip this version",
494
+ `Ignore ${fakeLatest}`,
393
495
  ]);
394
- if (!choice || choice === "Skip" || choice === "Skip this version") return;
496
+ if (choice !== updateAction) return;
395
497
 
396
498
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
397
- const loader = new BorderedLoader(tui, theme, `Running ${cmd.display}...`);
499
+ const loader = new BorderedLoader(tui, theme, `Running ${UPDATE_COMMANDS.self.display}...`);
398
500
  loader.onAbort = () => done();
399
501
  setTimeout(() => done(), 1500);
400
502
  return loader;
@@ -405,9 +507,6 @@ export default function (pi: ExtensionAPI) {
405
507
  return;
406
508
  }
407
509
 
408
- const restart = await ctx.ui.confirm(`Updated to ${fakeLatest}!`, "Restart now?");
409
- if (!restart) return;
410
-
411
510
  const ok = await restartPi(ctx);
412
511
  if (ok) { ctx.shutdown(); return; }
413
512
  ctx.ui.notify("Test restart failed.", "error");
@@ -422,35 +521,45 @@ export default function (pi: ExtensionAPI) {
422
521
  return;
423
522
  }
424
523
 
425
- const latest = await ctx.ui.custom<string | null>(
426
- (tui, theme, _kb, done) => {
427
- const loader = new BorderedLoader(
428
- tui,
429
- theme,
430
- "Checking for updates...",
431
- );
432
- loader.onAbort = () => done(null);
433
- fetchLatestVersion()
434
- .then((v) => done(v ?? null))
435
- .catch(() => done(null));
436
- return loader;
437
- },
438
- );
524
+ const result = await ctx.ui.custom<{
525
+ latest: string | undefined;
526
+ extensions: string[];
527
+ } | null>((tui, theme, _kb, done) => {
528
+ const loader = new BorderedLoader(
529
+ tui,
530
+ theme,
531
+ "Checking for updates...",
532
+ );
533
+ loader.onAbort = () => done(null);
534
+ Promise.all([
535
+ fetchLatestVersion().catch(() => undefined),
536
+ checkForExtensionUpdates(ctx.cwd).catch(() => [] as string[]),
537
+ ])
538
+ .then(([latest, extensions]) => done({ latest, extensions }))
539
+ .catch(() => done(null));
540
+ return loader;
541
+ });
439
542
 
440
- if (!latest) {
543
+ if (!result || (!result.latest && result.extensions.length === 0)) {
441
544
  ctx.ui.notify("Could not reach Pi update service.", "error");
442
545
  return;
443
546
  }
444
547
 
445
- saveLatestToCache(latest);
548
+ const { latest, extensions } = result;
549
+ if (latest) saveLatestToCache(latest);
550
+
551
+ const piLatest = latest && isNewer(latest, VERSION) ? latest : undefined;
446
552
 
447
- if (!isNewer(latest, VERSION)) {
448
- ctx.ui.notify(`Already on latest version (${VERSION}).`, "info");
553
+ if (!piLatest && extensions.length === 0) {
554
+ ctx.ui.notify(
555
+ `Already on latest version (${VERSION}). Extensions are up to date.`,
556
+ "info",
557
+ );
449
558
  return;
450
559
  }
451
560
 
452
- promptedVersions.add(latest);
453
- await showUpdatePrompt(ctx, latest);
561
+ if (piLatest) promptedVersions.add(piLatest);
562
+ await showUpdatePrompt(ctx, { piLatest, extensions });
454
563
  },
455
564
  });
456
565
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-updater",
3
- "version": "0.3.2",
4
- "description": "Codex-style auto-updater for pi. Checks for new versions on startup and prompts to install.",
3
+ "version": "0.4.0",
4
+ "description": "Codex-style auto-updater for pi. Checks pi and extension packages on startup and prompts to install updates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Toms clanker",
@@ -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
  }