Created
July 16, 2026 05:31
-
-
Save AlexanderMakarov/9aaea521960e628e550bb703b96b785f to your computer and use it in GitHub Desktop.
OMP fix: DefaultPackageManager shim + linkedom CJS interop for pi-task (#5658)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From ad3e61196994245b79d05002d87f1bab3db6519e Mon Sep 17 00:00:00 2001 | |
| From: 4ellendger <4ellendger@gmail.com> | |
| Date: Thu, 16 Jul 2026 09:27:24 +0400 | |
| Subject: [PATCH] fix(extensibility): restore DefaultPackageManager and | |
| linkedom CJS interop | |
| Legacy pi extensions such as @mjasnikovs/pi-task fail to load under OMP | |
| because the legacy-pi-coding-agent-shim does not export DefaultPackageManager | |
| and CommonJS dependencies like linkedom/canvas.cjs are served through async | |
| onLoad hooks without a default export interop layer. | |
| Add a DefaultPackageManager shim backed by OMP extension discovery and route | |
| hooked .cjs modules through synchronous createRequire-based interop, with a | |
| canvas-shim redirect for linkedom's optional native canvas dependency. | |
| --- | |
| .../legacy-default-package-manager.ts | 197 ++++++++++++++++++ | |
| .../legacy-pi-coding-agent-shim.ts | 8 + | |
| .../extensibility/plugins/legacy-pi-compat.ts | 53 ++++- | |
| .../legacy-pi-default-package-manager.test.ts | 45 ++++ | |
| .../legacy-pi-linkedom-canvas.test.ts | 50 +++++ | |
| 5 files changed, 351 insertions(+), 2 deletions(-) | |
| create mode 100644 packages/coding-agent/src/extensibility/legacy-default-package-manager.ts | |
| create mode 100644 packages/coding-agent/test/extensibility/legacy-pi-default-package-manager.test.ts | |
| create mode 100644 packages/coding-agent/test/extensibility/legacy-pi-linkedom-canvas.test.ts | |
| diff --git a/packages/coding-agent/src/extensibility/legacy-default-package-manager.ts b/packages/coding-agent/src/extensibility/legacy-default-package-manager.ts | |
| new file mode 100644 | |
| index 0000000..4f523d4 | |
| --- /dev/null | |
| +++ b/packages/coding-agent/src/extensibility/legacy-default-package-manager.ts | |
| @@ -0,0 +1,197 @@ | |
| +/** | |
| + * Compatibility {@link DefaultPackageManager} for legacy pi extensions that | |
| + * enumerate host extensions via `new DefaultPackageManager(...).resolve()`. | |
| + * | |
| + * OMP already discovers extensions through {@link discoverExtensionPaths}; this | |
| + * shim maps that discovery onto pi's ResolvedPaths shape so menus like pi-task's | |
| + * `/task-config` whitelist mirror what the host runtime would load. | |
| + */ | |
| + | |
| +import * as path from "node:path"; | |
| +import type { Settings } from "../config/settings"; | |
| +import { discoverExtensionPaths } from "./extensions/loader"; | |
| +import { getEnabledPlugins, resolvePluginExtensionPaths } from "./plugins/loader"; | |
| +import type { ScopedInstalledPlugin } from "./plugins/loader"; | |
| + | |
| +export type SourceScope = "user" | "project" | "temporary"; | |
| + | |
| +export interface PathMetadata { | |
| + source: string; | |
| + scope: SourceScope; | |
| + origin: "package" | "top-level"; | |
| + baseDir?: string; | |
| +} | |
| + | |
| +export interface ResolvedResource { | |
| + path: string; | |
| + enabled: boolean; | |
| + metadata: PathMetadata; | |
| +} | |
| + | |
| +export interface ResolvedPaths { | |
| + extensions: ResolvedResource[]; | |
| + skills: ResolvedResource[]; | |
| + prompts: ResolvedResource[]; | |
| + themes: ResolvedResource[]; | |
| +} | |
| + | |
| +type SettingsManagerLike = | |
| + | Settings | |
| + | Promise<Settings> | |
| + | { | |
| + getGlobalSettings?: () => { extensions?: string[]; disabledExtensions?: string[] }; | |
| + getProjectSettings?: () => { extensions?: string[]; disabledExtensions?: string[] }; | |
| + }; | |
| + | |
| +async function resolveSettingsManager(settingsManager: SettingsManagerLike): Promise<SettingsManagerLike> { | |
| + if (settingsManager && typeof (settingsManager as Promise<unknown>).then === "function") { | |
| + return await settingsManager; | |
| + } | |
| + return settingsManager; | |
| +} | |
| + | |
| +function isOmpSettings(settingsManager: SettingsManagerLike): settingsManager is Settings { | |
| + return typeof (settingsManager as Settings).get === "function"; | |
| +} | |
| + | |
| +async function buildPluginExtensionMetadata(cwd: string): Promise<Map<string, PathMetadata>> { | |
| + const metadataByPath = new Map<string, PathMetadata>(); | |
| + const plugins = await getEnabledPlugins(cwd); | |
| + for (const plugin of plugins) { | |
| + for (const extPath of resolvePluginExtensionPaths(plugin)) { | |
| + metadataByPath.set(path.resolve(extPath), { | |
| + source: `npm:${plugin.name}`, | |
| + scope: plugin.scope, | |
| + origin: "package", | |
| + baseDir: plugin.path, | |
| + }); | |
| + } | |
| + } | |
| + return metadataByPath; | |
| +} | |
| + | |
| +function inferExtensionMetadata( | |
| + extPath: string, | |
| + cwd: string, | |
| + agentDir: string, | |
| + pluginMetadata: Map<string, PathMetadata>, | |
| +): PathMetadata { | |
| + const resolved = path.resolve(extPath); | |
| + const fromPlugin = pluginMetadata.get(resolved); | |
| + if (fromPlugin) { | |
| + return fromPlugin; | |
| + } | |
| + | |
| + const agentExtensionsDir = path.join(agentDir, "extensions"); | |
| + if (resolved.startsWith(`${agentExtensionsDir}${path.sep}`) || resolved === agentExtensionsDir) { | |
| + return { source: "auto", scope: "user", origin: "top-level", baseDir: agentDir }; | |
| + } | |
| + | |
| + for (const projectRoot of [path.join(cwd, ".omp"), path.join(cwd, ".pi")]) { | |
| + const projectExtensionsDir = path.join(projectRoot, "extensions"); | |
| + if (resolved.startsWith(`${projectExtensionsDir}${path.sep}`) || resolved === projectExtensionsDir) { | |
| + return { source: "auto", scope: "project", origin: "top-level", baseDir: projectRoot }; | |
| + } | |
| + } | |
| + | |
| + return { source: "local", scope: "user", origin: "top-level" }; | |
| +} | |
| + | |
| +async function discoverHostExtensionPaths( | |
| + settingsManager: SettingsManagerLike, | |
| + cwd: string, | |
| +): Promise<{ paths: string[]; disabledExtensionIds: string[] }> { | |
| + if (isOmpSettings(settingsManager)) { | |
| + const disabledExtensionIds = settingsManager.get("disabledExtensions") ?? []; | |
| + const configuredPaths = settingsManager.get("extensions") ?? []; | |
| + const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds); | |
| + return { paths, disabledExtensionIds }; | |
| + } | |
| + | |
| + const global = settingsManager.getGlobalSettings?.() ?? {}; | |
| + const project = settingsManager.getProjectSettings?.() ?? {}; | |
| + const configuredPaths = [...(project.extensions ?? []), ...(global.extensions ?? [])]; | |
| + const disabledExtensionIds = [ | |
| + ...(project.disabledExtensions ?? []), | |
| + ...(global.disabledExtensions ?? []), | |
| + ]; | |
| + const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds); | |
| + return { paths, disabledExtensionIds }; | |
| +} | |
| + | |
| +export class DefaultPackageManager { | |
| + readonly cwd: string; | |
| + readonly agentDir: string; | |
| + readonly settingsManager: SettingsManagerLike; | |
| + | |
| + constructor(options: { cwd: string; agentDir: string; settingsManager: SettingsManagerLike }) { | |
| + this.cwd = path.resolve(options.cwd); | |
| + this.agentDir = path.resolve(options.agentDir); | |
| + this.settingsManager = options.settingsManager; | |
| + } | |
| + | |
| + setProgressCallback(_callback?: unknown): void { | |
| + // OMP plugin installs are handled by `omp plugin`; legacy pi install UX is unsupported. | |
| + } | |
| + | |
| + async resolve( | |
| + _onMissing?: (source: string) => Promise<"install" | "skip" | "error">, | |
| + ): Promise<ResolvedPaths> { | |
| + const settingsManager = await resolveSettingsManager(this.settingsManager); | |
| + const { paths } = await discoverHostExtensionPaths(settingsManager, this.cwd); | |
| + const pluginMetadata = await buildPluginExtensionMetadata(this.cwd); | |
| + const disabled = new Set( | |
| + isOmpSettings(settingsManager) ? (settingsManager.get("disabledExtensions") ?? []) : [], | |
| + ); | |
| + | |
| + const extensions: ResolvedResource[] = paths.map(extPath => { | |
| + const resolved = path.resolve(extPath); | |
| + const metadata = inferExtensionMetadata(resolved, this.cwd, this.agentDir, pluginMetadata); | |
| + const enabled = !disabled.has(`extension-module:${path.basename(resolved)}`); | |
| + return { path: resolved, enabled, metadata }; | |
| + }); | |
| + | |
| + return { extensions, skills: [], prompts: [], themes: [] }; | |
| + } | |
| + | |
| + async install(): Promise<void> { | |
| + throw new Error("DefaultPackageManager.install is not supported under OMP; use `omp plugin install`."); | |
| + } | |
| + | |
| + async installAndPersist(): Promise<void> { | |
| + return this.install(); | |
| + } | |
| + | |
| + async remove(): Promise<void> { | |
| + throw new Error("DefaultPackageManager.remove is not supported under OMP; use `omp plugin remove`."); | |
| + } | |
| + | |
| + async removeAndPersist(): Promise<boolean> { | |
| + await this.remove(); | |
| + return false; | |
| + } | |
| + | |
| + async update(): Promise<void> { | |
| + throw new Error("DefaultPackageManager.update is not supported under OMP; use `omp plugin update`."); | |
| + } | |
| + | |
| + listConfiguredPackages(): never[] { | |
| + return []; | |
| + } | |
| + | |
| + async resolveExtensionSources(): Promise<ResolvedPaths> { | |
| + return this.resolve(); | |
| + } | |
| + | |
| + addSourceToSettings(): boolean { | |
| + return false; | |
| + } | |
| + | |
| + removeSourceFromSettings(): boolean { | |
| + return false; | |
| + } | |
| + | |
| + getInstalledPath(): undefined { | |
| + return undefined; | |
| + } | |
| +} | |
| diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts | |
| index ec9e789..cc4b82e 100644 | |
| --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts | |
| +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts | |
| @@ -1193,6 +1193,14 @@ export async function createAgentSession( | |
| return ompCreateAgentSession(forwarded); | |
| } | |
| +export { | |
| + DefaultPackageManager, | |
| + type PathMetadata, | |
| + type ResolvedPaths, | |
| + type ResolvedResource, | |
| + type SourceScope, | |
| +} from "./legacy-default-package-manager"; | |
| + | |
| export * from "../index"; | |
| export { formatBytes as formatSize } from "../tools/render-utils"; | |
| export { Type } from "./typebox"; | |
| diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts | |
| index 3612661..f6de98d 100644 | |
| --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts | |
| +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts | |
| @@ -1259,6 +1259,47 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin | |
| * hook with source pre-rewritten during graph collection; Bun rejects a CJS | |
| * `require()` whose onLoad callback returns a promise. | |
| */ | |
| + | |
| +function isCommonJsModulePath(modulePath: string): boolean { | |
| + const ext = path.extname(modulePath); | |
| + return ext === ".cjs" || ext === ".cts"; | |
| +} | |
| + | |
| +function findCreateRequireAnchor(modulePath: string): string { | |
| + let dir = path.dirname(modulePath); | |
| + for (;;) { | |
| + const candidate = path.join(dir, "package.json"); | |
| + if (fs.existsSync(candidate)) { | |
| + return candidate; | |
| + } | |
| + const parent = path.dirname(dir); | |
| + if (parent === dir) { | |
| + return modulePath; | |
| + } | |
| + dir = parent; | |
| + } | |
| +} | |
| + | |
| +/** Bun's extension graph hook serves hooked .cjs modules to ESM importers. */ | |
| +function synthesizeCommonJsDefaultExportInterop(modulePath: string): string { | |
| + let targetPath = modulePath; | |
| + if (modulePath.endsWith("canvas.cjs")) { | |
| + // linkedom's canvas.cjs tries optional native `canvas`; OMP never ships it. | |
| + targetPath = modulePath.replace(/canvas\.cjs$/, "canvas-shim.cjs"); | |
| + } | |
| + const anchorPath = findCreateRequireAnchor(targetPath); | |
| + const requireSpecifier = JSON.stringify( | |
| + anchorPath === targetPath ? targetPath : `./${path.relative(path.dirname(anchorPath), targetPath).replace(/\\/g, "/")}`, | |
| + ); | |
| + const anchorSpecifier = JSON.stringify(anchorPath); | |
| + return [ | |
| + 'import { createRequire } from "node:module";', | |
| + `const require = createRequire(${anchorSpecifier});`, | |
| + `const __ompCjsModule = require(${requireSpecifier});`, | |
| + "export default __ompCjsModule;", | |
| + ].join("\n"); | |
| +} | |
| + | |
| function installExtensionGraphHook( | |
| entryRealPath: string, | |
| modules: Map<string, string>, | |
| @@ -1266,8 +1307,16 @@ function installExtensionGraphHook( | |
| const asyncModules = new Map<string, string>(); | |
| const syncCommonJsModules = new Map<string, string>(); | |
| for (const [modulePath, source] of modules) { | |
| - const destination = nativeAddonLoaderModulePaths.has(modulePath) ? syncCommonJsModules : asyncModules; | |
| - destination.set(modulePath, source); | |
| + if (nativeAddonLoaderModulePaths.has(modulePath)) { | |
| + syncCommonJsModules.set(modulePath, source); | |
| + } else if (isCommonJsModulePath(modulePath)) { | |
| + // ESM `import x from "./foo.cjs"` must be served from a synchronous onLoad | |
| + // hook. Async hooks make the target an "async module" that createRequire() | |
| + // cannot load (linkedom/canvas.cjs under pi-task). | |
| + syncCommonJsModules.set(modulePath, synthesizeCommonJsDefaultExportInterop(modulePath)); | |
| + } else { | |
| + asyncModules.set(modulePath, source); | |
| + } | |
| } | |
| if (asyncModules.size > 0) { | |
| diff --git a/packages/coding-agent/test/extensibility/legacy-pi-default-package-manager.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-default-package-manager.test.ts | |
| new file mode 100644 | |
| index 0000000..07f617a | |
| --- /dev/null | |
| +++ b/packages/coding-agent/test/extensibility/legacy-pi-default-package-manager.test.ts | |
| @@ -0,0 +1,45 @@ | |
| +import { afterAll, describe, expect, it } from "bun:test"; | |
| +import * as fs from "node:fs/promises"; | |
| +import * as os from "node:os"; | |
| +import * as path from "node:path"; | |
| +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; | |
| +import { DefaultPackageManager } from "@oh-my-pi/pi-coding-agent/extensibility/legacy-pi-coding-agent-shim"; | |
| +import { removeWithRetries } from "@oh-my-pi/pi-utils"; | |
| + | |
| +const tempRoots: string[] = []; | |
| + | |
| +afterAll(async () => { | |
| + for (const dir of tempRoots) { | |
| + await removeWithRetries(dir); | |
| + } | |
| +}); | |
| + | |
| +async function mkTempCwd(prefix: string): Promise<string> { | |
| + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); | |
| + tempRoots.push(dir); | |
| + return dir; | |
| +} | |
| + | |
| +describe("DefaultPackageManager (legacy pi shim)", () => { | |
| + it("resolve() returns discovered extensions with metadata", async () => { | |
| + const cwd = await mkTempCwd("omp-legacy-pm-"); | |
| + const agentDir = path.join(cwd, "agent"); | |
| + const extensionsDir = path.join(agentDir, "extensions"); | |
| + await fs.mkdir(extensionsDir, { recursive: true }); | |
| + const extPath = path.join(extensionsDir, "sample-ext.ts"); | |
| + await fs.writeFile( | |
| + extPath, | |
| + 'export default function (pi) { pi.registerCommand("sample", { handler: async () => {} }); }', | |
| + ); | |
| + | |
| + const settings = Settings.isolated({ extensions: [] }); | |
| + const pm = new DefaultPackageManager({ cwd, agentDir, settingsManager: settings }); | |
| + const resolved = await pm.resolve(() => Promise.resolve("skip")); | |
| + | |
| + const match = resolved.extensions.find(entry => entry.path === extPath); | |
| + expect(match).toBeDefined(); | |
| + expect(match?.enabled).toBe(true); | |
| + expect(match?.metadata.source).toBe("auto"); | |
| + expect(match?.metadata.scope).toBe("user"); | |
| + }); | |
| +}); | |
| diff --git a/packages/coding-agent/test/extensibility/legacy-pi-linkedom-canvas.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-linkedom-canvas.test.ts | |
| new file mode 100644 | |
| index 0000000..0667c34 | |
| --- /dev/null | |
| +++ b/packages/coding-agent/test/extensibility/legacy-pi-linkedom-canvas.test.ts | |
| @@ -0,0 +1,50 @@ | |
| +import { afterAll, describe, expect, it } from "bun:test"; | |
| +import * as fs from "node:fs/promises"; | |
| +import * as os from "node:os"; | |
| +import * as path from "node:path"; | |
| +import { | |
| + installLegacyPiSpecifierShim, | |
| + loadLegacyPiModule, | |
| +} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat"; | |
| +import { removeWithRetries } from "@oh-my-pi/pi-utils"; | |
| + | |
| +installLegacyPiSpecifierShim(); | |
| + | |
| +const tempRoots: string[] = []; | |
| + | |
| +afterAll(async () => { | |
| + for (const dir of tempRoots) { | |
| + await removeWithRetries(dir); | |
| + } | |
| +}); | |
| + | |
| +async function writePackage(files: Record<string, string>): Promise<string> { | |
| + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-legacy-linkedom-")); | |
| + tempRoots.push(dir); | |
| + for (const rel in files) { | |
| + const full = path.join(dir, rel); | |
| + await fs.mkdir(path.dirname(full), { recursive: true }); | |
| + await fs.writeFile(full, files[rel]); | |
| + } | |
| + return dir; | |
| +} | |
| + | |
| +describe("legacy-pi CommonJS default export interop (linkedom canvas)", () => { | |
| + it("loads linkedom parseHTML through the extension graph hook", async () => { | |
| + const dir = await writePackage({ | |
| + "package.json": JSON.stringify({ name: "linkedom-ext", version: "1.0.0", type: "module" }), | |
| + "index.ts": [ | |
| + 'import { parseHTML } from "linkedom";', | |
| + "const { document } = parseHTML('<html><body><p>hi</p></body></html>');", | |
| + "export const text = document.querySelector('p')?.textContent ?? '';", | |
| + "export default function (pi) { void pi; }", | |
| + ].join("\n"), | |
| + }); | |
| + | |
| + const { $ } = await import("bun"); | |
| + await $`bun add linkedom`.cwd(dir).quiet(); | |
| + | |
| + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { text: string }; | |
| + expect(mod.text).toBe("hi"); | |
| + }); | |
| +}); | |
| -- | |
| 2.43.0 | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment