diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ccf7d52b5..380a07957 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `searxng.safesearch` setting option for SearXNG searches +- `omp update` now honors an `omp.dist` distribution field published in the release's npm manifest and treats major-version bumps without one as binary-only: bun/npm-managed installs are migrated to the standalone GitHub release binary in place instead of running a package-manager install that a non-npm release (e.g. a runtime change) would break. Windows script-shim installs (npm's `omp.cmd`/`omp.ps1`) are taken over seamlessly by installing `omp.exe` beside the shims and retiring them. - Added support for Cloudflare AI Gateway routing for Gemini search - Added support for Exa MCP search provider - Added domain inclusion/exclusion filtering and URL deduplication for TinyFish search @@ -49,14 +50,12 @@ - Fixed `omp plugin uninstall --dry-run` actually removing the plugin on both the npm and marketplace routes; dry-run now reports what would be removed and leaves installed plugin state unchanged ([#8178](https://github.com/can1357/oh-my-pi/issues/8178)). - Fixed handled OMP shutdown persisting running subagents as terminally aborted instead of restoring their transcripts as parked and revivable. ([#8216](https://github.com/can1357/oh-my-pi/issues/8216)) - Fixed `always-ask` approval prompts opening before large edit previews finish rendering, preventing blind approvals ([#7957](https://github.com/can1357/oh-my-pi/issues/7957)). +- Fixed Pi-compatible extensions registering tools during asynchronous session startup being omitted from the live model tool registry. ### Removed - Removed the `resolveAgentModelSource` model-resolver export, whose only use was being fed to `resolveExplicitModelRole`. Replaced by `resolveAgentModelSelection`, which returns the expanded `patterns` and the pre-expansion `role` together so a spawn path cannot derive one without the other ([#7910](https://github.com/can1357/oh-my-pi/pull/7910) by [@enieuwy](https://github.com/enieuwy)). - A run is now attributed to the model that actually produced its output, not whichever model the session was last pointed at. A retry fallback that errored on its first request — an exhausted quota, a hard provider error — was credited with the whole run in the Agent Hub row and the settled task result, even when the previous model did every turn. Sessions expose the serving model directly, holding the last model that produced output while a candidate is armed but unproven, and transcript-derived history stops at the newest turn that produced output. -### Fixed - -- Fixed Pi-compatible extensions registering tools during asynchronous session startup being omitted from the live model tool registry. ## [17.2.12] - 2026-08-08 diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 364ff1a8c..23c2e4d70 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -63,9 +63,14 @@ function currentNativeTag(): string { return `${process.platform}-${process.arch}`; } +/** Distribution channel advertised by a release's published npm manifest. */ +export type ReleaseDist = "npm" | "binary"; + interface ReleaseInfo { tag: string; version: string; + /** Parsed `omp.dist` from the registry manifest; undefined when absent. */ + dist?: ReleaseDist; } export interface ReleaseBinaryAsset { @@ -80,6 +85,47 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +/** + * Parse the `omp.dist` field from a published package manifest. + * + * Forward-compatibility contract with future releases: a release that is not + * installable as an npm package (e.g. a native rewrite) publishes + * `"omp": { "dist": "binary" }` in its package.json. Any value other than + * "npm" — including values this updater does not know yet — maps to "binary" + * so already-deployed updaters never run a package-manager install against a + * release that no longer supports it. + */ +export function resolveReleaseDist(manifest: unknown): ReleaseDist | undefined { + if (!isRecord(manifest) || !isRecord(manifest.omp)) return undefined; + const dist = manifest.omp.dist; + if (dist === undefined) return undefined; + return dist === "npm" ? "npm" : "binary"; +} + +function majorVersion(version: string): number { + const major = Number.parseInt(version, 10); + return Number.isNaN(major) ? 0 : major; +} + +/** + * Whether the update must bypass bun/npm and install the release binary. + * + * An explicit `omp.dist` wins in both directions. Without one, a release with + * a higher major than the running build is assumed not npm-installable: the + * runtime may have changed out from under the package layout, and the pinned + * `@oh-my-pi/pi-natives*` companions ({@link buildBunInstallArgs}) may not + * exist at that version, which would strand bun/npm-managed installs behind a + * hard install failure. Homebrew and mise installs are unaffected — both + * already pull GitHub release binaries. + */ +export function shouldForceBinaryUpdate( + release: { version: string; dist?: ReleaseDist }, + currentVersion: string = VERSION, +): boolean { + if (release.dist !== undefined) return release.dist === "binary"; + return majorVersion(release.version) > majorVersion(currentVersion); +} + /** * Select and validate the binary asset from GitHub release metadata. */ @@ -394,9 +440,9 @@ interface UpdateMethodResolutionOptions { type UpdateTarget = | { method: "brew" } | { method: "mise" } - | { method: "bun" } - | { method: "npm" } - | { method: "binary"; path: string }; + | { method: "bun"; path?: string } + | { method: "npm"; path?: string } + | { method: "binary"; path: string; replacesSymlink: boolean }; function resolveUpdateMethod( ompPath: string, @@ -433,9 +479,18 @@ export function resolveUpdateMethodForTest( ): UpdateMethod { return resolveUpdateMethod(ompPath, bunBinDir, options); } -async function resolveUpdateTarget(): Promise { - const bunBinDir = await getBunGlobalBinDir(); - const npmBinDir = await getNpmGlobalBinDir(); +/** + * Resolve how the running install should be updated. + * + * `allowPackageManagers: false` skips the `bun pm bin -g` / `npm prefix -g` + * probes entirely — used for binary-only releases, where routing through a + * package manager is never valid and the probes would be wasted subprocesses. + * Homebrew/mise detection always runs: both managers install GitHub release + * binaries and stay valid regardless of how the release is distributed. + */ +async function resolveUpdateTarget(options: { allowPackageManagers: boolean }): Promise { + const bunBinDir = options.allowPackageManagers ? await getBunGlobalBinDir() : undefined; + const npmBinDir = options.allowPackageManagers ? await getNpmGlobalBinDir() : undefined; const homebrewPrefix = await getHomebrewFormulaPrefix(); const miseAvailable = $which("mise") !== undefined; const miseBinDirs = miseAvailable ? await getMiseBinDirs() : []; @@ -448,9 +503,11 @@ async function resolveUpdateTarget(): Promise { // overlaps the installer's default (~/.local/bin), that file type — not // directory containment — distinguishes a binary install from npm/bun. let ompIsRegularFile = false; + let ompIsSymlink = false; try { const stat = fs.lstatSync(ompPath); ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink(); + ompIsSymlink = stat.isSymbolicLink(); } catch {} const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, @@ -459,7 +516,8 @@ async function resolveUpdateTarget(): Promise { npmBinDir, ompIsRegularFile, }); - if (method === "binary") return { method, path: ompPath }; + if (method === "binary") return { method, path: ompPath, replacesSymlink: ompIsSymlink }; + if (method === "bun" || method === "npm") return { method, path: ompPath }; return { method }; } @@ -488,13 +546,16 @@ async function getLatestRelease(): Promise { throw new Error(`Failed to fetch release info: ${response.statusText}`); } - const data = (await response.json()) as { version: string }; + const data: unknown = await response.json(); + if (!isRecord(data) || typeof data.version !== "string") { + throw new Error("Malformed npm registry response: missing version"); + } const version = data.version; - const tag = `v${version}`; return { - tag, + tag: `v${version}`, version, + dist: resolveReleaseDist(data), }; } @@ -1108,6 +1169,99 @@ export async function updateViaBinaryAt( console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`)); } +/** + * Take over a Windows script-launcher install for a binary-only release. + * + * npm-managed Windows installs are launched through script shims + * (`omp`/`omp.cmd`/`omp.ps1`) that cannot be overwritten with a native + * executable. The release binary is installed as `omp.exe` beside them and + * the shims are then renamed aside: cmd.exe would already prefer `.exe` via + * PATHEXT, but PowerShell resolves `.ps1` first, so the takeover only sticks + * once the shims are out of the way. A working launcher exists at every + * step — the exe lands before any shim moves, a shim that refuses to move + * (a running `.cmd` can be renamed but may be held open some other way) is + * skipped, and a failed version verification moves everything back. + */ +export async function updateViaShimTakeover( + shimPath: string, + expectedVersion: string, + options: { + binaryName?: string; + fetchImpl?: Fetch; + githubToken?: string; + verifyInstalledVersion?: typeof verifyInstalledVersion; + } = {}, +): Promise { + const binaryName = options.binaryName ?? getBinaryName(); + const launcherDir = path.dirname(shimPath); + const exePath = path.join(launcherDir, `${APP_NAME}.exe`); + const tempPath = `${exePath}.new`; + const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken); + console.log(chalk.dim(`Downloading ${binaryName}…`)); + await downloadVerifiedBinary({ + url: asset.url, + targetPath: tempPath, + expectedSize: asset.size, + expectedDigest: asset.digest, + fetchImpl: options.fetchImpl, + }); + console.log(chalk.dim(`Verified ${asset.digest}`)); + + console.log(chalk.dim(`Installing ${APP_NAME}.exe beside the script launcher...`)); + await fs.promises.rename(tempPath, exePath); + // Retire the shims so PATH resolution lands on the new exe. Renamed, not + // deleted: restorable on verification failure, and Windows permits + // renaming a batch file that is still executing. + const backupSuffix = `${Date.now()}.${process.pid}.bak`; + const retired: Array<{ launcher: string; backup: string }> = []; + for (const ext of ["", ".cmd", ".ps1", ".bat"]) { + const launcher = path.join(launcherDir, `${APP_NAME}${ext}`); + const backup = `${launcher}.${backupSuffix}`; + try { + await fs.promises.rename(launcher, backup); + retired.push({ launcher, backup }); + } catch { + // Shim absent or immovable; .exe still outranks .cmd/.bat in PATHEXT. + } + } + + const verify = options.verifyInstalledVersion ?? verifyInstalledVersion; + const verification = await verify(expectedVersion); + if (!verification.ok) { + for (const { launcher, backup } of retired) { + try { + await fs.promises.rename(backup, launcher); + } catch {} + } + await unlinkIfExists(exePath); + throw new Error( + `${formatVerificationFailure(verification, expectedVersion)}; restored previous ${APP_NAME} launcher`, + ); + } + for (const { backup } of retired) { + await removeBackupBestEffort(backup); + } + // Reclaim exe backups and retired-shim leftovers from earlier attempts. + for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) { + await sweepStaleBackups(path.join(launcherDir, `${APP_NAME}${ext}`)); + } + printVerifiedVersion(expectedVersion); + console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`)); +} + +/** + * Platform-appropriate installer one-liner for recovery instructions. + * + * Forces the installer's binary mode (`--binary` / `-Binary`): the default + * mode prefers a bun-based install whenever bun is present, which would send + * a user recovering from a binary-only release straight back through bun. + */ +function installerHint(): string { + return process.platform === "win32" + ? "& ([scriptblock]::Create((irm https://omp.sh/install.ps1))) -Binary" + : "curl -fsSL https://omp.sh/install | sh -s -- --binary"; +} + /** * Run the update command. */ @@ -1141,19 +1295,47 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean }) return; } - // Choose update method based on the prioritized omp binary in PATH + // Choose update method based on the prioritized omp binary in PATH. For + // binary-only releases the package managers are never consulted: a bun/npm + // symlink resolves to method "binary" and is replaced in place, keeping the + // same PATH entry live. try { - const target = await resolveUpdateTarget(); + const forceBinary = shouldForceBinaryUpdate(release); + const target = await resolveUpdateTarget({ allowPackageManagers: !forceBinary }); if (target.method === "brew") { await updateViaHomebrew(release.version, opts.force); } else if (target.method === "mise") { await updateViaMise(release.version, opts.force); - } else if (target.method === "bun") { - await updateViaBun(release.version); - } else if (target.method === "npm") { - await updateViaNpm(release.version); + } else if (target.method === "bun" || target.method === "npm") { + if (forceBinary) { + // Reachable in forced mode only through a Windows script + // launcher resolved from PATH (the bun/npm bin-dir probes are + // skipped), so the launcher path is always known. + if (!target.path) throw new Error(`Could not resolve ${APP_NAME} launcher path in PATH`); + console.log(chalk.dim("This release ships as a standalone binary; replacing the script launcher.")); + await updateViaShimTakeover(target.path, release.version); + console.log( + chalk.yellow( + `This install is no longer managed by ${target.method}. Removing the old global package may delete this launcher; if it does, reinstall with: ${installerHint()}`, + ), + ); + } else if (target.method === "bun") { + await updateViaBun(release.version); + } else { + await updateViaNpm(release.version); + } } else { + if (forceBinary && target.replacesSymlink) { + console.log(chalk.dim("Replacing the package-manager launcher with the standalone binary.")); + } await updateViaBinaryAt(target.path, release.version); + if (forceBinary && target.replacesSymlink) { + console.log( + chalk.yellow( + `This install is no longer managed by bun/npm. Removing the old global package may delete this launcher; if it does, reinstall with: ${installerHint()}`, + ), + ); + } } } catch (err) { console.error(chalk.red(`Update failed: ${err}`)); diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index 24c52a97a..255e4d809 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -24,6 +24,7 @@ import { shouldForceBinaryUpdate, sweepStaleBackups, updateViaBinaryAt, + updateViaShimTakeover, } from "@oh-my-pi/pi-coding-agent/cli/update-cli"; import Update from "@oh-my-pi/pi-coding-agent/commands/update"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; @@ -777,3 +778,87 @@ describe("update-cli binary-only release gating", () => { expect(shouldForceBinaryUpdate({ version: "1.0.0" }, "2.0.0")).toBe(false); }); }); + +describe("update-cli script-shim takeover", () => { + const version = "18.0.0"; + const binaryName = "omp-windows-x64.exe"; + const url = `https://github.com/can1357/oh-my-pi/releases/download/v${version}/${binaryName}`; + const content = "native release binary"; + const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`; + + const fetchImpl = async (input: string | URL | Request): Promise => { + const requestUrl = String(input); + if (requestUrl.startsWith("https://api.github.com/")) { + return new Response( + JSON.stringify({ + tag_name: `v${version}`, + draft: false, + prerelease: false, + assets: [ + { + name: binaryName, + state: "uploaded", + size: Buffer.byteLength(content), + digest, + browser_download_url: url, + }, + ], + }), + ); + } + if (requestUrl === url) return new Response(content); + throw new Error(`Unexpected request: ${requestUrl}`); + }; + + const shims: Record = { + omp: "#!/bin/sh\nnode omp.js\n", + "omp.cmd": "@node omp.js %*\n", + "omp.ps1": "node omp.js @args\n", + }; + + async function writeShims(dir: string): Promise { + for (const name in shims) { + await Bun.write(path.join(dir, name), shims[name]); + } + } + + it("installs omp.exe beside the shims and retires them", async () => { + const dir = await makeTempDir(); + await writeShims(dir); + + await updateViaShimTakeover(path.join(dir, "omp.cmd"), version, { + binaryName, + fetchImpl, + githubToken: "test-token", + verifyInstalledVersion: async () => ({ ok: true, actual: version, path: path.join(dir, "omp.exe") }), + }); + + expect(await Bun.file(path.join(dir, "omp.exe")).text()).toBe(content); + for (const name in shims) { + expect(await Bun.file(path.join(dir, name)).exists()).toBe(false); + } + const residue = (await fs.readdir(dir)).filter(name => name.endsWith(".bak") || name.endsWith(".new")); + expect(residue).toEqual([]); + }); + + it("restores the shims and removes the exe when verification fails", async () => { + const dir = await makeTempDir(); + await writeShims(dir); + + await expect( + updateViaShimTakeover(path.join(dir, "omp.cmd"), version, { + binaryName, + fetchImpl, + githubToken: "test-token", + verifyInstalledVersion: async () => ({ ok: false, actual: "17.2.12", path: path.join(dir, "omp.cmd") }), + }), + ).rejects.toThrow("restored previous omp launcher"); + + expect(await Bun.file(path.join(dir, "omp.exe")).exists()).toBe(false); + for (const name in shims) { + expect(await Bun.file(path.join(dir, name)).text()).toBe(shims[name]); + } + const residue = (await fs.readdir(dir)).filter(name => name.endsWith(".bak") || name.endsWith(".new")); + expect(residue).toEqual([]); + }); +});