fix(coding-agent): cache extension imports for session switches

Closes #5905.
This commit is contained in:
Armin Ronacher
2026-06-20 21:05:55 +02:00
parent a1da88aed4
commit 5505316ea2
4 changed files with 226 additions and 10 deletions
@@ -117,6 +117,30 @@ function getAliases(): Record<string, string> {
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
let extensionCacheCwd: string | undefined;
let extensionCacheGeneration = 0;
const extensionCache = new Map<string, ExtensionFactory>();
interface ExtensionCacheToken {
cwd: string;
generation: number;
}
export function clearExtensionCache(): void {
extensionCache.clear();
extensionCacheCwd = undefined;
extensionCacheGeneration++;
}
function useExtensionCacheCwd(cwd: string): ExtensionCacheToken {
const resolvedCwd = resolvePath(cwd);
if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) {
clearExtensionCache();
}
extensionCacheCwd = resolvedCwd;
return { cwd: resolvedCwd, generation: extensionCacheGeneration };
}
/**
* Create a runtime with throwing stubs for action methods.
* Runner.bindCore() replaces these with real implementations.
@@ -328,7 +352,22 @@ function createExtensionAPI(
return api;
}
async function loadExtensionModule(extensionPath: string) {
function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken {
return (
cacheToken !== undefined &&
extensionCacheCwd === cacheToken.cwd &&
extensionCacheGeneration === cacheToken.generation
);
}
async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) {
if (isCurrentCacheToken(cacheToken)) {
const cachedFactory = extensionCache.get(extensionPath);
if (cachedFactory) {
return cachedFactory;
}
}
const jiti = createJiti(import.meta.url, {
moduleCache: false,
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
@@ -339,7 +378,13 @@ async function loadExtensionModule(extensionPath: string) {
const module = await jiti.import(extensionPath, { default: true });
const factory = module as ExtensionFactory;
return typeof factory !== "function" ? undefined : factory;
if (typeof factory !== "function") {
return undefined;
}
if (isCurrentCacheToken(cacheToken)) {
extensionCache.set(extensionPath, factory);
}
return factory;
}
/**
@@ -370,11 +415,12 @@ async function loadExtension(
cwd: string,
eventBus: EventBus,
runtime: ExtensionRuntime,
cacheToken?: ExtensionCacheToken,
): Promise<{ extension: Extension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try {
const factory = await loadExtensionModule(resolvedPath);
const factory = await loadExtensionModule(resolvedPath, cacheToken);
if (!factory) {
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
}
@@ -410,20 +456,28 @@ export async function loadExtensionFromFactory(
/**
* Load extensions from paths.
*/
export async function loadExtensions(
async function loadExtensionsInternal(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
useCache = false,
): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const cacheToken = useCache ? useExtensionCacheCwd(cwd) : undefined;
const resolvedCwd = cacheToken?.cwd ?? resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus();
const resolvedRuntime = runtime ?? createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime);
const { extension, error } = await loadExtension(
extPath,
resolvedCwd,
resolvedEventBus,
resolvedRuntime,
cacheToken,
);
if (error) {
errors.push({ path: extPath, error });
@@ -442,6 +496,24 @@ export async function loadExtensions(
};
}
export async function loadExtensions(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
): Promise<LoadExtensionsResult> {
return loadExtensionsInternal(paths, cwd, eventBus, runtime);
}
export async function loadExtensionsCached(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
): Promise<LoadExtensionsResult> {
return loadExtensionsInternal(paths, cwd, eventBus, runtime, true);
}
interface PiManifest {
extensions?: string[];
themes?: string[];
@@ -9,7 +9,12 @@ export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import {
clearExtensionCache,
createExtensionRuntime,
loadExtensionFromFactory,
loadExtensionsCached,
} from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
@@ -206,6 +211,7 @@ export class DefaultResourceLoader implements ResourceLoader {
private extensionThemeSourceInfos: Map<string, SourceInfo>;
private lastPromptPaths: string[];
private lastThemePaths: string[];
private loaded: boolean;
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = resolvePath(options.cwd);
@@ -252,6 +258,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.extensionThemeSourceInfos = new Map();
this.lastPromptPaths = [];
this.lastThemePaths = [];
this.loaded = false;
}
getExtensions(): LoadExtensionsResult {
@@ -331,6 +338,10 @@ export class DefaultResourceLoader implements ResourceLoader {
}
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
if (this.loaded) {
clearExtensionCache();
}
let preTrustExtensions: LoadExtensionsResult | undefined;
if (options?.resolveProjectTrust) {
preTrustExtensions = await this.loadProjectTrustExtensions();
@@ -475,6 +486,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.appendSystemPrompt = this.appendSystemPromptOverride
? this.appendSystemPromptOverride(baseAppend)
: baseAppend;
this.loaded = true;
}
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {
@@ -487,7 +499,7 @@ export class DefaultResourceLoader implements ResourceLoader {
const extensionPaths = this.noExtensions
? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
if (!options.includeInlineFactories) {
return extensionsResult;
}
@@ -507,7 +519,7 @@ export class DefaultResourceLoader implements ResourceLoader {
preTrustExtensions: LoadExtensionsResult | undefined,
): Promise<LoadExtensionsResult> {
if (!preTrustExtensions) {
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
@@ -527,7 +539,7 @@ export class DefaultResourceLoader implements ResourceLoader {
const resolvedPath = this.resolveExtensionLoadPath(path);
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
});
const remainingExtensions = await loadExtensions(
const remainingExtensions = await loadExtensionsCached(
remainingPaths,
this.cwd,
this.eventBus,