fix: clean stale plugin channel config

This commit is contained in:
Peter Steinberger
2026-04-27 11:41:37 +01:00
parent fa0f7d1e73
commit edb3e84898
6 changed files with 413 additions and 47 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- Memory-core: re-resolve the active runtime config whenever `memory_search` or `memory_get` executes, so provider changes made by `config.patch` stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010.
- WebChat: keep bare `/new` and `/reset` startup instructions out of visible chat history while preserving `/reset <note>` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg.
- CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode.
- Channels/setup: treat bundled channel plugins as already bundled during `channels add` and onboarding, enabling them without writing redundant `plugins.load.paths` entries or path install records. Fixes #72740. Thanks @iCodePoet.
- WhatsApp: honor gateway `HTTPS_PROXY` / `HTTP_PROXY` env vars for QR-login WebSocket connections, while respecting `NO_PROXY`, so proxied networks no longer fall back to direct `mmg.whatsapp.net` connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar.
- Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding `openclaw.local` probing conflicts and Gateway restart loops on hosts such as `Lobster` or `ubuntu`. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar.

View File

@@ -44,6 +44,7 @@ Notes:
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`; it can also be a path-list such as `/opt/openclaw/plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps`, where earlier roots are read-only lookup layers and the final root is the repair target.
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy.
- Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup.
- Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.<provider>`.
- Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order.

View File

