diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index dafbc5830c..51d3c46fbd 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1737,6 +1737,10 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require `30m`. Set `0m` to disable. - `model`: optional override model for heartbeat runs (`provider/model`). - `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`. +- `activeHours`: optional local-time window that controls when heartbeats run. + - `start`: start time (HH:MM, 24h). Inclusive. + - `end`: end time (HH:MM, 24h). Exclusive. Use `"24:00"` for end-of-day. + - `timezone`: `"user"` (default), `"local"`, or an IANA timezone id. - `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`. - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). - `prompt`: optional override for the heartbeat 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.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 3b882f5923..d1099dcf5f 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -14,6 +14,7 @@ surface anything that needs attention without spamming you. 2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended). 3. Decide where heartbeat messages should go (`target: "last"` is the default). 4. Optional: enable heartbeat reasoning delivery for transparency. +5. Optional: restrict heartbeats to active hours (local time). Example config: @@ -24,6 +25,7 @@ Example config: heartbeat: { every: "30m", target: "last", + // activeHours: { start: "08:00", end: "24:00", timezone: "user" }, // includeReasoning: true, // optional: send separate `Reasoning:` message too } } @@ -38,6 +40,8 @@ Example config: `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.` - The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a “Heartbeat” section and the run is flagged internally. +- Active hours (`heartbeat.activeHours`) are checked in the configured timezone. + Outside the window, heartbeats are skipped until the next tick inside the window. ## What the heartbeat prompt is for diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 0fcdcf49b1..35d7548abb 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -162,6 +162,15 @@ export type AgentDefaultsConfig = { heartbeat?: { /** Heartbeat interval (duration string, default unit: minutes; default: 30m). */ every?: string; + /** Optional active-hours window (local time); heartbeats run only inside this window. */ + activeHours?: { + /** Start time (24h, HH:MM). Inclusive. */ + start?: string; + /** End time (24h, HH:MM). Exclusive. Use "24:00" for end-of-day. */ + end?: string; + /** Timezone for the window ("user", "local", or IANA TZ id). Default: "user". */ + timezone?: string; + }; /** Heartbeat model override (provider/model). */ model?: string; /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 40dea6eb41..2934755065 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -11,6 +11,14 @@ import { export const HeartbeatSchema = z .object({ every: z.string().optional(), + activeHours: z + .object({ + start: z.string().optional(), + end: z.string().optional(), + timezone: z.string().optional(), + }) + .strict() + .optional(), model: z.string().optional(), includeReasoning: z.boolean().optional(), target: z @@ -42,6 +50,42 @@ export const HeartbeatSchema = z message: "invalid duration (use ms, s, m, h)", }); } + + const active = val.activeHours; + if (!active) return; + const timePattern = /^([01]\d|2[0-3]|24):([0-5]\d)$/; + const validateTime = (raw: string | undefined, opts: { allow24: boolean }, path: string) => { + if (!raw) return; + if (!timePattern.test(raw)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["activeHours", path], + message: 'invalid time (use "HH:MM" 24h format)', + }); + return; + } + const [hourStr, minuteStr] = raw.split(":"); + const hour = Number(hourStr); + const minute = Number(minuteStr); + if (hour === 24 && minute !== 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["activeHours", path], + message: "invalid time (24:00 is the only allowed 24:xx value)", + }); + return; + } + if (hour === 24 && !opts.allow24) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["activeHours", path], + message: "invalid time (start cannot be 24:00)", + }); + } + }; + + validateTime(active.start, { allow24: false }, "start"); + validateTime(active.end, { allow24: true }, "end"); }) .optional(); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 5ba1d8db6b..c65475d138 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -300,6 +300,30 @@ describe("runHeartbeatOnce", () => { } }); + it("skips outside active hours", async () => { + const cfg: ClawdbotConfig = { + agents: { + defaults: { + userTimezone: "UTC", + heartbeat: { + every: "30m", + activeHours: { start: "08:00", end: "24:00", timezone: "user" }, + }, + }, + }, + }; + + const res = await runHeartbeatOnce({ + cfg, + deps: { nowMs: () => Date.UTC(2025, 0, 1, 7, 0, 0) }, + }); + + expect(res.status).toBe("skipped"); + if (res.status === "skipped") { + expect(res.reason).toBe("quiet-hours"); + } + }); + it("uses the last non-empty payload for delivery", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 820cbdf5ca..012fff229b 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,4 +1,5 @@ import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveUserTimezone } from "../agents/date-time.js"; import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -69,6 +70,81 @@ export type HeartbeatSummary = { }; const DEFAULT_HEARTBEAT_TARGET = "last"; +const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/; + +function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string { + const trimmed = raw?.trim(); + if (!trimmed || trimmed === "user") { + return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); + } + if (trimmed === "local") { + const host = Intl.DateTimeFormat().resolvedOptions().timeZone; + return host?.trim() || "UTC"; + } + try { + new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date()); + return trimmed; + } catch { + return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); + } +} + +function parseActiveHoursTime(raw?: string, opts: { allow24: boolean }): number | null { + if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null; + const [hourStr, minuteStr] = raw.split(":"); + const hour = Number(hourStr); + const minute = Number(minuteStr); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; + if (hour === 24) { + if (!opts.allow24 || minute !== 0) return null; + return 24 * 60; + } + return hour * 60 + minute; +} + +function resolveMinutesInTimeZone(nowMs: number, timeZone: string): number | null { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(nowMs)); + const map: Record = {}; + for (const part of parts) { + if (part.type !== "literal") map[part.type] = part.value; + } + const hour = Number(map.hour); + const minute = Number(map.minute); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; + return hour * 60 + minute; + } catch { + return null; + } +} + +function isWithinActiveHours( + cfg: ClawdbotConfig, + heartbeat?: HeartbeatConfig, + nowMs?: number, +): boolean { + const active = heartbeat?.activeHours; + if (!active) return true; + + const startMin = parseActiveHoursTime(active.start, { allow24: false }); + const endMin = parseActiveHoursTime(active.end, { allow24: true }); + if (startMin === null || endMin === null) return true; + if (startMin === endMin) return true; + + const timeZone = resolveActiveHoursTimezone(cfg, active.timezone); + const currentMin = resolveMinutesInTimeZone(nowMs ?? Date.now(), timeZone); + if (currentMin === null) return true; + + if (endMin > startMin) { + return currentMin >= startMin && currentMin < endMin; + } + return currentMin >= startMin || currentMin < endMin; +} type HeartbeatAgentState = { agentId: string; @@ -341,12 +417,16 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: "disabled" }; } + const startedAt = opts.deps?.nowMs?.() ?? Date.now(); + if (!isWithinActiveHours(cfg, heartbeat, startedAt)) { + return { status: "skipped", reason: "quiet-hours" }; + } + const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(CommandLane.Main); if (queueSize > 0) { return { status: "skipped", reason: "requests-in-flight" }; } - const startedAt = opts.deps?.nowMs?.() ?? Date.now(); const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId); const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });