diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ecdb2bb238b..c970dfd6db6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -547,11 +547,13 @@ jobs:
path: dist-runtime-build.tar.zst
retention-days: 1
- - name: Upload A2UI bundle artifact
+ - name: Upload bundled plugin asset artifacts
uses: actions/upload-artifact@v7
with:
- name: canvas-a2ui-bundle
- path: extensions/canvas/src/host/a2ui/
+ name: bundled-plugin-assets
+ path: |
+ extensions/*/src/host/**/.bundle.hash
+ extensions/*/src/host/**/*.bundle.js
include-hidden-files: true
retention-days: 1
diff --git a/docs/refactor/canvas.md b/docs/refactor/canvas.md
index 6bab7b3f414..facccf98bef 100644
--- a/docs/refactor/canvas.md
+++ b/docs/refactor/canvas.md
@@ -50,6 +50,8 @@ Done:
- Added plugin-owned hosted media resolvers so Canvas document URLs resolve through the Canvas plugin instead of core importing Canvas document internals.
- Added `api.registerNodeCliFeature(...)` so Canvas can declare `openclaw nodes canvas` as a plugin-owned node feature without manually spelling the parent command path.
- Removed production `src/**` imports of `extensions/canvas/runtime-api.js`.
+- Moved the A2UI bundle source from `apps/shared/OpenClawKit/Tools/CanvasA2UI` to `extensions/canvas/src/host/a2ui-app`.
+- Moved A2UI build/copy implementation under `extensions/canvas/scripts` and replaced root build wiring with generic bundled-plugin asset hooks.
- Kept top-level `canvasHost` as a legacy read compatibility alias while doctor repairs old configs.
- Updated generated plugin inventory to include Canvas.
- Added plugin reference docs at `docs/plugins/reference/canvas.md`.
@@ -59,7 +61,7 @@ Known remaining core-owned Canvas surfaces:
- `src/config/types.gateway.ts` and related schema labels/help retain legacy `canvasHost` read/repair compatibility
- Gateway node hello and `nodes.canvasCapability.refresh` still carry `canvasHostUrl`/capability fields because native clients already speak that protocol shape
- native app Canvas protocol/client handlers under `apps/`
-- build/package output still copies A2UI to `dist/canvas-host/a2ui` for published artifact compatibility
+- published artifact output still uses `dist/canvas-host/a2ui` for backwards-compatible runtime lookup, but the copy step is now plugin-owned
## Target shape
@@ -69,6 +71,7 @@ Known remaining core-owned Canvas surfaces:
- agent tool registration
- node invoke command policy
- Canvas host and A2UI runtime
+- Canvas A2UI bundle source and asset build/copy scripts
- Canvas document creation and asset resolution
- Canvas CLI implementation
- Canvas docs page and plugin inventory entry
@@ -83,6 +86,7 @@ Core should own only generic seams:
- generic hosted media resolver registration
- generic node capability transport plus the existing Canvas protocol fields until native clients have a plugin-generic replacement
- generic config plumbing plus the legacy `canvasHost` alias for existing Canvas config
+- generic bundled-plugin asset hook discovery
Native apps may keep Canvas command handlers as clients of the protocol. They are not the plugin runtime owner.
@@ -104,6 +108,7 @@ Before calling the refactor complete:
- `rg "canvas-documents" src` is empty.
- `rg "registerNodesCanvasCommands|nodes-canvas" src` is empty; the Canvas plugin registers `openclaw nodes canvas` through nested plugin CLI metadata.
- `rg "createCanvasHostHandler|handleA2uiHttpRequest" src/gateway` returns no gateway runtime ownership.
+- `rg "apps/shared/OpenClawKit/Tools/CanvasA2UI|canvas-a2ui-copy|extensions/canvas/src/host/a2ui" scripts .github package.json` finds only compatibility wrappers or plugin-owned paths.
- `pnpm plugins:inventory:check` passes.
- `pnpm plugin-sdk:api:check` passes, or generated API baselines are intentionally updated and reviewed.
- Targeted Canvas tests pass.
@@ -118,7 +123,7 @@ Use targeted local checks while iterating:
pnpm test extensions/canvas/src/host/server.test.ts extensions/canvas/src/host/server.state-dir.test.ts extensions/canvas/src/host/file-resolver.test.ts
pnpm test src/gateway/server.plugin-node-capability-auth.test.ts src/gateway/server-import-boundary.test.ts
pnpm test extensions/canvas/src/config-migration.test.ts src/commands/doctor-legacy-config.migrations.test.ts
-pnpm test test/scripts/changed-lanes.test.ts test/scripts/bundle-a2ui.test.ts
+pnpm test test/scripts/changed-lanes.test.ts test/scripts/build-all.test.ts test/scripts/bundle-a2ui.test.ts test/scripts/bundled-plugin-assets.test.ts src/scripts/canvas-a2ui-copy.test.ts src/infra/run-node.test.ts
pnpm tsgo:extensions
pnpm plugins:inventory:check
pnpm plugin-sdk:api:check
diff --git a/extensions/canvas/package.json b/extensions/canvas/package.json
index 4e56787062d..fded840fbaf 100644
--- a/extensions/canvas/package.json
+++ b/extensions/canvas/package.json
@@ -13,6 +13,10 @@
"openclaw": {
"extensions": [
"./index.ts"
- ]
+ ],
+ "assetScripts": {
+ "build": "node scripts/bundle-a2ui.mjs",
+ "copy": "node scripts/copy-a2ui.mjs"
+ }
}
}
diff --git a/extensions/canvas/scripts/bundle-a2ui.mjs b/extensions/canvas/scripts/bundle-a2ui.mjs
new file mode 100644
index 00000000000..1407bf23647
--- /dev/null
+++ b/extensions/canvas/scripts/bundle-a2ui.mjs
@@ -0,0 +1,228 @@
+#!/usr/bin/env node
+
+import { spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync } from "node:fs";
+import fs from "node:fs/promises";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { resolvePnpmRunner } from "../../../scripts/pnpm-runner.mjs";
+
+const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const rootDir = path.resolve(pluginDir, "../..");
+const require = createRequire(import.meta.url);
+const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
+const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
+const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
+const rootPackageFile = path.join(rootDir, "package.json");
+const lockFile = path.join(rootDir, "pnpm-lock.yaml");
+const repoInputPaths = [rootPackageFile, lockFile, a2uiAppDir];
+const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
+ normalizePath(path.relative(rootDir, inputPath)),
+);
+
+function fail(message) {
+ console.error(message);
+ console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle");
+ console.error("If this persists, verify pnpm deps and try again.");
+ process.exit(1);
+}
+
+async function pathExists(targetPath) {
+ try {
+ await fs.stat(targetPath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function normalizePath(filePath) {
+ return filePath.split(path.sep).join("/");
+}
+
+export function isBundleHashInputPath(filePath, repoRoot = rootDir) {
+ return Boolean(filePath && repoRoot);
+}
+
+export function getLocalRolldownCliCandidates(repoRoot = rootDir) {
+ return [
+ path.join(repoRoot, "node_modules", "rolldown", "bin", "cli.mjs"),
+ path.join(repoRoot, "node_modules", ".pnpm", "node_modules", "rolldown", "bin", "cli.mjs"),
+ path.join(
+ repoRoot,
+ "node_modules",
+ ".pnpm",
+ "rolldown@1.0.0-rc.12",
+ "node_modules",
+ "rolldown",
+ "bin",
+ "cli.mjs",
+ ),
+ ];
+}
+
+export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
+ return [
+ path.join(repoRoot, "package.json"),
+ path.join(repoRoot, "pnpm-lock.yaml"),
+ path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui-app"),
+ ];
+}
+
+export function getBundleHashInputPaths(repoRoot = rootDir) {
+ return getBundleHashRepoInputPaths(repoRoot);
+}
+
+export function compareNormalizedPaths(left, right) {
+ const normalizedLeft = normalizePath(left);
+ const normalizedRight = normalizePath(right);
+ if (normalizedLeft < normalizedRight) {
+ return -1;
+ }
+ if (normalizedLeft > normalizedRight) {
+ return 1;
+ }
+ return 0;
+}
+
+async function walkFiles(entryPath, files) {
+ if (!isBundleHashInputPath(entryPath)) {
+ return;
+ }
+ const stat = await fs.stat(entryPath);
+ if (!stat.isDirectory()) {
+ files.push(entryPath);
+ return;
+ }
+ const entries = await fs.readdir(entryPath);
+ for (const entry of entries) {
+ await walkFiles(path.join(entryPath, entry), files);
+ }
+}
+
+function listTrackedInputFiles() {
+ const result = spawnSync("git", ["ls-files", "--", ...relativeRepoInputPaths], {
+ cwd: rootDir,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ if (result.status !== 0) {
+ return null;
+ }
+ const trackedFiles = result.stdout
+ .split("\n")
+ .filter(Boolean)
+ .map((filePath) => path.join(rootDir, filePath))
+ .filter((filePath) => existsSync(filePath))
+ .filter((filePath) => isBundleHashInputPath(filePath));
+ return trackedFiles;
+}
+
+async function computeHash() {
+ let files = listTrackedInputFiles();
+ if (!files) {
+ files = [];
+ for (const inputPath of getBundleHashRepoInputPaths(rootDir)) {
+ await walkFiles(inputPath, files);
+ }
+ }
+ files = [...new Set(files)].toSorted(compareNormalizedPaths);
+
+ const hash = createHash("sha256");
+ for (const filePath of files) {
+ hash.update(normalizePath(path.relative(rootDir, filePath)));
+ hash.update("\0");
+ hash.update(await fs.readFile(filePath));
+ hash.update("\0");
+ }
+ return hash.digest("hex");
+}
+
+function runStep(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: rootDir,
+ env: process.env,
+ stdio: "inherit",
+ ...options,
+ });
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1);
+ }
+}
+
+function runPnpm(pnpmArgs) {
+ const runner = resolvePnpmRunner({
+ pnpmArgs,
+ nodeExecPath: process.execPath,
+ npmExecPath: process.env.npm_execpath,
+ comSpec: process.env.ComSpec,
+ platform: process.platform,
+ });
+ runStep(runner.command, runner.args, {
+ shell: runner.shell,
+ windowsVerbatimArguments: runner.windowsVerbatimArguments,
+ });
+}
+
+async function main() {
+ const hasAppDir = await pathExists(a2uiAppDir);
+ const hasOutputFile = await pathExists(outputFile);
+ let hasA2uiPackage = true;
+ try {
+ require.resolve("@a2ui/lit");
+ require.resolve("@a2ui/lit/ui");
+ } catch {
+ hasA2uiPackage = false;
+ }
+ if (!hasA2uiPackage || !hasAppDir) {
+ if (hasOutputFile) {
+ console.log("A2UI package missing; keeping prebuilt bundle.");
+ return;
+ }
+ if (process.env.OPENCLAW_SPARSE_PROFILE || process.env.OPENCLAW_A2UI_SKIP_MISSING === "1") {
+ console.error(
+ "A2UI package missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.",
+ );
+ return;
+ }
+ fail(`A2UI package missing and no prebuilt bundle found at: ${outputFile}`);
+ }
+
+ const currentHash = await computeHash();
+ if (await pathExists(hashFile)) {
+ const previousHash = (await fs.readFile(hashFile, "utf8")).trim();
+ if (previousHash === currentHash && hasOutputFile) {
+ console.log("A2UI bundle up to date; skipping.");
+ return;
+ }
+ }
+
+ const localRolldownCliCandidates = getLocalRolldownCliCandidates(rootDir);
+ const localRolldownCli = (
+ await Promise.all(
+ localRolldownCliCandidates.map(async (candidate) =>
+ (await pathExists(candidate)) ? candidate : null,
+ ),
+ )
+ ).find(Boolean);
+
+ if (localRolldownCli) {
+ runStep(process.execPath, [
+ localRolldownCli,
+ "-c",
+ path.join(a2uiAppDir, "rolldown.config.mjs"),
+ ]);
+ } else {
+ runPnpm(["-s", "exec", "rolldown", "-c", path.join(a2uiAppDir, "rolldown.config.mjs")]);
+ }
+
+ await fs.writeFile(hashFile, `${currentHash}\n`, "utf8");
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main().catch((error) => {
+ fail(error instanceof Error ? error.message : String(error));
+ });
+}
diff --git a/scripts/canvas-a2ui-copy.ts b/extensions/canvas/scripts/copy-a2ui.mjs
similarity index 72%
rename from scripts/canvas-a2ui-copy.ts
rename to extensions/canvas/scripts/copy-a2ui.mjs
index 5f6e3f699ec..441953fd844 100644
--- a/scripts/canvas-a2ui-copy.ts
+++ b/extensions/canvas/scripts/copy-a2ui.mjs
@@ -1,21 +1,23 @@
+#!/usr/bin/env node
+
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const rootDir = path.resolve(pluginDir, "../..");
function getA2uiPaths(env = process.env) {
- const srcDir =
- env.OPENCLAW_A2UI_SRC_DIR ?? path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui");
- const outDir = env.OPENCLAW_A2UI_OUT_DIR ?? path.join(repoRoot, "dist", "canvas-host", "a2ui");
+ const srcDir = env.OPENCLAW_A2UI_SRC_DIR ?? path.join(pluginDir, "src", "host", "a2ui");
+ const outDir = env.OPENCLAW_A2UI_OUT_DIR ?? path.join(rootDir, "dist", "canvas-host", "a2ui");
return { srcDir, outDir };
}
-function shouldSkipMissingA2uiAssets(env = process.env): boolean {
+function shouldSkipMissingA2uiAssets(env = process.env) {
return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE);
}
-export async function copyA2uiAssets({ srcDir, outDir }: { srcDir: string; outDir: string }) {
+export async function copyA2uiAssets({ srcDir, outDir }) {
const skipMissing = shouldSkipMissingA2uiAssets(process.env);
try {
await fs.stat(path.join(srcDir, "index.html"));
diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js b/extensions/canvas/src/host/a2ui-app/bootstrap.js
similarity index 87%
rename from apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js
rename to extensions/canvas/src/host/a2ui-app/bootstrap.js
index b2d03165aa3..dfebeaed277 100644
--- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js
+++ b/extensions/canvas/src/host/a2ui-app/bootstrap.js
@@ -1,10 +1,9 @@
-import { html, css, LitElement, unsafeCSS } from "lit";
-import { repeat } from "lit/directives/repeat.js";
-import { ContextProvider } from "@lit/context";
-
import { v0_8 } from "@a2ui/lit";
-import "@a2ui/lit/ui";
+import { ContextProvider } from "@lit/context";
import { themeContext } from "@openclaw/a2ui-theme-context";
+import { html, css, LitElement, unsafeCSS } from "lit";
+import "@a2ui/lit/ui";
+import { repeat } from "lit/directives/repeat.js";
const modalStyles = css`
dialog {
@@ -97,8 +96,12 @@ const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}
const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)";
-const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)";
-const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)";
+const buttonShadow = isAndroid
+ ? "0 2px 10px rgba(6, 182, 212, 0.14)"
+ : "0 10px 25px rgba(6, 182, 212, 0.18)";
+const statusShadow = isAndroid
+ ? "0 2px 10px rgba(0, 0, 0, 0.18)"
+ : "0 10px 24px rgba(0, 0, 0, 0.25)";
const statusBlur = isAndroid ? "10px" : "14px";
const openclawTheme = {
@@ -125,7 +128,11 @@ const openclawTheme = {
MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Row: emptyClasses(),
Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
- Tabs: { container: emptyClasses(), element: emptyClasses(), controls: { all: emptyClasses(), selected: emptyClasses() } },
+ Tabs: {
+ container: emptyClasses(),
+ element: emptyClasses(),
+ controls: { all: emptyClasses(), selected: emptyClasses() },
+ },
Text: {
all: emptyClasses(),
h1: emptyClasses(),
@@ -235,11 +242,8 @@ class OpenClawA2UIHost extends LitElement {
height: 100%;
position: relative;
box-sizing: border-box;
- padding:
- var(--openclaw-a2ui-inset-top, 0px)
- var(--openclaw-a2ui-inset-right, 0px)
- var(--openclaw-a2ui-inset-bottom, 0px)
- var(--openclaw-a2ui-inset-left, 0px);
+ padding: var(--openclaw-a2ui-inset-top, 0px) var(--openclaw-a2ui-inset-right, 0px)
+ var(--openclaw-a2ui-inset-bottom, 0px) var(--openclaw-a2ui-inset-left, 0px);
}
#surfaces {
@@ -264,7 +268,12 @@ class OpenClawA2UIHost extends LitElement {
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
- font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
+ font:
+ 13px/1.2 system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Roboto",
+ sans-serif;
pointer-events: none;
backdrop-filter: blur(${unsafeCSS(statusBlur)});
-webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
@@ -285,7 +294,12 @@ class OpenClawA2UIHost extends LitElement {
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
- font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
+ font:
+ 13px/1.2 system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Roboto",
+ sans-serif;
pointer-events: none;
backdrop-filter: blur(${unsafeCSS(statusBlur)});
-webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
@@ -360,7 +374,10 @@ class OpenClawA2UIHost extends LitElement {
}
#makeActionId() {
- return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`;
+ return (
+ globalThis.crypto?.randomUUID?.() ??
+ `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`
+ );
}
#setToast(text, kind = "ok", timeoutMs = 1400) {
@@ -377,8 +394,12 @@ class OpenClawA2UIHost extends LitElement {
#handleActionStatus(evt) {
const detail = evt?.detail ?? null;
- if (!detail || typeof detail.id !== "string") {return;}
- if (!this.pendingAction || this.pendingAction.id !== detail.id) {return;}
+ if (!detail || typeof detail.id !== "string") {
+ return;
+ }
+ if (!this.pendingAction || this.pendingAction.id !== detail.id) {
+ return;
+ }
if (detail.ok) {
this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() };
@@ -421,7 +442,9 @@ class OpenClawA2UIHost extends LitElement {
for (const item of ctxItems) {
const key = item?.key;
const value = item?.value ?? null;
- if (!key || !value) {continue;}
+ if (!key || !value) {
+ continue;
+ }
if (typeof value.path === "string") {
const resolved = sourceNode
@@ -474,11 +497,23 @@ class OpenClawA2UIHost extends LitElement {
}
} catch (e) {
const msg = String(e?.message ?? e);
- this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
+ this.pendingAction = {
+ id: actionId,
+ name,
+ phase: "error",
+ startedAt: Date.now(),
+ error: msg,
+ };
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
} else {
- this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" };
+ this.pendingAction = {
+ id: actionId,
+ name,
+ phase: "error",
+ startedAt: Date.now(),
+ error: "missing native bridge",
+ };
this.#setToast("Failed: missing native bridge", "error", 4500);
}
}
@@ -525,24 +560,28 @@ class OpenClawA2UIHost extends LitElement {
? `Failed: ${this.pendingAction.name}`
: "";
- return html`
- ${this.pendingAction && this.pendingAction.phase !== "error"
- ? html`
`
+ return html` ${this.pendingAction && this.pendingAction.phase !== "error"
+ ? html``
: ""}
${this.toast
- ? html`${this.toast.text}
`
+ ? html`
+ ${this.toast.text}
+
`
: ""}
- ${repeat(
- this.surfaces,
- ([surfaceId]) => surfaceId,
- ([surfaceId, surface]) => html``
- )}
- `;
+ ${repeat(
+ this.surfaces,
+ ([surfaceId]) => surfaceId,
+ ([surfaceId, surface]) => html``,
+ )}
+ `;
}
}
diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs b/extensions/canvas/src/host/a2ui-app/rolldown.config.mjs
similarity index 93%
rename from apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs
rename to extensions/canvas/src/host/a2ui-app/rolldown.config.mjs
index e6df498e7ef..ab9cbfa64cc 100644
--- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs
+++ b/extensions/canvas/src/host/a2ui-app/rolldown.config.mjs
@@ -1,6 +1,6 @@
-import path from "node:path";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
+import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -8,16 +8,7 @@ const repoRoot = path.resolve(here, "../../../../..");
const require = createRequire(import.meta.url);
const uiRoot = path.resolve(repoRoot, "ui");
const fromHere = (p) => path.resolve(here, p);
-const outputFile = path.resolve(
- here,
- "../../../../..",
- "extensions",
- "canvas",
- "src",
- "host",
- "a2ui",
- "a2ui.bundle.js",
-);
+const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const a2uiLitIndex = require.resolve("@a2ui/lit");
const a2uiLitUi = require.resolve("@a2ui/lit/ui");
diff --git a/extensions/canvas/src/host/a2ui/.bundle.hash b/extensions/canvas/src/host/a2ui/.bundle.hash
index e9037560e88..d23f308a998 100644
--- a/extensions/canvas/src/host/a2ui/.bundle.hash
+++ b/extensions/canvas/src/host/a2ui/.bundle.hash
@@ -1 +1 @@
-8e45568ff64c7d2fd95957f076b635b6df99f115d1ee92e75ea63916566adb48
+992142e47ead0d7fb084464cc70f9752f2a7ffb8921598a94a02adaff0fc683c
diff --git a/package.json b/package.json
index c4579453f15..634f0ab0181 100644
--- a/package.json
+++ b/package.json
@@ -1302,10 +1302,10 @@
"audit:seams": "node scripts/audit-seams.mjs",
"build": "node scripts/build-all.mjs",
"build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts",
- "build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && 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 --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
+ "build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm plugins:assets:copy && 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 --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true",
"build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts",
- "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
+ "build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"canon:check": "node scripts/canon.mjs check",
"canon:check:json": "node scripts/canon.mjs check --json",
"canon:enforce": "node scripts/canon.mjs enforce --json",
@@ -1458,6 +1458,8 @@
"plugins:boundary-report:ci": "node --import tsx scripts/plugin-boundary-report.ts --summary --fail-on-cross-owner --fail-on-unclassified-unused-reserved --fail-on-eligible-compat",
"plugins:boundary-report:json": "node --import tsx scripts/plugin-boundary-report.ts --json",
"plugins:boundary-report:summary": "node --import tsx scripts/plugin-boundary-report.ts --summary",
+ "plugins:assets:build": "node scripts/bundled-plugin-assets.mjs --phase build",
+ "plugins:assets:copy": "node scripts/bundled-plugin-assets.mjs --phase copy",
"plugins:inventory:check": "node scripts/generate-plugin-inventory-doc.mjs --check",
"plugins:inventory:gen": "node scripts/generate-plugin-inventory-doc.mjs --write",
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs
index 4e943b861f3..377e3684853 100644
--- a/scripts/build-all.mjs
+++ b/scripts/build-all.mjs
@@ -11,7 +11,7 @@ const nodeBin = process.execPath;
const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096;
const BUILD_CACHE_VERSION = 2;
export const BUILD_ALL_STEPS = [
- { label: "canvas:a2ui:bundle", kind: "pnpm", pnpmArgs: ["canvas:a2ui:bundle"] },
+ { label: "plugins:assets:build", kind: "pnpm", pnpmArgs: ["plugins:assets:build"] },
{ label: "tsdown", kind: "node", args: ["scripts/tsdown-build.mjs"] },
{
label: "check-cli-bootstrap-imports",
@@ -53,13 +53,9 @@ export const BUILD_ALL_STEPS = [
args: ["scripts/check-plugin-sdk-exports.mjs"],
},
{
- label: "canvas-a2ui-copy",
- kind: "node",
- args: ["--import", "tsx", "scripts/canvas-a2ui-copy.ts"],
- cache: {
- inputs: ["scripts/canvas-a2ui-copy.ts", "extensions/canvas/src/host/a2ui"],
- outputs: ["dist/canvas-host/a2ui/index.html", "dist/canvas-host/a2ui/a2ui.bundle.js"],
- },
+ label: "plugins:assets:copy",
+ kind: "pnpm",
+ pnpmArgs: ["plugins:assets:copy"],
},
{
label: "copy-hook-metadata",
@@ -99,7 +95,7 @@ export const BUILD_ALL_STEPS = [
export const BUILD_ALL_PROFILES = {
full: BUILD_ALL_STEPS.map((step) => step.label),
ciArtifacts: [
- "canvas:a2ui:bundle",
+ "plugins:assets:build",
"tsdown",
"check-cli-bootstrap-imports",
"runtime-postbuild",
@@ -108,7 +104,7 @@ export const BUILD_ALL_PROFILES = {
"build:plugin-sdk:dts",
"write-plugin-sdk-entry-dts",
"check-plugin-sdk-exports",
- "canvas-a2ui-copy",
+ "plugins:assets:copy",
"copy-hook-metadata",
"copy-export-html-templates",
"write-build-info",
diff --git a/scripts/bundle-a2ui.mjs b/scripts/bundle-a2ui.mjs
index 46c7699fff8..ae2ea6439ed 100644
--- a/scripts/bundle-a2ui.mjs
+++ b/scripts/bundle-a2ui.mjs
@@ -1,235 +1,8 @@
#!/usr/bin/env node
-import { spawnSync } from "node:child_process";
-import { createHash } from "node:crypto";
-import { existsSync } from "node:fs";
-import fs from "node:fs/promises";
-import { createRequire } from "node:module";
-import path from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import { resolvePnpmRunner } from "./pnpm-runner.mjs";
-
-const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
-const require = createRequire(import.meta.url);
-const hashFile = path.join(rootDir, "extensions", "canvas", "src", "host", "a2ui", ".bundle.hash");
-const outputFile = path.join(
- rootDir,
- "extensions",
- "canvas",
- "src",
- "host",
- "a2ui",
- "a2ui.bundle.js",
-);
-const a2uiAppDir = path.join(rootDir, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI");
-const rootPackageFile = path.join(rootDir, "package.json");
-const lockFile = path.join(rootDir, "pnpm-lock.yaml");
-const repoInputPaths = [rootPackageFile, lockFile, a2uiAppDir];
-const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
- normalizePath(path.relative(rootDir, inputPath)),
-);
-
-function fail(message) {
- console.error(message);
- console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle");
- console.error("If this persists, verify pnpm deps and try again.");
- process.exit(1);
-}
-
-async function pathExists(targetPath) {
- try {
- await fs.stat(targetPath);
- return true;
- } catch {
- return false;
- }
-}
-
-function normalizePath(filePath) {
- return filePath.split(path.sep).join("/");
-}
-
-export function isBundleHashInputPath(filePath, repoRoot = rootDir) {
- return Boolean(filePath && repoRoot);
-}
-
-export function getLocalRolldownCliCandidates(repoRoot = rootDir) {
- return [
- path.join(repoRoot, "node_modules", "rolldown", "bin", "cli.mjs"),
- path.join(repoRoot, "node_modules", ".pnpm", "node_modules", "rolldown", "bin", "cli.mjs"),
- path.join(
- repoRoot,
- "node_modules",
- ".pnpm",
- "rolldown@1.0.0-rc.12",
- "node_modules",
- "rolldown",
- "bin",
- "cli.mjs",
- ),
- ];
-}
-
-export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
- return [
- path.join(repoRoot, "package.json"),
- path.join(repoRoot, "pnpm-lock.yaml"),
- path.join(repoRoot, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"),
- ];
-}
-
-export function getBundleHashInputPaths(repoRoot = rootDir) {
- return getBundleHashRepoInputPaths(repoRoot);
-}
-
-export function compareNormalizedPaths(left, right) {
- const normalizedLeft = normalizePath(left);
- const normalizedRight = normalizePath(right);
- if (normalizedLeft < normalizedRight) {
- return -1;
- }
- if (normalizedLeft > normalizedRight) {
- return 1;
- }
- return 0;
-}
-
-async function walkFiles(entryPath, files) {
- if (!isBundleHashInputPath(entryPath)) {
- return;
- }
- const stat = await fs.stat(entryPath);
- if (!stat.isDirectory()) {
- files.push(entryPath);
- return;
- }
- const entries = await fs.readdir(entryPath);
- for (const entry of entries) {
- await walkFiles(path.join(entryPath, entry), files);
- }
-}
-
-function listTrackedInputFiles() {
- const result = spawnSync("git", ["ls-files", "--", ...relativeRepoInputPaths], {
- cwd: rootDir,
- encoding: "utf8",
- stdio: ["ignore", "pipe", "pipe"],
- });
- if (result.status !== 0) {
- return null;
- }
- const trackedFiles = result.stdout
- .split("\n")
- .filter(Boolean)
- .map((filePath) => path.join(rootDir, filePath))
- .filter((filePath) => existsSync(filePath))
- .filter((filePath) => isBundleHashInputPath(filePath));
- return trackedFiles;
-}
-
-async function computeHash() {
- let files = listTrackedInputFiles();
- if (!files) {
- files = [];
- for (const inputPath of getBundleHashRepoInputPaths(rootDir)) {
- await walkFiles(inputPath, files);
- }
- }
- files = [...new Set(files)].toSorted(compareNormalizedPaths);
-
- const hash = createHash("sha256");
- for (const filePath of files) {
- hash.update(normalizePath(path.relative(rootDir, filePath)));
- hash.update("\0");
- hash.update(await fs.readFile(filePath));
- hash.update("\0");
- }
- return hash.digest("hex");
-}
-
-function runStep(command, args, options = {}) {
- const result = spawnSync(command, args, {
- cwd: rootDir,
- stdio: "inherit",
- env: process.env,
- ...options,
- });
- if (result.status !== 0) {
- process.exit(result.status ?? 1);
- }
-}
-
-function runPnpm(pnpmArgs) {
- const runner = resolvePnpmRunner({
- pnpmArgs,
- nodeExecPath: process.execPath,
- npmExecPath: process.env.npm_execpath,
- comSpec: process.env.ComSpec,
- platform: process.platform,
- });
- runStep(runner.command, runner.args, {
- shell: runner.shell,
- windowsVerbatimArguments: runner.windowsVerbatimArguments,
- });
-}
-
-async function main() {
- const hasAppDir = await pathExists(a2uiAppDir);
- const hasOutputFile = await pathExists(outputFile);
- let hasA2uiPackage = true;
- try {
- require.resolve("@a2ui/lit");
- require.resolve("@a2ui/lit/ui");
- } catch {
- hasA2uiPackage = false;
- }
- if (!hasA2uiPackage || !hasAppDir) {
- if (hasOutputFile) {
- console.log("A2UI package missing; keeping prebuilt bundle.");
- return;
- }
- if (process.env.OPENCLAW_SPARSE_PROFILE || process.env.OPENCLAW_A2UI_SKIP_MISSING === "1") {
- console.error(
- "A2UI package missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.",
- );
- return;
- }
- fail(`A2UI package missing and no prebuilt bundle found at: ${outputFile}`);
- }
-
- const currentHash = await computeHash();
- if (await pathExists(hashFile)) {
- const previousHash = (await fs.readFile(hashFile, "utf8")).trim();
- if (previousHash === currentHash && hasOutputFile) {
- console.log("A2UI bundle up to date; skipping.");
- return;
- }
- }
-
- const localRolldownCliCandidates = getLocalRolldownCliCandidates(rootDir);
- const localRolldownCli = (
- await Promise.all(
- localRolldownCliCandidates.map(async (candidate) =>
- (await pathExists(candidate)) ? candidate : null,
- ),
- )
- ).find(Boolean);
-
- if (localRolldownCli) {
- runStep(process.execPath, [
- localRolldownCli,
- "-c",
- path.join(a2uiAppDir, "rolldown.config.mjs"),
- ]);
- } else {
- runPnpm(["-s", "exec", "rolldown", "-c", path.join(a2uiAppDir, "rolldown.config.mjs")]);
- }
-
- await fs.writeFile(hashFile, `${currentHash}\n`, "utf8");
-}
+import { pathToFileURL } from "node:url";
+import { runBundledPluginAssetHooks } from "./bundled-plugin-assets.mjs";
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
- await main().catch((error) => {
- fail(error instanceof Error ? error.message : String(error));
- });
+ await runBundledPluginAssetHooks({ phase: "build", plugins: ["canvas"] });
}
diff --git a/scripts/bundled-plugin-assets.mjs b/scripts/bundled-plugin-assets.mjs
new file mode 100644
index 00000000000..4261a5eac94
--- /dev/null
+++ b/scripts/bundled-plugin-assets.mjs
@@ -0,0 +1,177 @@
+#!/usr/bin/env node
+
+import { spawnSync } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const VALID_PHASES = new Set(["build", "copy"]);
+
+async function readJsonFile(filePath) {
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
+}
+
+async function pathExists(filePath) {
+ try {
+ await fs.stat(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function packagePluginAliases(packageName) {
+ if (typeof packageName !== "string") {
+ return [];
+ }
+ const aliases = [packageName];
+ const unscopedName = packageName.split("/").at(-1);
+ if (unscopedName) {
+ aliases.push(unscopedName);
+ if (unscopedName.endsWith("-plugin")) {
+ aliases.push(unscopedName.slice(0, -"-plugin".length));
+ }
+ }
+ return aliases;
+}
+
+async function resolvePluginAliases(pluginDir, packageJson) {
+ const aliases = new Set([path.basename(pluginDir), ...packagePluginAliases(packageJson.name)]);
+ const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
+ if (await pathExists(manifestPath)) {
+ const manifest = await readJsonFile(manifestPath);
+ if (typeof manifest.id === "string" && manifest.id) {
+ aliases.add(manifest.id);
+ }
+ }
+ return aliases;
+}
+
+function resolveAssetCommand(packageJson, phase) {
+ const assetScripts = packageJson.openclaw?.assetScripts;
+ if (!assetScripts || typeof assetScripts !== "object") {
+ return null;
+ }
+ const command = assetScripts[phase];
+ return typeof command === "string" && command.trim() ? command.trim() : null;
+}
+
+export async function readBundledPluginAssetHooks(options = {}) {
+ const repoRoot = options.rootDir ?? rootDir;
+ const phase = options.phase;
+ if (!VALID_PHASES.has(phase)) {
+ throw new Error(`Unsupported bundled plugin asset phase: ${String(phase)}`);
+ }
+
+ const pluginFilters = new Set((options.plugins ?? []).filter(Boolean));
+ const extensionsDir = path.join(repoRoot, "extensions");
+ let entries;
+ try {
+ entries = await fs.readdir(extensionsDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ const hooks = [];
+ for (const entry of entries) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+ const pluginDir = path.join(extensionsDir, entry.name);
+ const packagePath = path.join(pluginDir, "package.json");
+ if (!(await pathExists(packagePath))) {
+ continue;
+ }
+
+ const packageJson = await readJsonFile(packagePath);
+ const aliases = await resolvePluginAliases(pluginDir, packageJson);
+ if (pluginFilters.size > 0 && ![...pluginFilters].some((plugin) => aliases.has(plugin))) {
+ continue;
+ }
+
+ const command = resolveAssetCommand(packageJson, phase);
+ if (!command) {
+ continue;
+ }
+
+ hooks.push({
+ aliases: [...aliases].toSorted(),
+ command,
+ packageName: packageJson.name,
+ phase,
+ pluginDir,
+ pluginId: aliases.has(entry.name) ? entry.name : [...aliases][0],
+ });
+ }
+
+ return hooks.toSorted((left, right) => left.pluginDir.localeCompare(right.pluginDir));
+}
+
+export async function runBundledPluginAssetHooks(options = {}) {
+ const phase = options.phase;
+ const hooks = await readBundledPluginAssetHooks(options);
+ if (hooks.length === 0) {
+ const scope = options.plugins?.length ? ` for ${options.plugins.join(", ")}` : "";
+ console.log(`No bundled plugin asset ${phase} hooks${scope}; skipping.`);
+ return;
+ }
+
+ for (const hook of hooks) {
+ console.log(`[${hook.pluginId}] ${phase}: ${hook.command}`);
+ const result = spawnSync(hook.command, {
+ cwd: hook.pluginDir,
+ env: process.env,
+ shell: true,
+ stdio: "inherit",
+ });
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1);
+ }
+ }
+}
+
+export function parseBundledPluginAssetArgs(argv) {
+ const args = [...argv];
+ const plugins = [];
+ let phase = null;
+
+ while (args.length > 0) {
+ const arg = args.shift();
+ if (arg === "--phase") {
+ phase = args.shift() ?? null;
+ continue;
+ }
+ if (arg?.startsWith("--phase=")) {
+ phase = arg.slice("--phase=".length);
+ continue;
+ }
+ if (arg === "--plugin") {
+ const plugin = args.shift();
+ if (plugin) {
+ plugins.push(plugin);
+ }
+ continue;
+ }
+ if (arg?.startsWith("--plugin=")) {
+ plugins.push(arg.slice("--plugin=".length));
+ continue;
+ }
+ throw new Error(`Unknown bundled plugin asset argument: ${String(arg)}`);
+ }
+
+ if (!VALID_PHASES.has(phase)) {
+ throw new Error(`Expected --phase ${[...VALID_PHASES].join("|")}`);
+ }
+
+ return { phase, plugins };
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
+ try {
+ await runBundledPluginAssetHooks(parseBundledPluginAssetArgs(process.argv.slice(2)));
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ }
+}
diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs
index 1b6c71a5365..81a3c66d67f 100644
--- a/scripts/changed-lanes.mjs
+++ b/scripts/changed-lanes.mjs
@@ -13,7 +13,6 @@ const TOOLING_PATH_RE =
const ROOT_GLOBAL_PATH_RE =
/^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u;
const LEGACY_ROOT_ASSET_PATH_RE = /^assets\//u;
-const CANVAS_A2UI_BUNDLE_INPUT_RE = /^apps\/shared\/OpenClawKit\/Tools\/CanvasA2UI\//u;
const LIVE_DOCKER_TOOLING_PATH_RE =
/^(?:scripts\/test-docker-all\.mjs|scripts\/test-docker-all\.sh|scripts\/lib\/live-docker-auth\.sh|scripts\/test-live-(?:acp-bind|cli-backend|codex-harness|gateway-models|models)-docker\.sh|src\/gateway\/gateway-acp-bind\.live\.test\.ts|src\/gateway\/live-agent-probes\.test\.ts)$/u;
const LIVE_DOCKER_PACKAGE_SCRIPT_RE = /^test:docker:live-[\w:-]+$/u;
@@ -172,14 +171,6 @@ export function detectChangedLanes(changedPaths, options = {}) {
continue;
}
- if (CANVAS_A2UI_BUNDLE_INPUT_RE.test(changedPath)) {
- lanes.core = true;
- lanes.coreTests = true;
- lanes.tooling = true;
- reasons.push(`${changedPath}: CanvasA2UI bundle input`);
- continue;
- }
-
if (APP_PATH_RE.test(changedPath)) {
lanes.apps = true;
reasons.push(`${changedPath}: app surface`);
diff --git a/scripts/e2e/lib/parallels-package-common.sh b/scripts/e2e/lib/parallels-package-common.sh
index 262f4748250..cffd29f8c71 100644
--- a/scripts/e2e/lib/parallels-package-common.sh
+++ b/scripts/e2e/lib/parallels-package-common.sh
@@ -48,7 +48,7 @@ parallels_package_write_dist_inventory() {
parallels_package_assert_no_generated_drift() {
local drift
- drift="$(git status --porcelain -- extensions/canvas/src/host/a2ui/.bundle.hash 2>/dev/null || true)"
+ drift="$(git status --porcelain -- ':(glob)extensions/*/src/host/**/.bundle.hash' 2>/dev/null || true)"
if [[ -z "$drift" ]]; then
return 0
fi
diff --git a/scripts/e2e/parallels/package-artifact.ts b/scripts/e2e/parallels/package-artifact.ts
index db194ec6e56..eb5cae6dee2 100644
--- a/scripts/e2e/parallels/package-artifact.ts
+++ b/scripts/e2e/parallels/package-artifact.ts
@@ -107,7 +107,7 @@ async function ensureCurrentBuildUnlocked(input: {
}
const drift = run(
"git",
- ["status", "--porcelain", "--", "extensions/canvas/src/host/a2ui/.bundle.hash"],
+ ["status", "--porcelain", "--", ":(glob)extensions/*/src/host/**/.bundle.hash"],
{
quiet: true,
},
diff --git a/scripts/pre-commit/filter-staged-files.mjs b/scripts/pre-commit/filter-staged-files.mjs
index e8587f8b7b7..2206a0240ce 100644
--- a/scripts/pre-commit/filter-staged-files.mjs
+++ b/scripts/pre-commit/filter-staged-files.mjs
@@ -22,14 +22,14 @@ if (mode !== "lint" && mode !== "format") {
const lintExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
const formatExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".md", ".mdx"]);
-const formatIgnoredPaths = new Set(["extensions/canvas/src/host/a2ui/a2ui.bundle.js"]);
+const formatIgnoredPathPatterns = [/^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u];
const shouldSelect = (filePath) => {
const ext = path.extname(filePath).toLowerCase();
if (mode === "lint") {
return lintExts.has(ext);
}
- if (formatIgnoredPaths.has(filePath)) {
+ if (formatIgnoredPathPatterns.some((pattern) => pattern.test(filePath))) {
return false;
}
return formatExts.has(ext);
diff --git a/scripts/prepush-ci.sh b/scripts/prepush-ci.sh
index bc7c121ba9b..cd0796f8bff 100644
--- a/scripts/prepush-ci.sh
+++ b/scripts/prepush-ci.sh
@@ -54,7 +54,7 @@ run_linux_ci_mirror() {
run_step pnpm build:strict-smoke
run_step pnpm lint:ui:no-raw-window-open
run_protocol_ci_mirror
- run_step pnpm canvas:a2ui:bundle
+ run_step pnpm plugins:assets:build
run_step node scripts/run-vitest.mjs run --config test/vitest/vitest.extensions.config.ts --maxWorkers=1
run_step env CI=true node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --maxWorkers=1
diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh
index ba1aab336b6..444c34c50ab 100755
--- a/scripts/restart-mac.sh
+++ b/scripts/restart-mac.sh
@@ -153,8 +153,8 @@ log "==> Killing existing OpenClaw instances"
kill_all_openclaw
stop_launch_agent
-# Bundle Gateway-hosted Canvas A2UI assets.
-run_step "bundle canvas a2ui" bash -lc "cd '${ROOT_DIR}' && pnpm canvas:a2ui:bundle"
+# Bundle Gateway-hosted plugin assets.
+run_step "bundle plugin assets" bash -lc "cd '${ROOT_DIR}' && pnpm plugins:assets:build"
# 2) Rebuild into the same path the packager consumes (.build).
run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true"
diff --git a/scripts/run-node-watch-paths.mjs b/scripts/run-node-watch-paths.mjs
index e1ec9f8ea23..c92fef8a3f3 100644
--- a/scripts/run-node-watch-paths.mjs
+++ b/scripts/run-node-watch-paths.mjs
@@ -9,10 +9,10 @@ export const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.conf
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
export const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
-const ignoredRunNodeRepoPaths = new Set([
- "extensions/canvas/src/host/a2ui/.bundle.hash",
- "extensions/canvas/src/host/a2ui/a2ui.bundle.js",
-]);
+const ignoredRunNodeRepoPathPatterns = [
+ /^extensions\/[^/]+\/src\/host\/.+\/\.bundle\.hash$/u,
+ /^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u,
+];
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
export const normalizeRunNodePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
@@ -41,7 +41,7 @@ const isRestartRelevantExtensionPath = (relativePath) => {
const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => {
const normalizedPath = normalizeRunNodePath(repoPath).replace(/^\.\/+/, "");
- if (ignoredRunNodeRepoPaths.has(normalizedPath)) {
+ if (ignoredRunNodeRepoPathPatterns.some((pattern) => pattern.test(normalizedPath))) {
return false;
}
if (runNodeConfigFiles.includes(normalizedPath)) {
diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs
index d0a9764aba6..22589fe73d1 100644
--- a/scripts/test-projects.test-support.mjs
+++ b/scripts/test-projects.test-support.mjs
@@ -374,6 +374,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/blacksmith-testbox-state.mjs", ["test/scripts/blacksmith-testbox-state.test.ts"]],
["scripts/blacksmith-testbox-runner.mjs", ["test/scripts/blacksmith-testbox-runner.test.ts"]],
["scripts/testbox-sync-sanity.mjs", ["test/scripts/testbox-sync-sanity.test.ts"]],
+ ["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
+ ["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
+ ["extensions/canvas/scripts/bundle-a2ui.mjs", ["test/scripts/bundle-a2ui.test.ts"]],
+ ["extensions/canvas/scripts/copy-a2ui.mjs", ["src/scripts/canvas-a2ui-copy.test.ts"]],
]);
const TOOLING_TEST_TARGETS = new Map([
["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]],
@@ -494,10 +498,10 @@ const SOURCE_TEST_TARGETS = new Map([
["src/auto-reply/reply/dispatch-acp-command-bypass.test.ts"],
],
]);
-const GENERATED_CHANGED_TEST_TARGETS = new Set([
- "extensions/canvas/src/host/a2ui/.bundle.hash",
- "extensions/canvas/src/host/a2ui/a2ui.bundle.js",
-]);
+const GENERATED_CHANGED_TEST_TARGET_PATTERNS = [
+ /^extensions\/[^/]+\/src\/host\/.+\/\.bundle\.hash$/u,
+ /^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u,
+];
const SOURCE_ROOTS_FOR_IMPORT_GRAPH = ["src", "extensions", "packages", "ui/src", "test"];
const IMPORTABLE_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
const IMPORT_SPECIFIER_PATTERN =
@@ -939,7 +943,7 @@ function shouldUseBroadChangedTargets(env = process.env) {
}
function isRoutableChangedTarget(changedPath) {
- if (GENERATED_CHANGED_TEST_TARGETS.has(changedPath)) {
+ if (GENERATED_CHANGED_TEST_TARGET_PATTERNS.some((pattern) => pattern.test(changedPath))) {
return false;
}
if (changedPath.endsWith(".live.test.ts")) {
diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts
index ec9c18a1dbe..20088b8225d 100644
--- a/src/infra/run-node.test.ts
+++ b/src/infra/run-node.test.ts
@@ -24,8 +24,8 @@ const ROOT_SRC = "src/index.ts";
const ROOT_TSCONFIG = "tsconfig.json";
const ROOT_PACKAGE = "package.json";
const ROOT_TSDOWN = "tsdown.config.ts";
-const GENERATED_A2UI_BUNDLE = "extensions/canvas/src/host/a2ui/a2ui.bundle.js";
-const GENERATED_A2UI_BUNDLE_HASH = "extensions/canvas/src/host/a2ui/.bundle.hash";
+const GENERATED_PLUGIN_ASSET_BUNDLE = "extensions/demo/src/host/assets/view.bundle.js";
+const GENERATED_PLUGIN_ASSET_BUNDLE_HASH = "extensions/demo/src/host/assets/.bundle.hash";
const DIST_ENTRY = "dist/entry.js";
const BUILD_STAMP = `dist/${BUILD_STAMP_FILE}`;
const RUNTIME_POSTBUILD_STAMP = `dist/${RUNTIME_POSTBUILD_STAMP_FILE}`;
@@ -1478,7 +1478,7 @@ describe("run-node script", () => {
});
});
- it("ignores dirty generated A2UI bundle artifacts when dist is current", async () => {
+ it("ignores dirty generated plugin bundle artifacts when dist is current", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {
files: {
@@ -1491,7 +1491,7 @@ describe("run-node script", () => {
const requirement = resolveBuildRequirement(
createBuildRequirementDeps(tmp, {
gitHead: "abc123\n",
- gitStatus: ` M ${GENERATED_A2UI_BUNDLE_HASH}\n M ${GENERATED_A2UI_BUNDLE}\n`,
+ gitStatus: ` M ${GENERATED_PLUGIN_ASSET_BUNDLE_HASH}\n M ${GENERATED_PLUGIN_ASSET_BUNDLE}\n`,
}),
);
diff --git a/src/scripts/canvas-a2ui-copy.test.ts b/src/scripts/canvas-a2ui-copy.test.ts
index c6167fa5526..f31ed29b818 100644
--- a/src/scripts/canvas-a2ui-copy.test.ts
+++ b/src/scripts/canvas-a2ui-copy.test.ts
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { copyA2uiAssets } from "../../scripts/canvas-a2ui-copy.js";
+import { copyA2uiAssets } from "../../extensions/canvas/scripts/copy-a2ui.mjs";
import { withTempDir } from "../test-utils/temp-dir.js";
const ORIGINAL_SKIP_MISSING = process.env.OPENCLAW_A2UI_SKIP_MISSING;
diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts
index 00f598813fa..b610e96b0a2 100644
--- a/test/scripts/build-all.test.ts
+++ b/test/scripts/build-all.test.ts
@@ -53,7 +53,7 @@ function withBuildCacheFixture(
describe("resolveBuildAllStep", () => {
it("routes pnpm steps through the npm_execpath pnpm runner on Windows", () => {
- const step = BUILD_ALL_STEPS.find((entry) => entry.label === "canvas:a2ui:bundle");
+ const step = BUILD_ALL_STEPS.find((entry) => entry.label === "plugins:assets:build");
expect(step).toBeTruthy();
const result = resolveBuildAllStep(step, {
@@ -65,7 +65,7 @@ describe("resolveBuildAllStep", () => {
expect(result).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
- args: ["C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "canvas:a2ui:bundle"],
+ args: ["C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "plugins:assets:build"],
options: {
stdio: "inherit",
env: {},
@@ -129,7 +129,7 @@ describe("resolveBuildAllSteps", () => {
it("uses a runtime artifact plus plugin SDK export profile for ci artifacts", () => {
expect(resolveBuildAllSteps("ciArtifacts").map((step) => step.label)).toEqual([
- "canvas:a2ui:bundle",
+ "plugins:assets:build",
"tsdown",
"check-cli-bootstrap-imports",
"runtime-postbuild",
@@ -138,7 +138,7 @@ describe("resolveBuildAllSteps", () => {
"build:plugin-sdk:dts",
"write-plugin-sdk-entry-dts",
"check-plugin-sdk-exports",
- "canvas-a2ui-copy",
+ "plugins:assets:copy",
"copy-hook-metadata",
"copy-export-html-templates",
"write-build-info",
diff --git a/test/scripts/bundle-a2ui.test.ts b/test/scripts/bundle-a2ui.test.ts
index f14d83db58d..e3ee72c8c6d 100644
--- a/test/scripts/bundle-a2ui.test.ts
+++ b/test/scripts/bundle-a2ui.test.ts
@@ -6,17 +6,17 @@ import {
getBundleHashRepoInputPaths,
getLocalRolldownCliCandidates,
isBundleHashInputPath,
-} from "../../scripts/bundle-a2ui.mjs";
+} from "../../extensions/canvas/scripts/bundle-a2ui.mjs";
describe("scripts/bundle-a2ui.mjs", () => {
- it("uses package metadata and CanvasA2UI sources as bundle hash inputs", () => {
+ it("uses package metadata and plugin-owned A2UI sources as bundle hash inputs", () => {
const repoRoot = path.resolve("repo-root");
const inputPaths = getBundleHashRepoInputPaths(repoRoot);
expect(inputPaths).toContain(path.join(repoRoot, "package.json"));
expect(inputPaths).toContain(path.join(repoRoot, "pnpm-lock.yaml"));
expect(inputPaths).toContain(
- path.join(repoRoot, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"),
+ path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui-app"),
);
expect(inputPaths).not.toContain(path.join(repoRoot, "vendor", "a2ui", "renderers", "lit"));
expect(isBundleHashInputPath(path.join(repoRoot, "package.json"), repoRoot)).toBe(true);
diff --git a/test/scripts/bundled-plugin-assets.test.ts b/test/scripts/bundled-plugin-assets.test.ts
new file mode 100644
index 00000000000..45a4e1c6fc9
--- /dev/null
+++ b/test/scripts/bundled-plugin-assets.test.ts
@@ -0,0 +1,73 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ parseBundledPluginAssetArgs,
+ readBundledPluginAssetHooks,
+} from "../../scripts/bundled-plugin-assets.mjs";
+
+async function withPluginAssetFixture(run: (rootDir: string) => Promise) {
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-assets-"));
+ try {
+ fs.mkdirSync(path.join(rootDir, "extensions", "canvas"), { recursive: true });
+ fs.writeFileSync(
+ path.join(rootDir, "extensions", "canvas", "package.json"),
+ JSON.stringify(
+ {
+ name: "@openclaw/canvas-plugin",
+ openclaw: {
+ assetScripts: {
+ build: "node scripts/bundle-a2ui.mjs",
+ copy: "node scripts/copy-a2ui.mjs",
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+ fs.writeFileSync(
+ path.join(rootDir, "extensions", "canvas", "openclaw.plugin.json"),
+ JSON.stringify({ id: "canvas" }, null, 2),
+ );
+ await run(rootDir);
+ } finally {
+ fs.rmSync(rootDir, { force: true, recursive: true });
+ }
+}
+
+describe("bundled plugin assets", () => {
+ it("discovers plugin-owned asset scripts by manifest id", async () => {
+ await withPluginAssetFixture(async (rootDir) => {
+ const hooks = await readBundledPluginAssetHooks({
+ phase: "build",
+ plugins: ["canvas"],
+ rootDir,
+ });
+
+ expect(hooks).toMatchObject([
+ {
+ command: "node scripts/bundle-a2ui.mjs",
+ phase: "build",
+ pluginId: "canvas",
+ },
+ ]);
+ });
+ });
+
+ it("skips cleanly when a requested plugin is absent", async () => {
+ await withPluginAssetFixture(async (rootDir) => {
+ await expect(
+ readBundledPluginAssetHooks({ phase: "copy", plugins: ["missing"], rootDir }),
+ ).resolves.toEqual([]);
+ });
+ });
+
+ it("parses phase and plugin filters", () => {
+ expect(parseBundledPluginAssetArgs(["--phase", "build", "--plugin=canvas"])).toEqual({
+ phase: "build",
+ plugins: ["canvas"],
+ });
+ });
+});
diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts
index d522bef63a9..c3cd26524cb 100644
--- a/test/scripts/changed-lanes.test.ts
+++ b/test/scripts/changed-lanes.test.ts
@@ -853,21 +853,19 @@ describe("scripts/changed-lanes", () => {
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all");
});
- it("routes CanvasA2UI bundle changes to core and tooling instead of all lanes", () => {
+ it("routes A2UI bundle source changes as extension changes", () => {
const result = detectChangedLanes([
- "apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js",
- "apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs",
+ "extensions/canvas/src/host/a2ui-app/bootstrap.js",
+ "extensions/canvas/src/host/a2ui-app/rolldown.config.mjs",
]);
const plan = createChangedCheckPlan(result);
expect(result.lanes).toMatchObject({
- core: true,
- coreTests: true,
- tooling: true,
+ extensions: true,
+ extensionTests: true,
all: false,
});
- expect(plan.commands.map((command) => command.args[0])).toContain("lint:scripts");
- expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core");
+ expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:extensions");
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all");
});
@@ -887,9 +885,9 @@ describe("scripts/changed-lanes", () => {
expect(plan.commands.map((command) => command.args[0])).not.toContain("test");
});
- it("does not route generated A2UI artifacts as direct Vitest targets", () => {
+ it("does not route generated plugin bundle artifacts as direct Vitest targets", () => {
const result = detectChangedLanes([
- "extensions/canvas/src/host/a2ui/.bundle.hash",
+ "extensions/demo/src/host/assets/.bundle.hash",
"test/scripts/bundle-a2ui.test.ts",
]);
const plan = createChangedCheckPlan(result);