From 7725dd67953657889dc5bd614975b9c1b2e07bab Mon Sep 17 00:00:00 2001 From: Zach Knickerbocker Date: Mon, 19 Jan 2026 15:42:07 -0500 Subject: [PATCH 1/2] feat: configurable heartbeat session --- docs/channels/whatsapp.md | 1 + docs/gateway/configuration.md | 120 +++----- docs/gateway/heartbeat.md | 16 +- src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + ...espects-ackmaxchars-heartbeat-acks.test.ts | 15 +- ...tbeat-runner.returns-default-unset.test.ts | 92 +++++- src/infra/heartbeat-runner.ts | 281 +++++------------- 8 files changed, 230 insertions(+), 298 deletions(-) diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 45a4798a2..a496d1654 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -334,6 +334,7 @@ WhatsApp sends audio as **voice notes** (PTT bubble). - `agents.defaults.heartbeat.model` (optional override) - `agents.defaults.heartbeat.target` - `agents.defaults.heartbeat.to` +- `agents.defaults.heartbeat.session` - `agents.list[].heartbeat.*` (per-agent overrides) - `session.*` (scope, idle, store, mainKey) - `web.enabled` (disable channel startup when false) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 00fd9e30f..6cdc39394 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t When validation fails: - The Gateway does not boot. -- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, `clawdbot gateway probe`, `clawdbot help`). +- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`). - Run `clawdbot doctor` to see the exact issues. - Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs. @@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include: - `alias` (optional model shortcut, e.g. `/opus`). - `params` (optional provider-specific API params passed through to the model request). -`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers. +`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`. These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Example: @@ -1569,7 +1569,7 @@ Example: } ``` -#### `agents.defaults.contextPruning` (TTL-aware tool-result pruning) +#### `agents.defaults.contextPruning` (tool-result pruning) `agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM. It does **not** modify the session history on disk (`*.jsonl` remains complete). @@ -1580,9 +1580,11 @@ High level: - Never touches user/assistant messages. - Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned). - Protects the bootstrap prefix (nothing before the first user message is pruned). -- Mode: - - `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`. - When it runs, it uses the same soft-trim + hard-clear behavior as before. +- Modes: + - `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`. + Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and** + there’s enough prunable tool-result bulk (`minPrunableToolChars`). + - `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks). Soft vs hard pruning (what changes in the context sent to the LLM): - **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle. @@ -1596,41 +1598,44 @@ Notes / current limitations: - Tool results containing **image blocks are skipped** (never trimmed/cleared) right now. - The estimated “context ratio” is based on **characters** (approximate), not exact tokens. - If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped. -- `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models). -- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again. -- For best results, match `contextPruning.ttl` to the model `cacheControlTtl` you set in `agents.defaults.models.*.params`. +- In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`). -Default (off, unless Anthropic auth profiles are detected): +Default (adaptive): +```json5 +{ + agents: { defaults: { contextPruning: { mode: "adaptive" } } } +} +``` + +To disable: ```json5 { agents: { defaults: { contextPruning: { mode: "off" } } } } ``` -Enable TTL-aware pruning: +Defaults (when `mode` is `"adaptive"` or `"aggressive"`): +- `keepLastAssistants`: `3` +- `softTrimRatio`: `0.3` (adaptive only) +- `hardClearRatio`: `0.5` (adaptive only) +- `minPrunableToolChars`: `50000` (adaptive only) +- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only) +- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }` + +Example (aggressive, minimal): ```json5 { - agents: { defaults: { contextPruning: { mode: "cache-ttl" } } } + agents: { defaults: { contextPruning: { mode: "aggressive" } } } } ``` -Defaults (when `mode` is `"cache-ttl"`): -- `ttl`: `"5m"` -- `keepLastAssistants`: `3` -- `softTrimRatio`: `0.3` -- `hardClearRatio`: `0.5` -- `minPrunableToolChars`: `50000` -- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` -- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }` - -Example (cache-ttl tuned): +Example (adaptive tuned): ```json5 { agents: { defaults: { contextPruning: { - mode: "cache-ttl", - ttl: "5m", + mode: "adaptive", keepLastAssistants: 3, softTrimRatio: 0.3, hardClearRatio: 0.5, @@ -1737,12 +1742,9 @@ 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`. +- `session`: optional session key to control which session the heartbeat runs in. Default: `main`. - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). +- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `msteams`, `signal`, `imessage`, `none`). Default: `last`. - `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. - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300). @@ -1773,7 +1775,6 @@ Note: `applyPatch` is only under `tools.exec`. - `tools.web.fetch.maxChars` (default 50000) - `tools.web.fetch.timeoutSeconds` (default 30) - `tools.web.fetch.cacheTtlMinutes` (default 15) -- `tools.web.fetch.maxRedirects` (default 3) - `tools.web.fetch.userAgent` (optional override) - `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only) - `tools.web.fetch.firecrawl.enabled` (default true when an API key is set) @@ -1840,7 +1841,7 @@ Example: `agents.defaults.subagents` configures sub-agent defaults: - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call. -- `maxConcurrent`: max concurrent sub-agent runs (default 8) +- `maxConcurrent`: max concurrent sub-agent runs (default 1) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) @@ -1974,7 +1975,7 @@ Notes: `agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can execute in parallel across sessions. Each session is still serialized (one run -per session key at a time). Default: 4. +per session key at a time). Default: 1. ### `agents.defaults.sandbox` @@ -2452,9 +2453,6 @@ Controls session scoping, reset policy, reset triggers, and where the session st dm: { mode: "idle", idleMinutes: 240 }, group: { mode: "idle", idleMinutes: 120 } }, - resetByChannel: { - discord: { mode: "idle", idleMinutes: 10080 } - }, resetTriggers: ["/new", "/reset"], // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json // You can override with {agentId} templating: @@ -2490,7 +2488,7 @@ Fields: - `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins. - `resetByType`: per-session overrides for `dm`, `group`, and `thread`. - If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility. -- `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`). +- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. @@ -2617,13 +2615,10 @@ Defaults: // noSandbox: false, // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // attachOnly: false, // set true when tunneling a remote CDP to localhost - // snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset } } ``` -Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly. - ### `ui` (Appearance) Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint). @@ -2647,13 +2642,6 @@ Defaults: - bind: `loopback` - port: `18789` (single port for WS + HTTP) -Bind modes: -- `loopback`: `127.0.0.1` (local-only) -- `lan`: `0.0.0.0` (all interfaces) -- `tailnet`: Tailscale IPv4 address (100.64.0.0/10) -- `auto`: prefer loopback, fall back to LAN if loopback cannot bind -- `custom`: `gateway.customBindHost` (IPv4), fallback to LAN if unavailable - ```json5 { gateway: { @@ -2684,15 +2672,14 @@ Notes: - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`. -- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. -- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). +- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - The onboarding wizard generates a gateway token by default (even on loopback). - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored. Auth and Tailscale: - `gateway.auth.mode` sets the handshake requirements (`token` or `password`). -- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing). +- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers @@ -2701,9 +2688,6 @@ Auth and Tailscale: `true`, Serve requests do not need a token/password; set `false` to require explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and auth mode is not `password`. -- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes. - These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them - instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`. - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. @@ -2712,7 +2696,6 @@ Remote client defaults (CLI): - `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`. - `gateway.remote.token` supplies the token for remote calls (leave unset for no auth). - `gateway.remote.password` supplies the password for remote calls (leave unset for no auth). -- `gateway.remote.tlsFingerprint` pins the gateway TLS cert fingerprint (sha256). macOS app behavior: - Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes. @@ -2726,36 +2709,12 @@ macOS app behavior: remote: { url: "ws://gateway.tailnet:18789", token: "your-token", - password: "your-password", - tlsFingerprint: "sha256:ab12cd34..." + password: "your-password" } } } ``` -### `gateway.nodes` (Node command allowlist) - -The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both -**declare** a command and have it **allowed** by the Gateway to run it. - -Use this section to extend or deny commands: - -```json5 -{ - gateway: { - nodes: { - allowCommands: ["custom.vendor.command"], // extra commands beyond defaults - denyCommands: ["sms.send"] // block a command even if declared - } - } -} -``` - -Notes: -- `allowCommands` extends the built-in per-platform defaults. -- `denyCommands` always wins (even if the node claims the command). -- `node.invoke` rejects commands that are not declared by the node. - ### `gateway.reload` (Config hot reload) The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. @@ -3003,7 +2962,7 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge ### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD) -When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.` +When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.` To make iOS/Android discover across networks (Vienna ⇄ London), pair this with: - a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended) @@ -3033,9 +2992,6 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m | `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) | | `{{To}}` | Destination identifier | | `{{MessageSid}}` | Channel message id (when available) | -| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened | -| `{{ReplyToId}}` | Reply-to message id (when available) | -| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened | | `{{SessionId}}` | Current session UUID | | `{{IsNewSession}}` | `"true"` when a new session was created | | `{{MediaUrl}}` | Inbound media pseudo-URL (if present) | diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 3c5740da3..cc6c2a7d5 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -127,18 +127,26 @@ Example: two agents, only the second agent runs heartbeats. - `every`: heartbeat interval (duration string; default unit = minutes). - `model`: optional model override for heartbeat runs (`provider/model`). - `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). +- `session`: optional session key for heartbeat runs. + - `main` (default): agent main session. + - Explicit session key (copy from `clawdbot sessions --json` or the [sessions CLI](/cli/sessions)). + - Session key formats: see [Sessions](/concepts/session) and [Groups](/concepts/groups). - `target`: - `last` (default): deliver to the last used external channel. - - explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`. + - explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `msteams` / `signal` / `imessage`. - `none`: run the heartbeat but **do not deliver** externally. -- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram, etc.). +- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). - `prompt`: overrides the default prompt body (not merged). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. ## Delivery behavior -- Heartbeats run in each agent’s **main session** (`agent::`), or `global` - when `session.scope = "global"`. +- Heartbeats run in the agent’s main session by default (`agent::`), + or `global` when `session.scope = "global"`. Set `session` to override to a + specific channel session (Discord/WhatsApp/etc.). +- `session` only affects the run context; delivery is controlled by `target` and `to`. +- To deliver to a specific channel/recipient, set `target` + `to`. With + `target: "last"`, delivery uses the last external channel for that session. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 35d7548ab..11f7cf10d 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -173,6 +173,8 @@ export type AgentDefaultsConfig = { }; /** Heartbeat model override (provider/model). */ model?: string; + /** Session key for heartbeat runs ("main" or explicit session key). */ + session?: string; /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */ target?: | "last" diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index b01fd264b..d34165907 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -20,6 +20,7 @@ export const HeartbeatSchema = z .strict() .optional(), model: z.string().optional(), + session: z.string().optional(), includeReasoning: z.boolean().optional(), target: z .union([ diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index aa961a301..7e7ae0b78 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -41,7 +41,6 @@ describe("resolveHeartbeatIntervalMs", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", ackMaxChars: 0, }, }, @@ -58,6 +57,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -102,7 +102,6 @@ describe("resolveHeartbeatIntervalMs", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", }, }, }, @@ -118,6 +117,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -164,7 +164,6 @@ describe("resolveHeartbeatIntervalMs", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", }, }, }, @@ -180,6 +179,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: originalUpdatedAt, + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -231,7 +231,7 @@ describe("resolveHeartbeatIntervalMs", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + heartbeat: { every: "5m", target: "whatsapp" }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -246,6 +246,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -291,7 +292,7 @@ describe("resolveHeartbeatIntervalMs", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "telegram", to: "123456" }, + heartbeat: { every: "5m", target: "telegram" }, }, }, channels: { telegram: { botToken: "test-bot-token-123" } }, @@ -306,6 +307,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "telegram", lastProvider: "telegram", lastTo: "123456", }, @@ -357,7 +359,7 @@ describe("resolveHeartbeatIntervalMs", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "telegram", to: "123456" }, + heartbeat: { every: "5m", target: "telegram" }, }, }, channels: { @@ -378,6 +380,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "telegram", lastProvider: "telegram", lastTo: "123456", }, diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index c65475d13..fc2027ccf 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -11,6 +11,7 @@ import { resolveMainSessionKey, resolveStorePath, } from "../config/sessions.js"; +import { buildAgentPeerSessionKey } from "../routing/session-key.js"; import { isHeartbeatEnabledForAgent, resolveHeartbeatIntervalMs, @@ -332,7 +333,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + heartbeat: { every: "5m", target: "whatsapp" }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -395,7 +396,7 @@ describe("runHeartbeatOnce", () => { { id: "main", default: true }, { id: "ops", - heartbeat: { every: "5m", target: "whatsapp", to: "+1555", prompt: "Ops check" }, + heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" }, }, ], }, @@ -451,6 +452,86 @@ describe("runHeartbeatOnce", () => { } }); + it("runs heartbeats in the explicit session key when configured", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + const groupId = "120363401234567890@g.us"; + const cfg: ClawdbotConfig = { + agents: { + defaults: { + heartbeat: { + every: "5m", + target: "last", + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const mainSessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(mainSessionKey); + const groupSessionKey = buildAgentPeerSessionKey({ + agentId, + channel: "whatsapp", + peerKind: "group", + peerId: groupId, + }); + cfg.agents?.defaults?.heartbeat && (cfg.agents.defaults.heartbeat.session = groupSessionKey); + + await fs.writeFile( + storePath, + JSON.stringify( + { + [mainSessionKey]: { + sessionId: "sid-main", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + [groupSessionKey]: { + sessionId: "sid-group", + updatedAt: Date.now() + 10_000, + lastChannel: "whatsapp", + lastTo: groupId, + }, + }, + null, + 2, + ), + ); + + replySpy.mockResolvedValue([{ text: "Group alert" }]); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenCalledWith(groupId, "Group alert", expect.any(Object)); + expect(replySpy).toHaveBeenCalledWith( + expect.objectContaining({ SessionKey: groupSessionKey }), + { isHeartbeat: true }, + cfg, + ); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("suppresses duplicate heartbeat payloads within 24h", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); @@ -459,7 +540,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + heartbeat: { every: "5m", target: "whatsapp" }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -517,7 +598,6 @@ describe("runHeartbeatOnce", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", includeReasoning: true, }, }, @@ -534,6 +614,7 @@ describe("runHeartbeatOnce", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -588,7 +669,6 @@ describe("runHeartbeatOnce", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", includeReasoning: true, }, }, @@ -605,6 +685,7 @@ describe("runHeartbeatOnce", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -672,6 +753,7 @@ describe("runHeartbeatOnce", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 92853a1f0..5a66714fd 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,5 +1,4 @@ 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, @@ -16,6 +15,8 @@ import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, + canonicalizeMainSessionAlias, + resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveStorePath, saveSessionStore, @@ -25,9 +26,8 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getQueueSize } from "../process/command-queue.js"; -import { CommandLane } from "../process/lanes.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { type HeartbeatRunResult, @@ -70,94 +70,6 @@ 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(opts: { allow24: boolean }, raw?: string): 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({ allow24: false }, active.start); - const endMin = parseActiveHoursTime({ allow24: true }, active.end); - 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; - heartbeat?: HeartbeatConfig; - intervalMs: number; - lastRunMs?: number; - nextDueMs: number; -}; - -export type HeartbeatRunner = { - stop: () => void; - updateConfig: (cfg: ClawdbotConfig) => void; -}; function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) { const list = cfg.agents?.list ?? []; @@ -286,17 +198,53 @@ function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig, heartbeat?: HeartbeatC ); } -function resolveHeartbeatSession(cfg: ClawdbotConfig, agentId?: string) { +function resolveHeartbeatSession( + cfg: ClawdbotConfig, + agentId?: string, + heartbeat?: HeartbeatConfig, +) { const sessionCfg = cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); - const sessionKey = + const mainSessionKey = scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId }); const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId; const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId }); const store = loadSessionStore(storePath); - const entry = store[sessionKey]; - return { sessionKey, storePath, store, entry }; + const mainEntry = store[mainSessionKey]; + + if (scope === "global") { + return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry }; + } + + const trimmed = heartbeat?.session?.trim() ?? ""; + if (!trimmed) { + return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry }; + } + + const normalized = trimmed.toLowerCase(); + if (normalized === "main" || normalized === "global") { + return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry }; + } + + const candidate = toAgentStoreSessionKey({ + agentId: resolvedAgentId, + requestKey: trimmed, + mainKey: cfg.session?.mainKey, + }); + const canonical = canonicalizeMainSessionAlias({ + cfg, + agentId: resolvedAgentId, + sessionKey: candidate, + }); + if (canonical !== "global") { + const sessionAgentId = resolveAgentIdFromSessionKey(canonical); + if (sessionAgentId === normalizeAgentId(resolvedAgentId)) { + return { sessionKey: canonical, storePath, store, entry: store[canonical] }; + } + } + + return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry }; } function resolveHeartbeatReplyPayload( @@ -417,17 +365,13 @@ 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); + const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main"); if (queueSize > 0) { return { status: "skipped", reason: "requests-in-flight" }; } - const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId); + const startedAt = opts.deps?.nowMs?.() ?? Date.now(); + const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat); const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const lastChannel = delivery.lastChannel; @@ -632,97 +576,24 @@ export function startHeartbeatRunner(opts: { cfg?: ClawdbotConfig; runtime?: RuntimeEnv; abortSignal?: AbortSignal; - runOnce?: typeof runHeartbeatOnce; -}): HeartbeatRunner { +}) { + const cfg = opts.cfg ?? loadConfig(); + const heartbeatAgents = resolveHeartbeatAgents(cfg); + const intervals = heartbeatAgents + .map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat)) + .filter((value): value is number => typeof value === "number"); + const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null; + if (!intervalMs) { + log.info("heartbeat: disabled", { enabled: false }); + } + const runtime = opts.runtime ?? defaultRuntime; - const runOnce = opts.runOnce ?? runHeartbeatOnce; - const state = { - cfg: opts.cfg ?? loadConfig(), - runtime, - agents: new Map(), - timer: null as NodeJS.Timeout | null, - stopped: false, - }; - let initialized = false; - - const resolveNextDue = (now: number, intervalMs: number, prevState?: HeartbeatAgentState) => { - if (typeof prevState?.lastRunMs === "number") { - return prevState.lastRunMs + intervalMs; - } - if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) { - return prevState.nextDueMs; - } - return now + intervalMs; - }; - - const scheduleNext = () => { - if (state.stopped) return; - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } - if (state.agents.size === 0) return; - const now = Date.now(); - let nextDue = Number.POSITIVE_INFINITY; - for (const agent of state.agents.values()) { - if (agent.nextDueMs < nextDue) nextDue = agent.nextDueMs; - } - if (!Number.isFinite(nextDue)) return; - const delay = Math.max(0, nextDue - now); - state.timer = setTimeout(() => { - requestHeartbeatNow({ reason: "interval", coalesceMs: 0 }); - }, delay); - state.timer.unref?.(); - }; - - const updateConfig = (cfg: ClawdbotConfig) => { - if (state.stopped) return; - const now = Date.now(); - const prevAgents = state.agents; - const prevEnabled = prevAgents.size > 0; - const nextAgents = new Map(); - const intervals: number[] = []; - for (const agent of resolveHeartbeatAgents(cfg)) { - const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat); - if (!intervalMs) continue; - intervals.push(intervalMs); - const prevState = prevAgents.get(agent.agentId); - const nextDueMs = resolveNextDue(now, intervalMs, prevState); - nextAgents.set(agent.agentId, { - agentId: agent.agentId, - heartbeat: agent.heartbeat, - intervalMs, - lastRunMs: prevState?.lastRunMs, - nextDueMs, - }); - } - - state.cfg = cfg; - state.agents = nextAgents; - const nextEnabled = nextAgents.size > 0; - if (!initialized) { - if (!nextEnabled) { - log.info("heartbeat: disabled", { enabled: false }); - } else { - log.info("heartbeat: started", { intervalMs: Math.min(...intervals) }); - } - initialized = true; - } else if (prevEnabled !== nextEnabled) { - if (!nextEnabled) { - log.info("heartbeat: disabled", { enabled: false }); - } else { - log.info("heartbeat: started", { intervalMs: Math.min(...intervals) }); - } - } - - scheduleNext(); - }; - + const lastRunByAgent = new Map(); const run: HeartbeatWakeHandler = async (params) => { if (!heartbeatsEnabled) { return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; } - if (state.agents.size === 0) { + if (heartbeatAgents.length === 0) { return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; } @@ -732,44 +603,52 @@ export function startHeartbeatRunner(opts: { const now = startedAt; let ran = false; - for (const agent of state.agents.values()) { - if (isInterval && now < agent.nextDueMs) { + for (const agent of heartbeatAgents) { + const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat); + if (!agentIntervalMs) continue; + const lastRun = lastRunByAgent.get(agent.agentId); + if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) { continue; } - const res = await runOnce({ - cfg: state.cfg, + const res = await runHeartbeatOnce({ + cfg, agentId: agent.agentId, heartbeat: agent.heartbeat, reason, - deps: { runtime: state.runtime }, + deps: { runtime }, }); if (res.status === "skipped" && res.reason === "requests-in-flight") { return res; } if (res.status !== "skipped" || res.reason !== "disabled") { - agent.lastRunMs = now; - agent.nextDueMs = now + agent.intervalMs; + lastRunByAgent.set(agent.agentId, now); } if (res.status === "ran") ran = true; } - scheduleNext(); if (ran) return { status: "ran", durationMs: Date.now() - startedAt }; return { status: "skipped", reason: isInterval ? "not-due" : "disabled" }; }; setHeartbeatWakeHandler(async (params) => run({ reason: params.reason })); - updateConfig(state.cfg); + + let timer: NodeJS.Timeout | null = null; + if (intervalMs) { + timer = setInterval(() => { + requestHeartbeatNow({ reason: "interval", coalesceMs: 0 }); + }, intervalMs); + timer.unref?.(); + log.info("heartbeat: started", { intervalMs }); + } const cleanup = () => { - state.stopped = true; setHeartbeatWakeHandler(null); - if (state.timer) clearTimeout(state.timer); - state.timer = null; + if (timer) clearInterval(timer); + timer = null; }; opts.abortSignal?.addEventListener("abort", cleanup, { once: true }); - return { stop: cleanup, updateConfig }; + return { stop: cleanup }; } From 39073d5196e15045c07d69fa05c31cc270c2394f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 01:08:11 +0000 Subject: [PATCH 2/2] fix: finish model list alias + heartbeat session (#1256) (thanks @zknicker) --- CHANGELOG.md | 1 + ...ists-allowlisted-models-model-list.test.ts | 37 +-- ...l-verbose-during-flight-run-toggle.test.ts | 1 + ...uick-model-picker-grouped-by-model.test.ts | 6 +- src/auto-reply/reply/commands-models.ts | 31 ++- .../directive-handling.model.chat-ux.test.ts | 12 +- .../reply/directive-handling.model.ts | 51 +--- ...tbeat-runner.returns-default-unset.test.ts | 4 +- src/infra/heartbeat-runner.ts | 233 +++++++++++++++--- 9 files changed, 259 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7af418e..8a6ae14d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot ## 2026.1.21 ### Changes +- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker. - CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. - CLI: exec approvals mutations render tables instead of raw JSON. - Exec approvals: support wildcard agent allowlists (`*`) across all agents. diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts index 2b42977cb..66f4ebee1 100644 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts @@ -60,7 +60,7 @@ describe("directive behavior", () => { vi.restoreAllMocks(); }); - it("moves /model list to /models", async () => { + it("aliases /model list to /models", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); @@ -84,13 +84,15 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model listing moved."); - expect(text).toContain("Use: /models (providers) or /models (models)"); + expect(text).toContain("Providers:"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- openai"); + expect(text).toContain("Use: /models "); expect(text).toContain("Switch: /model "); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("shows summary on /model when catalog is unavailable", async () => { + it("shows current model when catalog is unavailable", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); @@ -122,10 +124,10 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("moves /model list to /models even when no allowlist is set", async () => { + it("includes catalog providers when no allowlist is set", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + vi.mocked(loadModelCatalog).mockResolvedValue([ { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "grok-4", name: "Grok 4", provider: "xai" }, @@ -151,13 +153,15 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model listing moved."); - expect(text).toContain("Use: /models (providers) or /models (models)"); - expect(text).toContain("Switch: /model "); + expect(text).toContain("Providers:"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- openai"); + expect(text).toContain("- xai"); + expect(text).toContain("Use: /models "); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("moves /model list to /models even when catalog is present", async () => { + it("lists config-only providers when catalog is present", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); // Catalog present but missing custom providers: /model should still include @@ -173,7 +177,7 @@ describe("directive behavior", () => { const storePath = path.join(home, "sessions.json"); const res = await getReplyFromConfig( - { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, + { Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { agents: { @@ -202,13 +206,12 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model listing moved."); - expect(text).toContain("Use: /models (providers) or /models (models)"); - expect(text).toContain("Switch: /model "); + expect(text).toContain("Model set to minimax"); + expect(text).toContain("minimax/MiniMax-M2.1"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("moves /model list to /models without listing auth labels", async () => { + it("does not repeat missing auth labels on /model list", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); @@ -231,9 +234,7 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model listing moved."); - expect(text).toContain("Use: /models (providers) or /models (models)"); - expect(text).toContain("Switch: /model "); + expect(text).toContain("Providers:"); expect(text).not.toContain("missing (missing)"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts index c3d6a5fdd..74cd2b13e 100644 --- a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts @@ -215,6 +215,7 @@ describe("directive behavior", () => { expect(text).toContain("Switch: /model "); expect(text).toContain("Browse: /models (providers) or /models (models)"); expect(text).toContain("More: /model status"); + expect(text).not.toContain("openai/gpt-4.1-mini"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts index 0de7dedb6..876225295 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts @@ -123,7 +123,7 @@ describe("trigger handling", () => { expect(normalized).not.toContain("image"); }); }); - it("moves /model list to /models", async () => { + it("aliases /model list to /models", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); const res = await getReplyFromConfig( @@ -143,8 +143,8 @@ describe("trigger handling", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; const normalized = normalizeTestText(text ?? ""); - expect(normalized).toContain("Model listing moved."); - expect(normalized).toContain("Use: /models (providers) or /models (models)"); + expect(normalized).toContain("Providers:"); + expect(normalized).toContain("Use: /models "); expect(normalized).toContain("Switch: /model "); }); }); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 652c8d0e8..c7761a8be 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -7,6 +7,7 @@ import { resolveModelRefFromString, } from "../../agents/model-selection.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import type { ClawdbotConfig } from "../../config/config.js"; import type { ReplyPayload } from "../types.js"; import type { CommandHandler } from "./commands-types.js"; @@ -68,10 +69,11 @@ function parseModelsArgs(raw: string): { }; } -export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => { - if (!allowTextCommands) return null; - - const body = params.command.commandBodyNormalized.trim(); +export async function resolveModelsCommandReply(params: { + cfg: ClawdbotConfig; + commandBodyNormalized: string; +}): Promise { + const body = params.commandBodyNormalized.trim(); if (!body.startsWith("/models")) return null; const argText = body.replace(/^\/models\b/i, "").trim(); @@ -164,7 +166,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma "Use: /models ", "Switch: /model ", ]; - return { reply: { text: lines.join("\n") }, shouldContinue: false }; + return { text: lines.join("\n") }; } if (!byProvider.has(provider)) { @@ -176,7 +178,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma "", "Use: /models ", ]; - return { reply: { text: lines.join("\n") }, shouldContinue: false }; + return { text: lines.join("\n") }; } const models = [...(byProvider.get(provider) ?? new Set())].sort(); @@ -189,7 +191,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma "Browse: /models", "Switch: /model ", ]; - return { reply: { text: lines.join("\n") }, shouldContinue: false }; + return { text: lines.join("\n") }; } const effectivePageSize = all ? total : pageSize; @@ -203,7 +205,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma `Try: /models ${provider} ${safePage}`, `All: /models ${provider} all`, ]; - return { reply: { text: lines.join("\n") }, shouldContinue: false }; + return { text: lines.join("\n") }; } const startIndex = (safePage - 1) * effectivePageSize; @@ -226,5 +228,16 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma } const payload: ReplyPayload = { text: lines.join("\n") }; - return { reply: payload, shouldContinue: false }; + return payload; +} + +export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) return null; + + const reply = await resolveModelsCommandReply({ + cfg: params.cfg, + commandBodyNormalized: params.command.commandBodyNormalized, + }); + if (!reply) return null; + return { reply, shouldContinue: false }; }; diff --git a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts index 1e8b2dc7b..c1e2ab7d9 100644 --- a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts +++ b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts @@ -36,7 +36,7 @@ describe("/model chat UX", () => { expect(reply?.text).toContain("Switch: /model "); }); - it("suggests closest match for typos without switching", () => { + it("auto-applies closest match for typos", () => { const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5"); const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; @@ -52,9 +52,11 @@ describe("/model chat UX", () => { provider: "anthropic", }); - expect(resolved.modelSelection).toBeUndefined(); - expect(resolved.errorText).toContain("Did you mean:"); - expect(resolved.errorText).toContain("anthropic/claude-opus-4-5"); - expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5"); + expect(resolved.modelSelection).toEqual({ + provider: "anthropic", + model: "claude-opus-4-5", + isDefault: true, + }); + expect(resolved.errorText).toBeUndefined(); }); }); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index dd2f98914..667eb4b90 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -20,6 +20,7 @@ import { resolveProviderEndpointLabel, } from "./directive-handling.model-picker.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; +import { resolveModelsCommandReply } from "./commands-models.js"; import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js"; function buildModelPickerCatalog(params: { @@ -185,14 +186,11 @@ export async function maybeHandleModelDirectiveInfo(params: { }); if (wantsLegacyList) { - return { - text: [ - "Model listing moved.", - "", - "Use: /models (providers) or /models (models)", - "Switch: /model ", - ].join("\n"), - }; + const reply = await resolveModelsCommandReply({ + cfg: params.cfg, + commandBodyNormalized: "/models", + }); + return reply ?? { text: "No models available." }; } if (wantsSummary) { @@ -340,42 +338,7 @@ export function resolveModelSelectionFromDirective(params: { } if (resolved.selection) { - const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`; - const rawHasSlash = raw.includes("/"); - const shouldAutoSelect = (() => { - if (!rawHasSlash) return true; - const slash = raw.indexOf("/"); - if (slash <= 0) return true; - const rawProvider = normalizeProviderId(raw.slice(0, slash)); - const rawFragment = raw - .slice(slash + 1) - .trim() - .toLowerCase(); - if (!rawFragment) return false; - const resolvedProvider = normalizeProviderId(resolved.selection.provider); - if (rawProvider !== resolvedProvider) return false; - const resolvedModel = resolved.selection.model.toLowerCase(); - return ( - resolvedModel.startsWith(rawFragment) || - resolvedModel.includes(rawFragment) || - rawFragment.startsWith(resolvedModel) - ); - })(); - - if (shouldAutoSelect) { - modelSelection = resolved.selection; - } else { - return { - errorText: [ - `Unrecognized model: ${raw}`, - "", - `Did you mean: ${suggestion}`, - `Try: /model ${suggestion}`, - "", - "Browse: /models or /models ", - ].join("\n"), - }; - } + modelSelection = resolved.selection; } } diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index fc2027ccf..e52c578e7 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -478,7 +478,9 @@ describe("runHeartbeatOnce", () => { peerKind: "group", peerId: groupId, }); - cfg.agents?.defaults?.heartbeat && (cfg.agents.defaults.heartbeat.session = groupSessionKey); + if (cfg.agents?.defaults?.heartbeat) { + cfg.agents.defaults.heartbeat.session = groupSessionKey; + } await fs.writeFile( storePath, diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5a66714fd..c39ac6923 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, @@ -14,8 +15,8 @@ import { parseDurationMs } from "../cli/parse-duration.js"; import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { - loadSessionStore, canonicalizeMainSessionAlias, + loadSessionStore, resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveStorePath, @@ -26,6 +27,7 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getQueueSize } from "../process/command-queue.js"; +import { CommandLane } from "../process/lanes.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; @@ -70,6 +72,94 @@ 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(opts: { allow24: boolean }, raw?: string): 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({ allow24: false }, active.start); + const endMin = parseActiveHoursTime({ allow24: true }, active.end); + 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; + heartbeat?: HeartbeatConfig; + intervalMs: number; + lastRunMs?: number; + nextDueMs: number; +}; + +export type HeartbeatRunner = { + stop: () => void; + updateConfig: (cfg: ClawdbotConfig) => void; +}; function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) { const list = cfg.agents?.list ?? []; @@ -365,12 +455,16 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: "disabled" }; } - const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main"); + 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, heartbeat); const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); @@ -576,24 +670,97 @@ export function startHeartbeatRunner(opts: { cfg?: ClawdbotConfig; runtime?: RuntimeEnv; abortSignal?: AbortSignal; -}) { - const cfg = opts.cfg ?? loadConfig(); - const heartbeatAgents = resolveHeartbeatAgents(cfg); - const intervals = heartbeatAgents - .map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat)) - .filter((value): value is number => typeof value === "number"); - const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null; - if (!intervalMs) { - log.info("heartbeat: disabled", { enabled: false }); - } - + runOnce?: typeof runHeartbeatOnce; +}): HeartbeatRunner { const runtime = opts.runtime ?? defaultRuntime; - const lastRunByAgent = new Map(); + const runOnce = opts.runOnce ?? runHeartbeatOnce; + const state = { + cfg: opts.cfg ?? loadConfig(), + runtime, + agents: new Map(), + timer: null as NodeJS.Timeout | null, + stopped: false, + }; + let initialized = false; + + const resolveNextDue = (now: number, intervalMs: number, prevState?: HeartbeatAgentState) => { + if (typeof prevState?.lastRunMs === "number") { + return prevState.lastRunMs + intervalMs; + } + if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) { + return prevState.nextDueMs; + } + return now + intervalMs; + }; + + const scheduleNext = () => { + if (state.stopped) return; + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + if (state.agents.size === 0) return; + const now = Date.now(); + let nextDue = Number.POSITIVE_INFINITY; + for (const agent of state.agents.values()) { + if (agent.nextDueMs < nextDue) nextDue = agent.nextDueMs; + } + if (!Number.isFinite(nextDue)) return; + const delay = Math.max(0, nextDue - now); + state.timer = setTimeout(() => { + requestHeartbeatNow({ reason: "interval", coalesceMs: 0 }); + }, delay); + state.timer.unref?.(); + }; + + const updateConfig = (cfg: ClawdbotConfig) => { + if (state.stopped) return; + const now = Date.now(); + const prevAgents = state.agents; + const prevEnabled = prevAgents.size > 0; + const nextAgents = new Map(); + const intervals: number[] = []; + for (const agent of resolveHeartbeatAgents(cfg)) { + const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat); + if (!intervalMs) continue; + intervals.push(intervalMs); + const prevState = prevAgents.get(agent.agentId); + const nextDueMs = resolveNextDue(now, intervalMs, prevState); + nextAgents.set(agent.agentId, { + agentId: agent.agentId, + heartbeat: agent.heartbeat, + intervalMs, + lastRunMs: prevState?.lastRunMs, + nextDueMs, + }); + } + + state.cfg = cfg; + state.agents = nextAgents; + const nextEnabled = nextAgents.size > 0; + if (!initialized) { + if (!nextEnabled) { + log.info("heartbeat: disabled", { enabled: false }); + } else { + log.info("heartbeat: started", { intervalMs: Math.min(...intervals) }); + } + initialized = true; + } else if (prevEnabled !== nextEnabled) { + if (!nextEnabled) { + log.info("heartbeat: disabled", { enabled: false }); + } else { + log.info("heartbeat: started", { intervalMs: Math.min(...intervals) }); + } + } + + scheduleNext(); + }; + const run: HeartbeatWakeHandler = async (params) => { if (!heartbeatsEnabled) { return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; } - if (heartbeatAgents.length === 0) { + if (state.agents.size === 0) { return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; } @@ -603,52 +770,44 @@ export function startHeartbeatRunner(opts: { const now = startedAt; let ran = false; - for (const agent of heartbeatAgents) { - const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat); - if (!agentIntervalMs) continue; - const lastRun = lastRunByAgent.get(agent.agentId); - if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) { + for (const agent of state.agents.values()) { + if (isInterval && now < agent.nextDueMs) { continue; } - const res = await runHeartbeatOnce({ - cfg, + const res = await runOnce({ + cfg: state.cfg, agentId: agent.agentId, heartbeat: agent.heartbeat, reason, - deps: { runtime }, + deps: { runtime: state.runtime }, }); if (res.status === "skipped" && res.reason === "requests-in-flight") { return res; } if (res.status !== "skipped" || res.reason !== "disabled") { - lastRunByAgent.set(agent.agentId, now); + agent.lastRunMs = now; + agent.nextDueMs = now + agent.intervalMs; } if (res.status === "ran") ran = true; } + scheduleNext(); if (ran) return { status: "ran", durationMs: Date.now() - startedAt }; return { status: "skipped", reason: isInterval ? "not-due" : "disabled" }; }; setHeartbeatWakeHandler(async (params) => run({ reason: params.reason })); - - let timer: NodeJS.Timeout | null = null; - if (intervalMs) { - timer = setInterval(() => { - requestHeartbeatNow({ reason: "interval", coalesceMs: 0 }); - }, intervalMs); - timer.unref?.(); - log.info("heartbeat: started", { intervalMs }); - } + updateConfig(state.cfg); const cleanup = () => { + state.stopped = true; setHeartbeatWakeHandler(null); - if (timer) clearInterval(timer); - timer = null; + if (state.timer) clearTimeout(state.timer); + state.timer = null; }; opts.abortSignal?.addEventListener("abort", cleanup, { once: true }); - return { stop: cleanup }; + return { stop: cleanup, updateConfig }; }