diff --git a/CHANGELOG.md b/CHANGELOG.md
index 106b52bb1ea..b3865bb091c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -174,6 +174,7 @@ Docs: https://docs.openclaw.ai
- Telegram: split long default markdown sends and media follow-up text into safe HTML chunks, so outbound messages over Telegram's limit no longer fail as one oversized Bot API request. Fixes #75868. Thanks @zhengsx.
- Gateway/chat history: merge Claude CLI transcript imports for Anthropic-routed sessions that still have a Claude CLI binding, so local chat history does not hide CLI JSONL turns. Fixes #75850. Thanks @alfredjbclaw.
- Media: trim serialized JSON suffixes after local `MEDIA:` directive file extensions, so generated-image metadata cannot pollute the parsed media path and cause false `ENOENT` delivery failures. Fixes #75182. Thanks @TnzGit and @hclsys.
+- Plugins/runtime: hot-reload Gateway plugin runtime surfaces after plugin enable/disable changes while keeping source-changing plugin install, update, and uninstall operations restart-backed so loaded module code is not reused. Fixes #72097.
- Cron: make scheduler reload schedule comparison tolerate malformed persisted jobs, so one bad cron entry no longer aborts the whole tick. Fixes #75886. Thanks @samfox-ai.
- Doctor/channels: warn after migrations when default Telegram or Discord accounts have no configured token and their env fallback (`TELEGRAM_BOT_TOKEN` or `DISCORD_BOT_TOKEN`) is unavailable, with secret-safe migration docs for checking state-dir `.env`. Fixes #74298. Thanks @lolaopenclaw.
- Gateway/diagnostics: keep idle liveness samples in telemetry instead of visible warning logs unless diagnostic work is active, waiting, or queued. Thanks @vincentkoc.
diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md
index 8dab7e2079e..2619bf30595 100644
--- a/docs/tools/plugin.md
+++ b/docs/tools/plugin.md
@@ -55,6 +55,16 @@ temporary set of OpenClaw-owned plugin packages while that migration finishes.
+
+ In a running Gateway, owner-only `/plugins enable` and `/plugins disable`
+ trigger the Gateway config reloader. The Gateway reloads plugin runtime
+ surfaces in process, and new agent turns rebuild their tool list from the
+ refreshed registry. `/plugins install` changes plugin source code, so the
+ Gateway requests a restart instead of pretending the current process can
+ safely reload already-imported modules.
+
+
+
```bash
openclaw plugins inspect --runtime --json
@@ -251,20 +261,19 @@ tool name. If a tool allowlist references plugin tools, add the owning plugin id
to `plugins.allow` or remove `plugins.allow`; `openclaw doctor` warns about this
shape.
-Config changes **require a gateway restart**. If the Gateway is running with config
-watch + in-process restart enabled (the default `openclaw gateway` path), that
-restart is usually performed automatically a moment after the config write lands.
-There is no supported hot-reload path for native plugin runtime code or lifecycle
-hooks; restart the Gateway process that is serving the live channel before
-expecting updated `register(api)` code, `api.on(...)` hooks, tools, services, or
-provider/runtime hooks to run.
+Config changes made through `/plugins enable` or `/plugins disable` trigger an
+in-process Gateway plugin reload. New agent turns rebuild their tool list from
+the refreshed plugin registry. Source-changing operations such as install,
+update, and uninstall still restart the Gateway process because already-imported
+plugin modules cannot be safely replaced in place.
`openclaw plugins list` is a local plugin registry/config snapshot. An
`enabled` plugin there means the persisted registry and current config allow the
plugin to participate. It does not prove that an already-running remote Gateway
-child has restarted into the same plugin code. On VPS/container setups with
-wrapper processes, send restarts to the actual `openclaw gateway run` process,
-or use `openclaw gateway restart` against the running Gateway.
+has reloaded or restarted into the same plugin code. On VPS/container setups
+with wrapper processes, send restarts or reload-triggering writes to the actual
+`openclaw gateway run` process, or use `openclaw gateway restart` against the
+running Gateway when the reload reports a failure.
- **Disabled**: plugin exists but enablement rules turned it off. Config is preserved.
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index 4a914179317..bd0b316ba0a 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -250,8 +250,8 @@ User-invocable skills are also exposed as slash commands:
- In multi-account channels, config-targeted `/allowlist --account ` and `/config set channels..accounts....` also honor the target account's `configWrites`.
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
- - `/plugins install ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, `git:`, or `clawhub:`.
- - `/plugins enable|disable` updates plugin config and may prompt for a restart.
+ - `/plugins install ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, `git:`, or `clawhub:`, then requests a Gateway restart because plugin source modules changed.
+ - `/plugins enable|disable` updates plugin config and triggers Gateway plugin reload for new agent turns.
@@ -429,8 +429,9 @@ Examples:
- `/plugins list` and `/plugins show` use real plugin discovery against the current workspace plus on-disk config.
+- `/plugins install` installs from ClawHub, npm, git, local directories, and archives.
- `/plugins enable|disable` updates plugin config only; it does not install or uninstall plugins.
-- After enable/disable changes, restart the gateway to apply them.
+- Enable and disable changes hot-reload Gateway plugin runtime surfaces for new agent turns; install requests a Gateway restart because plugin source modules changed.
diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts
index 05a3c9a3994..b3807073bd6 100644
--- a/src/auto-reply/reply/commands-plugins.ts
+++ b/src/auto-reply/reply/commands-plugins.ts
@@ -424,7 +424,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
return {
shouldContinue: false,
reply: {
- text: `🔌 Installed plugin "${installed.pluginId}". Restart the gateway to load plugins.`,
+ text: `🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`,
},
};
}
@@ -531,7 +531,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
shouldContinue: false,
reply: {
text:
- `🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Restart the gateway to apply.` +
+ `🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Gateway reload will apply it to new agent turns.` +
(registryWarning ? `\n${registryWarning}` : ""),
},
};
diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts
index ffaf253e95b..d572d316966 100644
--- a/src/cli/plugins-cli-test-helpers.ts
+++ b/src/cli/plugins-cli-test-helpers.ts
@@ -66,6 +66,7 @@ export const buildPluginDiagnosticsReport: UnknownMock = vi.fn();
const buildPluginCompatibilityNotices: UnknownMock = vi.fn();
export const inspectPluginRegistry: AsyncUnknownMock = vi.fn();
export const refreshPluginRegistry: AsyncUnknownMock = vi.fn();
+export const clearPluginRegistryLoadCache: UnknownMock = vi.fn();
export const applyExclusiveSlotSelection: UnknownMock = vi.fn();
export const planPluginUninstall: UnknownMock = vi.fn();
export const applyPluginUninstallDirectoryRemoval: AsyncUnknownMock = vi.fn();
@@ -353,6 +354,13 @@ vi.mock("../plugins/plugin-registry.js", () => ({
)) as (typeof import("../plugins/plugin-registry.js"))["refreshPluginRegistry"],
}));
+vi.mock("../plugins/loader.js", () => ({
+ clearPluginRegistryLoadCache: ((...args: unknown[]) =>
+ invokeMock(clearPluginRegistryLoadCache, ...args)) as (
+ ...args: unknown[]
+ ) => unknown,
+}));
+
vi.mock("../plugins/slots.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -599,6 +607,7 @@ export function resetPluginsCliTestState() {
buildPluginCompatibilityNotices.mockReset();
inspectPluginRegistry.mockReset();
refreshPluginRegistry.mockReset();
+ clearPluginRegistryLoadCache.mockReset();
applyExclusiveSlotSelection.mockReset();
planPluginUninstall.mockReset();
applyPluginUninstallDirectoryRemoval.mockReset();
diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts
index b26934b23c2..9d01a58863f 100644
--- a/src/cli/plugins-install-persist.test.ts
+++ b/src/cli/plugins-install-persist.test.ts
@@ -3,10 +3,13 @@ import type { OpenClawConfig } from "../config/config.js";
import {
applyExclusiveSlotSelection,
buildPluginDiagnosticsReport,
+ clearPluginRegistryLoadCache,
enablePluginInConfig,
loadPluginManifestRegistry,
+ replaceConfigFile,
refreshPluginRegistry,
resetPluginsCliTestState,
+ runtimeLogs,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
} from "./plugins-cli-test-helpers.js";
@@ -60,6 +63,14 @@ describe("persistPluginInstall", () => {
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledConfig);
+ expect(replaceConfigFile).toHaveBeenCalledWith({
+ nextConfig: enabledConfig,
+ baseHash: "config-1",
+ writeOptions: {
+ afterWrite: { mode: "restart", reason: "plugin source changed" },
+ unsetPaths: [["plugins", "installs"]],
+ },
+ });
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: enabledConfig,
installRecords: {
@@ -71,6 +82,82 @@ describe("persistPluginInstall", () => {
},
reason: "source-changed",
});
+ expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1);
+ });
+
+ it("persists installs even when runtime cache invalidation fails", async () => {
+ const { persistPluginInstall } = await import("./plugins-install-persist.js");
+ const baseConfig = {
+ plugins: {
+ entries: {},
+ },
+ } as OpenClawConfig;
+ const enabledConfig = {
+ plugins: {
+ entries: {
+ alpha: { enabled: true },
+ },
+ },
+ } as OpenClawConfig;
+ enablePluginInConfig.mockReturnValue({ config: enabledConfig });
+ clearPluginRegistryLoadCache.mockImplementation(() => {
+ throw new Error("cache unavailable");
+ });
+
+ const next = await persistPluginInstall({
+ snapshot: {
+ config: baseConfig,
+ baseHash: "config-1",
+ },
+ pluginId: "alpha",
+ install: {
+ source: "npm",
+ spec: "alpha@1.0.0",
+ installPath: "/tmp/alpha",
+ },
+ });
+
+ expect(next).toEqual(enabledConfig);
+ expect(refreshPluginRegistry).toHaveBeenCalled();
+ expect(
+ runtimeLogs.some((line) => line.includes("Plugin runtime cache invalidation failed")),
+ ).toBe(true);
+ });
+
+ it("invalidates runtime cache even when registry refresh fails", async () => {
+ const { persistPluginInstall } = await import("./plugins-install-persist.js");
+ const baseConfig = {
+ plugins: {
+ entries: {},
+ },
+ } as OpenClawConfig;
+ const enabledConfig = {
+ plugins: {
+ entries: {
+ alpha: { enabled: true },
+ },
+ },
+ } as OpenClawConfig;
+ enablePluginInConfig.mockReturnValue({ config: enabledConfig });
+ refreshPluginRegistry.mockRejectedValueOnce(new Error("registry unavailable"));
+
+ const next = await persistPluginInstall({
+ snapshot: {
+ config: baseConfig,
+ baseHash: "config-1",
+ },
+ pluginId: "alpha",
+ install: {
+ source: "npm",
+ spec: "alpha@1.0.0",
+ installPath: "/tmp/alpha",
+ },
+ });
+
+ expect(next).toEqual(enabledConfig);
+ expect(refreshPluginRegistry).toHaveBeenCalled();
+ expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1);
+ expect(runtimeLogs.some((line) => line.includes("Plugin registry refresh failed"))).toBe(true);
});
it("removes stale denylist entries before enabling installed plugins", async () => {
diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts
index eb4decdcc7c..a9d2775a750 100644
--- a/src/cli/plugins-install-persist.ts
+++ b/src/cli/plugins-install-persist.ts
@@ -107,6 +107,9 @@ export async function persistPluginInstall(params: {
nextInstallRecords,
nextConfig: next,
baseHash: params.snapshot.baseHash,
+ writeOptions: {
+ afterWrite: { mode: "restart", reason: "plugin source changed" },
+ },
}),
{ command: "install" },
);
diff --git a/src/cli/plugins-install-record-commit.test.ts b/src/cli/plugins-install-record-commit.test.ts
index 23f382cc8c3..a746190554f 100644
--- a/src/cli/plugins-install-record-commit.test.ts
+++ b/src/cli/plugins-install-record-commit.test.ts
@@ -78,6 +78,7 @@ describe("commitConfigWithPendingPluginInstalls", () => {
},
baseHash: "config-1",
writeOptions: {
+ afterWrite: { mode: "restart", reason: "plugin source changed" },
unsetPaths: [["plugins", "installs"]],
},
});
@@ -97,6 +98,33 @@ describe("commitConfigWithPendingPluginInstalls", () => {
});
});
+ it("does not add restart intent when pending records match the plugin index", async () => {
+ const existingRecords: Record = {
+ demo: {
+ source: "npm",
+ spec: "demo@1.0.0",
+ },
+ };
+ mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(existingRecords);
+
+ await commitConfigWithPendingPluginInstalls({
+ nextConfig: {
+ plugins: {
+ installs: existingRecords,
+ },
+ },
+ baseHash: "config-1",
+ });
+
+ expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
+ nextConfig: {},
+ baseHash: "config-1",
+ writeOptions: {
+ unsetPaths: [["plugins", "installs"]],
+ },
+ });
+ });
+
it("rolls back plugin index writes when the config write fails", async () => {
const existingRecords: Record = {
existing: {
diff --git a/src/cli/plugins-install-record-commit.ts b/src/cli/plugins-install-record-commit.ts
index 93fc4dd9cb9..4cda3f3c36c 100644
--- a/src/cli/plugins-install-record-commit.ts
+++ b/src/cli/plugins-install-record-commit.ts
@@ -1,3 +1,4 @@
+import { isDeepStrictEqual } from "node:util";
import { replaceConfigFile } from "../config/config.js";
import type { ConfigWriteOptions } from "../config/io.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -18,6 +19,7 @@ function mergeUnsetPaths(
}
type ConfigCommit = (config: OpenClawConfig, writeOptions?: ConfigWriteOptions) => Promise;
+const PLUGIN_SOURCE_CHANGED_RESTART_REASON = "plugin source changed";
async function commitPluginInstallRecordsWithWriter(params: {
previousInstallRecords?: Record;
@@ -30,8 +32,15 @@ async function commitPluginInstallRecordsWithWriter(params: {
params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
await writePersistedInstalledPluginIndexInstallRecords(params.nextInstallRecords);
try {
+ const installRecordsChanged = !isDeepStrictEqual(
+ previousInstallRecords,
+ params.nextInstallRecords,
+ );
await params.commit(params.nextConfig, {
...params.writeOptions,
+ ...(installRecordsChanged && params.writeOptions?.afterWrite === undefined
+ ? { afterWrite: { mode: "restart", reason: PLUGIN_SOURCE_CHANGED_RESTART_REASON } }
+ : {}),
unsetPaths: mergeUnsetPaths(params.writeOptions?.unsetPaths, [
Array.from(PLUGIN_INSTALLS_CONFIG_PATH),
]),
diff --git a/src/cli/plugins-registry-refresh.ts b/src/cli/plugins-registry-refresh.ts
index 64c3eec4e4c..e4471194282 100644
--- a/src/cli/plugins-registry-refresh.ts
+++ b/src/cli/plugins-registry-refresh.ts
@@ -43,4 +43,16 @@ export async function refreshPluginRegistryAfterConfigMutation(params: {
} catch (error) {
params.logger?.warn?.(`Plugin registry refresh failed: ${formatErrorMessage(error)}`);
}
+ await invalidatePluginRuntimeDiscoveryAfterConfigMutation(params);
+}
+
+async function invalidatePluginRuntimeDiscoveryAfterConfigMutation(params: {
+ logger?: PluginRegistryRefreshLogger;
+}): Promise {
+ try {
+ const { clearPluginRegistryLoadCache } = await import("../plugins/loader.js");
+ clearPluginRegistryLoadCache();
+ } catch (error) {
+ params.logger?.warn?.(`Plugin runtime cache invalidation failed: ${formatErrorMessage(error)}`);
+ }
}
diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts
index f633ffd3e0d..8b165a03de7 100644
--- a/src/cli/plugins-uninstall-command.ts
+++ b/src/cli/plugins-uninstall-command.ts
@@ -167,6 +167,9 @@ export async function runPluginUninstallCommand(
nextInstallRecords,
nextConfig,
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
+ writeOptions: {
+ afterWrite: { mode: "restart", reason: "plugin source changed" },
+ },
}),
{ command: "uninstall" },
);
diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts
index 950d1ddd6b0..ce149013b10 100644
--- a/src/cli/plugins-update-command.ts
+++ b/src/cli/plugins-update-command.ts
@@ -111,6 +111,9 @@ export async function runPluginUpdateCommand(params: {
nextInstallRecords: nextPluginInstallRecords,
nextConfig,
baseHash: (await sourceSnapshotPromise)?.hash,
+ writeOptions: {
+ afterWrite: { mode: "restart", reason: "plugin source changed" },
+ },
});
} else {
await replaceConfigFile({
diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts
index fbd348ef6d0..16e7ab3d01e 100644
--- a/src/gateway/config-reload-plan.ts
+++ b/src/gateway/config-reload-plan.ts
@@ -18,6 +18,7 @@ export type GatewayReloadPlan = {
restartCron: boolean;
restartHeartbeat: boolean;
restartHealthMonitor: boolean;
+ reloadPlugins: boolean;
restartChannels: Set;
disposeMcpRuntimes: boolean;
noopPaths: string[];
@@ -35,6 +36,7 @@ type ReloadAction =
| "restart-cron"
| "restart-heartbeat"
| "restart-health-monitor"
+ | "reload-plugins"
| "dispose-mcp-runtimes"
| `restart-channel:${ChannelId}`;
@@ -97,6 +99,8 @@ const BASE_RELOAD_RULES: ReloadRule[] = [
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
{ prefix: "mcp", kind: "hot", actions: ["dispose-mcp-runtimes"] },
+ { prefix: "plugins.load", kind: "restart" },
+ { prefix: "plugins.installs", kind: "restart" },
];
const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
@@ -115,7 +119,7 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
{ prefix: "talk", kind: "none" },
{ prefix: "skills", kind: "none" },
{ prefix: "secrets", kind: "none" },
- { prefix: "plugins", kind: "restart" },
+ { prefix: "plugins", kind: "hot", actions: ["reload-plugins", "dispose-mcp-runtimes"] },
{ prefix: "ui", kind: "none" },
{ prefix: "gateway", kind: "restart" },
{ prefix: "discovery", kind: "restart" },
@@ -163,6 +167,17 @@ function listReloadRules(): ReloadRule[] {
),
),
);
+ const channelPluginStateRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [
+ {
+ prefix: `plugins.entries.${plugin.id}`,
+ kind: "hot",
+ actions: [
+ "reload-plugins",
+ "dispose-mcp-runtimes",
+ `restart-channel:${plugin.id}` as ReloadAction,
+ ],
+ },
+ ]);
const pluginReloadRules: ReloadRule[] = (registry?.reloads ?? []).flatMap((entry) =>
(entry.registration.restartPrefixes ?? [])
.map(
@@ -190,6 +205,7 @@ function listReloadRules(): ReloadRule[] {
...BASE_RELOAD_RULES,
...pluginReloadRules,
...channelReloadRules,
+ ...channelPluginStateRules,
...BASE_RELOAD_RULES_TAIL,
];
cachedReloadRules = rules;
@@ -286,6 +302,7 @@ export function buildGatewayReloadPlan(
restartCron: false,
restartHeartbeat: false,
restartHealthMonitor: false,
+ reloadPlugins: false,
restartChannels: new Set(),
disposeMcpRuntimes: false,
noopPaths: [],
@@ -313,6 +330,9 @@ export function buildGatewayReloadPlan(
case "restart-health-monitor":
plan.restartHealthMonitor = true;
break;
+ case "reload-plugins":
+ plan.reloadPlugins = true;
+ break;
case "dispose-mcp-runtimes":
plan.disposeMcpRuntimes = true;
break;
diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts
index 8e4af2faf5b..c657561b8cc 100644
--- a/src/gateway/config-reload.test.ts
+++ b/src/gateway/config-reload.test.ts
@@ -11,6 +11,7 @@ import type {
ConfigWriteNotification,
OpenClawConfig,
} from "../config/config.js";
+import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
pinActivePluginChannelRegistry,
resetPluginRuntimeStateForTest,
@@ -233,6 +234,25 @@ describe("buildGatewayReloadPlan", () => {
expect(afterPinPlan.restartChannels).toEqual(new Set(["telegram"]));
});
+ it("restarts loaded channel plugins when plugin entry state changes", () => {
+ const plan = buildGatewayReloadPlan(["plugins.entries.telegram.enabled"]);
+
+ expect(plan.restartGateway).toBe(false);
+ expect(plan.reloadPlugins).toBe(true);
+ expect(plan.disposeMcpRuntimes).toBe(true);
+ expect(plan.restartChannels).toEqual(new Set(["telegram"]));
+ });
+
+ it("keeps installed channel plugin source changes restart-backed", () => {
+ const plan = buildGatewayReloadPlan(["plugins.installs.telegram.installPath"]);
+
+ expect(plan.restartGateway).toBe(true);
+ expect(plan.reloadPlugins).toBe(false);
+ expect(plan.disposeMcpRuntimes).toBe(false);
+ expect(plan.restartChannels).toEqual(new Set());
+ expect(plan.restartReasons).toEqual(["plugins.installs.telegram.installPath"]);
+ });
+
it("restarts heartbeat when model-related config changes", () => {
const plan = buildGatewayReloadPlan([
"models.providers.openai.models",
@@ -281,7 +301,7 @@ describe("buildGatewayReloadPlan", () => {
]);
});
- it("keeps colliding whole-record plugin install changes as restart reasons", () => {
+ it("restarts for whole-record plugin install changes", () => {
const plan = buildGatewayReloadPlan(
["plugins.installs.lossless.resolvedAt", "plugins.installs.lossless.resolvedAt"],
{
@@ -291,6 +311,8 @@ describe("buildGatewayReloadPlan", () => {
);
expect(plan.restartGateway).toBe(true);
+ expect(plan.reloadPlugins).toBe(false);
+ expect(plan.disposeMcpRuntimes).toBe(false);
expect(plan.restartReasons).toEqual([
"plugins.installs.lossless.resolvedAt",
"plugins.installs.lossless.resolvedAt",
@@ -298,6 +320,23 @@ describe("buildGatewayReloadPlan", () => {
expect(plan.noopPaths).toEqual([]);
});
+ it("requires restart when plugin load paths change", () => {
+ const plan = buildGatewayReloadPlan(["plugins.load.paths.0"]);
+
+ expect(plan.restartGateway).toBe(true);
+ expect(plan.reloadPlugins).toBe(false);
+ expect(plan.disposeMcpRuntimes).toBe(false);
+ expect(plan.restartReasons).toEqual(["plugins.load.paths.0"]);
+ });
+
+ it("hot-reloads plugin entry config changes", () => {
+ const plan = buildGatewayReloadPlan(["plugins.entries.lossless-claw.config.mode"]);
+ expect(plan.restartGateway).toBe(false);
+ expect(plan.reloadPlugins).toBe(true);
+ expect(plan.disposeMcpRuntimes).toBe(true);
+ expect(plan.hotReasons).toContain("plugins.entries.lossless-claw.config.mode");
+ });
+
it("lists plugin install metadata and whole-record paths structurally", () => {
const prev = {
plugins: {
@@ -541,6 +580,8 @@ function createReloaderHarness(
initialInternalWriteHash?: string | null;
recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise;
+ initialPluginInstallRecords?: Record;
+ readPluginInstallRecords?: () => Promise>;
onRecovered?: (params: {
reason: string;
snapshot: ConfigFileSnapshot;
@@ -573,6 +614,8 @@ function createReloaderHarness(
readSnapshot,
recoverSnapshot: options.recoverSnapshot,
promoteSnapshot: options.promoteSnapshot,
+ initialPluginInstallRecords: options.initialPluginInstallRecords ?? {},
+ readPluginInstallRecords: options.readPluginInstallRecords ?? (async () => ({})),
onRecovered: options.onRecovered,
subscribeToWrites,
onHotReload,
@@ -809,13 +852,14 @@ describe("startGatewayConfigReloader", () => {
expect(recoverSnapshot).not.toHaveBeenCalled();
expect(readSnapshot).toHaveBeenCalledTimes(1);
- expect(onHotReload).not.toHaveBeenCalled();
- expect(onRestart).toHaveBeenCalledTimes(1);
- expect(onRestart).toHaveBeenCalledWith(
+ expect(onRestart).not.toHaveBeenCalled();
+ expect(onHotReload).toHaveBeenCalledTimes(1);
+ expect(onHotReload).toHaveBeenCalledWith(
expect.objectContaining({
changedPaths: ["plugins.entries.lossless-claw.config.cacheAwareCompaction"],
- restartGateway: true,
- restartReasons: ["plugins.entries.lossless-claw.config.cacheAwareCompaction"],
+ restartGateway: false,
+ reloadPlugins: true,
+ hotReasons: ["plugins.entries.lossless-claw.config.cacheAwareCompaction"],
}),
expect.objectContaining({
plugins: expect.objectContaining({
@@ -1162,6 +1206,189 @@ describe("startGatewayConfigReloader", () => {
expect(harness.onHotReload).not.toHaveBeenCalled();
expect(harness.onRestart).toHaveBeenCalledTimes(1);
+ expect(harness.onRestart).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changedPaths: [
+ "plugins.installs.lossless.resolvedAt",
+ "plugins.installs.lossless.resolvedAt",
+ ],
+ restartGateway: true,
+ restartReasons: [
+ "plugins.installs.lossless.resolvedAt",
+ "plugins.installs.lossless.resolvedAt",
+ ],
+ }),
+ expect.objectContaining({
+ plugins: expect.objectContaining({
+ installs: expect.objectContaining({
+ "lossless.resolvedAt": expect.objectContaining({
+ source: "npm",
+ }),
+ }),
+ }),
+ }),
+ );
+
+ await harness.reloader.stop();
+ });
+
+ it("queues restart when an external plugin source write only changes the managed index", async () => {
+ const activeConfig: OpenClawConfig = {
+ gateway: { reload: { debounceMs: 0 } },
+ plugins: {
+ allow: ["lossless-claw"],
+ entries: {
+ "lossless-claw": { enabled: true },
+ },
+ },
+ };
+ const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce(
+ makeSnapshot({
+ sourceConfig: activeConfig,
+ runtimeConfig: activeConfig,
+ config: activeConfig,
+ hash: "external-plugin-index-1",
+ }),
+ );
+ const readPluginInstallRecords = vi.fn().mockResolvedValueOnce({
+ "lossless-claw": {
+ source: "npm",
+ spec: "@martian-engineering/lossless-claw",
+ installPath: "/tmp/openclaw/plugins/lossless-claw",
+ installedAt: "2026-04-22T00:00:00.000Z",
+ },
+ } satisfies Record);
+ const harness = createReloaderHarness(readSnapshot, {
+ initialCompareConfig: activeConfig,
+ initialPluginInstallRecords: {},
+ readPluginInstallRecords,
+ });
+
+ harness.watcher.emit("change");
+ await vi.runOnlyPendingTimersAsync();
+
+ expect(harness.onHotReload).not.toHaveBeenCalled();
+ expect(harness.onRestart).toHaveBeenCalledTimes(1);
+ expect(harness.onRestart).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changedPaths: ["plugins.installs.lossless-claw"],
+ restartGateway: true,
+ restartReasons: ["plugins.installs.lossless-claw"],
+ }),
+ activeConfig,
+ );
+
+ await harness.reloader.stop();
+ });
+
+ it("keeps external plugin policy-only writes on the hot reload path", async () => {
+ const previousConfig: OpenClawConfig = {
+ gateway: { reload: { debounceMs: 0 } },
+ plugins: {
+ entries: {
+ telegram: { enabled: false },
+ },
+ },
+ };
+ const nextConfig: OpenClawConfig = {
+ gateway: { reload: { debounceMs: 0 } },
+ plugins: {
+ entries: {
+ telegram: { enabled: true },
+ },
+ },
+ };
+ const installRecords = {
+ telegram: {
+ source: "npm",
+ spec: "@openclaw/telegram",
+ installPath: "/tmp/openclaw/plugins/telegram",
+ },
+ } satisfies Record;
+ const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce(
+ makeSnapshot({
+ sourceConfig: nextConfig,
+ runtimeConfig: nextConfig,
+ config: nextConfig,
+ hash: "external-plugin-policy-1",
+ }),
+ );
+ const readPluginInstallRecords = vi.fn().mockResolvedValueOnce(installRecords);
+ const harness = createReloaderHarness(readSnapshot, {
+ initialCompareConfig: previousConfig,
+ initialPluginInstallRecords: installRecords,
+ readPluginInstallRecords,
+ });
+
+ harness.watcher.emit("change");
+ await vi.runOnlyPendingTimersAsync();
+
+ expect(harness.onRestart).not.toHaveBeenCalled();
+ expect(harness.onHotReload).toHaveBeenCalledTimes(1);
+ expect(harness.onHotReload).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changedPaths: ["plugins.entries.telegram.enabled"],
+ restartGateway: false,
+ reloadPlugins: true,
+ hotReasons: ["plugins.entries.telegram.enabled"],
+ }),
+ nextConfig,
+ );
+
+ await harness.reloader.stop();
+ });
+
+ it("queues restart when an external plugin source write also changes plugin config", async () => {
+ const previousConfig: OpenClawConfig = {
+ gateway: { reload: { debounceMs: 0 } },
+ plugins: {
+ allow: ["lossless-claw"],
+ },
+ };
+ const nextConfig: OpenClawConfig = {
+ gateway: { reload: { debounceMs: 0 } },
+ plugins: {
+ allow: ["lossless-claw"],
+ entries: {
+ "lossless-claw": { enabled: true },
+ },
+ },
+ };
+ const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce(
+ makeSnapshot({
+ sourceConfig: nextConfig,
+ runtimeConfig: nextConfig,
+ config: nextConfig,
+ hash: "external-plugin-source-and-config-1",
+ }),
+ );
+ const readPluginInstallRecords = vi.fn().mockResolvedValueOnce({
+ "lossless-claw": {
+ source: "npm",
+ spec: "@martian-engineering/lossless-claw",
+ installPath: "/tmp/openclaw/plugins/lossless-claw",
+ installedAt: "2026-04-22T00:00:00.000Z",
+ },
+ } satisfies Record);
+ const harness = createReloaderHarness(readSnapshot, {
+ initialCompareConfig: previousConfig,
+ initialPluginInstallRecords: {},
+ readPluginInstallRecords,
+ });
+
+ harness.watcher.emit("change");
+ await vi.runOnlyPendingTimersAsync();
+
+ expect(harness.onHotReload).not.toHaveBeenCalled();
+ expect(harness.onRestart).toHaveBeenCalledTimes(1);
+ expect(harness.onRestart).toHaveBeenCalledWith(
+ expect.objectContaining({
+ changedPaths: ["plugins.entries", "plugins.installs.lossless-claw"],
+ restartGateway: true,
+ restartReasons: ["plugins.installs.lossless-claw"],
+ }),
+ nextConfig,
+ );
await harness.reloader.stop();
});
diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts
index 723b6eef1df..ba2f12e5e84 100644
--- a/src/gateway/config-reload.ts
+++ b/src/gateway/config-reload.ts
@@ -11,7 +11,12 @@ import {
import { resolveConfigWriteFollowUp } from "../config/runtime-snapshot.js";
import type { GatewayReloadMode } from "../config/types.gateway.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
+import type { PluginInstallRecord } from "../config/types.plugins.js";
import { validateConfigObjectWithPlugins } from "../config/validation.js";
+import {
+ loadInstalledPluginIndexInstallRecords,
+ loadInstalledPluginIndexInstallRecordsSync,
+} from "../plugins/installed-plugin-index-records.js";
import { isPlainObject } from "../utils.js";
import {
buildGatewayReloadPlan,
@@ -71,6 +76,7 @@ function isNoopReloadPlan(plan: GatewayReloadPlan): boolean {
!plan.restartCron &&
!plan.restartHeartbeat &&
!plan.restartHealthMonitor &&
+ !plan.reloadPlugins &&
!plan.disposeMcpRuntimes &&
plan.restartChannels.size === 0
);
@@ -158,6 +164,16 @@ type GatewayConfigReloader = {
stop: () => Promise;
};
+type PluginInstallRecords = Record;
+
+function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig {
+ return {
+ plugins: {
+ installs: records,
+ },
+ };
+}
+
export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
@@ -167,6 +183,8 @@ export function startGatewayConfigReloader(opts: {
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise;
recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise;
+ initialPluginInstallRecords?: PluginInstallRecords;
+ readPluginInstallRecords?: () => Promise;
onRecovered?: (params: {
reason: string;
snapshot: ConfigFileSnapshot;
@@ -196,6 +214,10 @@ export function startGatewayConfigReloader(opts: {
afterWrite?: ConfigWriteNotification["afterWrite"];
} | null = null;
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
+ let currentPluginInstallRecords =
+ opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
+ const readPluginInstallRecords =
+ opts.readPluginInstallRecords ?? loadInstalledPluginIndexInstallRecords;
const scheduleAfter = (wait: number) => {
if (stopped) {
@@ -294,17 +316,47 @@ export function startGatewayConfigReloader(opts: {
nextCompareConfig: OpenClawConfig,
afterWrite?: ConfigWriteNotification["afterWrite"],
) => {
- const changedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig);
- const pluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
+ const configChangedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig);
+ const configPluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
currentCompareConfig,
nextCompareConfig,
);
- const pluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths(
+ const configPluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths(
currentCompareConfig,
nextCompareConfig,
);
+ let nextPluginInstallRecords = currentPluginInstallRecords;
+ try {
+ nextPluginInstallRecords = await readPluginInstallRecords();
+ } catch (err) {
+ opts.log.warn(`config reload plugin install record check failed: ${String(err)}`);
+ }
+ const previousPluginInstallConfig = asPluginInstallConfig(currentPluginInstallRecords);
+ const nextPluginInstallConfig = asPluginInstallConfig(nextPluginInstallRecords);
+ const pluginInstallRecordChangedPaths = diffConfigPaths(
+ previousPluginInstallConfig,
+ nextPluginInstallConfig,
+ );
+ const pluginInstallRecordTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
+ previousPluginInstallConfig,
+ nextPluginInstallConfig,
+ );
+ const pluginInstallRecordWholeRecordPaths = listPluginInstallWholeRecordPaths(
+ previousPluginInstallConfig,
+ nextPluginInstallConfig,
+ );
+ const changedPaths = [...configChangedPaths, ...pluginInstallRecordChangedPaths];
+ const pluginInstallTimestampNoopPaths = [
+ ...configPluginInstallTimestampNoopPaths,
+ ...pluginInstallRecordTimestampNoopPaths,
+ ];
+ const pluginInstallWholeRecordPaths = [
+ ...configPluginInstallWholeRecordPaths,
+ ...pluginInstallRecordWholeRecordPaths,
+ ];
currentConfig = nextConfig;
currentCompareConfig = nextCompareConfig;
+ currentPluginInstallRecords = nextPluginInstallRecords;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) {
return;
diff --git a/src/gateway/plugin-channel-reload-targets.test.ts b/src/gateway/plugin-channel-reload-targets.test.ts
new file mode 100644
index 00000000000..b6c55c04e42
--- /dev/null
+++ b/src/gateway/plugin-channel-reload-targets.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import {
+ listChannelPluginConfigTargetIds,
+ pluginConfigTargetsChanged,
+} from "./plugin-channel-reload-targets.js";
+
+describe("plugin channel reload targets", () => {
+ it("matches channel plugin config changes by owning plugin id", () => {
+ const targets = listChannelPluginConfigTargetIds({
+ channelId: "matrix",
+ pluginId: "acme-chat",
+ aliases: ["matrix-chat"],
+ });
+
+ expect(pluginConfigTargetsChanged(targets, ["plugins.entries.acme-chat.config.mode"])).toBe(
+ true,
+ );
+ expect(pluginConfigTargetsChanged(targets, ["plugins.installs.acme-chat.source"])).toBe(true);
+ expect(pluginConfigTargetsChanged(targets, ["plugins.entries.matrix.config.mode"])).toBe(true);
+ expect(pluginConfigTargetsChanged(targets, ["plugins.entries.matrix-chat.enabled"])).toBe(true);
+ expect(pluginConfigTargetsChanged(targets, ["plugins.entries.other.enabled"])).toBe(false);
+ });
+});
diff --git a/src/gateway/plugin-channel-reload-targets.ts b/src/gateway/plugin-channel-reload-targets.ts
new file mode 100644
index 00000000000..4bad088f674
--- /dev/null
+++ b/src/gateway/plugin-channel-reload-targets.ts
@@ -0,0 +1,40 @@
+import type { ChannelId } from "../channels/plugins/index.js";
+import { normalizeOptionalString } from "../shared/string-coerce.js";
+
+export type ChannelPluginReloadTarget = {
+ channelId: ChannelId;
+ pluginId?: string | null;
+ aliases?: readonly string[] | null;
+};
+
+function addNormalizedTarget(targets: Set, value: string | null | undefined): void {
+ const normalized = normalizeOptionalString(value);
+ if (normalized) {
+ targets.add(normalized);
+ }
+}
+
+export function listChannelPluginConfigTargetIds(
+ target: ChannelPluginReloadTarget,
+): ReadonlySet {
+ const targets = new Set();
+ addNormalizedTarget(targets, target.channelId);
+ addNormalizedTarget(targets, target.pluginId);
+ for (const alias of target.aliases ?? []) {
+ addNormalizedTarget(targets, alias);
+ }
+ return targets;
+}
+
+export function pluginConfigTargetsChanged(
+ targetIds: Iterable,
+ changedPaths: readonly string[],
+): boolean {
+ const prefixes = Array.from(targetIds, (id) => [
+ `plugins.entries.${id}`,
+ `plugins.installs.${id}`,
+ ]).flat();
+ return changedPaths.some((path) =>
+ prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}.`)),
+ );
+}
diff --git a/src/gateway/server-aux-handlers.test.ts b/src/gateway/server-aux-handlers.test.ts
index 6a7b867a406..b3f320dff88 100644
--- a/src/gateway/server-aux-handlers.test.ts
+++ b/src/gateway/server-aux-handlers.test.ts
@@ -24,6 +24,7 @@ function createReloadPlan(overrides?: Partial): GatewayReload
restartCron: overrides?.restartCron ?? false,
restartHeartbeat: overrides?.restartHeartbeat ?? false,
restartHealthMonitor: overrides?.restartHealthMonitor ?? false,
+ reloadPlugins: overrides?.reloadPlugins ?? false,
restartChannels: overrides?.restartChannels ?? new Set(),
disposeMcpRuntimes: overrides?.disposeMcpRuntimes ?? false,
noopPaths: overrides?.noopPaths ?? [],
diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts
index ac7fd771bed..dfb875d8182 100644
--- a/src/gateway/server-plugins.test.ts
+++ b/src/gateway/server-plugins.test.ts
@@ -7,6 +7,7 @@ import type { PluginDiagnostic } from "../plugins/types.js";
import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js";
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
+const clearActivatedPluginRuntimeState = vi.hoisted(() => vi.fn());
const loadPluginLookUpTable = vi.hoisted(() =>
vi.fn(() => ({
startup: {
@@ -34,6 +35,7 @@ const handleGatewayRequest = vi.hoisted(() =>
);
vi.mock("../plugins/loader.js", () => ({
+ clearActivatedPluginRuntimeState,
loadOpenClawPlugins,
}));
@@ -307,6 +309,7 @@ beforeAll(async () => {
});
beforeEach(() => {
+ clearActivatedPluginRuntimeState.mockClear();
loadOpenClawPlugins.mockReset();
loadPluginLookUpTable.mockReset().mockReturnValue({
startup: {
@@ -629,6 +632,7 @@ describe("loadGatewayPlugins", () => {
baseMethods: ["sessions.get"],
});
+ expect(clearActivatedPluginRuntimeState).toHaveBeenCalledTimes(1);
expect(loadOpenClawPlugins).not.toHaveBeenCalled();
expect(result.pluginRegistry.plugins).toEqual([]);
expect(result.gatewayMethods).toEqual(["sessions.get"]);
diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts
index a3d3e622bea..78ee3a68e5e 100644
--- a/src/gateway/server-plugins.ts
+++ b/src/gateway/server-plugins.ts
@@ -3,7 +3,7 @@ import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
-import { loadOpenClawPlugins } from "../plugins/loader.js";
+import { clearActivatedPluginRuntimeState, loadOpenClawPlugins } from "../plugins/loader.js";
import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
@@ -575,6 +575,7 @@ export function loadGatewayPlugins(params: {
).startup.pluginIds,
];
if (pluginIds.length === 0) {
+ clearActivatedPluginRuntimeState();
const pluginRegistry = createEmptyPluginRegistry();
setActivePluginRegistry(pluginRegistry, undefined, "gateway-bindable", params.workspaceDir);
return {
diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts
index 7b37b390719..8a539109082 100644
--- a/src/gateway/server-reload-handlers.test.ts
+++ b/src/gateway/server-reload-handlers.test.ts
@@ -5,7 +5,9 @@ import {
setActiveEmbeddedRun,
type EmbeddedPiQueueHandle,
} from "../agents/pi-embedded-runner/runs.js";
-import { __testing } from "./server-reload-handlers.js";
+import type { ChannelKind } from "./config-reload-plan.js";
+import type { GatewayPluginReloadResult } from "./server-reload-handlers.js";
+import { __testing, createGatewayReloadHandlers } from "./server-reload-handlers.js";
describe("gateway reload recovery handlers", () => {
afterEach(() => {
@@ -49,3 +51,107 @@ describe("gateway reload recovery handlers", () => {
expect(logReload.warn).not.toHaveBeenCalled();
});
});
+
+describe("gateway plugin hot reload handlers", () => {
+ it("stops removed channel plugins from broad activation before swapping plugin runtime", async () => {
+ const previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
+ const previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
+ delete process.env.OPENCLAW_SKIP_CHANNELS;
+ delete process.env.OPENCLAW_SKIP_PROVIDERS;
+ const cron = { start: vi.fn(async () => {}), stop: vi.fn() };
+ const heartbeatRunner = {
+ stop: vi.fn(),
+ updateConfig: vi.fn(),
+ };
+ const setState = vi.fn();
+ const startChannel = vi.fn(async () => {});
+ const events: string[] = [];
+ const stopChannel = vi.fn(async () => {
+ events.push("stop");
+ });
+ const reloadPlugins = vi.fn(
+ async (params: {
+ beforeReplace: (channels: ReadonlySet) => Promise;
+ }): Promise => {
+ events.push("reload:start");
+ await params.beforeReplace(new Set(["discord"]));
+ events.push("registry:replace");
+ return {
+ restartChannels: new Set(),
+ activeChannels: new Set(),
+ };
+ },
+ );
+ const { applyHotReload } = createGatewayReloadHandlers({
+ deps: {} as never,
+ broadcast: vi.fn(),
+ getState: () => ({
+ hooksConfig: {} as never,
+ hookClientIpConfig: {} as never,
+ heartbeatRunner: heartbeatRunner as never,
+ cronState: { cron, storePath: "/tmp/cron.json", cronEnabled: false } as never,
+ channelHealthMonitor: null,
+ }),
+ setState,
+ startChannel,
+ stopChannel,
+ reloadPlugins,
+ logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ logChannels: { info: vi.fn(), error: vi.fn() },
+ logCron: { error: vi.fn() },
+ logReload: { info: vi.fn(), warn: vi.fn() },
+ createHealthMonitor: () => null,
+ });
+
+ try {
+ await applyHotReload(
+ {
+ changedPaths: ["plugins.enabled"],
+ restartGateway: false,
+ restartReasons: [],
+ hotReasons: ["plugins.enabled"],
+ reloadHooks: false,
+ restartGmailWatcher: false,
+ restartCron: false,
+ restartHeartbeat: false,
+ restartHealthMonitor: false,
+ reloadPlugins: true,
+ restartChannels: new Set(),
+ disposeMcpRuntimes: false,
+ noopPaths: [],
+ },
+ {
+ plugins: {
+ enabled: false,
+ },
+ },
+ );
+ } finally {
+ if (previousSkipChannels === undefined) {
+ delete process.env.OPENCLAW_SKIP_CHANNELS;
+ } else {
+ process.env.OPENCLAW_SKIP_CHANNELS = previousSkipChannels;
+ }
+ if (previousSkipProviders === undefined) {
+ delete process.env.OPENCLAW_SKIP_PROVIDERS;
+ } else {
+ process.env.OPENCLAW_SKIP_PROVIDERS = previousSkipProviders;
+ }
+ }
+
+ expect(reloadPlugins).toHaveBeenCalledWith(
+ expect.objectContaining({
+ nextConfig: {
+ plugins: {
+ enabled: false,
+ },
+ },
+ changedPaths: ["plugins.enabled"],
+ }),
+ );
+ expect(stopChannel).toHaveBeenCalledWith("discord");
+ expect(startChannel).not.toHaveBeenCalled();
+ expect(events).toEqual(["reload:start", "stop", "registry:replace"]);
+ expect(setState).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts
index 77d53ae1943..624d0355856 100644
--- a/src/gateway/server-reload-handlers.ts
+++ b/src/gateway/server-reload-handlers.ts
@@ -58,6 +58,11 @@ type GatewayReloadLog = {
warn: (msg: string) => void;
};
+export type GatewayPluginReloadResult = {
+ restartChannels: ReadonlySet;
+ activeChannels: ReadonlySet;
+};
+
const MCP_RUNTIME_RELOAD_DISPOSE_TIMEOUT_MS = 5_000;
const CHANNEL_RELOAD_DEFERRAL_POLL_MS = 500;
const CHANNEL_RELOAD_STILL_PENDING_WARN_MS = 30_000;
@@ -109,6 +114,11 @@ type GatewayReloadHandlerParams = {
setState: (state: GatewayHotReloadState) => void;
startChannel: (name: ChannelKind) => Promise;
stopChannel: (name: ChannelKind) => Promise;
+ reloadPlugins: (params: {
+ nextConfig: OpenClawConfig;
+ changedPaths: readonly string[];
+ beforeReplace: (channels: ReadonlySet) => Promise;
+ }) => Promise;
logHooks: {
info: (msg: string) => void;
warn: (msg: string) => void;
@@ -260,6 +270,41 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams)
resetDirectoryCache();
+ const channelsToRestart = new Set(plan.restartChannels);
+ const channelsStoppedBeforePluginReload = new Set();
+ let activePluginChannelsAfterReload: ReadonlySet | null = null;
+ const shouldSkipChannelRestart = () =>
+ isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
+ isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS);
+ if (plan.reloadPlugins) {
+ const stopChannelsBeforePluginReplace = async (channels: ReadonlySet) => {
+ for (const channel of channels) {
+ channelsToRestart.add(channel);
+ }
+ if (channelsToRestart.size === 0 || shouldSkipChannelRestart()) {
+ return;
+ }
+ await waitForActiveWorkBeforeChannelReload(channelsToRestart, nextConfig);
+ for (const channel of channelsToRestart) {
+ if (channelsStoppedBeforePluginReload.has(channel)) {
+ continue;
+ }
+ params.logChannels.info(`stopping ${channel} channel before plugin reload`);
+ await params.stopChannel(channel);
+ channelsStoppedBeforePluginReload.add(channel);
+ }
+ };
+ const pluginReloadResult = await params.reloadPlugins({
+ nextConfig,
+ changedPaths: plan.changedPaths,
+ beforeReplace: stopChannelsBeforePluginReplace,
+ });
+ for (const channel of pluginReloadResult.restartChannels) {
+ channelsToRestart.add(channel);
+ }
+ activePluginChannelsAfterReload = pluginReloadResult.activeChannels;
+ }
+
if (plan.restartCron) {
state.cronState.cron.stop();
nextState.cronState = buildGatewayCronService({
@@ -303,22 +348,26 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams)
});
}
- if (plan.restartChannels.size > 0) {
- if (
- isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
- isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS)
- ) {
+ if (channelsToRestart.size > 0) {
+ if (shouldSkipChannelRestart()) {
params.logChannels.info(
"skipping channel reload (OPENCLAW_SKIP_CHANNELS=1 or OPENCLAW_SKIP_PROVIDERS=1)",
);
} else {
- await waitForActiveWorkBeforeChannelReload(plan.restartChannels, nextConfig);
+ if (!plan.reloadPlugins) {
+ await waitForActiveWorkBeforeChannelReload(channelsToRestart, nextConfig);
+ }
const restartChannel = async (name: ChannelKind) => {
+ if (plan.reloadPlugins && activePluginChannelsAfterReload?.has(name) === false) {
+ return;
+ }
params.logChannels.info(`restarting ${name} channel`);
- await params.stopChannel(name);
+ if (!channelsStoppedBeforePluginReload.has(name)) {
+ await params.stopChannel(name);
+ }
await params.startChannel(name);
};
- for (const channel of plan.restartChannels) {
+ for (const channel of channelsToRestart) {
await restartChannel(channel);
}
}
@@ -421,6 +470,7 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe
setState: params.setState,
startChannel: params.startChannel,
stopChannel: params.stopChannel,
+ reloadPlugins: params.reloadPlugins,
logHooks: params.logHooks,
logChannels: params.logChannels,
logCron: params.logCron,
diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts
index ed6273b7a04..86c809f0cfb 100644
--- a/src/gateway/server.impl.ts
+++ b/src/gateway/server.impl.ts
@@ -4,6 +4,7 @@ import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.
import type { CanvasHostServer } from "../canvas-host/server.js";
import type { ChannelRuntimeSurface } from "../channels/plugins/channel-runtime-surface.types.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
+import { getLoadedChannelPluginEntryById } from "../channels/plugins/registry-loaded.js";
import { createDefaultDeps } from "../cli/deps.js";
import { isRestartEnabled } from "../config/commands.flags.js";
import {
@@ -58,6 +59,10 @@ import {
} from "../tasks/task-registry.maintenance.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { resolveGatewayAuth } from "./auth.js";
+import {
+ listChannelPluginConfigTargetIds,
+ pluginConfigTargetsChanged,
+} from "./plugin-channel-reload-targets.js";
import { createGatewayAuxHandlers } from "./server-aux-handlers.js";
import { createChannelManager } from "./server-channels.js";
import { resolveGatewayControlUiRootState } from "./server-control-ui-root.js";
@@ -69,6 +74,7 @@ import { loadGatewayModelCatalog } from "./server-model-catalog.js";
import { bootstrapGatewayNetworkRuntime } from "./server-network-runtime.js";
import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js";
import { setFallbackGatewayContextResolver } from "./server-plugins.js";
+import type { GatewayPluginReloadResult } from "./server-reload-handlers.js";
import { createGatewayRequestContext } from "./server-request-context.js";
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
import {
@@ -1041,6 +1047,109 @@ export async function startGatewayServer(
logDiscovery.warn(`gateway discovery refresh failed after plugin load: ${String(err)}`);
}
};
+ const listAttachedChannelConfigTargets = () =>
+ new Map(
+ listChannelPlugins().map((plugin) => [
+ plugin.id,
+ listChannelPluginConfigTargetIds({
+ channelId: plugin.id,
+ pluginId: getLoadedChannelPluginEntryById(plugin.id)?.pluginId,
+ aliases: plugin.meta.aliases,
+ }),
+ ]),
+ );
+ const reloadAttachedGatewayPlugins = async (params: {
+ nextConfig: OpenClawConfig;
+ changedPaths: readonly string[];
+ beforeReplace: (channels: ReadonlySet) => Promise;
+ }): Promise => {
+ const beforeChannelTargets = listAttachedChannelConfigTargets();
+ const beforeChannelIds = new Set(beforeChannelTargets.keys());
+ const [{ loadPluginLookUpTable }, { prepareGatewayPluginLoad }, { startPluginServices }] =
+ await Promise.all([
+ import("../plugins/plugin-lookup-table.js"),
+ import("./server-plugin-bootstrap.js"),
+ import("../plugins/services.js"),
+ ]);
+ const nextPluginLookUpTable = loadPluginLookUpTable({
+ config: params.nextConfig,
+ workspaceDir: defaultWorkspaceDir,
+ env: process.env,
+ activationSourceConfig: params.nextConfig,
+ });
+ const nextStartupPluginIds = new Set(nextPluginLookUpTable.startup.pluginIds);
+ const nextStartupChannelIds = new Set();
+ for (const plugin of nextPluginLookUpTable.manifestRegistry.plugins) {
+ if (!nextStartupPluginIds.has(plugin.id)) {
+ continue;
+ }
+ if (plugin.channels.length === 0) {
+ nextStartupChannelIds.add(plugin.id);
+ continue;
+ }
+ for (const channelId of plugin.channels) {
+ nextStartupChannelIds.add(channelId);
+ }
+ }
+ const channelsToStopBeforeReplace = new Set();
+ for (const channelId of beforeChannelIds) {
+ const targetIds = beforeChannelTargets.get(channelId) ?? new Set([channelId]);
+ if (
+ !nextStartupChannelIds.has(channelId) ||
+ pluginConfigTargetsChanged(targetIds, params.changedPaths)
+ ) {
+ channelsToStopBeforeReplace.add(channelId);
+ }
+ }
+ await params.beforeReplace(channelsToStopBeforeReplace);
+ setCurrentPluginMetadataSnapshot(nextPluginLookUpTable, { config: params.nextConfig });
+ const loaded = prepareGatewayPluginLoad({
+ cfg: params.nextConfig,
+ workspaceDir: defaultWorkspaceDir,
+ log,
+ coreGatewayMethodNames: baseMethods,
+ baseMethods,
+ pluginLookUpTable: nextPluginLookUpTable,
+ });
+ const previousPluginServices = runtimeState.pluginServices;
+ runtimeState.pluginServices = null;
+ if (previousPluginServices) {
+ await previousPluginServices.stop().catch((err) => {
+ log.warn(`plugin services stop failed during reload: ${String(err)}`);
+ });
+ }
+ replaceAttachedPluginRuntime(loaded);
+ await refreshAttachedGatewayDiscovery(loaded.pluginRegistry);
+ try {
+ runtimeState.pluginServices = await startPluginServices({
+ registry: loaded.pluginRegistry,
+ config: params.nextConfig,
+ workspaceDir: defaultWorkspaceDir,
+ });
+ } catch (err) {
+ log.warn(`plugin services failed to start after reload: ${String(err)}`);
+ }
+ const afterChannelTargets = listAttachedChannelConfigTargets();
+ const afterChannelIds = new Set(afterChannelTargets.keys());
+ const restartChannels = new Set();
+ for (const channelId of new Set([...beforeChannelIds, ...afterChannelIds])) {
+ const targetIds =
+ afterChannelTargets.get(channelId) ??
+ beforeChannelTargets.get(channelId) ??
+ new Set([channelId]);
+ if (
+ afterChannelIds.has(channelId) &&
+ (beforeChannelIds.has(channelId) !== afterChannelIds.has(channelId) ||
+ pluginConfigTargetsChanged(targetIds, params.changedPaths))
+ ) {
+ restartChannels.add(channelId);
+ }
+ }
+ return {
+ restartChannels,
+ activeChannels: afterChannelIds,
+ };
+ };
const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port;
@@ -1287,6 +1396,7 @@ export async function startGatewayServer(
},
startChannel,
stopChannel,
+ reloadPlugins: reloadAttachedGatewayPlugins,
logHooks,
logChannels,
logCron,
diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts
index 45db86744b9..170c18ff7c4 100644
--- a/src/gateway/server.reload.test.ts
+++ b/src/gateway/server.reload.test.ts
@@ -890,6 +890,40 @@ describe("gateway hot reload", () => {
});
});
+ it("reloads plugin runtime surfaces and disposes MCP runtimes on plugin config hot reloads", async () => {
+ await withNonMinimalGatewayServer(async () => {
+ const onHotReload = hoisted.getOnHotReload();
+ expect(onHotReload).toBeTypeOf("function");
+
+ await onHotReload?.(
+ {
+ changedPaths: ["plugins.entries.discord.enabled"],
+ restartGateway: false,
+ restartReasons: [],
+ hotReasons: ["plugins.entries.discord.enabled"],
+ reloadHooks: false,
+ restartGmailWatcher: false,
+ restartCron: false,
+ restartHeartbeat: false,
+ restartHealthMonitor: false,
+ reloadPlugins: true,
+ restartChannels: new Set(),
+ disposeMcpRuntimes: true,
+ noopPaths: [],
+ },
+ {
+ plugins: {
+ entries: {
+ discord: { enabled: false },
+ },
+ },
+ },
+ );
+
+ expect(hoisted.disposeAllSessionMcpRuntimes).toHaveBeenCalledTimes(1);
+ });
+ });
+
it("serves secrets.reload immediately after startup without race failures", async () => {
await writeEnvRefConfig();
process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret
diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts
index 036a9360e77..6902f71b172 100644
--- a/src/gateway/server/plugins-http.test.ts
+++ b/src/gateway/server/plugins-http.test.ts
@@ -273,7 +273,7 @@ describe("createGatewayPluginRequestHandler", () => {
expect(routeHandler).toHaveBeenCalledTimes(1);
});
- it("falls back to the provided registry when the pinned route registry is empty", async () => {
+ it("does not fall back to stale routes when the pinned route registry is empty", async () => {
const explicitRouteHandler = vi.fn(async (_req, res: ServerResponse) => {
res.statusCode = 200;
return true;
@@ -293,8 +293,8 @@ describe("createGatewayPluginRequestHandler", () => {
const { res } = makeMockHttpResponse();
const handled = await handler({ url: "/demo" } as IncomingMessage, res);
- expect(handled).toBe(true);
- expect(explicitRouteHandler).toHaveBeenCalledTimes(1);
+ expect(handled).toBe(false);
+ expect(explicitRouteHandler).not.toHaveBeenCalled();
});
it("handles routes registered into the pinned startup registry after the active registry changes", async () => {
diff --git a/src/plugins/loader-cache-state.test.ts b/src/plugins/loader-cache-state.test.ts
index 99cd30d16da..362bfad281d 100644
--- a/src/plugins/loader-cache-state.test.ts
+++ b/src/plugins/loader-cache-state.test.ts
@@ -41,4 +41,18 @@ describe("PluginLoaderCacheState", () => {
expect(cache.isLoadInFlight("demo")).toBe(false);
expect(cache.hasOpenAllowlistWarning("demo-warning")).toBe(false);
});
+
+ it("clears cached registries without dropping in-flight load guards", () => {
+ const cache = new PluginLoaderCacheState(2);
+
+ cache.set("demo", "registry");
+ cache.beginLoad("demo");
+ cache.recordOpenAllowlistWarning("demo-warning");
+
+ cache.clearCachedRegistries();
+
+ expect(cache.get("demo")).toBeUndefined();
+ expect(cache.isLoadInFlight("demo")).toBe(true);
+ expect(cache.hasOpenAllowlistWarning("demo-warning")).toBe(false);
+ });
});
diff --git a/src/plugins/loader-cache-state.ts b/src/plugins/loader-cache-state.ts
index 840c0b2f549..16b48eb07a3 100644
--- a/src/plugins/loader-cache-state.ts
+++ b/src/plugins/loader-cache-state.ts
@@ -33,6 +33,11 @@ export class PluginLoaderCacheState {
this.#openAllowlistWarningCache.clear();
}
+ clearCachedRegistries(): void {
+ this.#registryCache.clear();
+ this.#openAllowlistWarningCache.clear();
+ }
+
get(cacheKey: string): T | undefined {
return this.#registryCache.get(cacheKey);
}
diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts
index 846aaf9a1b7..eeff5987471 100644
--- a/src/plugins/loader.runtime-registry.test.ts
+++ b/src/plugins/loader.runtime-registry.test.ts
@@ -1,7 +1,9 @@
import { afterEach, describe, expect, it } from "vitest";
+import { getCompactionProvider, registerCompactionProvider } from "./compaction-provider.js";
import {
__testing,
clearPluginLoaderCache,
+ clearPluginRegistryLoadCache,
loadOpenClawPlugins,
resolveRuntimePluginRegistry,
} from "./loader.js";
@@ -624,3 +626,53 @@ describe("clearPluginLoaderCache", () => {
expect(getMemoryEmbeddingProvider("stale")).toBeUndefined();
});
});
+
+describe("loadOpenClawPlugins active runtime clearing", () => {
+ it("clears plugin-owned global providers before activating a new registry", () => {
+ registerCompactionProvider({
+ id: "stale-compaction",
+ label: "Stale Compaction",
+ summarize: async () => "stale",
+ });
+ registerMemoryEmbeddingProvider({
+ id: "stale-memory",
+ create: async () => ({ provider: null }),
+ });
+
+ loadOpenClawPlugins({ onlyPluginIds: [] });
+
+ expect(getCompactionProvider("stale-compaction")).toBeUndefined();
+ expect(getMemoryEmbeddingProvider("stale-memory")).toBeUndefined();
+ });
+});
+
+describe("clearPluginRegistryLoadCache", () => {
+ it("preserves plugin-owned runtime registries while invalidating load snapshots", () => {
+ registerMemoryEmbeddingProvider({
+ id: "still-live",
+ create: async () => ({ provider: null }),
+ });
+ registerMemoryPromptSection(() => ["still live"]);
+
+ clearPluginRegistryLoadCache();
+
+ expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["still live"]);
+ expect(getMemoryEmbeddingProvider("still-live")).toBeDefined();
+ });
+
+ it("invalidates full-workspace load snapshots", () => {
+ const loadOptions = {
+ config: {
+ plugins: {
+ allow: ["demo"],
+ },
+ },
+ workspaceDir: "/tmp/workspace-a",
+ };
+ const registry = loadOpenClawPlugins(loadOptions);
+
+ clearPluginRegistryLoadCache();
+
+ expect(loadOpenClawPlugins(loadOptions)).not.toBe(registry);
+ });
+});
diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts
index 27ca6a881a7..ab127678da1 100644
--- a/src/plugins/loader.ts
+++ b/src/plugins/loader.ts
@@ -285,6 +285,10 @@ function createPluginCandidatesFromManifestRegistry(
export function clearPluginLoaderCache(): void {
pluginLoaderCacheState.clear();
fullWorkspacePluginLoaderCacheState.clear();
+ clearActivatedPluginRuntimeState();
+}
+
+export function clearActivatedPluginRuntimeState(): void {
clearAgentHarnesses();
clearPluginCommands();
clearCompactionProviders();
@@ -294,6 +298,11 @@ export function clearPluginLoaderCache(): void {
clearMemoryPluginState();
}
+export function clearPluginRegistryLoadCache(): void {
+ pluginLoaderCacheState.clearCachedRegistries();
+ fullWorkspacePluginLoaderCacheState.clearCachedRegistries();
+}
+
const defaultLogger = () => createSubsystemLogger("plugins");
function isPromiseLike(value: unknown): value is PromiseLike {
@@ -1338,11 +1347,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (requestedOnlyPluginIdSet && requestedOnlyPluginIdSet.size === 0) {
const emptyRegistry = createEmptyPluginRegistry();
if (options.activate !== false) {
- clearAgentHarnesses();
- clearPluginCommands();
- clearPluginInteractiveHandlers();
- clearDetachedTaskLifecycleRuntimeRegistration();
- clearMemoryPluginState();
+ clearActivatedPluginRuntimeState();
activatePluginRegistry(
emptyRegistry,
`empty-plugin-scope::${resolveRuntimeSubagentMode(options.runtimeOptions)}::${options.workspaceDir ?? ""}`,
@@ -1415,11 +1420,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
// Clear previously registered plugin state before reloading.
// Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins.
if (shouldActivate) {
- clearAgentHarnesses();
- clearPluginCommands();
- clearPluginInteractiveHandlers();
- clearDetachedTaskLifecycleRuntimeRegistration();
- clearMemoryPluginState();
+ clearActivatedPluginRuntimeState();
}
// Lazy: avoid creating module loaders when all plugins are disabled (common in unit tests).
diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts
index 9b796357f69..a2541fb60c5 100644
--- a/src/plugins/runtime.test.ts
+++ b/src/plugins/runtime.test.ts
@@ -117,10 +117,10 @@ describe("plugin runtime route registry", () => {
it.each([
{
- name: "falls back to the provided registry when the pinned route registry has no routes",
+ name: "keeps an explicitly pinned empty route registry authoritative",
pinnedRegistry: createEmptyPluginRegistry(),
explicitRegistry: createRegistryWithRoute("/demo"),
- expected: "explicit",
+ expected: "pinned",
},
{
name: "prefers the pinned route registry when it already owns routes",
diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts
index 0b2e6201b82..d831a21eaed 100644
--- a/src/plugins/runtime.ts
+++ b/src/plugins/runtime.ts
@@ -204,6 +204,9 @@ export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry):
if (!routeRegistry) {
return fallback;
}
+ if (state.httpRoute.pinned) {
+ return routeRegistry;
+ }
const routeCount = routeRegistry.httpRoutes?.length ?? 0;
const fallbackRouteCount = fallback.httpRoutes?.length ?? 0;
if (routeCount === 0 && fallbackRouteCount > 0) {