diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 797543f64..ca0bed10c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2401,6 +2401,8 @@ - Fixed empty assistant stop retry continuations preserving auto-retry state until a non-empty assistant turn completes or recovery reaches its retry cap. - Fixed TTSR rule conditions never matching streamed `edit`/`write` tool calls whose wire format obscures the real content (hashline `+` body rows, apply_patch envelopes, JSON-escaped `write` content). The edit and write tools now expose a `matcherDigest` normalization and TTSR matches against the introduced source text, so rule regexes stay universal regardless of the active edit mode. +- Fixed `omp update` leaving older Bun install-cache copies of globally installed `omp` packages behind. After a successful Bun global install, the update command now prunes stale `pkg@version@@@N` cache directories and their marker entries for packages present in Bun's global `node_modules` tree, while leaving unrelated shared-cache packages untouched. + ### Changed - Changed the JJ utility API to mirror Git's scoped helpers: repository operations now live under `jj.repo` (`root`, `resolve`, `is`, `clearRootCache`), and diff file listing is available as `jj.diff.changedFiles`. @@ -2413,7 +2415,6 @@ - Fixed an unhandled `EPIPE` rejection when an MCP stdio server exits between returning the `initialize` response and the client's `notifications/initialized` send. `StdioTransport.notify()` and `#sendResponse()` now route stdin writes through a shared helper that catches synchronous sink failures: `notify()` tears the transport down (firing `onClose`) and surfaces a `Transport closed while sending notification` rejection so `connectToServer()` treats the handshake as a failed connection instead of returning a "connected" handle wrapping a dead transport; `#sendResponse()` stays silent because a dead subprocess has no use for the response. `StdioTransport.close()` is now the authoritative resource teardown — it no longer early-returns when `#handleClose()` has already flipped `#connected`, so the subprocess and read loop are always cleaned up (including in the `connectToServer()` failure path) ([#1710](https://github.com/can1357/oh-my-pi/issues/1710)). - Fixed startup model resolution ignoring cached discovery rows for special built-in providers (`google-antigravity`, `google-gemini-cli`, `openai-codex`) until the background refresh completed ([#1721](https://github.com/can1357/oh-my-pi/issues/1721)). - Fixed Windows clipboard-image paste keeping `Ctrl+V` unregistered by default. The TUI now registers `Ctrl+V` plus the Windows Terminal-safe `Alt+V` fallback, and the keybinding docs call out when to use the fallback ([#1708](https://github.com/can1357/oh-my-pi/issues/1708)). - ## [15.8.0] - 2026-06-02 ### Added diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 9add11fdc..458db8146 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -272,6 +272,252 @@ function compareVersions(a: string, b: string): number { return 0; } +interface BunInstallCachePruneResult { + scannedPackages: number; + removedEntries: number; +} + +interface BunCachePackageGroup { + actualDirs: Map; + markerDir?: string; + markerEntries: Map; +} + +function stripBunCacheVersionSuffix(name: string): string { + const suffixIndex = name.indexOf("@@@"); + return suffixIndex === -1 ? name : name.slice(0, suffixIndex); +} + +function compareSemverIdentifier(a: string, b: string): number { + const aNumber = /^\d+$/.test(a); + const bNumber = /^\d+$/.test(b); + if (aNumber && bNumber) return Number(a) - Number(b); + if (aNumber) return -1; + if (bNumber) return 1; + return a.localeCompare(b); +} + +function compareSemverLikeVersions(a: string, b: string): number { + const [aCoreWithPrerelease] = a.split("+", 1); + const [bCoreWithPrerelease] = b.split("+", 1); + const [aCore, aPrerelease] = aCoreWithPrerelease.split("-", 2); + const [bCore, bPrerelease] = bCoreWithPrerelease.split("-", 2); + const aParts = aCore.split("."); + const bParts = bCore.split("."); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const diff = Number(aParts[i] ?? 0) - Number(bParts[i] ?? 0); + if (diff !== 0 && Number.isFinite(diff)) return diff; + } + if (!aPrerelease && !bPrerelease) return 0; + if (!aPrerelease) return 1; + if (!bPrerelease) return -1; + const aPrereleaseParts = aPrerelease.split("."); + const bPrereleaseParts = bPrerelease.split("."); + for (let i = 0; i < Math.max(aPrereleaseParts.length, bPrereleaseParts.length); i++) { + const aPart = aPrereleaseParts[i]; + const bPart = bPrereleaseParts[i]; + if (aPart === undefined) return -1; + if (bPart === undefined) return 1; + const diff = compareSemverIdentifier(aPart, bPart); + if (diff !== 0) return diff; + } + return 0; +} + +async function readdirIfExists(dir: string): Promise { + try { + return await fs.promises.readdir(dir, { withFileTypes: true }); + } catch (err) { + if (isEnoent(err)) return []; + throw err; + } +} + +function getBunCacheGroup(groups: Map, packageName: string): BunCachePackageGroup { + let group = groups.get(packageName); + if (!group) { + group = { actualDirs: new Map(), markerEntries: new Map() }; + groups.set(packageName, group); + } + return group; +} + +function addVersionPath(entries: Map, version: string, entryPath: string): void { + const paths = entries.get(version); + if (paths) { + paths.push(entryPath); + return; + } + entries.set(version, [entryPath]); +} + +async function addBunCacheActualDir( + groups: Map, + dirPath: string, + packageNames: Set | undefined, +): Promise { + try { + const manifest = (await Bun.file(path.join(dirPath, "package.json")).json()) as Partial< + Record<"name" | "version", unknown> + >; + if (typeof manifest.name !== "string" || typeof manifest.version !== "string") return; + if (packageNames && !packageNames.has(manifest.name)) return; + const group = getBunCacheGroup(groups, manifest.name); + addVersionPath(group.actualDirs, manifest.version, dirPath); + } catch (err) { + if (isEnoent(err)) return; + throw err; + } +} + +async function addBunCacheMarkerDir( + groups: Map, + packageName: string, + markerDir: string, + packageNames: Set | undefined, +): Promise { + if (packageNames && !packageNames.has(packageName)) return; + const markerEntries = await readdirIfExists(markerDir); + const group = getBunCacheGroup(groups, packageName); + group.markerDir = markerDir; + for (const entry of markerEntries) { + const cacheVersion = stripBunCacheVersionSuffix(entry.name); + addVersionPath(group.markerEntries, cacheVersion, path.join(markerDir, entry.name)); + } +} + +async function collectBunCacheGroups( + cacheDir: string, + packageNames: Set | undefined, +): Promise> { + const groups = new Map(); + for (const entry of await readdirIfExists(cacheDir)) { + if (!entry.isDirectory()) continue; + const entryPath = path.join(cacheDir, entry.name); + if (entry.name.startsWith("@")) { + for (const scopedEntry of await readdirIfExists(entryPath)) { + if (!scopedEntry.isDirectory()) continue; + const scopedEntryPath = path.join(entryPath, scopedEntry.name); + const versionSeparator = scopedEntry.name.lastIndexOf("@"); + if (versionSeparator === -1) { + await addBunCacheMarkerDir(groups, `${entry.name}/${scopedEntry.name}`, scopedEntryPath, packageNames); + } else { + await addBunCacheActualDir(groups, scopedEntryPath, packageNames); + } + } + continue; + } + const versionSeparator = entry.name.lastIndexOf("@"); + if (versionSeparator === -1) { + await addBunCacheMarkerDir(groups, entry.name, entryPath, packageNames); + } else { + await addBunCacheActualDir(groups, entryPath, packageNames); + } + } + return groups; +} + +async function removeCacheEntries(paths: string[]): Promise { + for (const entryPath of paths) { + await fs.promises.rm(entryPath, { recursive: true, force: true }); + } + return paths.length; +} + +/** + * Prune Bun's package cache so each package keeps only its newest cached version. + * + * Bun stores package cache entries as both a package marker directory + * (`react/19.2.6@@@1`) and a materialized package directory + * (`react@19.2.6@@@1`). Global `omp` updates can leave one full copy per + * release. The marker and materialized entries are removed together so the + * cache stays internally consistent. + */ +export async function pruneBunInstallCache( + cacheDir: string, + packageNames?: Set, +): Promise { + const groups = await collectBunCacheGroups(cacheDir, packageNames); + let scannedPackages = 0; + let removedEntries = 0; + for (const group of groups.values()) { + if (group.actualDirs.size === 0) continue; + scannedPackages++; + let latestVersion: string | undefined; + for (const version of group.actualDirs.keys()) { + if (!latestVersion || compareSemverLikeVersions(version, latestVersion) > 0) latestVersion = version; + } + if (!latestVersion) continue; + for (const [version, paths] of group.actualDirs) { + if (version !== latestVersion) removedEntries += await removeCacheEntries(paths); + } + for (const [version, paths] of group.markerEntries) { + if (version !== latestVersion) removedEntries += await removeCacheEntries(paths); + } + } + return { scannedPackages, removedEntries }; +} + +async function resolveBunInstallCacheDir(): Promise { + try { + const result = await $`bun pm cache`.quiet().nothrow(); + if (result.exitCode !== 0) return undefined; + const output = result.text().trim(); + return output.length > 0 ? output : undefined; + } catch { + return undefined; + } +} + +export function resolveBunGlobalNodeModulesDirFromLocations( + globalBinDir: string | undefined, + cacheDir: string | undefined, +): string | undefined { + if (globalBinDir && globalBinDir.length > 0) { + return path.join(path.dirname(globalBinDir), "install", "global", "node_modules"); + } + if (cacheDir && cacheDir.length > 0) { + return path.join(path.dirname(cacheDir), "global", "node_modules"); + } + return undefined; +} + +async function resolveBunGlobalNodeModulesDir(cacheDir: string): Promise { + try { + const result = await $`bun pm bin -g`.quiet().nothrow(); + const globalBinDir = result.exitCode === 0 ? result.text().trim() : undefined; + return resolveBunGlobalNodeModulesDirFromLocations(globalBinDir, cacheDir); + } catch { + return resolveBunGlobalNodeModulesDirFromLocations(undefined, cacheDir); + } +} + +async function collectInstalledPackageNames(nodeModulesDir: string): Promise> { + const packageNames = new Set(); + for (const entry of await readdirIfExists(nodeModulesDir)) { + if (!entry.isDirectory() || entry.name === ".bin") continue; + if (entry.name.startsWith("@")) { + for (const scopedEntry of await readdirIfExists(path.join(nodeModulesDir, entry.name))) { + if (scopedEntry.isDirectory()) packageNames.add(`${entry.name}/${scopedEntry.name}`); + } + continue; + } + packageNames.add(entry.name); + } + return packageNames; +} + +async function pruneBunCacheAfterGlobalInstall(): Promise { + const cacheDir = await resolveBunInstallCacheDir(); + if (!cacheDir) return undefined; + const globalNodeModulesDir = await resolveBunGlobalNodeModulesDir(cacheDir); + const packageNames = globalNodeModulesDir + ? await collectInstalledPackageNames(globalNodeModulesDir) + : new Set(); + if (packageNames.size === 0 && !path.basename(cacheDir).toLowerCase().includes("omp")) return undefined; + return await pruneBunInstallCache(cacheDir, packageNames.size === 0 ? undefined : packageNames); +} + /** * Get the appropriate binary name for this platform. */ @@ -524,6 +770,14 @@ async function updateViaBun(expectedVersion: string): Promise { } await printVerification(expectedVersion); + try { + const pruneResult = await pruneBunCacheAfterGlobalInstall(); + if (pruneResult && pruneResult.removedEntries > 0) { + console.log(chalk.dim(`Pruned ${pruneResult.removedEntries} stale Bun cache entries`)); + } + } catch (err) { + console.log(chalk.yellow(`Warning: could not prune stale Bun cache entries: ${err}`)); + } } async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise { diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index 6446e4cf6..35c40ea5d 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -8,7 +8,9 @@ import { buildHomebrewUpdateArgs, buildMiseForceInstallArgs, buildMiseUpgradeArgs, + pruneBunInstallCache, replaceBinaryForUpdate, + resolveBunGlobalNodeModulesDirFromLocations, resolveUpdateMethodForTest, sweepStaleBackups, } from "@oh-my-pi/pi-coding-agent/cli/update-cli"; @@ -137,6 +139,87 @@ describe("update-cli bun install command", () => { expect(args).toContain("@oh-my-pi/pi-natives@15.9.0"); expect(args.some(arg => arg.startsWith("@oh-my-pi/pi-natives-"))).toBe(false); }); + + it("derives global node_modules from supported bun global locations", () => { + expect(resolveBunGlobalNodeModulesDirFromLocations(path.join("home", ".bun", "bin"), undefined)).toBe( + path.join("home", ".bun", "install", "global", "node_modules"), + ); + expect( + resolveBunGlobalNodeModulesDirFromLocations(undefined, path.join("home", ".bun", "install", "cache")), + ).toBe(path.join("home", ".bun", "install", "global", "node_modules")); + }); +}); + +describe("update-cli bun cache pruning", () => { + it("keeps only the newest cached version for filtered global install packages", async () => { + const dir = await makeTempDir(); + await Bun.write(path.join(dir, "react", "18.3.1@@@1"), ""); + await Bun.write(path.join(dir, "react", "19.2.6@@@1"), ""); + await Bun.write( + path.join(dir, "react@18.3.1@@@1", "package.json"), + JSON.stringify({ name: "react", version: "18.3.1" }), + ); + await Bun.write( + path.join(dir, "react@19.2.6@@@1", "package.json"), + JSON.stringify({ name: "react", version: "19.2.6" }), + ); + await Bun.write(path.join(dir, "@oh-my-pi", "pi-utils", "15.7.6@@@1"), ""); + await Bun.write(path.join(dir, "@oh-my-pi", "pi-utils", "15.8.0@@@1"), ""); + await Bun.write( + path.join(dir, "@oh-my-pi", "pi-utils@15.7.6@@@1", "package.json"), + JSON.stringify({ name: "@oh-my-pi/pi-utils", version: "15.7.6" }), + ); + await Bun.write( + path.join(dir, "@oh-my-pi", "pi-utils@15.8.0@@@1", "package.json"), + JSON.stringify({ name: "@oh-my-pi/pi-utils", version: "15.8.0" }), + ); + await Bun.write(path.join(dir, "chalk", "4.1.2@@@1"), ""); + await Bun.write(path.join(dir, "chalk", "5.6.2@@@1"), ""); + await Bun.write( + path.join(dir, "chalk@4.1.2@@@1", "package.json"), + JSON.stringify({ name: "chalk", version: "4.1.2" }), + ); + await Bun.write( + path.join(dir, "chalk@5.6.2@@@1", "package.json"), + JSON.stringify({ name: "chalk", version: "5.6.2" }), + ); + + const result = await pruneBunInstallCache(dir, new Set(["react", "@oh-my-pi/pi-utils"])); + + expect(result).toEqual({ scannedPackages: 2, removedEntries: 4 }); + expect(await Bun.file(path.join(dir, "react", "18.3.1@@@1")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "react@18.3.1@@@1", "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "react", "19.2.6@@@1")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "react@19.2.6@@@1", "package.json")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "@oh-my-pi", "pi-utils", "15.7.6@@@1")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "@oh-my-pi", "pi-utils@15.7.6@@@1", "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "@oh-my-pi", "pi-utils", "15.8.0@@@1")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "@oh-my-pi", "pi-utils@15.8.0@@@1", "package.json")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "chalk", "4.1.2@@@1")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "chalk@4.1.2@@@1", "package.json")).exists()).toBe(true); + }); + + it("treats a stable release as newer than a matching prerelease", async () => { + const dir = await makeTempDir(); + await Bun.write(path.join(dir, "pkg", "1.0.0-beta.1@@@1"), ""); + await Bun.write(path.join(dir, "pkg", "1.0.0@@@1"), ""); + await Bun.write( + path.join(dir, "pkg@1.0.0-beta.1@@@1", "package.json"), + JSON.stringify({ name: "pkg", version: "1.0.0-beta.1" }), + ); + await Bun.write( + path.join(dir, "pkg@1.0.0@@@1", "package.json"), + JSON.stringify({ name: "pkg", version: "1.0.0" }), + ); + + const result = await pruneBunInstallCache(dir); + + expect(result).toEqual({ scannedPackages: 1, removedEntries: 2 }); + expect(await Bun.file(path.join(dir, "pkg", "1.0.0-beta.1@@@1")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "pkg@1.0.0-beta.1@@@1", "package.json")).exists()).toBe(false); + expect(await Bun.file(path.join(dir, "pkg", "1.0.0@@@1")).exists()).toBe(true); + expect(await Bun.file(path.join(dir, "pkg@1.0.0@@@1", "package.json")).exists()).toBe(true); + }); }); describe("update-cli binary replacement", () => {