From bbd66c475e41c49b0db4ae47d52c6d1a400b0b2e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 19:45:39 -0700 Subject: [PATCH] fix(plugins): fail closed when plugin runtime is unavailable during registration (#130597) * fix(plugins): fail closed when plugin runtime is unavailable Core fabricated an empty object as `PluginRuntime` for the `cli-metadata` and `setup-only` registration passes, so any plugin touching `api.runtime` during `register()` died with an opaque `TypeError: Cannot read properties of undefined (reading 'openSyncKeyedStore')` that named neither the contract nor the mode. Replace those fabrications with one shared fail-closed runtime that throws a named, actionable error identifying the plugin and registration mode. Symbol reads stay inert so inspection and reflection cannot trigger the guard. Also resolve the lightweight `cli-metadata` entry beside the resolved plugin entry, not only at the package root. Published plugin packages emit it at `dist/cli-metadata.js`, so the probe always missed and the loader fell back to executing the full heavy entry for CLI metadata collection. Documents which registration modes expose a live `api.runtime`; the SDK reference previously claimed it was injected into every plugin. * fix(ui): restore sidebar lazy import boundaries Remove the redundant viewer-facepile dynamic import: sidebar consumers already register that element through the static graph. Import pet data and sprite helpers from their owning leaf modules, delete the registration module's re-export barrel, and make the element itself the lobster-pet.runtime.ts lazy boundary. Keep shared sprite styles with the look renderer so standalone pages do not need element registration. Regenerate the measured boot inventory with the canonical browser probe. Use Lit's typed lifecycle map to remove the obsolete assertion baseline entry and keep the now-private facepile variant type local. The full build has no INEFFECTIVE_DYNAMIC_IMPORT warnings. Relevant UI coverage passes (503 tests plus 5 Chromium E2E tests); the unchanged plugin runtime regression suites pass all 58 tests. The broad plugin suite still reports schema-version, doctor-closure, hook-process, and catalog failures. --- config/assertion-safety-baseline.txt | 5 +- docs/plugins/sdk-entrypoints.md | 18 +++-- docs/plugins/sdk-runtime.md | 2 +- src/plugins/api-builder.ts | 22 ++++++ src/plugins/captured-registration.test.ts | 15 ++++ src/plugins/captured-registration.ts | 10 ++- src/plugins/loader-cli-registry.ts | 28 +++---- src/plugins/loader.cli-metadata.test.ts | 76 +++++++++++++++++++ src/plugins/setup-registry.test.ts | 23 ++++++ src/plugins/setup-registry.ts | 6 +- ui/config/control-ui-boot-modules.json | 3 +- ui/src/components/app-sidebar.ts | 9 +-- ui/src/components/lobster-pet-look.ts | 3 +- ui/src/components/lobster-pet-plans.ts | 2 +- .../components/lobster-pet-variants.test.ts | 12 +-- ...{lobster-pet.ts => lobster-pet.runtime.ts} | 25 +----- ui/src/components/lobster-pet.test.ts | 16 ++-- ui/src/components/viewer-facepile.ts | 2 +- ui/src/pages/about/view.ts | 5 +- ui/src/pages/agents/memory/view.ts | 8 +- ui/src/pages/config/memory-overview.test.ts | 7 +- ui/src/pages/config/memory-overview.ts | 4 +- .../config/view-appearance-preferences.ts | 7 +- ui/src/pages/lobsterdex/lobsterdex-page.ts | 2 +- ui/src/pages/lobsterdex/view.ts | 8 +- ui/src/styles/lobster-pet.css | 4 +- 26 files changed, 209 insertions(+), 113 deletions(-) rename ui/src/components/{lobster-pet.ts => lobster-pet.runtime.ts} (97%) diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 61d2285ed34c..a0f0752645ff 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3561,7 +3561,7 @@ src/plugins/lazy-service-module.ts 1 src/plugins/legacy-session-surfaces.ts 3 src/plugins/loader-channel-runtime.ts 1 src/plugins/loader-channel-setup.ts 17 -src/plugins/loader-cli-registry.ts 4 +src/plugins/loader-cli-registry.ts 2 src/plugins/loader-discovery.ts 1 src/plugins/loader-load-context.ts 5 src/plugins/loader-module-runtime.ts 13 @@ -3641,7 +3641,7 @@ src/plugins/sdk-alias.ts 3 src/plugins/services.ts 1 src/plugins/session-catalog-history-import.ts 2 src/plugins/setup-registry-loader-state.ts 1 -src/plugins/setup-registry.ts 13 +src/plugins/setup-registry.ts 12 src/plugins/slots.ts 3 src/plugins/toggle-config.ts 3 src/plugins/tool-descriptor-cache.ts 4 @@ -3999,7 +3999,6 @@ ui/src/components/github-link-hovercard.runtime.ts 2 ui/src/components/hub-tabs.ts 2 ui/src/components/input-dialog.ts 1 ui/src/components/lobster-dex.ts 2 -ui/src/components/lobster-pet.ts 1 ui/src/components/login-gate.ts 3 ui/src/components/markdown-assistant-transcript.ts 1 ui/src/components/markdown-code-blocks.ts 1 diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 8345c4957a7e..239d2bd917ef 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -391,14 +391,16 @@ limited to config-only routes or methods required by that setup flow. `api.registrationMode` tells your plugin how it was loaded: -| Mode | When | What to register | -| ------------------ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `"full"` | Normal gateway startup | Everything | -| `"discovery"` | Read-only capability discovery | Channel registration, static CLI descriptors, and inert providers; skip sockets, workers, clients, and services | -| `"tool-discovery"` | Scoped load to list or run specific plugins' tools | Capability/tool registration only; no channel activation | -| `"setup-only"` | Disabled/unconfigured channel | Channel registration only | -| `"setup-runtime"` | Setup flow with runtime available | Channel registration plus only the lightweight runtime needed during setup | -| `"cli-metadata"` | Root help / CLI metadata capture | CLI descriptors only | +| Mode | When | Runtime | What to register | +| ------------------ | -------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | +| `"full"` | Normal gateway startup | Live | Everything | +| `"discovery"` | Read-only capability discovery | Live | Channel registration, static CLI descriptors, and inert providers; skip sockets, workers, clients, and services | +| `"tool-discovery"` | Scoped load to list or run specific plugins' tools | Live | Capability/tool registration only; no channel activation | +| `"setup-only"` | Disabled/unconfigured channel | Unavailable | Channel registration only | +| `"setup-runtime"` | Setup flow with runtime available | Live | Channel registration plus only the lightweight runtime needed during setup | +| `"cli-metadata"` | Root help / CLI metadata capture | Unavailable | CLI descriptors only | + +In `"cli-metadata"` and `"setup-only"` modes, accessing a runtime capability throws an error naming the plugin and mode. Defer runtime access out of `register()` or declare root commands in the manifest's `cliCommands` so CLI metadata can be collected without executing the plugin. `defineChannelPluginEntry` handles this split automatically. If you use `definePluginEntry` directly for a channel, check mode yourself and remember diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index caf6da97657d..3ef6b7921281 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -9,7 +9,7 @@ read_when: - You are implementing model-picker persistence in a channel plugin --- -Reference for the `api.runtime` object injected into every plugin during registration. Use these helpers instead of importing host internals directly. +Reference for the live `api.runtime` object available during `"full"`, `"discovery"`, `"tool-discovery"`, and `"setup-runtime"` registration. During `"cli-metadata"` and `"setup-only"` registration, runtime capabilities are intentionally unavailable: accessing one throws an error naming the plugin and mode. Defer runtime access out of `register()` or, for root CLI commands, declare `cliCommands` in the plugin manifest. Use runtime helpers instead of importing host internals directly. diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index 546e31e384df..ddb195fbfd7d 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -186,6 +186,28 @@ const noopRegisterMemoryCorpusSupplement: OpenClawPluginApi["registerMemoryCorpu () => {}; const noopOn: OpenClawPluginApi["on"] = () => {}; +export function createUnavailableRuntime( + registrationMode: "cli-metadata" | "setup-only", + pluginId?: string, +): PluginRuntime { + const owner = pluginId ? `Plugin "${pluginId}"` : "Plugin"; + const guidance = + registrationMode === "cli-metadata" + ? "Declare root commands in the manifest's cliCommands or defer runtime access out of register()." + : "Defer runtime access out of register()."; + // SAFETY: String capabilities fail closed; symbols stay inert so reflection cannot trigger runtime errors. + return new Proxy(Object.create(null) as PluginRuntime, { + get(_target, property) { + if (typeof property === "symbol") { + return undefined; + } + throw new Error( + `${owner} runtime is intentionally unavailable during "${registrationMode}" registration. ${guidance}`, + ); + }, + }); +} + export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi { const handlers = params.handlers ?? {}; const registerCli = handlers.registerCli ?? noopRegisterCli; diff --git a/src/plugins/captured-registration.test.ts b/src/plugins/captured-registration.test.ts index f7d93f79e273..5e5dbaaa5715 100644 --- a/src/plugins/captured-registration.test.ts +++ b/src/plugins/captured-registration.test.ts @@ -4,6 +4,20 @@ import { capturePluginRegistration } from "./captured-registration.js"; import type { AnyAgentTool, OpenClawPluginApi } from "./types.js"; describe("captured plugin registration", () => { + it("rejects runtime access while capturing CLI metadata without activating the real runtime", () => { + expect(() => + capturePluginRegistration({ + id: "captured-cli-plugin", + registrationMode: "cli-metadata", + register(api) { + api.runtime.state.openSyncKeyedStore({ namespace: "example", maxEntries: 1 }); + }, + }), + ).toThrow( + 'Plugin "captured-cli-plugin" runtime is intentionally unavailable during "cli-metadata" registration.', + ); + }); + it("preserves root machine-output metadata", () => { const machineOutput = ({ stdoutIsTTY }: { stdoutIsTTY: boolean }) => !stdoutIsTTY; const captured = capturePluginRegistration({ @@ -156,6 +170,7 @@ describe("captured plugin registration", () => { expect(captured.textTransforms[0]?.input).toHaveLength(1); expect(captured.agentToolResultMiddlewares).toHaveLength(1); expect(captured.agentToolResultMiddlewares[0]?.runtimes).toEqual(["codex"]); + expect(captured.api.runtime.version).toEqual(expect.any(String)); }); it("enforces captured middleware runtime and tool scopes", async () => { diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index e462a25de4c9..10ab1853f1e0 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -12,7 +12,7 @@ import { agentToolResultMiddlewareRegistrationCoversTool, normalizeAgentToolResultMiddlewareRuntimes, } from "./agent-tool-result-middleware.js"; -import { buildPluginApi } from "./api-builder.js"; +import { buildPluginApi, createUnavailableRuntime } from "./api-builder.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { EmbeddingProviderAdapter } from "./embedding-providers.js"; import type { @@ -137,6 +137,7 @@ export function createCapturedPluginRegistration(params?: { const pluginId = params?.id ?? "captured-plugin-registration"; const pluginName = params?.name ?? "Captured Plugin Registration"; const pluginSource = params?.source ?? "captured-plugin-registration"; + const registrationMode = params?.registrationMode ?? "full"; const noopLogger = { info() {}, warn() {}, @@ -180,9 +181,12 @@ export function createCapturedPluginRegistration(params?: { id: pluginId, name: pluginName, source: pluginSource, - registrationMode: params?.registrationMode ?? "full", + registrationMode, config: params?.config ?? ({} as OpenClawConfig), - runtime: createPluginRuntime(), + runtime: + registrationMode === "cli-metadata" || registrationMode === "setup-only" + ? createUnavailableRuntime(registrationMode, pluginId) + : createPluginRuntime(), logger: noopLogger, resolvePath: (input) => input, handlers: { diff --git a/src/plugins/loader-cli-registry.ts b/src/plugins/loader-cli-registry.ts index ca040c39c317..9a5770565a8b 100644 --- a/src/plugins/loader-cli-registry.ts +++ b/src/plugins/loader-cli-registry.ts @@ -3,7 +3,7 @@ import path from "node:path"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { describeRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js"; import { resolveUserPath } from "../utils.js"; -import { buildPluginApi } from "./api-builder.js"; +import { buildPluginApi, createUnavailableRuntime } from "./api-builder.js"; import { resolveEffectiveEnableState, resolveEffectivePluginActivationState, @@ -41,17 +41,9 @@ import { withProfile } from "./plugin-load-profile.js"; import { normalizePluginPolicyId } from "./plugin-policy-id.js"; import { createPluginIdScopeSet } from "./plugin-scope.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; -import type { PluginRuntime } from "./runtime/types.js"; import { hasKind, kindsEqual } from "./slots.js"; import type { OpenClawPluginModule } from "./types.js"; -const CLI_METADATA_ENTRY_BASENAMES = [ - "cli-metadata.ts", - "cli-metadata.js", - "cli-metadata.mjs", - "cli-metadata.cjs", -] as const; - export async function loadOpenClawPluginCliRegistry( options: PluginLoadOptions = {}, ): Promise { @@ -64,7 +56,7 @@ export async function loadOpenClawPluginCliRegistry( }); const { registry, registerCli, rollbackPluginGlobalSideEffects } = createPluginRegistry({ logger, - runtime: {} as PluginRuntime, + runtime: createUnavailableRuntime("cli-metadata"), coreGatewayHandlers: options.coreGatewayHandlers as Record, ...(options.coreGatewayMethodNames !== undefined && { coreGatewayMethodNames: options.coreGatewayMethodNames, @@ -198,7 +190,7 @@ export async function loadOpenClawPluginCliRegistry( pushPluginLoadError(`invalid config: ${validatedConfig.error.join(", ")}`); continue; } - const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir); + const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir, candidate.source); const sourceForCliMetadata = candidate.origin === "bundled" ? cliMetadataSource @@ -322,7 +314,7 @@ export async function loadOpenClawPluginCliRegistry( registrationMode: "cli-metadata", config: context.cfg, pluginConfig: validatedConfig.value, - runtime: {} as PluginRuntime, + runtime: createUnavailableRuntime("cli-metadata", record.id), logger, resolvePath: (input) => resolveUserPath(input), handlers: { @@ -354,11 +346,13 @@ export async function loadOpenClawPluginCliRegistry( return registry; } -function resolveCliMetadataEntrySource(rootDir: string): string | null { - for (const basename of CLI_METADATA_ENTRY_BASENAMES) { - const candidate = path.join(rootDir, basename); - if (fs.existsSync(candidate)) { - return candidate; +function resolveCliMetadataEntrySource(rootDir: string, source: string): string | null { + for (const directory of new Set([rootDir, path.dirname(source)])) { + for (const extension of [".ts", ".js", ".mjs", ".cjs"]) { + const candidate = path.join(directory, `cli-metadata${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } } } return null; diff --git a/src/plugins/loader.cli-metadata.test.ts b/src/plugins/loader.cli-metadata.test.ts index 84dbe535e726..a8535007caf4 100644 --- a/src/plugins/loader.cli-metadata.test.ts +++ b/src/plugins/loader.cli-metadata.test.ts @@ -79,6 +79,82 @@ describe("plugin loader CLI metadata", () => { }, ); + it("rejects runtime access during CLI metadata registration with actionable plugin guidance", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "runtime-dependent", + filename: "runtime-dependent.cjs", + body: `module.exports = { + id: "runtime-dependent", + register(api) { + api.runtime.state.openSyncKeyedStore({ namespace: "example", maxEntries: 1 }); + }, +};`, + }); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }, + }); + + const pluginError = registry.plugins.find((entry) => entry.id === plugin.id)?.error; + expect(pluginError).toContain('Plugin "runtime-dependent"'); + expect(pluginError).toContain('"cli-metadata" registration'); + expect(pluginError).toContain("runtime is intentionally unavailable"); + expect(pluginError).toContain("cliCommands"); + expect(pluginError).toContain("defer runtime access out of register()"); + expect(pluginError).not.toContain("Cannot read properties of undefined"); + }); + + it("loads packaged CLI metadata beside the resolved dist entry without evaluating the heavy entry", async () => { + useNoBundledPlugins(); + const pluginDir = makePluginLoaderTempDir(); + const distDir = path.join(pluginDir, "dist"); + const heavyMarker = path.join(pluginDir, "heavy-loaded.txt"); + fs.mkdirSync(distDir); + const plugin = writePlugin({ + id: "packaged-cli-metadata", + dir: pluginDir, + filename: "dist/index.js", + body: `require("node:fs").writeFileSync(${JSON.stringify(heavyMarker)}, "loaded"); +module.exports = { id: "packaged-cli-metadata", register() {} };`, + }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "packaged-cli-metadata", + openclaw: { extensions: ["./dist/index.js"] }, + }), + ); + fs.writeFileSync( + path.join(distDir, "cli-metadata.js"), + `module.exports = { + id: "packaged-cli-metadata", + register(api) { + api.registerCli(() => {}, { + descriptors: [{ name: "packaged-light", description: "Light entry", hasSubcommands: false }], + }); + }, +};`, + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: [plugin.id], + }, + }, + }); + + expect(fs.existsSync(heavyMarker)).toBe(false); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("packaged-light"); + }); + it("suppresses trust warning logs during CLI metadata loads", async () => { useNoBundledPlugins(); const stateDir = makePluginLoaderTempDir(); diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index e7e04fcefb4a..a932b706e6a1 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -983,6 +983,29 @@ describe("setup-registry module loader", () => { ); }); + it("reports unavailable setup runtime access with the plugin id and registration mode", () => { + const pluginRoot = makeTempDir(); + writeSetupApiStub(pluginRoot); + mockSinglePlugin({ id: "runtime-dependent-setup", rootDir: pluginRoot }); + mocks.createJiti.mockImplementation(() => () => ({ + default: { + register(api: import("./types.js").OpenClawPluginApi) { + api.runtime.state.openSyncKeyedStore({ namespace: "example", maxEntries: 1 }); + }, + }, + })); + + expect(resolvePluginSetupRegistry({ env: {} }).diagnostics).toMatchObject([ + { + pluginId: "runtime-dependent-setup", + code: "setup-registration-failed", + message: expect.stringContaining( + 'Plugin "runtime-dependent-setup" runtime is intentionally unavailable during "setup-only" registration.', + ), + }, + ]); + }); + it("publishes each plugin setup registration atomically on synchronous success", () => { const throwingRoot = makeTempDir(); const healthyRoot = makeTempDir(); diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 91d0f1a9b4c6..f96b2af3f8db 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -9,7 +9,7 @@ import { } from "@openclaw/normalization-core/string-normalization"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { buildPluginApi } from "./api-builder.js"; +import { buildPluginApi, createUnavailableRuntime } from "./api-builder.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { getCurrentPluginMetadataSnapshotState } from "./current-plugin-metadata-state.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; @@ -23,7 +23,6 @@ import { } from "./plugin-module-loader-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; import { resolvePreferredBundledRootArtifact } from "./plugin-runtime-artifact-selection.js"; -import type { PluginRuntime } from "./runtime/types.js"; import { listSetupCliBackendIds, listSetupProviderIds } from "./setup-descriptors.js"; import { pluginSetupRegistryLoaderState } from "./setup-registry-loader-state.js"; import type { @@ -94,7 +93,6 @@ type SetupAutoEnableReason = { type PluginApiBuildParams = Parameters[0]; -const EMPTY_RUNTIME = {} as PluginRuntime; const NOOP_LOGGER: PluginLogger = { info() {}, warn() {}, @@ -312,7 +310,7 @@ function buildSetupPluginApi(params: { rootDir: params.record.rootDir, registrationMode: "setup-only", config: {} as OpenClawConfig, - runtime: EMPTY_RUNTIME, + runtime: createUnavailableRuntime("setup-only", params.record.id), logger: NOOP_LOGGER, resolvePath: (input) => input, handlers: params.handlers, diff --git a/ui/config/control-ui-boot-modules.json b/ui/config/control-ui-boot-modules.json index 5369ac2bbcc0..556dc0ae2fb2 100644 --- a/ui/config/control-ui-boot-modules.json +++ b/ui/config/control-ui-boot-modules.json @@ -1208,7 +1208,7 @@ "ui/src/components/lobster-pet-sprites-wild.ts", "ui/src/components/lobster-pet-sprites.ts", "ui/src/components/lobster-pet-traffic.ts", - "ui/src/components/lobster-pet.ts", + "ui/src/components/lobster-pet.runtime.ts", "ui/src/components/login-gate.ts", "ui/src/components/macos-titlebar-controls.ts", "ui/src/components/markdown-assistant-transcript.ts", @@ -1425,7 +1425,6 @@ "ui/src/lib/sessions/session-group-catalog.ts", "ui/src/lib/sessions/session-key.ts", "ui/src/lib/sessions/session-mutations.ts", - "ui/src/lib/sessions/session-placement-recovery-migration.runtime.ts", "ui/src/lib/sessions/session-placement-recovery-storage-key.ts", "ui/src/lib/sessions/session-placement-recovery.ts", "ui/src/lib/sessions/session-placement-startup.ts", diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 16a2341ba86a..aa61f8ac8103 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -69,12 +69,7 @@ import { SessionOrganizerController } from "./session-organizer-controller.ts"; import { SidebarMenusController } from "./sidebar-menus-controller.ts"; // The shared loader retries transient chunk failures online; a deploy-pruned // chunk still stays off until reload when that retry fails, by design. -const sidebarChromeImport = createIdleImport(() => - Promise.all([ - customElements.get("openclaw-lobster-pet") ? undefined : import("./lobster-pet.ts"), - customElements.get("openclaw-viewer-facepile") ? undefined : import("./viewer-facepile.ts"), - ]), -); +const lobsterPetImport = createIdleImport(() => import("./lobster-pet.runtime.ts")); class AppSidebar extends AppSidebarSessionNavigationElement implements SessionListHost { @state() override sidebarNarrationLines: ReadonlyMap = new Map(); @@ -333,7 +328,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi ); // The decorative pet's large module stays out of startup and upgrades in place. // Its first visit is at least 15 seconds after load, so idle loading cannot miss one. - sidebarChromeImport.schedule(); + lobsterPetImport.schedule(); this.catalogRendererImport.schedule(); } diff --git a/ui/src/components/lobster-pet-look.ts b/ui/src/components/lobster-pet-look.ts index c0ac7041c13e..7b704a6cda9b 100644 --- a/ui/src/components/lobster-pet-look.ts +++ b/ui/src/components/lobster-pet-look.ts @@ -1,3 +1,4 @@ +import "../styles/lobster-pet.css"; import { expectDefined } from "@openclaw/normalization-core"; import { html, nothing, svg } from "lit"; import { fnv1aUtf16 } from "../lib/fnv1a.ts"; @@ -56,8 +57,6 @@ import { TAIL_FAN, } from "./lobster-pet-sprites.ts"; -export { LOBSTER_PET_PALETTES } from "./lobster-pet-palettes.ts"; - const RETRO_GEOMETRY_PALETTES: ReadonlySet = new Set(["retro", "goldenretro"]); const PALETTE_FRAME_CLASSES: Partial> = { diff --git a/ui/src/components/lobster-pet-plans.ts b/ui/src/components/lobster-pet-plans.ts index dfacd7125778..6365a962237e 100644 --- a/ui/src/components/lobster-pet-plans.ts +++ b/ui/src/components/lobster-pet-plans.ts @@ -9,12 +9,12 @@ import type { LobsterRunOutcome, } from "./lobster-pet-contract.ts"; import { - LOBSTER_PET_PALETTES, canonicalLobsterLook, lobsterPetName, mulberry32, SPOT_ZONES, } from "./lobster-pet-look.ts"; +import { LOBSTER_PET_PALETTES } from "./lobster-pet-palettes.ts"; export { SPOT_ZONES }; diff --git a/ui/src/components/lobster-pet-variants.test.ts b/ui/src/components/lobster-pet-variants.test.ts index 1079db38763f..15917c42d1c0 100644 --- a/ui/src/components/lobster-pet-variants.test.ts +++ b/ui/src/components/lobster-pet-variants.test.ts @@ -2,15 +2,11 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; +import { lobsterPetSeed } from "./lobster-pet-contract.ts"; +import { canonicalLobsterLook, createLobsterPetLook } from "./lobster-pet-look.ts"; import { LOBSTER_PALETTE_LORE } from "./lobster-pet-lore.ts"; -import { LOBSTER_PALETTE_WEIGHTS } from "./lobster-pet-palettes.ts"; -import { - LOBSTER_PET_PALETTES, - canonicalLobsterLook, - createLobsterPetLook, - lobsterPetSeed, - moonPhaseFraction, -} from "./lobster-pet.ts"; +import { moonPhaseFraction } from "./lobster-pet-moon.ts"; +import { LOBSTER_PALETTE_WEIGHTS, LOBSTER_PET_PALETTES } from "./lobster-pet-palettes.ts"; type LobsterPetPaletteId = ReturnType["palette"]["id"]; diff --git a/ui/src/components/lobster-pet.ts b/ui/src/components/lobster-pet.runtime.ts similarity index 97% rename from ui/src/components/lobster-pet.ts rename to ui/src/components/lobster-pet.runtime.ts index 6e23128ad121..a141922c315f 100644 --- a/ui/src/components/lobster-pet.ts +++ b/ui/src/components/lobster-pet.runtime.ts @@ -4,9 +4,8 @@ // Drawn in the smooth OpenClaw lobster style (see the dreams scene and // icons.lobster). Look and personality are seeded per session + page load so // every new session hatches a slightly different lobster. -import "../styles/lobster-pet.css"; import { expectDefined } from "@openclaw/normalization-core"; -import { LitElement, nothing } from "lit"; +import { LitElement, nothing, type PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; import { isLobsterDay } from "../../../src/shared/lobster-day.js"; import { patchSettings } from "../app/settings.ts"; @@ -21,24 +20,6 @@ import * as lobsterLook from "./lobster-pet-look.ts"; import * as plans from "./lobster-pet-plans.ts"; import { LobsterLedgeTraffic } from "./lobster-pet-traffic.ts"; -export { - lobsterPetSeed, - resolveLobsterPetMode, - resolveLobsterRunOutcome, - type LobsterPetLook, - type LobsterPetMode, - type LobsterRunOutcome, -} from "./lobster-pet-contract.ts"; -export { - LOBSTER_PET_PALETTES, - canonicalLobsterLook, - createLobsterPetLook, - lobsterLookStyle, - renderLobsterSvg, -} from "./lobster-pet-look.ts"; -export { lobsterPaletteName } from "./lobster-pet-lore.ts"; -export { moonPhaseFraction } from "./lobster-pet-moon.ts"; - class LobsterPet extends LitElement { override createRenderRoot() { return this; @@ -157,7 +138,7 @@ class LobsterPet extends LitElement { ); } - override willUpdate(changed: Map) { + override willUpdate(changed: PropertyValues) { const seedChanged = this.look === null || changed.has("seed"); if (seedChanged) { this.look = lobsterLook.createLobsterPetLook(this.seed); @@ -196,7 +177,7 @@ class LobsterPet extends LitElement { this.outcomePresenceOwner = null; this.trackVigil(); } else if (changed.has("mode")) { - const previousMode = changed.get("mode") as contract.LobsterPetMode | undefined; + const previousMode = changed.get("mode"); const finished = previousMode === "busy" && this.mode === "idle"; const presenceOwner = finished && this.vigil ? "vigil" : null; this.trackVigil(); diff --git a/ui/src/components/lobster-pet.test.ts b/ui/src/components/lobster-pet.test.ts index ed1758780b6f..c62ea15e1ece 100644 --- a/ui/src/components/lobster-pet.test.ts +++ b/ui/src/components/lobster-pet.test.ts @@ -4,6 +4,13 @@ import { expectDefined } from "@openclaw/normalization-core"; import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getLobsterdex, getLobsterdexEntries } from "./lobster-dex.ts"; +import { resolveLobsterPetMode, resolveLobsterRunOutcome } from "./lobster-pet-contract.ts"; +import { + canonicalLobsterLook, + createLobsterPetLook, + renderLobsterSvg, +} from "./lobster-pet-look.ts"; +import { LOBSTER_PET_PALETTES } from "./lobster-pet-palettes.ts"; import { LOBSTER_BOTTLE_FORTUNES, pickLobsterEntrance, @@ -11,14 +18,7 @@ import { planLobsterPasser, resolveLobsterLoadIdentity, } from "./lobster-pet-plans.ts"; -import { - LOBSTER_PET_PALETTES, - canonicalLobsterLook, - createLobsterPetLook, - renderLobsterSvg, - resolveLobsterPetMode, - resolveLobsterRunOutcome, -} from "./lobster-pet.ts"; +import "./lobster-pet.runtime.ts"; type LobsterPetMode = ReturnType; diff --git a/ui/src/components/viewer-facepile.ts b/ui/src/components/viewer-facepile.ts index 47a179bc77db..398aa81640e4 100644 --- a/ui/src/components/viewer-facepile.ts +++ b/ui/src/components/viewer-facepile.ts @@ -37,7 +37,7 @@ function renderViewerAvatar(view: IdentityAvatarView) { return html`${renderIdentityAvatarImage({ view, fallbackSelector: ".viewer-avatar" })}${fallback}`; } -export type ViewerAvatarVariant = "session" | "footer" | "profile"; +type ViewerAvatarVariant = "session" | "footer" | "profile"; class ViewerAvatar extends OpenClawLightDomContentsElement { @property({ attribute: false }) user: PresenceViewer | null = null; diff --git a/ui/src/pages/about/view.ts b/ui/src/pages/about/view.ts index 90c7e1d5e5e0..acfd17c3eca6 100644 --- a/ui/src/pages/about/view.ts +++ b/ui/src/pages/about/view.ts @@ -1,14 +1,13 @@ -import "../../styles/lobster-pet.css"; import { expectDefined } from "@openclaw/normalization-core"; import { html, nothing, type TemplateResult } from "lit"; import type { ControlUiBuildInfo } from "../../build-info.ts"; import { icons } from "../../components/icons.ts"; import { canonicalLobsterLook, - LOBSTER_PET_PALETTES, lobsterLookStyle, renderLobsterSvg, -} from "../../components/lobster-pet.ts"; +} from "../../components/lobster-pet-look.ts"; +import { LOBSTER_PET_PALETTES } from "../../components/lobster-pet-palettes.ts"; import { renderSettingsPage, renderSettingsRow, diff --git a/ui/src/pages/agents/memory/view.ts b/ui/src/pages/agents/memory/view.ts index 0210e6a55c47..113db658ab33 100644 --- a/ui/src/pages/agents/memory/view.ts +++ b/ui/src/pages/agents/memory/view.ts @@ -1,15 +1,11 @@ // Control UI view renders dreaming screen content. -import "../../../styles/lobster-pet.css"; import { expectDefined } from "@openclaw/normalization-core"; import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { renderHubTabs } from "../../../components/hub-tabs.ts"; -import { - createLobsterPetLook, - lobsterPetSeed, - renderLobsterSvg, -} from "../../../components/lobster-pet.ts"; +import { lobsterPetSeed } from "../../../components/lobster-pet-contract.ts"; +import { createLobsterPetLook, renderLobsterSvg } from "../../../components/lobster-pet-look.ts"; import { toSanitizedMarkdownHtml } from "../../../components/markdown.ts"; import "../../../components/modal-dialog.ts"; import { t } from "../../../i18n/index.ts"; diff --git a/ui/src/pages/config/memory-overview.test.ts b/ui/src/pages/config/memory-overview.test.ts index 921039ec25d0..671356d5c153 100644 --- a/ui/src/pages/config/memory-overview.test.ts +++ b/ui/src/pages/config/memory-overview.test.ts @@ -9,13 +9,14 @@ import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-m // per-load salt, so the palette (and with it sprite geometry like the sleeping // eye peek) varies per test process. Pin a canonical look so pose assertions // stay deterministic. -vi.mock("../../components/lobster-pet.ts", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../components/lobster-pet-look.ts", async (importOriginal) => { + const actual = await importOriginal(); + const { LOBSTER_PET_PALETTES } = await import("../../components/lobster-pet-palettes.ts"); return { ...actual, createLobsterPetLook: () => actual.canonicalLobsterLook( - expectDefined(actual.LOBSTER_PET_PALETTES[0], "canonical lobster palette"), + expectDefined(LOBSTER_PET_PALETTES[0], "canonical lobster palette"), ), }; }); diff --git a/ui/src/pages/config/memory-overview.ts b/ui/src/pages/config/memory-overview.ts index 836403adb946..133f464053e5 100644 --- a/ui/src/pages/config/memory-overview.ts +++ b/ui/src/pages/config/memory-overview.ts @@ -1,11 +1,11 @@ import { html, nothing } from "lit"; import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-methods/doctor.ts"; +import { lobsterPetSeed } from "../../components/lobster-pet-contract.ts"; import { createLobsterPetLook, lobsterLookStyle, - lobsterPetSeed, renderLobsterSvg, -} from "../../components/lobster-pet.ts"; +} from "../../components/lobster-pet-look.ts"; import { renderSettingsNavRow, renderSettingsRow, diff --git a/ui/src/pages/config/view-appearance-preferences.ts b/ui/src/pages/config/view-appearance-preferences.ts index 48690550b5f7..143d238d8d8c 100644 --- a/ui/src/pages/config/view-appearance-preferences.ts +++ b/ui/src/pages/config/view-appearance-preferences.ts @@ -9,14 +9,13 @@ import { import { icons } from "../../components/icons.ts"; import { getLobsterdexEntries } from "../../components/lobster-dex.ts"; import { previewLobsterChirp } from "../../components/lobster-pet-audio.ts"; -import { LOBSTER_PALETTE_LORE } from "../../components/lobster-pet-lore.ts"; import { - LOBSTER_PET_PALETTES, canonicalLobsterLook, lobsterLookStyle, - lobsterPaletteName, renderLobsterSvg, -} from "../../components/lobster-pet.ts"; +} from "../../components/lobster-pet-look.ts"; +import { LOBSTER_PALETTE_LORE, lobsterPaletteName } from "../../components/lobster-pet-lore.ts"; +import { LOBSTER_PET_PALETTES } from "../../components/lobster-pet-palettes.ts"; import "../../components/tooltip.ts"; import { renderSettingsDefaultState, diff --git a/ui/src/pages/lobsterdex/lobsterdex-page.ts b/ui/src/pages/lobsterdex/lobsterdex-page.ts index 1ccef5c766c0..ab6ceef8704c 100644 --- a/ui/src/pages/lobsterdex/lobsterdex-page.ts +++ b/ui/src/pages/lobsterdex/lobsterdex-page.ts @@ -3,7 +3,7 @@ import { state } from "lit/decorators.js"; import { titleForRoute } from "../../app-navigation.ts"; import { getLobsterdexEntries } from "../../components/lobster-dex.ts"; import type { LobsterPetPaletteId } from "../../components/lobster-pet-contract.ts"; -import { LOBSTER_PET_PALETTES } from "../../components/lobster-pet.ts"; +import { LOBSTER_PET_PALETTES } from "../../components/lobster-pet-palettes.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { copyToClipboard } from "../../lib/clipboard.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; diff --git a/ui/src/pages/lobsterdex/view.ts b/ui/src/pages/lobsterdex/view.ts index 5208aca972b4..cb0080d43585 100644 --- a/ui/src/pages/lobsterdex/view.ts +++ b/ui/src/pages/lobsterdex/view.ts @@ -1,16 +1,14 @@ import { html, nothing } from "lit"; import { icons } from "../../components/icons.ts"; import type { LobsterPetPaletteId } from "../../components/lobster-pet-contract.ts"; -import { LOBSTER_PALETTE_LORE } from "../../components/lobster-pet-lore.ts"; import { - LOBSTER_PET_PALETTES, canonicalLobsterLook, lobsterLookStyle, - lobsterPaletteName, renderLobsterSvg, -} from "../../components/lobster-pet.ts"; +} from "../../components/lobster-pet-look.ts"; +import { LOBSTER_PALETTE_LORE, lobsterPaletteName } from "../../components/lobster-pet-lore.ts"; +import { LOBSTER_PET_PALETTES } from "../../components/lobster-pet-palettes.ts"; import { i18n, t } from "../../i18n/index.ts"; -import "../../styles/lobster-pet.css"; type LobsterdexViewEntry = { firstSeenAt: number | null; diff --git a/ui/src/styles/lobster-pet.css b/ui/src/styles/lobster-pet.css index 55fa54900915..eb830178a163 100644 --- a/ui/src/styles/lobster-pet.css +++ b/ui/src/styles/lobster-pet.css @@ -83,7 +83,7 @@ openclaw-lobster-pet[data-dex-complete]::after { pointer-events: none; } -/* ---- Rare palette variants (weights + lore in lobster-pet.ts) ---- */ +/* ---- Rare palette variants (lobster-pet-palettes.ts + lobster-pet-lore.ts) ---- */ /* Ghost/albino: pale translucent shell, icy glints. */ .lobster-pet--palette-ghost { @@ -555,7 +555,7 @@ openclaw-lobster-pet[data-dex-complete]::after { } } -/* Split two-tone: the right body half (drawn in lobster-pet.ts) plus the +/* Split two-tone: the right body half (drawn in lobster-pet-look.ts) plus the right claw and antenna wear the second shell color. */ .lobster-pet--palette-split { --lob-shell2: #46536b;