Skip to content

Commit 4b2b282

Browse files
committed
fix(plugins): load bundled extensions from dist
1 parent 756d9b5 commit 4b2b282

File tree

13 files changed

+202
-36
lines changed

13 files changed

+202
-36
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
5858
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
5959
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
6060
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
61+
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse.
6162

6263
## 2026.3.13
6364

extensions/llm-task/src/llm-task-tool.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
22

3-
vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
3+
vi.mock("openclaw/extension-api", () => {
44
return {
55
runEmbeddedPiAgent: vi.fn(async () => ({
66
meta: { startedAt: Date.now() },
@@ -9,7 +9,7 @@ vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
99
};
1010
});
1111

12-
import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js";
12+
import { runEmbeddedPiAgent } from "openclaw/extension-api";
1313
import { createLlmTaskTool } from "./llm-task-tool.js";
1414

1515
// oxlint-disable-next-line typescript/no-explicit-any

extensions/llm-task/src/llm-task-tool.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,20 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33
import { Type } from "@sinclair/typebox";
44
import Ajv from "ajv";
5+
import { runEmbeddedPiAgent } from "openclaw/extension-api";
56
import {
67
formatThinkingLevels,
78
formatXHighModelHint,
89
normalizeThinkLevel,
910
resolvePreferredOpenClawTmpDir,
1011
supportsXHighThinking,
1112
} from "openclaw/plugin-sdk/llm-task";
12-
// NOTE: This extension is intended to be bundled with OpenClaw.
13-
// When running from source (tests/dev), OpenClaw internals live under src/.
14-
// When running from a built install, internals live under dist/ (no src/ tree).
15-
// So we resolve internal imports dynamically with src-first, dist-fallback.
1613
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/llm-task";
1714

1815
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
1916

2017
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
21-
// Source checkout (tests/dev)
22-
try {
23-
const mod = await import("../../../src/agents/pi-embedded-runner.js");
24-
// oxlint-disable-next-line typescript/no-explicit-any
25-
if (typeof (mod as any).runEmbeddedPiAgent === "function") {
26-
// oxlint-disable-next-line typescript/no-explicit-any
27-
return (mod as any).runEmbeddedPiAgent;
28-
}
29-
} catch {
30-
// ignore
31-
}
32-
33-
// Bundled install (built)
34-
// NOTE: there is no src/ tree in a packaged install. Prefer a stable internal entrypoint.
35-
const distExtensionApi = "../../../dist/extensionAPI.js";
36-
const mod = (await import(distExtensionApi)) as { runEmbeddedPiAgent?: unknown };
37-
// oxlint-disable-next-line typescript/no-explicit-any
38-
const fn = (mod as any).runEmbeddedPiAgent;
39-
if (typeof fn !== "function") {
40-
throw new Error("Internal error: runEmbeddedPiAgent not available");
41-
}
42-
return fn as RunEmbeddedPiAgentFn;
18+
return runEmbeddedPiAgent;
4319
}
4420

4521
function stripCodeFences(s: string): string {

extensions/whatsapp/src/channel.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import {
2+
applyAccountNameToChannelSection,
3+
buildChannelConfigSchema,
24
buildAccountScopedDmSecurityPolicy,
35
collectAllowlistProviderGroupPolicyWarnings,
46
collectOpenGroupPolicyRouteAllowlistWarnings,
5-
} from "openclaw/plugin-sdk/compat";
6-
import {
7-
applyAccountNameToChannelSection,
8-
buildChannelConfigSchema,
97
createActionGate,
108
createWhatsAppOutboundBase,
119
DEFAULT_ACCOUNT_ID,

extensions/whatsapp/src/runtime.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
2-
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
1+
import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
32

43
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
54
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@
212212
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
213213
"default": "./dist/plugin-sdk/keyed-async-queue.js"
214214
},
215+
"./extension-api": "./dist/extensionAPI.js",
215216
"./cli-entry": "./openclaw.mjs"
216217
},
217218
"scripts": {
@@ -224,8 +225,8 @@
224225
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity",
225226
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
226227
"android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
227-
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
228-
"build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
228+
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
229+
"build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
229230
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true",
230231
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
231232
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env node
2+
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
6+
const repoRoot = process.cwd();
7+
const extensionsRoot = path.join(repoRoot, "extensions");
8+
const distExtensionsRoot = path.join(repoRoot, "dist", "extensions");
9+
10+
function rewritePackageExtensions(entries) {
11+
if (!Array.isArray(entries)) {
12+
return undefined;
13+
}
14+
15+
return entries
16+
.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
17+
.map((entry) => {
18+
const normalized = entry.replace(/^\.\//, "");
19+
const rewritten = normalized.replace(/\.[^.]+$/u, ".js");
20+
return `./${rewritten}`;
21+
});
22+
}
23+
24+
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
25+
if (!dirent.isDirectory()) {
26+
continue;
27+
}
28+
29+
const pluginDir = path.join(extensionsRoot, dirent.name);
30+
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
31+
if (!fs.existsSync(manifestPath)) {
32+
continue;
33+
}
34+
35+
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
36+
fs.mkdirSync(distPluginDir, { recursive: true });
37+
fs.copyFileSync(manifestPath, path.join(distPluginDir, "openclaw.plugin.json"));
38+
39+
const packageJsonPath = path.join(pluginDir, "package.json");
40+
if (!fs.existsSync(packageJsonPath)) {
41+
continue;
42+
}
43+
44+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
45+
if (packageJson.openclaw && "extensions" in packageJson.openclaw) {
46+
packageJson.openclaw = {
47+
...packageJson.openclaw,
48+
extensions: rewritePackageExtensions(packageJson.openclaw.extensions),
49+
};
50+
}
51+
52+
fs.writeFileSync(
53+
path.join(distPluginDir, "package.json"),
54+
`${JSON.stringify(packageJson, null, 2)}\n`,
55+
"utf8",
56+
);
57+
}

src/plugin-sdk/subpaths.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as extensionApi from "openclaw/extension-api";
12
import * as compatSdk from "openclaw/plugin-sdk/compat";
23
import * as discordSdk from "openclaw/plugin-sdk/discord";
34
import * as imessageSdk from "openclaw/plugin-sdk/imessage";
@@ -132,4 +133,8 @@ describe("plugin-sdk subpath exports", () => {
132133
const zalo = await import("openclaw/plugin-sdk/zalo");
133134
expect(typeof zalo.resolveClientIp).toBe("function");
134135
});
136+
137+
it("exports the extension api bridge", () => {
138+
expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function");
139+
});
135140
});

src/plugin-sdk/whatsapp.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export {
2525
listWhatsAppDirectoryGroupsFromConfig,
2626
listWhatsAppDirectoryPeersFromConfig,
2727
} from "../channels/plugins/directory-config.js";
28+
export {
29+
collectAllowlistProviderGroupPolicyWarnings,
30+
collectOpenGroupPolicyRouteAllowlistWarnings,
31+
} from "../channels/plugins/group-policy-warnings.js";
32+
export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js";
2833
export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js";
2934

3035
export {
@@ -44,5 +49,6 @@ export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp
4449
export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js";
4550

4651
export { createActionGate, readStringParam } from "../agents/tools/common.js";
52+
export { createPluginRuntimeStore } from "./runtime-store.js";
4753

4854
export { normalizeE164 } from "../utils.js";

src/plugins/loader.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,22 @@ function createPluginSdkAliasFixture(params?: {
284284
return { root, srcFile, distFile };
285285
}
286286

287+
function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) {
288+
const root = makeTempDir();
289+
const srcFile = path.join(root, "src", "extensionAPI.ts");
290+
const distFile = path.join(root, "dist", "extensionAPI.js");
291+
mkdirSafe(path.dirname(srcFile));
292+
mkdirSafe(path.dirname(distFile));
293+
fs.writeFileSync(
294+
path.join(root, "package.json"),
295+
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
296+
"utf-8",
297+
);
298+
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
299+
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
300+
return { root, srcFile, distFile };
301+
}
302+
287303
afterEach(() => {
288304
clearPluginLoaderCache();
289305
if (prevBundledDir === undefined) {
@@ -2187,4 +2203,24 @@ describe("loadOpenClawPlugins", () => {
21872203
);
21882204
expect(resolved).toBe(srcFile);
21892205
});
2206+
2207+
it("prefers dist extension-api alias when loader runs from dist", () => {
2208+
const { root, distFile } = createExtensionApiAliasFixture();
2209+
2210+
const resolved = __testing.resolveExtensionApiAlias({
2211+
modulePath: path.join(root, "dist", "plugins", "loader.js"),
2212+
});
2213+
expect(resolved).toBe(distFile);
2214+
});
2215+
2216+
it("prefers src extension-api alias when loader runs from src in non-production", () => {
2217+
const { root, srcFile } = createExtensionApiAliasFixture();
2218+
2219+
const resolved = withEnv({ NODE_ENV: undefined }, () =>
2220+
__testing.resolveExtensionApiAlias({
2221+
modulePath: path.join(root, "src", "plugins", "loader.ts"),
2222+
}),
2223+
);
2224+
expect(resolved).toBe(srcFile);
2225+
});
21902226
});

0 commit comments

Comments
 (0)