From ef7971e3a4cc635a5950ff035eca6e7ff8b252dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 13:17:02 +0000 Subject: [PATCH] fix: normalize heartbeat targets --- CHANGELOG.md | 1 + docs/gateway/heartbeat.md | 2 +- src/cli/cron-cli/register.cron-add.ts | 5 +- src/cli/cron-cli/register.cron-edit.ts | 3 +- src/config/config.plugin-validation.test.ts | 56 ++++++++++++++++++--- src/config/schema.test.ts | 18 +++++++ src/config/schema.ts | 44 +++++++++++++++- src/config/types.agent-defaults.ts | 15 ++---- src/config/validation.ts | 37 +++++++++++++- src/config/zod-schema.agent-runtime.ts | 14 +----- src/cron/normalize.ts | 3 +- src/discord/monitor/provider.ts | 3 +- src/routing/resolve-route.ts | 4 +- 13 files changed, 163 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca126388..0c9d8979f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.clawd.bot ### Fixes - Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518) +- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints. - Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3) - Sessions: reject array-backed session stores to prevent silent wipes. (#1469) - Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 591c51764..0bccc2b39 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -82,7 +82,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. every: "30m", // default: 30m (0m disables) model: "anthropic/claude-opus-4-5", includeReasoning: false, // default: false (deliver separate Reasoning: message when available) - target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none + target: "last", // last | none | (core or plugin, e.g. "bluebubbles") to: "+15551234567", // optional channel-specific override prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 2fda55e98..74e23a1e7 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import type { CronJob } from "../../cron/types.js"; import { danger } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { parsePositiveIntOrUndefined } from "../program/helpers.js"; @@ -138,7 +139,9 @@ export function registerCronAddCommand(cron: Command) { } const agentId = - typeof opts.agent === "string" && opts.agent.trim() ? opts.agent.trim() : undefined; + typeof opts.agent === "string" && opts.agent.trim() + ? normalizeAgentId(opts.agent.trim()) + : undefined; const payload = (() => { const systemEvent = typeof opts.systemEvent === "string" ? opts.systemEvent.trim() : ""; diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 45eda04da..efb1edead 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { danger } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { getCronChannelOptions, @@ -90,7 +91,7 @@ export function registerCronEditCommand(cron: Command) { throw new Error("Use --agent or --clear-agent, not both"); } if (typeof opts.agent === "string" && opts.agent.trim()) { - patch.agentId = opts.agent.trim(); + patch.agentId = normalizeAgentId(opts.agent.trim()); } if (opts.clearAgent) { patch.agentId = null; diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index e4eba84db..a73c5b46c 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -9,6 +9,7 @@ async function writePluginFixture(params: { dir: string; id: string; schema: Record; + channels?: string[]; }) { await fs.mkdir(params.dir, { recursive: true }); await fs.writeFile( @@ -16,16 +17,16 @@ async function writePluginFixture(params: { `export default { id: "${params.id}", register() {} };`, "utf-8", ); + const manifest: Record = { + id: params.id, + configSchema: params.schema, + }; + if (params.channels) { + manifest.channels = params.channels; + } await fs.writeFile( path.join(params.dir, "clawdbot.plugin.json"), - JSON.stringify( - { - id: params.id, - configSchema: params.schema, - }, - null, - 2, - ), + JSON.stringify(manifest, null, 2), "utf-8", ); } @@ -149,4 +150,43 @@ describe("config plugin validation", () => { expect(res.ok).toBe(true); }); }); + + it("accepts plugin heartbeat targets", async () => { + await withTempHome(async (home) => { + process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); + const pluginDir = path.join(home, "bluebubbles-plugin"); + await writePluginFixture({ + dir: pluginDir, + id: "bluebubbles-plugin", + channels: ["bluebubbles"], + schema: { type: "object" }, + }); + + vi.resetModules(); + const { validateConfigObjectWithPlugins } = await import("./config.js"); + const res = validateConfigObjectWithPlugins({ + agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] }, + plugins: { enabled: false, load: { paths: [pluginDir] } }, + }); + expect(res.ok).toBe(true); + }); + }); + + it("rejects unknown heartbeat targets", async () => { + await withTempHome(async (home) => { + process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); + vi.resetModules(); + const { validateConfigObjectWithPlugins } = await import("./config.js"); + const res = validateConfigObjectWithPlugins({ + agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues).toContainEqual({ + path: "agents.defaults.heartbeat.target", + message: "unknown heartbeat target: not-a-channel", + }); + } + }); + }); }); diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 17b298899..c6525ad82 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -84,4 +84,22 @@ describe("config schema", () => { const channelProps = channelSchema?.properties as Record | undefined; expect(channelProps?.accessToken).toBeTruthy(); }); + + it("adds heartbeat target hints with dynamic channels", () => { + const res = buildConfigSchema({ + channels: [ + { + id: "bluebubbles", + label: "BlueBubbles", + configSchema: { type: "object" }, + }, + ], + }); + + const defaultsHint = res.uiHints["agents.defaults.heartbeat.target"]; + const listHint = res.uiHints["agents.list.*.heartbeat.target"]; + expect(defaultsHint?.help).toContain("bluebubbles"); + expect(defaultsHint?.help).toContain("last"); + expect(listHint?.help).toContain("bluebubbles"); + }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index f9601962f..d7ad28b5c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,3 +1,4 @@ +import { CHANNEL_IDS } from "../channels/registry.js"; import { VERSION } from "../version.js"; import { ClawdbotSchema } from "./zod-schema.js"; @@ -807,6 +808,44 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]): return next; } +function listHeartbeatTargetChannels(channels: ChannelUiMetadata[]): string[] { + const seen = new Set(); + const ordered: string[] = []; + for (const id of CHANNEL_IDS) { + const normalized = id.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + ordered.push(normalized); + } + for (const channel of channels) { + const normalized = channel.id.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + ordered.push(normalized); + } + return ordered; +} + +function applyHeartbeatTargetHints( + hints: ConfigUiHints, + channels: ChannelUiMetadata[], +): ConfigUiHints { + const next: ConfigUiHints = { ...hints }; + const channelList = listHeartbeatTargetChannels(channels); + const channelHelp = channelList.length ? ` Known channels: ${channelList.join(", ")}.` : ""; + const help = `Delivery target ("last", "none", or a channel id).${channelHelp}`; + const paths = ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"]; + for (const path of paths) { + const current = next[path] ?? {}; + next[path] = { + ...current, + help: current.help ?? help, + placeholder: current.placeholder ?? "last", + }; + } + return next; +} + function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema { const next = cloneSchema(schema); const root = asSchemaObject(next); @@ -908,7 +947,10 @@ export function buildConfigSchema(params?: { const channels = params?.channels ?? []; if (plugins.length === 0 && channels.length === 0) return base; const mergedHints = applySensitiveHints( - applyChannelHints(applyPluginHints(base.uiHints, plugins), channels), + applyHeartbeatTargetHints( + applyChannelHints(applyPluginHints(base.uiHints, plugins), channels), + channels, + ), ); const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels); return { diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 491cc1003..2a42d3623 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -4,6 +4,7 @@ import type { HumanDelayConfig, TypingMode, } from "./types.base.js"; +import type { ChannelId } from "../channels/plugins/types.js"; import type { SandboxBrowserSettings, SandboxDockerSettings, @@ -177,18 +178,8 @@ export type AgentDefaultsConfig = { model?: string; /** Session key for heartbeat runs ("main" or explicit session key). */ session?: string; - /** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */ - target?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "mattermost" - | "msteams" - | "signal" - | "imessage" - | "none"; + /** Delivery target ("last", "none", or a channel id). */ + target?: "last" | "none" | ChannelId; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; /** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */ diff --git a/src/config/validation.ts b/src/config/validation.ts index 6ee848f1f..3638b4b09 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { CHANNEL_IDS } from "../channels/registry.js"; +import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js"; import { normalizePluginsConfig, resolveEnableState, @@ -226,6 +226,41 @@ export function validateConfigObjectWithPlugins(raw: unknown): } } + const heartbeatChannelIds = new Set(); + for (const channelId of CHANNEL_IDS) { + heartbeatChannelIds.add(channelId.toLowerCase()); + } + for (const record of registry.plugins) { + for (const channelId of record.channels) { + const trimmed = channelId.trim(); + if (trimmed) heartbeatChannelIds.add(trimmed.toLowerCase()); + } + } + + const validateHeartbeatTarget = (target: string | undefined, path: string) => { + if (typeof target !== "string") return; + const trimmed = target.trim(); + if (!trimmed) { + issues.push({ path, message: "heartbeat target must not be empty" }); + return; + } + const normalized = trimmed.toLowerCase(); + if (normalized === "last" || normalized === "none") return; + if (normalizeChatChannelId(trimmed)) return; + if (heartbeatChannelIds.has(normalized)) return; + issues.push({ path, message: `unknown heartbeat target: ${target}` }); + }; + + validateHeartbeatTarget( + config.agents?.defaults?.heartbeat?.target, + "agents.defaults.heartbeat.target", + ); + if (Array.isArray(config.agents?.list)) { + for (const [index, entry] of config.agents.list.entries()) { + validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`); + } + } + let selectedMemoryPluginId: string | null = null; const seenPlugins = new Set(); for (const record of registry.plugins) { diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index d34165907..5f82cff77 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -22,19 +22,7 @@ export const HeartbeatSchema = z model: z.string().optional(), session: z.string().optional(), includeReasoning: z.boolean().optional(), - target: z - .union([ - z.literal("last"), - z.literal("whatsapp"), - z.literal("telegram"), - z.literal("discord"), - z.literal("slack"), - z.literal("msteams"), - z.literal("signal"), - z.literal("imessage"), - z.literal("none"), - ]) - .optional(), + target: z.string().optional(), to: z.string().optional(), prompt: z.string().optional(), ackMaxChars: z.number().int().nonnegative().optional(), diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 3e5515ffe..25304edb4 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -1,3 +1,4 @@ +import { normalizeAgentId } from "../routing/session-key.js"; import { parseAbsoluteTimeMs } from "./parse.js"; import { migrateLegacyCronPayload } from "./payload-migration.js"; import type { CronJobCreate, CronJobPatch } from "./types.js"; @@ -75,7 +76,7 @@ export function normalizeCronJobInput( next.agentId = null; } else if (typeof agentId === "string") { const trimmed = agentId.trim(); - if (trimmed) next.agentId = trimmed; + if (trimmed) next.agentId = normalizeAgentId(trimmed); else delete next.agentId; } } diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 2c8fbd9c9..2ee33aaea 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -96,7 +96,8 @@ function formatDiscordDeployErrorDetails(err: unknown): string { try { bodyText = JSON.stringify(rawBody); } catch { - bodyText = inspect(rawBody, { depth: 3 }); + bodyText = + typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 }); } if (bodyText) { const maxLen = 800; diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 744cac1af..cc22ce51a 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -96,9 +96,9 @@ function pickFirstExistingAgentId(cfg: ClawdbotConfig, agentId: string): string if (!trimmed) return normalizeAgentId(resolveDefaultAgentId(cfg)); const normalized = normalizeAgentId(trimmed); const agents = listAgents(cfg); - if (agents.length === 0) return trimmed; + if (agents.length === 0) return normalized; const match = agents.find((agent) => normalizeAgentId(agent.id) === normalized); - if (match?.id?.trim()) return match.id.trim(); + if (match?.id?.trim()) return normalizeAgentId(match.id.trim()); return normalizeAgentId(resolveDefaultAgentId(cfg)); }