fix(coding-agent): keep codemode working after the install is updated or removed

Resolve the QuickJS wasm path once and spawn the bundled codemode worker
from an in-memory data: URL. Show a restart hint when errors occur after
the install changed on disk.

closes #10439
This commit is contained in:
Armin Ronacher 2026-10-04 22:49:20 +02:00
parent f5d20047b3
commit 1b094148b9
5 changed files with 113 additions and 11 deletions

View file

@ -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

View file

@ -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;

View file

@ -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 });

View file

@ -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" });
});
});

View file

@ -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)`);