Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save AlexanderMakarov/9aaea521960e628e550bb703b96b785f to your computer and use it in GitHub Desktop.

Select an option

Save AlexanderMakarov/9aaea521960e628e550bb703b96b785f to your computer and use it in GitHub Desktop.
OMP fix: DefaultPackageManager shim + linkedom CJS interop for pi-task (#5658)
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