diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4661b5076..e59e662f6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed subscription logins such as Sign in with ChatGPT failing with `refresh_token_invalidated` after a request was cancelled during an OAuth token refresh +- Fixed codemode failing for the rest of a session after a pnpm global update removed the running install, and added a restart hint when errors occur after pi was updated or removed on disk ([#10439](https://github.com/earendil-works/pi/issues/10439)) ## [1.0.2] - 2026-10-04 diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 66d86f8c7..61dd63f38 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -480,16 +480,20 @@ export function getBundledInteractiveAssetPath(name: string): string { return join(getInteractiveAssetsDir(), name); } -let embeddedQuickJSWasmPath: string | undefined; +let quickJSWasmPath: string | undefined; /** Called by the Bun entry with the path of the QuickJS wasm file embedded in the compiled executable. */ export function setEmbeddedQuickJSWasmPath(path: string): void { - embeddedQuickJSWasmPath = path; + quickJSWasmPath = path; } -/** Get path to `quickjs-wasi/quickjs.wasm`, the VM that runs codemode scripts. */ +/** + * Get path to `quickjs-wasi/quickjs.wasm`, the VM that runs codemode scripts. Resolved once so the + * compiled module cached per path keeps working after an update removes this install (#10439). + */ export function getQuickJSWasmPath(): string { - return embeddedQuickJSWasmPath ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm"); + quickJSWasmPath ??= createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm"); + return quickJSWasmPath; } /** Resolve the codemode worker entry for a release runtime. */ @@ -505,13 +509,44 @@ export function resolveCodemodeWorkerSpecifier( return undefined; } +let codemodeWorkerDataUrl: URL | undefined; + /** * Get the codemode worker entry, or undefined to use the worker that ships next to pi-codemode. * The Bun and Node release builds both pass the worker as an extra entrypoint. */ export function getCodemodeWorkerSpecifier(): string | URL | undefined { const runtime = isBunBinary ? "bun-binary" : isBundledNode ? "bundled-node" : "unbundled"; - return resolveCodemodeWorkerSpecifier(runtime, import.meta.url); + const specifier = resolveCodemodeWorkerSpecifier(runtime, import.meta.url); + if (runtime !== "bundled-node" || !(specifier instanceof URL)) return specifier; + // Spawn workers from an in-memory copy. An update replaces or deletes the file while this + // process keeps running (#10439). The bundle build keeps the worker free of relative imports + // and import.meta, so it runs from a data: URL. + codemodeWorkerDataUrl ??= new URL(`data:text/javascript;base64,${readFileSync(specifier).toString("base64")}`); + return codemodeWorkerDataUrl; +} + +export type InstallChange = { kind: "updated"; version: string } | { kind: "removed" }; + +/** + * Detect that the package this process runs from changed on disk, for example after `pi update` + * in another terminal. Code loaded on demand can then be missing or from another version. + * + * Checks the package.json read at startup. Resolving it again would walk up past a deleted install + * and could find an unrelated package.json, such as one in the home directory. + */ +export function detectInstallChange(packageJsonPath = startupPackageJsonPath): InstallChange | undefined { + // The Bun binary embeds its code, so replacing the executable does not affect this process. + if (isBunBinary || !packageJsonPath) return undefined; + let installed: PackageJson; + try { + installed = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as PackageJson; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "removed" } : undefined; + } + return installed.version && installed.version !== VERSION + ? { kind: "updated", version: installed.version } + : undefined; } // ============================================================================= @@ -528,8 +563,12 @@ interface PackageJson { } let pkg: PackageJson = {}; +/** The package.json this process started from, if one existed. */ +let startupPackageJsonPath: string | undefined; try { - pkg = JSON.parse(stripBom(readFileSync(getPackageJsonPath(), "utf-8"))) as PackageJson; + const packageJsonPath = getPackageJsonPath(); + pkg = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as PackageJson; + startupPackageJsonPath = packageJsonPath; } catch (e: unknown) { const err = e as NodeJS.ErrnoException; if (err.code !== "ENOENT") throw e; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8d195a715..265d4336e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -58,6 +58,7 @@ import { APP_NAME, APP_TITLE, CONFIG_DIR_NAME, + detectInstallChange, getAgentDir, getAuthPath, getDebugLogPath, @@ -545,6 +546,7 @@ export class InteractiveMode { /** The `/bug` hint is shown at most once per session so error output stays readable. */ private bugReportHintShown = false; + private installChangeWarningShown = false; // Extension UI state private extensionSelector: ExtensionSelectorComponent | undefined = undefined; @@ -2168,9 +2170,32 @@ export class InteractiveMode { private maybeSuggestBugReport(message: AssistantMessage): void { if (message.stopReason !== "error" || isRetryableAssistantError(message)) return; if (/\b(?:abort(?:ed)?|cancel(?:l?ed)?)\b/i.test(message.errorMessage ?? "")) return; + if (this.maybeShowInstallChangeWarning()) return; this.suggestBugReport(); } + /** + * After an error, check whether an update replaced or removed this install while the session ran. + * Code loaded on demand then fails with missing modules until restart (#10439). Returns true when + * the install changed. + */ + private maybeShowInstallChangeWarning(): boolean { + if (this.installChangeWarningShown) return true; + const change = detectInstallChange(); + if (!change) return false; + this.installChangeWarningShown = true; + const cause = + change.kind === "updated" + ? `${APP_NAME} was updated to ${change.version} while this session was running (${VERSION})` + : `The ${APP_NAME} installation this session runs from was removed or replaced`; + const resumeCommand = formatResumeCommand(this.sessionManager); + const restart = resumeCommand + ? `Restart with \`${resumeCommand}\` to continue this session.` + : `Restart ${APP_NAME}.`; + this.showWarning(`${cause}. Features that load code on demand can fail until restart. ${restart}`); + return true; + } + private renderCurrentSessionState(): void { this.loadedResourcesContainer.clear(); this.chatContainer.clear(); @@ -3590,6 +3615,7 @@ export class InteractiveMode { } case "tool_execution_end": { + if (event.isError) this.maybeShowInstallChangeWarning(); const component = this.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.result, isError: event.isError }); diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 300f9e06a..ff652cae3 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "os"; import { delimiter, join } from "path"; import { afterEach, describe, expect, test } from "vitest"; import { + detectInstallChange, detectInstallMethod, findNodePackageDir, getSelfUpdateCommand, @@ -449,3 +450,15 @@ describe("detectInstallMethod", () => { ); }); }); + +describe("detectInstallChange", () => { + // Regression test for #10439: a deleted pnpm install must not fall back to a package.json further up. + test("reports a removed install instead of reading a package.json further up", () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-install-change-")); + const installDir = join(tempDir, "global", "hash"); + mkdirSync(installDir, { recursive: true }); + writeFileSync(join(tempDir, "package.json"), JSON.stringify({ version: "0.0.1" })); + rmSync(installDir, { recursive: true, force: true }); + expect(detectInstallChange(join(installDir, "package.json"))).toEqual({ kind: "removed" }); + }); +}); diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 745bdab15..2e16fb145 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -186,7 +186,6 @@ if (dirname(bedrockLoaderOutput) !== dirname(oauthLoaderOutput)) { const lazyEntryPoints = { anthropic: join(aiDistDir, "auth", "oauth", "anthropic.js"), "bedrock-converse-stream": join(aiDistDir, "api", "bedrock-converse-stream.js"), - "codemode-worker": join(codingAgentDistDir, "extensions", "codemode", "worker.js"), "github-copilot": join(aiDistDir, "auth", "oauth", "github-copilot.js"), "image-resize-worker": join(codingAgentDistDir, "utils", "image-resize-worker.js"), "kimi-coding": join(aiDistDir, "auth", "oauth", "kimi-coding.js"), @@ -215,6 +214,30 @@ const lazyResult = await build({ splitting: false, }); +// getCodemodeWorkerSpecifier() in config.ts spawns the codemode worker from an in-memory data: URL +// so codemode survives an update that replaces or deletes the install (#10439). A data: URL module +// has no file location, so the worker must not use the createRequire(import.meta.url) banner, +// require(), or imports other than Node builtins. +const codemodeWorkerResult = await build({ + ...commonBuildOptions(), + banner: undefined, + entryNames: "[name]", + entryPoints: { "codemode-worker": join(codingAgentDistDir, "extensions", "codemode", "worker.js") }, + outdir: dirname(bedrockLoaderOutput), + splitting: false, +}); +for (const [outputPath, output] of Object.entries(codemodeWorkerResult.metafile.outputs)) { + const invalid = output.imports.filter((imported) => imported.kind !== "import-statement" || !isBuiltin(imported.path)); + if (invalid.length > 0) { + throw new Error( + `Codemode worker must only import Node builtins: ${invalid.map((imported) => imported.path).join(", ")}`, + ); + } + if (readFileSync(resolve(repoRoot, outputPath), "utf8").includes("import.meta")) { + throw new Error("Codemode worker must not use import.meta"); + } +} + const imageResizeWorkerOutput = resolve(dirname(bedrockLoaderOutput), "image-resize-worker.js"); if (dirname(imageResizeOutput) !== dirname(imageResizeWorkerOutput)) { throw new Error("Image resize implementation and worker were emitted into different directories"); @@ -224,7 +247,8 @@ if (dirname(configOutput) !== dirname(bedrockLoaderOutput)) { throw new Error("config.ts and the codemode worker were emitted into different directories"); } -validateExternalImports([mainResult.metafile, lazyResult.metafile]); +const metafiles = [mainResult.metafile, lazyResult.metafile, codemodeWorkerResult.metafile]; +validateExternalImports(metafiles); const cliLauncher = `#!/usr/bin/env node import { createRequire, enableCompileCache } from "node:module"; @@ -235,7 +259,6 @@ writeFileSync(join(bundleDir, "cli.js"), cliLauncher); chmodSync(join(bundleDir, "cli.js"), 0o755); chmodSync(join(bundleDir, "rpc-entry.js"), 0o755); -const files = - new Set([...Object.keys(mainResult.metafile.outputs), ...Object.keys(lazyResult.metafile.outputs)]).size + 1; -const mib = (outputBytes([mainResult.metafile, lazyResult.metafile]) + cliLauncher.length) / (1024 * 1024); +const files = new Set(metafiles.flatMap((metafile) => Object.keys(metafile.outputs))).size + 1; +const mib = (outputBytes(metafiles) + cliLauncher.length) / (1024 * 1024); console.log(`Built ${relative(repoRoot, bundleDir)} (${files} files, ${mib.toFixed(1)} MiB)`);