@@ -99,10 +99,18 @@ vi.mock("./channel-plugin-blockers.js", () => ({
vi.mock("./stale-plugin-config.js", () => ({
scanStalePluginConfig: (cfg: {
plugins?: { allow?: string[]; entries?: Record<string, unknown> };
channels?: Record<string, unknown>;
}) => {
const knownIds = new Set(manifestState.plugins.map((plugin) => plugin.id));
const ids = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})];
return [...new Set(ids)].filter((id) => !knownIds.has(id)).map((id) => ({ id }));
const hits = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})]
.filter((id) => !knownIds.has(id))
.map((id) => ({ id, surface: "plugin" }));
if (cfg.channels?.["openclaw-weixin"]) {
hits.push({ id: "openclaw-weixin", surface: "channel" });
}
return hits.filter(
(hit, index) => hits.findIndex((candidate) => candidate.id === hit.id) === index,
);
},
isStalePluginAutoRepairBlocked: () =>
manifestState.diagnostics.some((diagnostic) => diagnostic.level === "error"),
@@ -113,16 +121,19 @@ vi.mock("./stale-plugin-config.js", () => ({
}: {
autoRepairBlocked: boolean;
doctorFixCommand: string;
hits: Array<{ id: string }>;
hits: Array<{ id: string; surface: string }>;
}) =>
hits.map(
(hit) =>
`plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused. ${
autoRepairBlocked
? `Auto-removal is paused; rerun "${doctorFixCommand}".`
: `Run "${doctorFixCommand}".`
}`,
),
hits.map((hit) => {
const prefix =
hit.surface === "channel"
? `channels.${hit.id}: dangling channel config.`
: `plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused.`;
return `${prefix} ${
autoRepairBlocked
? `Auto-removal is paused; rerun "${doctorFixCommand}".`
: `Run "${doctorFixCommand}".`
}`;
}),
}));
vi.mock("./bundled-plugin-load-paths.js", () => ({
@@ -239,6 +250,23 @@ describe("doctor preview warnings", () => {
expect(warnings[0]).not.toContain("Auto-removal is paused");
});
it("includes stale channel config warnings without plugin config", async () => {
const warnings = await collectDoctorPreviewWarnings({
cfg: {
channels: {
"openclaw-weixin": {
enabled: true,
},
},
},
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([
expect.stringContaining("channels.openclaw-weixin: dangling channel config"),
]);
});
it("includes bundled plugin load path migration warnings", async () => {
const packageRoot = path.resolve("app-node-modules", "openclaw");
const legacyPath = path.join(packageRoot, "extensions", "feishu");

View File

@@ -123,7 +123,7 @@ export async function collectDoctorPreviewWarnings(params: {
}
}
if (hasPluginConfig) {
if (hasPluginConfig || hasChannelConfig) {
const {
collectStalePluginConfigWarnings,
isStalePluginAutoRepairBlocked,
@@ -139,7 +139,9 @@ export async function collectDoctorPreviewWarnings(params: {
}).join("\n"),
);
}
}
if (hasPluginConfig) {
const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js");
warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env }));
}

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
import {
@@ -8,6 +9,18 @@ import {
scanStalePluginConfig,
} from "./stale-plugin-config.js";
const installedPluginIndexMocks = vi.hoisted(() => ({
loadInstalledPluginIndexInstallRecordsSync: vi.fn<() => Record<string, PluginInstallRecord>>(
() => ({}),
),
}));
vi.mock("../../../plugins/installed-plugin-index-records.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/installed-plugin-index-records.js")>()),
loadInstalledPluginIndexInstallRecordsSync:
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync,
}));
function manifest(id: string): PluginManifestRecord {
return {
id,
@@ -25,6 +38,8 @@ function manifest(id: string): PluginManifestRecord {
describe("doctor stale plugin config helpers", () => {
beforeEach(() => {
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReset();
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({});
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [manifest("discord"), manifest("voice-call"), manifest("openai")],
diagnostics: [],
@@ -99,6 +114,113 @@ describe("doctor stale plugin config helpers", () => {
]);
});
it("removes stale third-party channel config and dependent channel refs", () => {
const result = maybeRepairStalePluginConfig({
plugins: {
allow: ["discord", "openclaw-weixin"],
entries: {
discord: { enabled: true },
"openclaw-weixin": { enabled: true },
},
},
channels: {
"openclaw-weixin": {
enabled: true,
token: "stale",
},
telegram: {
botToken: "keep",
},
modelByChannel: {
openai: {
"openclaw-weixin": "openai/gpt-5.4",
telegram: "openai/gpt-5.4",
},
},
},
agents: {
defaults: {
heartbeat: {
target: "openclaw-weixin",
every: "30m",
},
},
list: [
{
id: "pi",
heartbeat: {
target: "openclaw-weixin",
},
},
{
id: "ops",
heartbeat: {
target: "telegram",
},
},
],
},
} as OpenClawConfig);
expect(result.changes).toEqual([
"- plugins.allow: removed 1 stale plugin id (openclaw-weixin)",
"- plugins.entries: removed 1 stale plugin entry (openclaw-weixin)",
"- channels: removed 1 stale channel config (openclaw-weixin)",
"- agents heartbeat: removed 2 stale heartbeat targets (openclaw-weixin)",
"- channels.modelByChannel: removed 1 stale channel model override (openclaw-weixin)",
]);
expect(result.config.plugins?.allow).toEqual(["discord"]);
expect(result.config.plugins?.entries).toEqual({
discord: { enabled: true },
});
expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined();
expect(result.config.channels?.telegram).toEqual({ botToken: "keep" });
expect(result.config.channels?.modelByChannel).toEqual({
openai: {
telegram: "openai/gpt-5.4",
},
});
expect(result.config.agents?.defaults?.heartbeat).toEqual({ every: "30m" });
expect(result.config.agents?.list?.[0]?.heartbeat).toEqual({});
expect(result.config.agents?.list?.[1]?.heartbeat).toEqual({ target: "telegram" });
});
it("does not remove unknown channel config without stale plugin evidence", () => {
const cfg = {
channels: {
telegrm: {
botToken: "typo",
},
},
} as OpenClawConfig;
expect(scanStalePluginConfig(cfg)).toEqual([]);
expect(maybeRepairStalePluginConfig(cfg)).toEqual({ config: cfg, changes: [] });
});
it("uses missing persisted install records as stale channel evidence", () => {
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({
"openclaw-weixin": {
source: "npm",
resolvedName: "@tencent-weixin/openclaw-weixin",
installedAt: "2026-04-12T00:00:00.000Z",
},
});
const result = maybeRepairStalePluginConfig({
channels: {
"openclaw-weixin": {
enabled: true,
},
},
} as OpenClawConfig);
expect(result.changes).toEqual([
"- channels: removed 1 stale channel config (openclaw-weixin)",
]);
expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined();
});
it("does not auto-repair stale refs while plugin discovery has errors", () => {
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [],

View File

@@ -1,11 +1,15 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
import { CHANNEL_IDS } from "../../../channels/ids.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { normalizePluginId } from "../../../plugins/config-state.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "../../../plugins/installed-plugin-index-records.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js";
import { sanitizeForLog } from "../../../terminal/ansi.js";
import { asObjectRecord } from "./object.js";
type StalePluginSurface = "allow" | "entries";
const CHANNEL_CONFIG_META_KEYS = new Set(["defaults", "modelByChannel"]);
type StalePluginSurface = "allow" | "entries" | "channel" | "heartbeat" | "modelByChannel";
type StalePluginConfigHit = {
pluginId: string;
@@ -15,6 +19,8 @@ type StalePluginConfigHit = {
type StalePluginRegistryState = {
knownIds: Set<string>;
knownChannelIds: Set<string>;
missingInstalledIds: Set<string>;
hasDiscoveryErrors: boolean;
};
@@ -29,8 +35,37 @@ function collectPluginRegistryState(
env,
includeDisabled: true,
});
const knownIds = new Set(registry.plugins.map((plugin) => plugin.id));
const installedIds = new Set<string>();
for (const pluginId of Object.keys(cfg.plugins?.installs ?? {})) {
const normalized = normalizePluginId(pluginId);
if (normalized) {
installedIds.add(normalized);
}
}
try {
for (const pluginId of Object.keys(loadInstalledPluginIndexInstallRecordsSync({ env }))) {
const normalized = normalizePluginId(pluginId);
if (normalized) {
installedIds.add(normalized);
}
}
} catch {
// Missing/corrupt install-record state must not block normal doctor scans.
}
const knownChannelIds = new Set(CHANNEL_IDS.map((channelId) => normalizePluginId(channelId)));
for (const plugin of registry.plugins) {
for (const channelId of plugin.channels) {
const normalized = normalizePluginId(channelId);
if (normalized) {
knownChannelIds.add(normalized);
}
}
}
return {
knownIds: new Set(registry.plugins.map((plugin) => plugin.id)),
knownIds,
knownChannelIds,
missingInstalledIds: new Set([...installedIds].filter((pluginId) => !knownIds.has(pluginId))),
hasDiscoveryErrors: registry.diagnostics.some((diag) => diag.level === "error"),
};
}
@@ -46,22 +81,19 @@ export function scanStalePluginConfig(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): StalePluginConfigHit[] {
const plugins = asObjectRecord(cfg.plugins);
if (!plugins) {
return [];
}
return scanStalePluginConfigWithState(plugins, collectPluginRegistryState(cfg, env));
return scanStalePluginConfigWithState(cfg, collectPluginRegistryState(cfg, env));
}
function scanStalePluginConfigWithState(
plugins: Record<string, unknown>,
cfg: OpenClawConfig,
registryState: StalePluginRegistryState,
): StalePluginConfigHit[] {
const plugins = asObjectRecord(cfg.plugins);
const { knownIds } = registryState;
const hits: StalePluginConfigHit[] = [];
const staleEvidenceIds = new Set(registryState.missingInstalledIds);
const allow = Array.isArray(plugins.allow) ? plugins.allow : [];
const allow = Array.isArray(plugins?.allow) ? plugins.allow : [];
for (const rawPluginId of allow) {
if (typeof rawPluginId !== "string") {
continue;
@@ -75,26 +107,139 @@ function scanStalePluginConfigWithState(
pathLabel: "plugins.allow",
surface: "allow",
});
staleEvidenceIds.add(pluginId);
}
const entries = asObjectRecord(plugins.entries);
if (!entries) {
return hits;
}
for (const rawPluginId of Object.keys(entries)) {
if (knownIds.has(normalizePluginId(rawPluginId))) {
continue;
const entries = asObjectRecord(plugins?.entries);
if (entries) {
for (const rawPluginId of Object.keys(entries)) {
const pluginId = normalizePluginId(rawPluginId);
if (!pluginId || knownIds.has(pluginId)) {
continue;
}
hits.push({
pluginId: rawPluginId,
pathLabel: `plugins.entries.${rawPluginId}`,
surface: "entries",
});
staleEvidenceIds.add(pluginId);
}
}
const staleChannelIds = collectDanglingChannelIds({
cfg,
registryState,
staleEvidenceIds,
});
for (const channelId of staleChannelIds) {
hits.push({
pluginId: rawPluginId,
pathLabel: `plugins.entries.${rawPluginId}`,
surface: "entries",
pluginId: channelId,
pathLabel: `channels.${channelId}`,
surface: "channel",
});
}
for (const hit of collectDependentChannelConfigHits(cfg, staleChannelIds)) {
hits.push(hit);
}
return hits;
}
function collectDanglingChannelIds(params: {
cfg: OpenClawConfig;
registryState: StalePluginRegistryState;
staleEvidenceIds: ReadonlySet<string>;
}): string[] {
const channels = asObjectRecord(params.cfg.channels);
if (!channels) {
return [];
}
const ids: string[] = [];
const seen = new Set<string>();
for (const channelId of Object.keys(channels)) {
if (CHANNEL_CONFIG_META_KEYS.has(channelId)) {
continue;
}
const normalized = normalizePluginId(channelId);
if (
!normalized ||
params.registryState.knownChannelIds.has(normalized) ||
!params.staleEvidenceIds.has(normalized) ||
seen.has(normalized)
) {
continue;
}
seen.add(normalized);
ids.push(channelId);
}
return ids;
}
function collectDependentChannelConfigHits(
cfg: OpenClawConfig,
channelIds: readonly string[],
): StalePluginConfigHit[] {
if (channelIds.length === 0) {
return [];
}
const staleChannelIds = new Set(channelIds.map((channelId) => normalizePluginId(channelId)));
const hits: StalePluginConfigHit[] = [];
const defaultTarget = cfg.agents?.defaults?.heartbeat?.target;
if (typeof defaultTarget === "string" && staleChannelIds.has(normalizePluginId(defaultTarget))) {
hits.push({
pluginId: defaultTarget,
pathLabel: "agents.defaults.heartbeat.target",
surface: "heartbeat",
});
}
for (const [index, agent] of (cfg.agents?.list ?? []).entries()) {
const target = agent?.heartbeat?.target;
if (typeof target !== "string" || !staleChannelIds.has(normalizePluginId(target))) {
continue;
}
hits.push({
pluginId: target,
pathLabel: `agents.list.${index}.heartbeat.target`,
surface: "heartbeat",
});
}
const modelByChannel = asObjectRecord(cfg.channels?.modelByChannel);
if (modelByChannel) {
for (const [providerId, channelMap] of Object.entries(modelByChannel)) {
const channels = asObjectRecord(channelMap);
if (!channels) {
continue;
}
for (const channelId of Object.keys(channels)) {
if (!staleChannelIds.has(normalizePluginId(channelId))) {
continue;
}
hits.push({
pluginId: channelId,
pathLabel: `channels.modelByChannel.${providerId}.${channelId}`,
surface: "modelByChannel",
});
}
}
}
return hits;
}
function formatStalePluginHitWarning(hit: StalePluginConfigHit): string {
if (hit.surface === "allow" || hit.surface === "entries") {
return `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`;
}
if (hit.surface === "channel") {
return `- ${hit.pathLabel}: dangling channel config for missing plugin "${hit.pluginId}" was found.`;
}
if (hit.surface === "heartbeat") {
return `- ${hit.pathLabel}: heartbeat target references missing channel plugin "${hit.pluginId}".`;
}
return `- ${hit.pathLabel}: model override references missing channel plugin "${hit.pluginId}".`;
}
export function collectStalePluginConfigWarnings(params: {
hits: StalePluginConfigHit[];
doctorFixCommand: string;
@@ -103,16 +248,14 @@ export function collectStalePluginConfigWarnings(params: {
if (params.hits.length === 0) {
return [];
}
const lines = params.hits.map(
(hit) => `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`,
);
const lines = params.hits.map((hit) => formatStalePluginHitWarning(hit));
if (params.autoRepairBlocked) {
lines.push(
`- Auto-removal is paused because plugin discovery currently has errors. Fix plugin discovery first, then rerun "${params.doctorFixCommand}".`,
);
} else {
lines.push(
`- Run "${params.doctorFixCommand}" to remove stale plugins.allow and plugins.entries ids.`,
`- Run "${params.doctorFixCommand}" to remove stale plugin ids and dangling channel references.`,
);
}
return lines.map((line) => sanitizeForLog(line));
@@ -125,29 +268,21 @@ export function maybeRepairStalePluginConfig(
config: OpenClawConfig;
changes: string[];
} {
const plugins = asObjectRecord(cfg.plugins);
if (!plugins) {
return { config: cfg, changes: [] };
}
const registryState = collectPluginRegistryState(cfg, env);
if (registryState.hasDiscoveryErrors) {
return { config: cfg, changes: [] };
}
const hits = scanStalePluginConfigWithState(plugins, registryState);
const hits = scanStalePluginConfigWithState(cfg, registryState);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const nextPlugins = asObjectRecord(next.plugins);
if (!nextPlugins) {
return { config: cfg, changes: [] };
}
const allowIds = hits.filter((hit) => hit.surface === "allow").map((hit) => hit.pluginId);
if (allowIds.length > 0 && Array.isArray(nextPlugins.allow)) {
if (allowIds.length > 0 && Array.isArray(nextPlugins?.allow)) {
const staleAllowIds = new Set(allowIds.map((pluginId) => normalizePluginId(pluginId)));
nextPlugins.allow = nextPlugins.allow.filter(
(pluginId) => typeof pluginId !== "string" || !staleAllowIds.has(normalizePluginId(pluginId)),
@@ -156,7 +291,7 @@ export function maybeRepairStalePluginConfig(
const entryIds = hits.filter((hit) => hit.surface === "entries").map((hit) => hit.pluginId);
if (entryIds.length > 0) {
const entries = asObjectRecord(nextPlugins.entries);
const entries = asObjectRecord(nextPlugins?.entries);
if (entries) {
const staleEntryIds = new Set(entryIds.map((pluginId) => normalizePluginId(pluginId)));
for (const pluginId of Object.keys(entries)) {
@@ -167,6 +302,11 @@ export function maybeRepairStalePluginConfig(
}
}
const channelIds = hits.filter((hit) => hit.surface === "channel").map((hit) => hit.pluginId);
if (channelIds.length > 0) {
removeDanglingChannelReferences(next, channelIds);
}
const changes: string[] = [];
if (allowIds.length > 0) {
changes.push(
@@ -178,6 +318,78 @@ export function maybeRepairStalePluginConfig(
`- plugins.entries: removed ${entryIds.length} stale plugin entr${entryIds.length === 1 ? "y" : "ies"} (${entryIds.join(", ")})`,
);
}
if (channelIds.length > 0) {
changes.push(
`- channels: removed ${channelIds.length} stale channel config${channelIds.length === 1 ? "" : "s"} (${channelIds.join(", ")})`,
);
const heartbeatCount = hits.filter((hit) => hit.surface === "heartbeat").length;
if (heartbeatCount > 0) {
changes.push(
`- agents heartbeat: removed ${heartbeatCount} stale heartbeat target${heartbeatCount === 1 ? "" : "s"} (${channelIds.join(", ")})`,
);
}
const modelByChannelCount = hits.filter((hit) => hit.surface === "modelByChannel").length;
if (modelByChannelCount > 0) {
changes.push(
`- channels.modelByChannel: removed ${modelByChannelCount} stale channel model override${modelByChannelCount === 1 ? "" : "s"} (${channelIds.join(", ")})`,
);
}
}
return { config: next, changes };
}
function removeDanglingChannelReferences(config: OpenClawConfig, channelIds: readonly string[]) {
const staleChannelIds = new Set(channelIds.map((channelId) => normalizePluginId(channelId)));
const channels = asObjectRecord(config.channels);
if (channels) {
for (const channelId of Object.keys(channels)) {
if (CHANNEL_CONFIG_META_KEYS.has(channelId)) {
continue;
}
if (staleChannelIds.has(normalizePluginId(channelId))) {
delete channels[channelId];
}
}
const modelByChannel = asObjectRecord(channels.modelByChannel);
if (modelByChannel) {
for (const [providerId, channelMap] of Object.entries(modelByChannel)) {
const channelsForProvider = asObjectRecord(channelMap);
if (!channelsForProvider) {
continue;
}
for (const channelId of Object.keys(channelsForProvider)) {
if (staleChannelIds.has(normalizePluginId(channelId))) {
delete channelsForProvider[channelId];
}
}
if (Object.keys(channelsForProvider).length === 0) {
delete modelByChannel[providerId];
}
}
if (Object.keys(modelByChannel).length === 0) {
delete channels.modelByChannel;
}
}
}
const defaultsHeartbeat = config.agents?.defaults?.heartbeat;
if (
defaultsHeartbeat &&
typeof defaultsHeartbeat.target === "string" &&
staleChannelIds.has(normalizePluginId(defaultsHeartbeat.target))
) {
delete defaultsHeartbeat.target;
}
for (const agent of config.agents?.list ?? []) {
const heartbeat = agent.heartbeat;
if (
heartbeat &&
typeof heartbeat.target === "string" &&
staleChannelIds.has(normalizePluginId(heartbeat.target))
) {
delete heartbeat.target;
}
}
}