Compare commits

..
Author SHA1 Message Date
Peter Steinberger c01bfcbc12 fix: load CLI plugin registry for channel-aware commands (#1338) (thanks @MaudeBot) 2026-01-21 00:48:36 +00:00
Maude Bot 47110e88c7 fix(cli): load plugin registry for message/channels commands
Fixes #1327 - 'clawdbot message --channel telegram' fails with
'Unknown channel: telegram' because plugins weren't loaded.

The Commander code path (non-route-first) calls ensureConfigReady() in
preAction but doesn't load the plugin registry. Channel plugins like
telegram are registered during plugin loading, so getChannelPlugin()
returns undefined without it.

This adds ensurePluginRegistryLoaded() call for commands that need
channel plugin access: message, channels, directory.
2026-01-20 23:59:43 +00:00
67 changed files with 726 additions and 1753 deletions

No files matched your search

+1 -6
View File
@@ -18,25 +18,20 @@ Docs: https://docs.clawd.bot
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete. - Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh. - Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled. - Security: warn when <=300B models run without sandboxing and with web tools enabled.
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
### Fixes ### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco. - Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. - Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry. - Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk. - Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner. - Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. - Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot. - UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl. - TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) - Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander. - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) - Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
@@ -473,7 +473,6 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -495,7 +494,6 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -516,7 +514,6 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -538,7 +535,6 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -839,47 +835,35 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String?, agentid: String?
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -473,7 +473,6 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -495,7 +494,6 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -516,7 +514,6 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -538,7 +535,6 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -839,47 +835,35 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String?, agentid: String?
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -1340,9 +1324,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int public let ts: Int
public let channelorder: [String] public let channelorder: [String]
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable] public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable] public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable]
@@ -1351,9 +1332,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int, ts: Int,
channelorder: [String], channelorder: [String],
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable], channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable], channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable] channeldefaultaccountid: [String: AnyCodable]
@@ -1361,9 +1339,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts self.ts = ts
self.channelorder = channelorder self.channelorder = channelorder
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1372,9 +1347,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts case ts
case channelorder = "channelOrder" case channelorder = "channelOrder"
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"
-5
View File
@@ -286,11 +286,6 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- CLI: `clawdbot message send --media <mp4> --gif-playback` - CLI: `clawdbot message send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true` - Gateway: `send` params include `gifPlayback: true`
## Voice notes (PTT audio)
WhatsApp sends audio as **voice notes** (PTT bubble).
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
## Media limits + optimization ## Media limits + optimization
- Default outbound cap: 5 MB (per media item). - Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`. - Override: `agents.defaults.mediaMaxMb`.
-13
View File
@@ -14,16 +14,3 @@ Related:
Tip: run `clawdbot cron --help` for the full command surface. Tip: run `clawdbot cron --help` for the full command surface.
## Common edits
Update delivery settings without changing the message:
```bash
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
```
Disable delivery for an isolated job:
```bash
clawdbot cron edit <job-id> --no-deliver
```
-15
View File
@@ -77,21 +77,6 @@ Client Gateway
safely retry; the server keeps a short‑lived dedupe cache. safely retry; the server keeps a short‑lived dedupe cache.
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`. - Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
## Pairing + local trust
- All WS clients (operators + nodes) include a **device identity** on `connect`.
- New device IDs require pairing approval; the Gateway issues a **device token**
for subsequent connects.
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
auto‑approved to keep same‑host UX smooth.
- **Non‑local** connects must sign the `connect.challenge` nonce and require
explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
[Security](/gateway/security).
## Protocol typing and codegen ## Protocol typing and codegen
- TypeBox schemas define the protocol. - TypeBox schemas define the protocol.
-2
View File
@@ -195,8 +195,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role. - Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval - Pairing approvals are required for new device IDs unless local auto-approval
is enabled. is enabled.
- **Local** connects include loopback and the gateway host’s own tailnet address
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node). - All WS clients must include `device` identity during `connect` (operator + node).
- Non-local connections must sign the server-provided `connect.challenge` nonce. - Non-local connections must sign the server-provided `connect.challenge` nonce.
-6
View File
@@ -270,12 +270,6 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access. protect local WS access.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Local device pairing:
- Device pairing is auto‑approved for **local** connects (loopback or the
gateway host’s own tailnet address) to keep same‑host clients smooth.
- Other tailnet peers are **not** treated as local; they still need pairing
approval.
Auth modes: Auth modes:
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups). - `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`). - `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
-51
View File
@@ -1,51 +0,0 @@
---
summary: "Network hub: gateway surfaces, pairing, discovery, and security"
read_when:
- You need the network architecture + security overview
- You are debugging local vs tailnet access or pairing
- You want the canonical list of networking docs
---
# Network hub
This hub links the core docs for how Clawdbot connects, pairs, and secures
devices across localhost, LAN, and tailnet.
## Core model
- [Gateway architecture](/concepts/architecture)
- [Gateway protocol](/gateway/protocol)
- [Gateway runbook](/gateway)
- [Web surfaces + bind modes](/web)
## Pairing + identity
- [Pairing overview (DM + nodes)](/start/pairing)
- [Gateway-owned node pairing](/gateway/pairing)
- [Devices CLI (pairing + token rotation)](/cli/devices)
- [Pairing CLI (DM approvals)](/cli/pairing)
Local trust:
- Local connections (loopback or the gateway host’s own tailnet address) can be
auto‑approved for pairing to keep same‑host UX smooth.
- Non‑local tailnet/LAN clients still require explicit pairing approval.
## Discovery + transports
- [Discovery & transports](/gateway/discovery)
- [Bonjour / mDNS](/gateway/bonjour)
- [Remote access (SSH)](/gateway/remote)
- [Tailscale](/gateway/tailscale)
## Nodes + bridge
- [Nodes overview](/nodes)
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
- [Node runbook: iOS](/platforms/ios)
- [Node runbook: Android](/platforms/android)
## Security
- [Security overview](/gateway/security)
- [Gateway config reference](/gateway/configuration)
- [Troubleshooting](/gateway/troubleshooting)
- [Doctor](/gateway/doctor)
-44
View File
@@ -49,50 +49,6 @@ Repair/migrate:
clawdbot doctor clawdbot doctor
``` ```
## Advanced: expose WSL services over LAN (portproxy)
WSL has its own virtual network. If another machine needs to reach a service
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
so you may need to refresh the forwarding rule.
Example (PowerShell **as Administrator**):
```powershell
$Distro = "Ubuntu-24.04"
$ListenPort = 2222
$TargetPort = 22
$WslIp = (wsl -d $Distro -- hostname -I).Trim().Split(" ")[0]
if (-not $WslIp) { throw "WSL IP not found." }
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
connectaddress=$WslIp connectport=$TargetPort
```
Allow the port through Windows Firewall (one-time):
```powershell
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
-Protocol TCP -LocalPort $ListenPort -Action Allow
```
Refresh the portproxy after WSL restarts:
```powershell
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
connectaddress=$WslIp connectport=$TargetPort | Out-Null
```
Notes:
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
`clawdbot status --all` to confirm.
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
- If you want this automatic, register a Scheduled Task to run the refresh
step at login.
## Step-by-step WSL2 install ## Step-by-step WSL2 install
### 1) Install WSL2 + Ubuntu ### 1) Install WSL2 + Ubuntu
-1
View File
@@ -32,7 +32,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Core concepts ## Core concepts
- [Architecture](/concepts/architecture) - [Architecture](/concepts/architecture)
- [Network hub](/network)
- [Agent runtime](/concepts/agent) - [Agent runtime](/concepts/agent)
- [Agent workspace](/concepts/agent-workspace) - [Agent workspace](/concepts/agent-workspace)
- [Memory](/concepts/memory) - [Memory](/concepts/memory)
+1 -4
View File
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
- `requires.env` — list; env var must exist **or** be provided in config. - `requires.env` — list; env var must exist **or** be provided in config.
- `requires.config` — list of `clawdbot.json` paths that must be truthy. - `requires.config` — list of `clawdbot.json` paths that must be truthy.
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`. - `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download). - `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
Note on sandboxing: Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time. - `requires.bins` is checked on the **host** at skill load time.
@@ -134,13 +134,10 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
Notes: Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). - If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts.
- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform.
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun). - Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
This only affects **skill installs**; the Gateway runtime should still be Node This only affects **skill installs**; the Gateway runtime should still be Node
(Bun is not recommended for WhatsApp/Telegram). (Bun is not recommended for WhatsApp/Telegram).
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible. - Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
If no `metadata.clawdbot` is present, the skill is always eligible (unless If no `metadata.clawdbot` is present, the skill is always eligible (unless
disabled in config or blocked by `skills.allowBundled` for bundled skills). disabled in config or blocked by `skills.allowBundled` for bundled skills).
-49
View File
@@ -1,49 +0,0 @@
---
name: sherpa-onnx-tts
description: Local text-to-speech via sherpa-onnx (offline, no cloud)
metadata: {"clawdbot":{"emoji":"🗣️","os":["darwin","linux","win32"],"requires":{"env":["SHERPA_ONNX_RUNTIME_DIR","SHERPA_ONNX_MODEL_DIR"]},"install":[{"id":"download-runtime-macos","kind":"download","os":["darwin"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (macOS)"},{"id":"download-runtime-linux-x64","kind":"download","os":["linux"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Linux x64)"},{"id":"download-runtime-win-x64","kind":"download","os":["win32"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Windows x64)"},{"id":"download-model-lessac","kind":"download","url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2","archive":"tar.bz2","extract":true,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/models","label":"Download Piper en_US lessac (high)"}]}}
---
# sherpa-onnx-tts
Local TTS using the sherpa-onnx offline CLI.
## Install
1) Download the runtime for your OS (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/runtime`)
2) Download a voice model (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/models`)
Update `~/.clawdbot/clawdbot.json`:
```json5
{
skills: {
entries: {
"sherpa-onnx-tts": {
env: {
SHERPA_ONNX_RUNTIME_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/runtime",
SHERPA_ONNX_MODEL_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/models/vits-piper-en_US-lessac-high"
}
}
}
}
}
```
The wrapper lives in this skill folder. Run it directly, or add the wrapper to PATH:
```bash
export PATH="{baseDir}/bin:$PATH"
```
## Usage
```bash
{baseDir}/bin/sherpa-onnx-tts -o ./tts.wav "Hello from local TTS."
```
Notes:
- Pick a different model from the sherpa-onnx `tts-models` release if you want another voice.
- If the model dir has multiple `.onnx` files, set `SHERPA_ONNX_MODEL_FILE` or pass `--model-file`.
- You can also pass `--tokens-file` or `--data-dir` to override the defaults.
- Windows: run `node {baseDir}\\bin\\sherpa-onnx-tts -o tts.wav "Hello from local TTS."`
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
function usage(message) {
if (message) {
console.error(message);
}
console.error(
"\nUsage: sherpa-onnx-tts [--runtime-dir <dir>] [--model-dir <dir>] [--model-file <file>] [--tokens-file <file>] [--data-dir <dir>] [--output <file>] \"text\"",
);
console.error("\nRequired env (or flags):\n SHERPA_ONNX_RUNTIME_DIR\n SHERPA_ONNX_MODEL_DIR");
process.exit(1);
}
function resolveRuntimeDir(explicit) {
const value = explicit || process.env.SHERPA_ONNX_RUNTIME_DIR || "";
return value.trim();
}
function resolveModelDir(explicit) {
const value = explicit || process.env.SHERPA_ONNX_MODEL_DIR || "";
return value.trim();
}
function resolveModelFile(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_MODEL_FILE || "").trim();
if (explicit) return explicit;
try {
const candidates = fs
.readdirSync(modelDir)
.filter((entry) => entry.endsWith(".onnx"))
.map((entry) => path.join(modelDir, entry));
if (candidates.length === 1) return candidates[0];
} catch {
return "";
}
return "";
}
function resolveTokensFile(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_TOKENS_FILE || "").trim();
if (explicit) return explicit;
const candidate = path.join(modelDir, "tokens.txt");
return fs.existsSync(candidate) ? candidate : "";
}
function resolveDataDir(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_DATA_DIR || "").trim();
if (explicit) return explicit;
const candidate = path.join(modelDir, "espeak-ng-data");
return fs.existsSync(candidate) ? candidate : "";
}
function resolveBinary(runtimeDir) {
const binName = process.platform === "win32" ? "sherpa-onnx-offline-tts.exe" : "sherpa-onnx-offline-tts";
return path.join(runtimeDir, "bin", binName);
}
function prependEnvPath(current, next) {
if (!next) return current;
if (!current) return next;
return `${next}${path.delimiter}${current}`;
}
const args = process.argv.slice(2);
let runtimeDir = "";
let modelDir = "";
let modelFile = "";
let tokensFile = "";
let dataDir = "";
let output = "tts.wav";
const textParts = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--runtime-dir") {
runtimeDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--model-dir") {
modelDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--model-file") {
modelFile = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--tokens-file") {
tokensFile = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--data-dir") {
dataDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "-o" || arg === "--output") {
output = args[i + 1] || output;
i += 1;
continue;
}
if (arg === "--text") {
textParts.push(args[i + 1] || "");
i += 1;
continue;
}
textParts.push(arg);
}
runtimeDir = resolveRuntimeDir(runtimeDir);
modelDir = resolveModelDir(modelDir);
if (!runtimeDir || !modelDir) {
usage("Missing runtime/model directory.");
}
modelFile = resolveModelFile(modelDir, modelFile);
tokensFile = resolveTokensFile(modelDir, tokensFile);
dataDir = resolveDataDir(modelDir, dataDir);
if (!modelFile || !tokensFile || !dataDir) {
usage(
"Model directory is missing required files. Set SHERPA_ONNX_MODEL_FILE, SHERPA_ONNX_TOKENS_FILE, SHERPA_ONNX_DATA_DIR or pass --model-file/--tokens-file/--data-dir.",
);
}
const text = textParts.join(" ").trim();
if (!text) {
usage("Missing text.");
}
const bin = resolveBinary(runtimeDir);
if (!fs.existsSync(bin)) {
usage(`TTS binary not found: ${bin}`);
}
const env = { ...process.env };
const libDir = path.join(runtimeDir, "lib");
if (process.platform === "darwin") {
env.DYLD_LIBRARY_PATH = prependEnvPath(env.DYLD_LIBRARY_PATH || "", libDir);
} else if (process.platform === "win32") {
env.PATH = prependEnvPath(env.PATH || "", [path.join(runtimeDir, "bin"), libDir].join(path.delimiter));
} else {
env.LD_LIBRARY_PATH = prependEnvPath(env.LD_LIBRARY_PATH || "", libDir);
}
const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const child = spawnSync(
bin,
[
`--vits-model=${modelFile}`,
`--vits-tokens=${tokensFile}`,
`--vits-data-dir=${dataDir}`,
`--output-filename=${outputPath}`,
text,
],
{
stdio: "inherit",
env,
},
);
if (typeof child.status === "number") {
process.exit(child.status);
}
if (child.error) {
console.error(child.error.message || String(child.error));
}
process.exit(1);
+1 -162
View File
@@ -1,13 +1,10 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { Readable } from "node:stream";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { pipeline } from "node:stream/promises";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveBrewExecutable } from "../infra/brew.js"; import { resolveBrewExecutable } from "../infra/brew.js";
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { import {
hasBinary, hasBinary,
loadWorkspaceSkillEntries, loadWorkspaceSkillEntries,
@@ -16,7 +13,6 @@ import {
type SkillInstallSpec, type SkillInstallSpec,
type SkillsInstallPreferences, type SkillsInstallPreferences,
} from "./skills.js"; } from "./skills.js";
import { resolveSkillKey } from "./skills/frontmatter.js";
export type SkillInstallRequest = { export type SkillInstallRequest = {
workspaceDir: string; workspaceDir: string;
@@ -116,165 +112,11 @@ function buildInstallCommand(
if (!spec.package) return { argv: null, error: "missing uv package" }; if (!spec.package) return { argv: null, error: "missing uv package" };
return { argv: ["uv", "tool", "install", spec.package] }; return { argv: ["uv", "tool", "install", spec.package] };
} }
case "download": {
return { argv: null, error: "download install handled separately" };
}
default: default:
return { argv: null, error: "unsupported installer" }; return { argv: null, error: "unsupported installer" };
} }
} }
function resolveDownloadTargetDir(entry: SkillEntry, spec: SkillInstallSpec): string {
if (spec.targetDir?.trim()) return resolveUserPath(spec.targetDir);
const key = resolveSkillKey(entry.skill, entry);
return path.join(CONFIG_DIR, "tools", key);
}
function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | undefined {
const explicit = spec.archive?.trim().toLowerCase();
if (explicit) return explicit;
const lower = filename.toLowerCase();
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tar.gz";
if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) return "tar.bz2";
if (lower.endsWith(".zip")) return "zip";
return undefined;
}
async function downloadFile(
url: string,
destPath: string,
timeoutMs: number,
): Promise<{ bytes: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs));
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok || !response.body) {
throw new Error(`Download failed (${response.status} ${response.statusText})`);
}
await ensureDir(path.dirname(destPath));
const file = fs.createWriteStream(destPath);
const body = response.body;
// Node's Readable.fromWeb expects the node:stream/web type; cast through unknown for DOM streams.
const nodeStream = body as unknown as NodeJS.ReadableStream;
const readable =
typeof nodeStream.pipe === "function"
? nodeStream
: Readable.fromWeb(body as unknown as NodeReadableStream);
await pipeline(readable, file);
const stat = await fs.promises.stat(destPath);
return { bytes: stat.size };
} finally {
clearTimeout(timeout);
}
}
async function extractArchive(params: {
archivePath: string;
archiveType: string;
targetDir: string;
stripComponents?: number;
timeoutMs: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
const { archivePath, archiveType, targetDir, stripComponents, timeoutMs } = params;
if (archiveType === "zip") {
if (!hasBinary("unzip")) {
return { stdout: "", stderr: "unzip not found on PATH", code: null };
}
const argv = ["unzip", "-q", archivePath, "-d", targetDir];
return await runCommandWithTimeout(argv, { timeoutMs });
}
if (!hasBinary("tar")) {
return { stdout: "", stderr: "tar not found on PATH", code: null };
}
const argv = ["tar", "xf", archivePath, "-C", targetDir];
if (typeof stripComponents === "number" && Number.isFinite(stripComponents)) {
argv.push("--strip-components", String(Math.max(0, Math.floor(stripComponents))));
}
return await runCommandWithTimeout(argv, { timeoutMs });
}
async function installDownloadSpec(params: {
entry: SkillEntry;
spec: SkillInstallSpec;
timeoutMs: number;
}): Promise<SkillInstallResult> {
const { entry, spec, timeoutMs } = params;
const url = spec.url?.trim();
if (!url) {
return {
ok: false,
message: "missing download url",
stdout: "",
stderr: "",
code: null,
};
}
let filename = "";
try {
const parsed = new URL(url);
filename = path.basename(parsed.pathname);
} catch {
filename = path.basename(url);
}
if (!filename) filename = "download";
const targetDir = resolveDownloadTargetDir(entry, spec);
await ensureDir(targetDir);
const archivePath = path.join(targetDir, filename);
let downloaded = 0;
try {
const result = await downloadFile(url, archivePath, timeoutMs);
downloaded = result.bytes;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, message, stdout: "", stderr: message, code: null };
}
const archiveType = resolveArchiveType(spec, filename);
const shouldExtract = spec.extract ?? Boolean(archiveType);
if (!shouldExtract) {
return {
ok: true,
message: `Downloaded to ${archivePath}`,
stdout: `downloaded=${downloaded}`,
stderr: "",
code: 0,
};
}
if (!archiveType) {
return {
ok: false,
message: "extract requested but archive type could not be detected",
stdout: "",
stderr: "",
code: null,
};
}
const extractResult = await extractArchive({
archivePath,
archiveType,
targetDir,
stripComponents: spec.stripComponents,
timeoutMs,
});
const success = extractResult.code === 0;
return {
ok: success,
message: success
? `Downloaded and extracted to ${targetDir}`
: formatInstallFailureMessage(extractResult),
stdout: extractResult.stdout.trim(),
stderr: extractResult.stderr.trim(),
code: extractResult.code,
};
}
async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> { async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> {
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable()); const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
if (!exe) return undefined; if (!exe) return undefined;
@@ -325,9 +167,6 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
code: null, code: null,
}; };
} }
if (spec.kind === "download") {
return await installDownloadSpec({ entry, spec, timeoutMs });
}
const prefs = resolveSkillsInstallPreferences(params.config); const prefs = resolveSkillsInstallPreferences(params.config);
const command = buildInstallCommand(spec, prefs); const command = buildInstallCommand(spec, prefs);
+29 -42
View File
@@ -100,49 +100,36 @@ function normalizeInstallOptions(
): SkillInstallOption[] { ): SkillInstallOption[] {
const install = entry.clawdbot?.install ?? []; const install = entry.clawdbot?.install ?? [];
if (install.length === 0) return []; if (install.length === 0) return [];
const preferred = selectPreferredInstallSpec(install, prefs);
const platform = process.platform;
const filtered = install.filter((spec) => {
const osList = spec.os ?? [];
return osList.length === 0 || osList.includes(platform);
});
if (filtered.length === 0) return [];
const toOption = (spec: SkillInstallSpec, index: number): SkillInstallOption => {
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
const bins = spec.bins ?? [];
let label = (spec.label ?? "").trim();
if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
}
if (!label) {
if (spec.kind === "brew" && spec.formula) {
label = `Install ${spec.formula} (brew)`;
} else if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
} else if (spec.kind === "go" && spec.module) {
label = `Install ${spec.module} (go)`;
} else if (spec.kind === "uv" && spec.package) {
label = `Install ${spec.package} (uv)`;
} else if (spec.kind === "download" && spec.url) {
const url = spec.url.trim();
const last = url.split("/").pop();
label = `Download ${last && last.length > 0 ? last : url}`;
} else {
label = "Run installer";
}
}
return { id, kind: spec.kind, label, bins };
};
const allDownloads = filtered.every((spec) => spec.kind === "download");
if (allDownloads) {
return filtered.map((spec, index) => toOption(spec, index));
}
const preferred = selectPreferredInstallSpec(filtered, prefs);
if (!preferred) return []; if (!preferred) return [];
return [toOption(preferred.spec, preferred.index)]; const { spec, index } = preferred;
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
const bins = spec.bins ?? [];
let label = (spec.label ?? "").trim();
if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
}
if (!label) {
if (spec.kind === "brew" && spec.formula) {
label = `Install ${spec.formula} (brew)`;
} else if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
} else if (spec.kind === "go" && spec.module) {
label = `Install ${spec.module} (go)`;
} else if (spec.kind === "uv" && spec.package) {
label = `Install ${spec.package} (uv)`;
} else {
label = "Run installer";
}
}
return [
{
id,
kind: spec.kind,
label,
bins,
},
];
} }
function buildSkillStatus( function buildSkillStatus(
@@ -109,33 +109,4 @@ describe("buildWorkspaceSkillStatus", () => {
} }
} }
}); });
it("filters install options by OS", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const skillDir = path.join(workspaceDir, "skills", "install-skill");
await writeSkill({
dir: skillDir,
name: "install-skill",
description: "OS-specific installs",
metadata:
'{"clawdbot":{"requires":{"bins":["missing-bin"]},"install":[{"id":"mac","kind":"download","os":["darwin"],"url":"https://example.com/mac.tar.bz2"},{"id":"linux","kind":"download","os":["linux"],"url":"https://example.com/linux.tar.bz2"},{"id":"win","kind":"download","os":["win32"],"url":"https://example.com/win.tar.bz2"}]}}',
});
const report = buildWorkspaceSkillStatus(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
});
const skill = report.skills.find((entry) => entry.name === "install-skill");
expect(skill).toBeDefined();
if (process.platform === "darwin") {
expect(skill?.install.map((opt) => opt.id)).toEqual(["mac"]);
} else if (process.platform === "linux") {
expect(skill?.install.map((opt) => opt.id)).toEqual(["linux"]);
} else if (process.platform === "win32") {
expect(skill?.install.map((opt) => opt.id)).toEqual(["win"]);
} else {
expect(skill?.install).toEqual([]);
}
});
}); });
+1 -8
View File
@@ -35,7 +35,7 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
const kindRaw = const kindRaw =
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : ""; typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
const kind = kindRaw.trim().toLowerCase(); const kind = kindRaw.trim().toLowerCase();
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") { if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") {
return undefined; return undefined;
} }
@@ -47,16 +47,9 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
if (typeof raw.label === "string") spec.label = raw.label; if (typeof raw.label === "string") spec.label = raw.label;
const bins = normalizeStringList(raw.bins); const bins = normalizeStringList(raw.bins);
if (bins.length > 0) spec.bins = bins; if (bins.length > 0) spec.bins = bins;
const osList = normalizeStringList(raw.os);
if (osList.length > 0) spec.os = osList;
if (typeof raw.formula === "string") spec.formula = raw.formula; if (typeof raw.formula === "string") spec.formula = raw.formula;
if (typeof raw.package === "string") spec.package = raw.package; if (typeof raw.package === "string") spec.package = raw.package;
if (typeof raw.module === "string") spec.module = raw.module; if (typeof raw.module === "string") spec.module = raw.module;
if (typeof raw.url === "string") spec.url = raw.url;
if (typeof raw.archive === "string") spec.archive = raw.archive;
if (typeof raw.extract === "boolean") spec.extract = raw.extract;
if (typeof raw.stripComponents === "number") spec.stripComponents = raw.stripComponents;
if (typeof raw.targetDir === "string") spec.targetDir = raw.targetDir;
return spec; return spec;
} }
+1 -7
View File
@@ -2,18 +2,12 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
export type SkillInstallSpec = { export type SkillInstallSpec = {
id?: string; id?: string;
kind: "brew" | "node" | "go" | "uv" | "download"; kind: "brew" | "node" | "go" | "uv";
label?: string; label?: string;
bins?: string[]; bins?: string[];
os?: string[];
formula?: string; formula?: string;
package?: string; package?: string;
module?: string; module?: string;
url?: string;
archive?: string;
extract?: boolean;
stripComponents?: number;
targetDir?: string;
}; };
export type ClawdbotSkillMetadata = { export type ClawdbotSkillMetadata = {
+2 -2
View File
@@ -65,7 +65,7 @@ describe("cron tool", () => {
data: { data: {
name: "wake-up", name: "wake-up",
schedule: { atMs: 123 }, schedule: { atMs: 123 },
payload: { kind: "systemEvent", text: "hello" }, payload: { text: "hello" },
}, },
}, },
}); });
@@ -105,7 +105,7 @@ describe("cron tool", () => {
job: { job: {
name: "reminder", name: "reminder",
schedule: { atMs: 123 }, schedule: { atMs: 123 },
payload: { kind: "systemEvent", text: "Reminder: the thing." }, payload: { text: "Reminder: the thing." },
}, },
}); });
@@ -13,11 +13,6 @@ const mocks = vi.hoisted(() => ({
aborted: false, aborted: false,
})), })),
})); }));
const diagnosticMocks = vi.hoisted(() => ({
logMessageQueued: vi.fn(),
logMessageProcessed: vi.fn(),
logSessionStateChange: vi.fn(),
}));
vi.mock("./route-reply.js", () => ({ vi.mock("./route-reply.js", () => ({
isRoutableChannel: (channel: string | undefined) => isRoutableChannel: (channel: string | undefined) =>
@@ -39,12 +34,6 @@ vi.mock("./abort.js", () => ({
}, },
})); }));
vi.mock("../../logging/diagnostic.js", () => ({
logMessageQueued: diagnosticMocks.logMessageQueued,
logMessageProcessed: diagnosticMocks.logMessageProcessed,
logSessionStateChange: diagnosticMocks.logSessionStateChange,
}));
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
const { resetInboundDedupe } = await import("./inbound-dedupe.js"); const { resetInboundDedupe } = await import("./inbound-dedupe.js");
@@ -61,9 +50,6 @@ function createDispatcher(): ReplyDispatcher {
describe("dispatchReplyFromConfig", () => { describe("dispatchReplyFromConfig", () => {
beforeEach(() => { beforeEach(() => {
resetInboundDedupe(); resetInboundDedupe();
diagnosticMocks.logMessageQueued.mockReset();
diagnosticMocks.logMessageProcessed.mockReset();
diagnosticMocks.logSessionStateChange.mockReset();
}); });
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => { it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({ mocks.tryFastAbortFromMessage.mockResolvedValue({
@@ -200,74 +186,4 @@ describe("dispatchReplyFromConfig", () => {
expect(replyResolver).toHaveBeenCalledTimes(1); expect(replyResolver).toHaveBeenCalledTimes(1);
}); });
it("emits diagnostics when enabled", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "slack",
Surface: "slack",
SessionKey: "agent:main:main",
MessageSid: "msg-1",
To: "slack:C123",
});
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1);
expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
state: "processing",
reason: "message_start",
});
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
outcome: "completed",
sessionKey: "agent:main:main",
}),
);
});
it("marks diagnostics skipped for duplicate inbound messages", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550123",
MessageSid: "msg-dup",
});
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
await dispatchReplyFromConfig({
ctx,
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
await dispatchReplyFromConfig({
ctx,
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
outcome: "skipped",
reason: "duplicate",
}),
);
});
}); });
+82 -147
View File
@@ -1,11 +1,5 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import {
logMessageProcessed,
logMessageQueued,
logSessionStateChange,
} from "../../logging/diagnostic.js";
import { getReplyFromConfig } from "../reply.js"; import { getReplyFromConfig } from "../reply.js";
import type { FinalizedMsgContext } from "../templating.js"; import type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -27,55 +21,8 @@ export async function dispatchReplyFromConfig(params: {
replyResolver?: typeof getReplyFromConfig; replyResolver?: typeof getReplyFromConfig;
}): Promise<DispatchFromConfigResult> { }): Promise<DispatchFromConfigResult> {
const { ctx, cfg, dispatcher } = params; const { ctx, cfg, dispatcher } = params;
const diagnosticsEnabled = isDiagnosticsEnabled(cfg);
const channel = String(ctx.Surface ?? ctx.Provider ?? "unknown").toLowerCase();
const chatId = ctx.To ?? ctx.From;
const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
const sessionKey = ctx.SessionKey;
const startTime = diagnosticsEnabled ? Date.now() : 0;
const canTrackSession = diagnosticsEnabled && Boolean(sessionKey);
const recordProcessed = (
outcome: "completed" | "skipped" | "error",
opts?: {
reason?: string;
error?: string;
},
) => {
if (!diagnosticsEnabled) return;
logMessageProcessed({
channel,
chatId,
messageId,
sessionKey,
durationMs: Date.now() - startTime,
outcome,
reason: opts?.reason,
error: opts?.error,
});
};
const markProcessing = () => {
if (!canTrackSession || !sessionKey) return;
logMessageQueued({ sessionKey, channel, source: "dispatch" });
logSessionStateChange({
sessionKey,
state: "processing",
reason: "message_start",
});
};
const markIdle = (reason: string) => {
if (!canTrackSession || !sessionKey) return;
logSessionStateChange({
sessionKey,
state: "idle",
reason,
});
};
if (shouldSkipDuplicateInbound(ctx)) { if (shouldSkipDuplicateInbound(ctx)) {
recordProcessed("skipped", { reason: "duplicate" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
} }
@@ -121,107 +68,95 @@ export async function dispatchReplyFromConfig(params: {
} }
}; };
markProcessing(); const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
if (fastAbort.handled) {
try { const payload = {
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg }); text: formatAbortReplyText(fastAbort.stoppedSubagents),
if (fastAbort.handled) { } satisfies ReplyPayload;
const payload = {
text: formatAbortReplyText(fastAbort.stoppedSubagents),
} satisfies ReplyPayload;
let queuedFinal = false;
let routedFinalCount = 0;
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({
payload,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
queuedFinal = result.ok;
if (result.ok) routedFinalCount += 1;
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
);
}
} else {
queuedFinal = dispatcher.sendFinalReply(payload);
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
recordProcessed("completed", { reason: "fast_abort" });
markIdle("message_completed");
return { queuedFinal, counts };
}
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
if (shouldRouteToOriginating) {
// Fire-and-forget for streaming tool results when routing.
void sendPayloadAsync(payload);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendToolResult(payload);
}
},
onBlockReply: (payload: ReplyPayload, context) => {
if (shouldRouteToOriginating) {
// Await routed sends so upstream can enforce ordering/timeouts.
return sendPayloadAsync(payload, context?.abortSignal);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendBlockReply(payload);
}
},
},
cfg,
);
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
let queuedFinal = false; let queuedFinal = false;
let routedFinalCount = 0; let routedFinalCount = 0;
for (const reply of replies) { if (shouldRouteToOriginating && originatingChannel && originatingTo) {
if (shouldRouteToOriginating && originatingChannel && originatingTo) { const result = await routeReply({
// Route final reply to originating channel. payload,
const result = await routeReply({ channel: originatingChannel,
payload: reply, to: originatingTo,
channel: originatingChannel, sessionKey: ctx.SessionKey,
to: originatingTo, accountId: ctx.AccountId,
sessionKey: ctx.SessionKey, threadId: ctx.MessageThreadId,
accountId: ctx.AccountId, cfg,
threadId: ctx.MessageThreadId, });
cfg, queuedFinal = result.ok;
}); if (result.ok) routedFinalCount += 1;
if (!result.ok) { if (!result.ok) {
logVerbose( logVerbose(
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`, `dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
); );
}
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
} else {
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
} }
} else {
queuedFinal = dispatcher.sendFinalReply(payload);
} }
await dispatcher.waitForIdle(); await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts(); const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount; counts.final += routedFinalCount;
recordProcessed("completed");
markIdle("message_completed");
return { queuedFinal, counts }; return { queuedFinal, counts };
} catch (err) {
recordProcessed("error", { error: String(err) });
markIdle("message_error");
throw err;
} }
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
if (shouldRouteToOriginating) {
// Fire-and-forget for streaming tool results when routing.
void sendPayloadAsync(payload);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendToolResult(payload);
}
},
onBlockReply: (payload: ReplyPayload, context) => {
if (shouldRouteToOriginating) {
// Await routed sends so upstream can enforce ordering/timeouts.
return sendPayloadAsync(payload, context?.abortSignal);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendBlockReply(payload);
}
},
},
cfg,
);
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
let queuedFinal = false;
let routedFinalCount = 0;
for (const reply of replies) {
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
// Route final reply to originating channel.
const result = await routeReply({
payload: reply,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
);
}
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
} else {
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
}
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
return { queuedFinal, counts };
} }
+4 -4
View File
@@ -1,5 +1,4 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { import {
channelsAddCommand, channelsAddCommand,
channelsCapabilitiesCommand, channelsCapabilitiesCommand,
@@ -13,9 +12,11 @@ import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveCliChannelOptions } from "./channel-options.js";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
import { runCommandWithRuntime } from "./cli-utils.js"; import { runCommandWithRuntime } from "./cli-utils.js";
import { hasExplicitOptions } from "./command-options.js"; import { hasExplicitOptions } from "./command-options.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
const optionNamesAdd = [ const optionNamesAdd = [
"channel", "channel",
@@ -58,9 +59,7 @@ function runChannelsCommandWithDanger(action: () => Promise<void>, label: string
} }
export function registerChannelsCli(program: Command) { export function registerChannelsCli(program: Command) {
const channelNames = listChannelPlugins() const channelNames = resolveCliChannelOptions().join("|");
.map((plugin) => plugin.id)
.join("|");
const channels = program const channels = program
.command("channels") .command("channels")
.description("Manage chat channel accounts") .description("Manage chat channel accounts")
@@ -72,6 +71,7 @@ export function registerChannelsCli(program: Command) {
"docs.clawd.bot/cli/channels", "docs.clawd.bot/cli/channels",
)}\n`, )}\n`,
); );
markCommandRequiresPluginRegistry(channels);
channels channels
.command("list") .command("list")
+3 -59
View File
@@ -173,7 +173,7 @@ describe("cron cli", () => {
expect(clearPatch?.patch?.agentId).toBeNull(); expect(clearPatch?.patch?.agentId).toBeNull();
}); });
it("allows model/thinking updates without --message", async () => { it("does not include model/thinking when no payload change is requested", async () => {
callGatewayFromCli.mockClear(); callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js"); const { registerCronCli } = await import("./cron-cli.js");
@@ -186,64 +186,8 @@ describe("cron cli", () => {
}); });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as { const patch = updateCall?.[2] as { patch?: { payload?: unknown } };
patch?: { payload?: { kind?: string; model?: string; thinking?: string } };
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn"); expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.payload?.model).toBe("opus");
expect(patch?.patch?.payload?.thinking).toBe("low");
});
it("updates delivery settings without requiring --message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
["cron", "edit", "job-1", "--deliver", "--channel", "telegram", "--to", "19098680"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
kind?: string;
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
};
};
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.deliver).toBe(true);
expect(patch?.patch?.payload?.channel).toBe("telegram");
expect(patch?.patch?.payload?.to).toBe("19098680");
expect(patch?.patch?.payload?.message).toBeUndefined();
});
it("supports --no-deliver on cron edit", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(["cron", "edit", "job-1", "--no-deliver"], { from: "user" });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { kind?: string; deliver?: boolean } };
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.deliver).toBe(false);
}); });
}); });
+27 -42
View File
@@ -38,15 +38,14 @@ export function registerCronEditCommand(cron: Command) {
.option( .option(
"--deliver", "--deliver",
"Deliver agent output (required when using last-route delivery without --to)", "Deliver agent output (required when using last-route delivery without --to)",
false,
) )
.option("--no-deliver", "Disable delivery")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`) .option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option( .option(
"--to <dest>", "--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
) )
.option("--best-effort-deliver", "Do not fail job if delivery fails") .option("--best-effort-deliver", "Do not fail job if delivery fails", false)
.option("--no-best-effort-deliver", "Fail job when delivery fails")
.option("--post-prefix <prefix>", "Prefix for summary system event") .option("--post-prefix <prefix>", "Prefix for summary system event")
.action(async (id, opts) => { .action(async (id, opts) => {
try { try {
@@ -106,49 +105,35 @@ export function registerCronEditCommand(cron: Command) {
}; };
} }
const hasSystemEventPatch = typeof opts.systemEvent === "string"; const payloadChosen = [opts.systemEvent, opts.message].filter(Boolean).length;
const model = if (payloadChosen > 1) throw new Error("Choose at most one payload change");
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined; if (opts.systemEvent) {
const thinking =
typeof opts.thinking === "string" && opts.thinking.trim()
? opts.thinking.trim()
: undefined;
const timeoutSeconds = opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined;
const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds));
const hasAgentTurnPatch =
typeof opts.message === "string" ||
Boolean(model) ||
Boolean(thinking) ||
hasTimeoutSeconds ||
typeof opts.deliver === "boolean" ||
typeof opts.channel === "string" ||
typeof opts.to === "string" ||
typeof opts.bestEffortDeliver === "boolean";
if (hasSystemEventPatch && hasAgentTurnPatch) {
throw new Error("Choose at most one payload change");
}
if (hasSystemEventPatch) {
patch.payload = { patch.payload = {
kind: "systemEvent", kind: "systemEvent",
text: String(opts.systemEvent), text: String(opts.systemEvent),
}; };
} else if (hasAgentTurnPatch) { } else if (opts.message) {
const payload: Record<string, unknown> = { kind: "agentTurn" }; const model =
if (typeof opts.message === "string") payload.message = String(opts.message); typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined;
if (model) payload.model = model; const thinking =
if (thinking) payload.thinking = thinking; typeof opts.thinking === "string" && opts.thinking.trim()
if (hasTimeoutSeconds) { ? opts.thinking.trim()
payload.timeoutSeconds = timeoutSeconds; : undefined;
} const timeoutSeconds = opts.timeoutSeconds
if (typeof opts.deliver === "boolean") payload.deliver = opts.deliver; ? Number.parseInt(String(opts.timeoutSeconds), 10)
if (typeof opts.channel === "string") payload.channel = opts.channel; : undefined;
if (typeof opts.to === "string") payload.to = opts.to; patch.payload = {
if (typeof opts.bestEffortDeliver === "boolean") { kind: "agentTurn",
payload.bestEffortDeliver = opts.bestEffortDeliver; message: String(opts.message),
} model,
patch.payload = payload; thinking,
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
deliver: opts.deliver ? true : undefined,
channel: typeof opts.channel === "string" ? opts.channel : undefined,
to: typeof opts.to === "string" ? opts.to : undefined,
bestEffortDeliver: opts.bestEffortDeliver ? true : undefined,
};
} }
if (typeof opts.postPrefix === "string") { if (typeof opts.postPrefix === "string") {
+2 -3
View File
@@ -1,13 +1,12 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import type { CronJob, CronSchedule } from "../../cron/types.js"; import type { CronJob, CronSchedule } from "../../cron/types.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import { resolveCliChannelOptions } from "../channel-options.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { callGatewayFromCli } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js";
export const getCronChannelOptions = () => export const getCronChannelOptions = () => ["last", ...resolveCliChannelOptions()].join("|");
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
try { try {
+2
View File
@@ -8,6 +8,7 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
function parseLimit(value: unknown): number | null { function parseLimit(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === "number" && Number.isFinite(value)) {
@@ -42,6 +43,7 @@ export function registerDirectoryCli(program: Command) {
.action(() => { .action(() => {
directory.help({ error: true }); directory.help({ error: true });
}); });
markCommandRequiresPluginRegistry(directory);
const withChannel = (cmd: Command) => const withChannel = (cmd: Command) =>
cmd cmd
+12 -4
View File
@@ -34,12 +34,16 @@ vi.mock("../channels/plugins/index.js", () => ({
normalizeChannelId, normalizeChannelId,
})); }));
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", async (importOriginal) => {
loadConfig: vi.fn().mockReturnValue({}), const actual = await importOriginal<typeof import("../config/config.js")>();
})); return {
...actual,
loadConfig: vi.fn().mockReturnValue({}),
};
});
describe("pairing cli", () => { describe("pairing cli", () => {
it("evaluates pairing channels when registering the CLI (not at import)", async () => { it("defers pairing channel lookup until command execution", async () => {
listPairingChannels.mockClear(); listPairingChannels.mockClear();
const { registerPairingCli } = await import("./pairing-cli.js"); const { registerPairingCli } = await import("./pairing-cli.js");
@@ -49,6 +53,10 @@ describe("pairing cli", () => {
program.name("test"); program.name("test");
registerPairingCli(program); registerPairingCli(program);
expect(listPairingChannels).not.toHaveBeenCalled();
listChannelPairingRequests.mockResolvedValueOnce([]);
await program.parseAsync(["pairing", "list", "telegram"], { from: "user" });
expect(listPairingChannels).toHaveBeenCalledTimes(1); expect(listPairingChannels).toHaveBeenCalledTimes(1);
}); });
+11 -4
View File
@@ -10,7 +10,9 @@ import {
} from "../pairing/pairing-store.js"; } from "../pairing/pairing-store.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveCliChannelOptions } from "./channel-options.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
/** Parse channel, allowing extension channels not in core registry. */ /** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel { function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
@@ -44,7 +46,9 @@ async function notifyApproved(channel: PairingChannel, id: string) {
} }
export function registerPairingCli(program: Command) { export function registerPairingCli(program: Command) {
const channels = listPairingChannels(); const channelOptions = resolveCliChannelOptions();
const channelHint =
channelOptions.length > 0 ? `Channel (${channelOptions.join(", ")})` : "Channel";
const pairing = program const pairing = program
.command("pairing") .command("pairing")
.description("Secure DM pairing (approve inbound requests)") .description("Secure DM pairing (approve inbound requests)")
@@ -53,14 +57,16 @@ export function registerPairingCli(program: Command) {
() => () =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`, `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`,
); );
markCommandRequiresPluginRegistry(pairing);
pairing pairing
.command("list") .command("list")
.description("List pending pairing requests") .description("List pending pairing requests")
.option("--channel <channel>", `Channel (${channels.join(", ")})`) .option("--channel <channel>", channelHint)
.argument("[channel]", `Channel (${channels.join(", ")})`) .argument("[channel]", channelHint)
.option("--json", "Print JSON", false) .option("--json", "Print JSON", false)
.action(async (channelArg, opts) => { .action(async (channelArg, opts) => {
const channels = listPairingChannels();
const channelRaw = opts.channel ?? channelArg; const channelRaw = opts.channel ?? channelArg;
if (!channelRaw) { if (!channelRaw) {
throw new Error( throw new Error(
@@ -87,11 +93,12 @@ export function registerPairingCli(program: Command) {
pairing pairing
.command("approve") .command("approve")
.description("Approve a pairing code and allow that sender") .description("Approve a pairing code and allow that sender")
.option("--channel <channel>", `Channel (${channels.join(", ")})`) .option("--channel <channel>", channelHint)
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)") .argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)") .argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
.option("--notify", "Notify the requester on the same channel", false) .option("--notify", "Notify the requester on the same channel", false)
.action(async (codeOrChannel, code, opts) => { .action(async (codeOrChannel, code, opts) => {
const channels = listPairingChannels();
const channelRaw = opts.channel ?? codeOrChannel; const channelRaw = opts.channel ?? codeOrChannel;
const resolvedCode = opts.channel ? codeOrChannel : code; const resolvedCode = opts.channel ? codeOrChannel : code;
if (!opts.channel && !code) { if (!opts.channel && !code) {
+24
View File
@@ -0,0 +1,24 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import {
commandRequiresPluginRegistry,
markCommandRequiresPluginRegistry,
} from "./command-metadata.js";
describe("commandRequiresPluginRegistry", () => {
it("detects direct requirement", () => {
const program = new Command();
const cmd = program.command("message");
markCommandRequiresPluginRegistry(cmd);
expect(commandRequiresPluginRegistry(cmd)).toBe(true);
});
it("walks parent chain", () => {
const program = new Command();
const parent = program.command("channels");
const child = parent.command("list");
markCommandRequiresPluginRegistry(parent);
expect(commandRequiresPluginRegistry(child)).toBe(true);
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { Command } from "commander";
const REQUIRES_PLUGIN_REGISTRY = Symbol.for("clawdbot.requiresPluginRegistry");
type CommandWithPluginRequirement = Command & {
[REQUIRES_PLUGIN_REGISTRY]?: boolean;
};
export function markCommandRequiresPluginRegistry(command: Command): Command {
(command as CommandWithPluginRequirement)[REQUIRES_PLUGIN_REGISTRY] = true;
return command;
}
export function commandRequiresPluginRegistry(command?: Command | null): boolean {
let current: Command | null | undefined = command;
while (current) {
if ((current as CommandWithPluginRequirement)[REQUIRES_PLUGIN_REGISTRY]) return true;
current = current.parent ?? undefined;
}
return false;
}
+1
View File
@@ -86,6 +86,7 @@ const routeSessions: RouteSpec = {
const routeAgentsList: RouteSpec = { const routeAgentsList: RouteSpec = {
match: (path) => path[0] === "agents" && path[1] === "list", match: (path) => path[0] === "agents" && path[1] === "list",
loadPlugins: true,
run: async (argv) => { run: async (argv) => {
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings"); const bindings = hasFlag(argv, "--bindings");
+56
View File
@@ -0,0 +1,56 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
vi.mock("../plugin-registry.js", () => ({
ensurePluginRegistryLoaded: vi.fn(),
}));
vi.mock("./config-guard.js", () => ({
ensureConfigReady: vi.fn(async () => {}),
}));
vi.mock("../banner.js", () => ({
emitCliBanner: vi.fn(),
}));
vi.mock("../argv.js", () => ({
getCommandPath: vi.fn(() => ["message"]),
hasHelpOrVersion: vi.fn(() => false),
}));
const loadRegisterPreActionHooks = async () => {
const mod = await import("./preaction.js");
return mod.registerPreActionHooks;
};
const loadEnsurePluginRegistryLoaded = async () => {
const mod = await import("../plugin-registry.js");
return mod.ensurePluginRegistryLoaded;
};
describe("registerPreActionHooks", () => {
beforeEach(async () => {
const ensurePluginRegistryLoaded = await loadEnsurePluginRegistryLoaded();
vi.mocked(ensurePluginRegistryLoaded).mockClear();
});
it("loads plugins for marked commands", async () => {
const registerPreActionHooks = await loadRegisterPreActionHooks();
const ensurePluginRegistryLoaded = await loadEnsurePluginRegistryLoaded();
const program = new Command();
registerPreActionHooks(program, "test");
const message = program.command("message").action(() => {});
markCommandRequiresPluginRegistry(message);
const originalArgv = process.argv;
const argv = ["node", "clawdbot", "message"];
process.argv = argv;
try {
await program.parseAsync(argv);
} finally {
process.argv = originalArgv;
}
expect(vi.mocked(ensurePluginRegistryLoaded)).toHaveBeenCalledTimes(1);
});
});
+4 -6
View File
@@ -2,8 +2,9 @@ import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js"; import { emitCliBanner } from "../banner.js";
import { getCommandPath, hasHelpOrVersion } from "../argv.js"; import { getCommandPath, hasHelpOrVersion } from "../argv.js";
import { ensureConfigReady } from "./config-guard.js";
import { ensurePluginRegistryLoaded } from "../plugin-registry.js"; import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
import { commandRequiresPluginRegistry } from "./command-metadata.js";
import { ensureConfigReady } from "./config-guard.js";
function setProcessTitleForCommand(actionCommand: Command) { function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand; let current: Command = actionCommand;
@@ -15,20 +16,17 @@ function setProcessTitleForCommand(actionCommand: Command) {
process.title = `clawdbot-${name}`; process.title = `clawdbot-${name}`;
} }
// Commands that need channel plugins loaded
const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]);
export function registerPreActionHooks(program: Command, programVersion: string) { export function registerPreActionHooks(program: Command, programVersion: string) {
program.hook("preAction", async (_thisCommand, actionCommand) => { program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand); setProcessTitleForCommand(actionCommand);
emitCliBanner(programVersion); emitCliBanner(programVersion);
const argv = process.argv; const argv = process.argv;
if (hasHelpOrVersion(argv)) return; if (hasHelpOrVersion(argv)) return;
const needsPlugins = commandRequiresPluginRegistry(actionCommand);
const commandPath = getCommandPath(argv, 2); const commandPath = getCommandPath(argv, 2);
if (commandPath[0] === "doctor") return; if (commandPath[0] === "doctor") return;
await ensureConfigReady({ runtime: defaultRuntime, commandPath }); await ensureConfigReady({ runtime: defaultRuntime, commandPath });
// Load plugins for commands that need channel access if (needsPlugins) {
if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
ensurePluginRegistryLoaded(); ensurePluginRegistryLoaded();
} }
}); });
+4 -1
View File
@@ -14,10 +14,11 @@ import { theme } from "../../terminal/theme.js";
import { hasExplicitOptions } from "../command-options.js"; import { hasExplicitOptions } from "../command-options.js";
import { createDefaultDeps } from "../deps.js"; import { createDefaultDeps } from "../deps.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { collectOption } from "./helpers.js"; import { collectOption } from "./helpers.js";
export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) { export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) {
program const agent = program
.command("agent") .command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)") .description("Run an agent turn via the Gateway (use --local for embedded)")
.requiredOption("-m, --message <text>", "Message body for the agent") .requiredOption("-m, --message <text>", "Message body for the agent")
@@ -67,6 +68,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
await agentCliCommand(opts, defaultRuntime, deps); await agentCliCommand(opts, defaultRuntime, deps);
}); });
}); });
markCommandRequiresPluginRegistry(agent);
const agents = program const agents = program
.command("agents") .command("agents")
@@ -76,6 +78,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
() => () =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agents", "docs.clawd.bot/cli/agents")}\n`, `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agents", "docs.clawd.bot/cli/agents")}\n`,
); );
markCommandRequiresPluginRegistry(agents);
agents agents
.command("list") .command("list")
+3 -1
View File
@@ -8,9 +8,10 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
export function registerConfigureCommand(program: Command) { export function registerConfigureCommand(program: Command) {
program const configure = program
.command("configure") .command("configure")
.description("Interactive prompt to set up credentials, devices, and agent defaults") .description("Interactive prompt to set up credentials, devices, and agent defaults")
.addHelpText( .addHelpText(
@@ -48,4 +49,5 @@ export function registerConfigureCommand(program: Command) {
await configureCommandWithSections(sections as never, defaultRuntime); await configureCommandWithSections(sections as never, defaultRuntime);
}); });
}); });
markCommandRequiresPluginRegistry(configure);
} }
+2
View File
@@ -2,6 +2,7 @@ import type { Command } from "commander";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import type { ProgramContext } from "./context.js"; import type { ProgramContext } from "./context.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { createMessageCliHelpers } from "./message/helpers.js"; import { createMessageCliHelpers } from "./message/helpers.js";
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js"; import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
import { import {
@@ -39,6 +40,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/mes
.action(() => { .action(() => {
message.help({ error: true }); message.help({ error: true });
}); });
markCommandRequiresPluginRegistry(message);
const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions); const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions);
registerMessageSendCommand(message, helpers); registerMessageSendCommand(message, helpers);
+3 -1
View File
@@ -12,6 +12,7 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
function resolveInstallDaemonFlag( function resolveInstallDaemonFlag(
command: unknown, command: unknown,
@@ -32,7 +33,7 @@ function resolveInstallDaemonFlag(
} }
export function registerOnboardCommand(program: Command) { export function registerOnboardCommand(program: Command) {
program const onboard = program
.command("onboard") .command("onboard")
.description("Interactive wizard to set up the gateway, workspace, and skills") .description("Interactive wizard to set up the gateway, workspace, and skills")
.addHelpText( .addHelpText(
@@ -150,4 +151,5 @@ export function registerOnboardCommand(program: Command) {
); );
}); });
}); });
markCommandRequiresPluginRegistry(onboard);
} }
@@ -7,6 +7,7 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { parsePositiveIntOrUndefined } from "./helpers.js"; import { parsePositiveIntOrUndefined } from "./helpers.js";
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean { function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
@@ -24,7 +25,7 @@ function parseTimeoutMs(timeout: unknown): number | null | undefined {
} }
export function registerStatusHealthSessionsCommands(program: Command) { export function registerStatusHealthSessionsCommands(program: Command) {
program const status = program
.command("status") .command("status")
.description("Show channel health and recent session recipients") .description("Show channel health and recent session recipients")
.option("--json", "Output JSON instead of text", false) .option("--json", "Output JSON instead of text", false)
@@ -72,8 +73,9 @@ Examples:
); );
}); });
}); });
markCommandRequiresPluginRegistry(status);
program const health = program
.command("health") .command("health")
.description("Fetch health from the running gateway") .description("Fetch health from the running gateway")
.option("--json", "Output JSON instead of text", false) .option("--json", "Output JSON instead of text", false)
@@ -103,6 +105,7 @@ Examples:
); );
}); });
}); });
markCommandRequiresPluginRegistry(health);
program program
.command("sessions") .command("sessions")
+3 -1
View File
@@ -5,6 +5,7 @@ import { sandboxExplainCommand } from "../commands/sandbox-explain.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
// --- Types --- // --- Types ---
@@ -142,7 +143,7 @@ export function registerSandboxCli(program: Command) {
// --- Explain Command --- // --- Explain Command ---
sandbox const explain = sandbox
.command("explain") .command("explain")
.description("Explain effective sandbox/tool policy for a session/agent") .description("Explain effective sandbox/tool policy for a session/agent")
.option("--session <key>", "Session key to inspect (defaults to agent main)") .option("--session <key>", "Session key to inspect (defaults to agent main)")
@@ -161,4 +162,5 @@ export function registerSandboxCli(program: Command) {
), ),
), ),
); );
markCommandRequiresPluginRegistry(explain);
} }
+2
View File
@@ -8,6 +8,7 @@ import { fixSecurityFootguns } from "../security/fix.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js"; import { isRich, theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
type SecurityAuditOptions = { type SecurityAuditOptions = {
json?: boolean; json?: boolean;
@@ -36,6 +37,7 @@ export function registerSecurityCli(program: Command) {
() => () =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.clawd.bot/cli/security")}\n`, `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.clawd.bot/cli/security")}\n`,
); );
markCommandRequiresPluginRegistry(security);
security security
.command("audit") .command("audit")
+6
View File
@@ -51,6 +51,12 @@ function coerceSchedule(schedule: UnknownRecord) {
function coercePayload(payload: UnknownRecord) { function coercePayload(payload: UnknownRecord) {
const next: UnknownRecord = { ...payload }; const next: UnknownRecord = { ...payload };
const kind = typeof payload.kind === "string" ? payload.kind : undefined;
if (!kind) {
if (typeof payload.text === "string") next.kind = "systemEvent";
else if (typeof payload.message === "string") next.kind = "agentTurn";
}
// Back-compat: older configs used `provider` for delivery channel. // Back-compat: older configs used `provider` for delivery channel.
migrateLegacyCronPayload(next); migrateLegacyCronPayload(next);
return next; return next;
+2 -64
View File
@@ -1,13 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { computeNextRunAtMs } from "../schedule.js"; import { computeNextRunAtMs } from "../schedule.js";
import type { import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js";
CronJob,
CronJobCreate,
CronJobPatch,
CronPayload,
CronPayloadPatch,
} from "../types.js";
import { import {
normalizeOptionalAgentId, normalizeOptionalAgentId,
normalizeOptionalText, normalizeOptionalText,
@@ -109,7 +103,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
if (patch.schedule) job.schedule = patch.schedule; if (patch.schedule) job.schedule = patch.schedule;
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget; if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
if (patch.wakeMode) job.wakeMode = patch.wakeMode; if (patch.wakeMode) job.wakeMode = patch.wakeMode;
if (patch.payload) job.payload = mergeCronPayload(job.payload, patch.payload); if (patch.payload) job.payload = patch.payload;
if (patch.isolation) job.isolation = patch.isolation; if (patch.isolation) job.isolation = patch.isolation;
if (patch.state) job.state = { ...job.state, ...patch.state }; if (patch.state) job.state = { ...job.state, ...patch.state };
if ("agentId" in patch) { if ("agentId" in patch) {
@@ -118,62 +112,6 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
assertSupportedJobSpec(job); assertSupportedJobSpec(job);
} }
function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload {
if (patch.kind !== existing.kind) {
return buildPayloadFromPatch(patch);
}
if (patch.kind === "systemEvent") {
if (existing.kind !== "systemEvent") {
return buildPayloadFromPatch(patch);
}
const text = typeof patch.text === "string" ? patch.text : existing.text;
return { kind: "systemEvent", text };
}
if (existing.kind !== "agentTurn") {
return buildPayloadFromPatch(patch);
}
const next: Extract<CronPayload, { kind: "agentTurn" }> = { ...existing };
if (typeof patch.message === "string") next.message = patch.message;
if (typeof patch.model === "string") next.model = patch.model;
if (typeof patch.thinking === "string") next.thinking = patch.thinking;
if (typeof patch.timeoutSeconds === "number") next.timeoutSeconds = patch.timeoutSeconds;
if (typeof patch.deliver === "boolean") next.deliver = patch.deliver;
if (typeof patch.channel === "string") next.channel = patch.channel;
if (typeof patch.to === "string") next.to = patch.to;
if (typeof patch.bestEffortDeliver === "boolean") {
next.bestEffortDeliver = patch.bestEffortDeliver;
}
return next;
}
function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
if (patch.kind === "systemEvent") {
if (typeof patch.text !== "string" || patch.text.length === 0) {
throw new Error('cron.update payload.kind="systemEvent" requires text');
}
return { kind: "systemEvent", text: patch.text };
}
if (typeof patch.message !== "string" || patch.message.length === 0) {
throw new Error('cron.update payload.kind="agentTurn" requires message');
}
return {
kind: "agentTurn",
message: patch.message,
model: patch.model,
thinking: patch.thinking,
timeoutSeconds: patch.timeoutSeconds,
deliver: patch.deliver,
channel: patch.channel,
to: patch.to,
bestEffortDeliver: patch.bestEffortDeliver,
};
}
export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) { export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) {
if (opts.forced) return true; if (opts.forced) return true;
return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs; return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs;
+3 -18
View File
@@ -25,20 +25,6 @@ export type CronPayload =
bestEffortDeliver?: boolean; bestEffortDeliver?: boolean;
}; };
export type CronPayloadPatch =
| { kind: "systemEvent"; text?: string }
| {
kind: "agentTurn";
message?: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
deliver?: boolean;
channel?: CronMessageChannel;
to?: string;
bestEffortDeliver?: boolean;
};
export type CronIsolation = { export type CronIsolation = {
postToMainPrefix?: string; postToMainPrefix?: string;
/** /**
@@ -86,7 +72,6 @@ export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" |
state?: Partial<CronJobState>; state?: Partial<CronJobState>;
}; };
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "state" | "payload">> & { export type CronJobPatch = Partial<
payload?: CronPayloadPatch; Omit<CronJob, "id" | "createdAtMs" | "state"> & { state: CronJobState }
state?: Partial<CronJobState>; >;
};
-41
View File
@@ -1,41 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const testTailnetIPv4 = { value: undefined as string | undefined };
const testTailnetIPv6 = { value: undefined as string | undefined };
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
}));
import { isLocalGatewayAddress } from "./net.js";
describe("gateway net", () => {
beforeEach(() => {
testTailnetIPv4.value = undefined;
testTailnetIPv6.value = undefined;
});
test("treats loopback as local", () => {
expect(isLocalGatewayAddress("127.0.0.1")).toBe(true);
expect(isLocalGatewayAddress("127.0.1.1")).toBe(true);
expect(isLocalGatewayAddress("::1")).toBe(true);
expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true);
});
test("treats local tailnet IPv4 as local", () => {
testTailnetIPv4.value = "100.64.0.1";
expect(isLocalGatewayAddress("100.64.0.1")).toBe(true);
expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true);
});
test("ignores non-matching tailnet IPv4", () => {
testTailnetIPv4.value = "100.64.0.1";
expect(isLocalGatewayAddress("100.64.0.2")).toBe(false);
});
test("treats local tailnet IPv6 as local", () => {
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
});
});
+1 -17
View File
@@ -1,6 +1,6 @@
import net from "node:net"; import net from "node:net";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
export function isLoopbackAddress(ip: string | undefined): boolean { export function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false; if (!ip) return false;
@@ -11,22 +11,6 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
return false; return false;
} }
function normalizeIPv4MappedAddress(ip: string): string {
if (ip.startsWith("::ffff:")) return ip.slice("::ffff:".length);
return ip;
}
export function isLocalGatewayAddress(ip: string | undefined): boolean {
if (isLoopbackAddress(ip)) return true;
if (!ip) return false;
const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase());
const tailnetIPv4 = pickPrimaryTailnetIPv4();
if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) return true;
const tailnetIPv6 = pickPrimaryTailnetIPv6();
if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) return true;
return false;
}
/** /**
* Resolves gateway bind host with fallback strategy. * Resolves gateway bind host with fallback strategy.
* *
+2 -43
View File
@@ -52,30 +52,6 @@ export const CronPayloadSchema = Type.Union([
), ),
]); ]);
export const CronPayloadPatchSchema = Type.Union([
Type.Object(
{
kind: Type.Literal("systemEvent"),
text: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
Type.Object(
{
kind: Type.Literal("agentTurn"),
message: Type.Optional(NonEmptyString),
model: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
deliver: Type.Optional(Type.Boolean()),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffortDeliver: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
),
]);
export const CronIsolationSchema = Type.Object( export const CronIsolationSchema = Type.Object(
{ {
postToMainPrefix: Type.Optional(Type.String()), postToMainPrefix: Type.Optional(Type.String()),
@@ -144,35 +120,18 @@ export const CronAddParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const CronJobPatchSchema = Type.Object(
{
name: Type.Optional(NonEmptyString),
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
description: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
schedule: Type.Optional(CronScheduleSchema),
sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])),
wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
payload: Type.Optional(CronPayloadPatchSchema),
isolation: Type.Optional(CronIsolationSchema),
state: Type.Optional(Type.Partial(CronJobStateSchema)),
},
{ additionalProperties: false },
);
export const CronUpdateParamsSchema = Type.Union([ export const CronUpdateParamsSchema = Type.Union([
Type.Object( Type.Object(
{ {
id: NonEmptyString, id: NonEmptyString,
patch: CronJobPatchSchema, patch: Type.Partial(CronAddParamsSchema),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
Type.Object( Type.Object(
{ {
jobId: NonEmptyString, jobId: NonEmptyString,
patch: CronJobPatchSchema, patch: Type.Partial(CronAddParamsSchema),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
+73 -73
View File
@@ -3,92 +3,92 @@ import { Type } from "@sinclair/typebox";
import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js";
export const SessionsListParamsSchema = Type.Object( export const SessionsListParamsSchema = Type.Object(
{ {
limit: Type.Optional(Type.Integer({ minimum: 1 })), limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
/** /**
* Read first 8KB of each session transcript to derive title from first user message. * Read first 8KB of each session transcript to derive title from first user message.
* Performs a file read per session - use `limit` to bound result set on large stores. * Performs a file read per session - use `limit` to bound result set on large stores.
*/ */
includeDerivedTitles: Type.Optional(Type.Boolean()), includeDerivedTitles: Type.Optional(Type.Boolean()),
/** /**
* Read last 16KB of each session transcript to extract most recent message preview. * Read last 16KB of each session transcript to extract most recent message preview.
* Performs a file read per session - use `limit` to bound result set on large stores. * Performs a file read per session - use `limit` to bound result set on large stores.
*/ */
includeLastMessage: Type.Optional(Type.Boolean()), includeLastMessage: Type.Optional(Type.Boolean()),
label: Type.Optional(SessionLabelString), label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString),
search: Type.Optional(Type.String()), search: Type.Optional(Type.String()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsResolveParamsSchema = Type.Object( export const SessionsResolveParamsSchema = Type.Object(
{ {
key: Type.Optional(NonEmptyString), key: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString), label: Type.Optional(SessionLabelString),
agentId: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsPatchParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
responseUsage: Type.Optional( responseUsage: Type.Optional(
Type.Union([ Type.Union([
Type.Literal("off"), Type.Literal("off"),
Type.Literal("tokens"), Type.Literal("tokens"),
Type.Literal("full"), Type.Literal("full"),
// Backward compat with older clients/stores. // Backward compat with older clients/stores.
Type.Literal("on"), Type.Literal("on"),
Type.Null(), Type.Null(),
]), ]),
), ),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional( sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
), ),
groupActivation: Type.Optional( groupActivation: Type.Optional(
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
), ),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsResetParamsSchema = Type.Object( export const SessionsResetParamsSchema = Type.Object(
{ key: NonEmptyString }, { key: NonEmptyString },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsDeleteParamsSchema = Type.Object( export const SessionsDeleteParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
deleteTranscript: Type.Optional(Type.Boolean()), deleteTranscript: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsCompactParamsSchema = Type.Object( export const SessionsCompactParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
maxLines: Type.Optional(Type.Integer({ minimum: 1 })), maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
+1 -2
View File
@@ -3,7 +3,6 @@ import type { WebSocketServer } from "ws";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js"; import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { stopGmailWatcher } from "../hooks/gmail-watcher.js"; import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import type { PluginServicesHandle } from "../plugins/services.js"; import type { PluginServicesHandle } from "../plugins/services.js";
export function createGatewayCloseHandler(params: { export function createGatewayCloseHandler(params: {
@@ -14,7 +13,7 @@ export function createGatewayCloseHandler(params: {
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>; stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
pluginServices: PluginServicesHandle | null; pluginServices: PluginServicesHandle | null;
cron: { stop: () => void }; cron: { stop: () => void };
heartbeatRunner: HeartbeatRunner; heartbeatRunner: { stop: () => void };
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>; nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
tickInterval: ReturnType<typeof setInterval>; tickInterval: ReturnType<typeof setInterval>;
+4 -3
View File
@@ -1,7 +1,7 @@
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js"; import type { loadConfig } from "../config/config.js";
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js"; import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
import { import {
authorizeGatewaySigusr1Restart, authorizeGatewaySigusr1Restart,
@@ -18,7 +18,7 @@ import { buildGatewayCronService, type GatewayCronState } from "./server-cron.js
type GatewayHotReloadState = { type GatewayHotReloadState = {
hooksConfig: ReturnType<typeof resolveHooksConfig>; hooksConfig: ReturnType<typeof resolveHooksConfig>;
heartbeatRunner: HeartbeatRunner; heartbeatRunner: { stop: () => void };
cronState: GatewayCronState; cronState: GatewayCronState;
browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> | null; browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> | null;
}; };
@@ -57,7 +57,8 @@ export function createGatewayReloadHandlers(params: {
} }
if (plan.restartHeartbeat) { if (plan.restartHeartbeat) {
nextState.heartbeatRunner.updateConfig(nextConfig); state.heartbeatRunner.stop();
nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig });
} }
resetDirectoryCache(); resetDirectoryCache();
+3 -93
View File
@@ -122,7 +122,7 @@ describe("gateway server cron", () => {
data: { data: {
name: "wrapped", name: "wrapped",
schedule: { atMs }, schedule: { atMs },
payload: { kind: "systemEvent", text: "hello" }, payload: { text: "hello" },
}, },
}); });
expect(addRes.ok).toBe(true); expect(addRes.ok).toBe(true);
@@ -166,7 +166,7 @@ describe("gateway server cron", () => {
id: jobId, id: jobId,
patch: { patch: {
schedule: { atMs }, schedule: { atMs },
payload: { kind: "systemEvent", text: "updated" }, payload: { text: "updated" },
}, },
}); });
expect(updateRes.ok).toBe(true); expect(updateRes.ok).toBe(true);
@@ -182,96 +182,6 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
}); });
test("merges agentTurn payload patches", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "patch merge",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello", model: "opus" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: {
payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" },
},
});
expect(updateRes.ok).toBe(true);
const updated = updateRes.payload as
| {
payload?: {
kind?: unknown;
message?: unknown;
model?: unknown;
deliver?: unknown;
channel?: unknown;
to?: unknown;
};
}
| undefined;
expect(updated?.payload?.kind).toBe("agentTurn");
expect(updated?.payload?.message).toBe("hello");
expect(updated?.payload?.model).toBe("opus");
expect(updated?.payload?.deliver).toBe(true);
expect(updated?.payload?.channel).toBe("telegram");
expect(updated?.payload?.to).toBe("19098680");
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("rejects payload kind changes without required fields", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "patch reject",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: {
payload: { kind: "agentTurn", deliver: true },
},
});
expect(updateRes.ok).toBe(false);
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("accepts jobId for cron.update", async () => { test("accepts jobId for cron.update", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.cronStorePath = path.join(dir, "cron", "jobs.json");
@@ -300,7 +210,7 @@ describe("gateway server cron", () => {
jobId, jobId,
patch: { patch: {
schedule: { atMs }, schedule: { atMs },
payload: { kind: "systemEvent", text: "updated" }, payload: { text: "updated" },
}, },
}); });
expect(updateRes.ok).toBe(true); expect(updateRes.ok).toBe(true);
+1 -14
View File
@@ -13,7 +13,6 @@ import {
readConfigFileSnapshot, readConfigFileSnapshot,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
@@ -27,7 +26,6 @@ import {
} from "../infra/skills-remote.js"; } from "../infra/skills-remote.js";
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js"; import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js";
import type { PluginServicesHandle } from "../plugins/services.js"; import type { PluginServicesHandle } from "../plugins/services.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -200,10 +198,6 @@ export async function startGatewayServer(
} }
const cfgAtStart = loadConfig(); const cfgAtStart = loadConfig();
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
}
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
initSubagentRegistry(); initSubagentRegistry();
const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
@@ -539,12 +533,5 @@ export async function startGatewayServer(
httpServer, httpServer,
}); });
return { return { close };
close: async (opts) => {
if (diagnosticsEnabled) {
stopDiagnosticHeartbeat();
}
await close(opts);
},
};
} }
+3 -9
View File
@@ -21,11 +21,7 @@ const hoisted = vi.hoisted(() => {
})); }));
const heartbeatStop = vi.fn(); const heartbeatStop = vi.fn();
const heartbeatUpdateConfig = vi.fn(); const startHeartbeatRunner = vi.fn(() => ({ stop: heartbeatStop }));
const startHeartbeatRunner = vi.fn(() => ({
stop: heartbeatStop,
updateConfig: heartbeatUpdateConfig,
}));
const startGmailWatcher = vi.fn(async () => ({ started: true })); const startGmailWatcher = vi.fn(async () => ({ started: true }));
const stopGmailWatcher = vi.fn(async () => {}); const stopGmailWatcher = vi.fn(async () => {});
@@ -120,7 +116,6 @@ const hoisted = vi.hoisted(() => {
browserStop, browserStop,
startBrowserControlServerIfEnabled, startBrowserControlServerIfEnabled,
heartbeatStop, heartbeatStop,
heartbeatUpdateConfig,
startHeartbeatRunner, startHeartbeatRunner,
startGmailWatcher, startGmailWatcher,
stopGmailWatcher, stopGmailWatcher,
@@ -242,9 +237,8 @@ describe("gateway hot reload", () => {
expect(hoisted.browserStop).toHaveBeenCalledTimes(1); expect(hoisted.browserStop).toHaveBeenCalledTimes(1);
expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2); expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2);
expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1); expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(2);
expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledTimes(1); expect(hoisted.heartbeatStop).toHaveBeenCalledTimes(1);
expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledWith(nextConfig);
expect(hoisted.cronInstances.length).toBe(2); expect(hoisted.cronInstances.length).toBe(2);
expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1); expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1);
@@ -25,7 +25,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js"; import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js"; import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js"; import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLocalGatewayAddress } from "../../net.js"; import { isLoopbackAddress } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { import {
type ConnectParams, type ConnectParams,
@@ -347,7 +347,7 @@ export function attachGatewayWsMessageHandler(params: {
close(1008, "device signature expired"); close(1008, "device signature expired");
return; return;
} }
const nonceRequired = !isLocalGatewayAddress(remoteAddr); const nonceRequired = !isLoopbackAddress(remoteAddr);
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (nonceRequired && !providedNonce) { if (nonceRequired && !providedNonce) {
setHandshakeState("failed"); setHandshakeState("failed");
@@ -524,7 +524,7 @@ export function attachGatewayWsMessageHandler(params: {
role, role,
scopes, scopes,
remoteIp: remoteAddr, remoteIp: remoteAddr,
silent: isLocalGatewayAddress(remoteAddr), silent: isLoopbackAddress(remoteAddr),
}); });
const context = buildRequestContext(); const context = buildRequestContext();
if (pairing.request.silent === true) { if (pairing.request.silent === true) {
@@ -656,7 +656,7 @@ export function attachGatewayWsMessageHandler(params: {
if (presenceKey) { if (presenceKey) {
upsertPresence(presenceKey, { upsertPresence(presenceKey, {
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(), host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr, ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
version: connectParams.client.version, version: connectParams.client.version,
platform: connectParams.client.platform, platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily, deviceFamily: connectParams.client.deviceFamily,
+7 -7
View File
@@ -141,11 +141,9 @@ export type DiagnosticEventPayload =
| DiagnosticRunAttemptEvent | DiagnosticRunAttemptEvent
| DiagnosticHeartbeatEvent; | DiagnosticHeartbeatEvent;
export type DiagnosticEventInput = DiagnosticEventPayload extends infer Event type DiagnosticEventInput<T extends DiagnosticEventPayload = DiagnosticEventPayload> =
? Event extends DiagnosticEventPayload T extends DiagnosticEventPayload ? Omit<T, "seq" | "ts"> : never;
? Omit<Event, "seq" | "ts">
: never
: never;
let seq = 0; let seq = 0;
const listeners = new Set<(evt: DiagnosticEventPayload) => void>(); const listeners = new Set<(evt: DiagnosticEventPayload) => void>();
@@ -153,12 +151,14 @@ export function isDiagnosticsEnabled(config?: ClawdbotConfig): boolean {
return config?.diagnostics?.enabled === true; return config?.diagnostics?.enabled === true;
} }
export function emitDiagnosticEvent(event: DiagnosticEventInput) { export function emitDiagnosticEvent<T extends DiagnosticEventPayload>(
event: DiagnosticEventInput<T>,
) {
const enriched = { const enriched = {
...event, ...event,
seq: (seq += 1), seq: (seq += 1),
ts: Date.now(), ts: Date.now(),
} satisfies DiagnosticEventPayload; } as DiagnosticEventPayload;
for (const listener of listeners) { for (const listener of listeners) {
try { try {
listener(enriched); listener(enriched);
@@ -1,57 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { startHeartbeatRunner } from "./heartbeat-runner.js";
describe("startHeartbeatRunner", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("updates scheduling when config changes without restart", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runner = startHeartbeatRunner({
cfg: {
agents: { defaults: { heartbeat: { every: "30m" } } },
} as ClawdbotConfig,
runOnce: runSpy,
});
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
expect(runSpy).toHaveBeenCalledTimes(1);
expect(runSpy.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ agentId: "main", reason: "interval" }),
);
runner.updateConfig({
agents: {
defaults: { heartbeat: { every: "30m" } },
list: [
{ id: "main", heartbeat: { every: "10m" } },
{ id: "ops", heartbeat: { every: "15m" } },
],
},
} as ClawdbotConfig);
await vi.advanceTimersByTimeAsync(10 * 60_000 + 1_000);
expect(runSpy).toHaveBeenCalledTimes(2);
expect(runSpy.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ agentId: "main", heartbeat: { every: "10m" } }),
);
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1_000);
expect(runSpy).toHaveBeenCalledTimes(3);
expect(runSpy.mock.calls[2]?.[0]).toEqual(
expect.objectContaining({ agentId: "ops", heartbeat: { every: "15m" } }),
);
runner.stop();
});
});
+34 -112
View File
@@ -70,19 +70,6 @@ export type HeartbeatSummary = {
const DEFAULT_HEARTBEAT_TARGET = "last"; const DEFAULT_HEARTBEAT_TARGET = "last";
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) { function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
const list = cfg.agents?.list ?? []; const list = cfg.agents?.list ?? [];
return list.some((entry) => Boolean(entry?.heartbeat)); return list.some((entry) => Boolean(entry?.heartbeat));
@@ -552,97 +539,24 @@ export function startHeartbeatRunner(opts: {
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; 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 runtime = opts.runtime ?? defaultRuntime;
const runOnce = opts.runOnce ?? runHeartbeatOnce; const lastRunByAgent = new Map<string, number>();
const state = {
cfg: opts.cfg ?? loadConfig(),
runtime,
agents: new Map<string, HeartbeatAgentState>(),
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<string, HeartbeatAgentState>();
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) => { const run: HeartbeatWakeHandler = async (params) => {
if (!heartbeatsEnabled) { if (!heartbeatsEnabled) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
} }
if (state.agents.size === 0) { if (heartbeatAgents.length === 0) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
} }
@@ -652,44 +566,52 @@ export function startHeartbeatRunner(opts: {
const now = startedAt; const now = startedAt;
let ran = false; let ran = false;
for (const agent of state.agents.values()) { for (const agent of heartbeatAgents) {
if (isInterval && now < agent.nextDueMs) { 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; continue;
} }
const res = await runOnce({ const res = await runHeartbeatOnce({
cfg: state.cfg, cfg,
agentId: agent.agentId, agentId: agent.agentId,
heartbeat: agent.heartbeat, heartbeat: agent.heartbeat,
reason, reason,
deps: { runtime: state.runtime }, deps: { runtime },
}); });
if (res.status === "skipped" && res.reason === "requests-in-flight") { if (res.status === "skipped" && res.reason === "requests-in-flight") {
return res; return res;
} }
if (res.status !== "skipped" || res.reason !== "disabled") { if (res.status !== "skipped" || res.reason !== "disabled") {
agent.lastRunMs = now; lastRunByAgent.set(agent.agentId, now);
agent.nextDueMs = now + agent.intervalMs;
} }
if (res.status === "ran") ran = true; if (res.status === "ran") ran = true;
} }
scheduleNext();
if (ran) return { status: "ran", durationMs: Date.now() - startedAt }; if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" }; return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
}; };
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason })); 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 = () => { const cleanup = () => {
state.stopped = true;
setHeartbeatWakeHandler(null); setHeartbeatWakeHandler(null);
if (state.timer) clearTimeout(state.timer); if (timer) clearInterval(timer);
state.timer = null; timer = null;
}; };
opts.abortSignal?.addEventListener("abort", cleanup, { once: true }); opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
return { stop: cleanup, updateConfig }; return { stop: cleanup };
} }
-1
View File
@@ -338,7 +338,6 @@ export function startDiagnosticHeartbeat() {
} }
} }
}, 30_000); }, 30_000);
heartbeatInterval.unref?.();
} }
export function stopDiagnosticHeartbeat() { export function stopDiagnosticHeartbeat() {
+49 -8
View File
@@ -2,6 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const buildTelegramMessageContext = vi.hoisted(() => vi.fn()); const buildTelegramMessageContext = vi.hoisted(() => vi.fn());
const dispatchTelegramMessage = vi.hoisted(() => vi.fn()); const dispatchTelegramMessage = vi.hoisted(() => vi.fn());
const logMessageQueued = vi.hoisted(() => vi.fn());
const logMessageProcessed = vi.hoisted(() => vi.fn());
const logSessionStateChange = vi.hoisted(() => vi.fn());
const diagnosticLogger = vi.hoisted(() => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}));
vi.mock("./bot-message-context.js", () => ({ vi.mock("./bot-message-context.js", () => ({
buildTelegramMessageContext, buildTelegramMessageContext,
@@ -11,12 +19,25 @@ vi.mock("./bot-message-dispatch.js", () => ({
dispatchTelegramMessage, dispatchTelegramMessage,
})); }));
vi.mock("../logging/diagnostic.js", () => ({
diagnosticLogger,
logMessageQueued,
logMessageProcessed,
logSessionStateChange,
}));
import { createTelegramMessageProcessor } from "./bot-message.js"; import { createTelegramMessageProcessor } from "./bot-message.js";
describe("telegram bot message processor", () => { describe("telegram bot message diagnostics", () => {
beforeEach(() => { beforeEach(() => {
buildTelegramMessageContext.mockReset(); buildTelegramMessageContext.mockReset();
dispatchTelegramMessage.mockReset(); dispatchTelegramMessage.mockReset();
logMessageQueued.mockReset();
logMessageProcessed.mockReset();
logSessionStateChange.mockReset();
diagnosticLogger.info.mockReset();
diagnosticLogger.debug.mockReset();
diagnosticLogger.error.mockReset();
}); });
const baseDeps = { const baseDeps = {
@@ -42,19 +63,39 @@ describe("telegram bot message processor", () => {
resolveBotTopicsEnabled: () => false, resolveBotTopicsEnabled: () => false,
}; };
it("dispatches when context is available", async () => { it("decrements queue depth after successful processing", async () => {
buildTelegramMessageContext.mockResolvedValue({ route: { sessionKey: "agent:main:main" } }); buildTelegramMessageContext.mockResolvedValue({
route: { sessionKey: "agent:main:main" },
});
const processMessage = createTelegramMessageProcessor(baseDeps); const processMessage = createTelegramMessageProcessor(baseDeps);
await processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {}); await processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {});
expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1); expect(logMessageQueued).toHaveBeenCalledTimes(1);
expect(logSessionStateChange).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
state: "idle",
reason: "message_completed",
});
}); });
it("skips dispatch when no context is produced", async () => { it("decrements queue depth after processing error", async () => {
buildTelegramMessageContext.mockResolvedValue(null); buildTelegramMessageContext.mockResolvedValue({
route: { sessionKey: "agent:main:main" },
});
dispatchTelegramMessage.mockRejectedValue(new Error("boom"));
const processMessage = createTelegramMessageProcessor(baseDeps); const processMessage = createTelegramMessageProcessor(baseDeps);
await processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {});
expect(dispatchTelegramMessage).not.toHaveBeenCalled(); await expect(
processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {}),
).rejects.toThrow("boom");
expect(logMessageQueued).toHaveBeenCalledTimes(1);
expect(logSessionStateChange).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
state: "idle",
reason: "message_error",
});
}); });
}); });
+123 -32
View File
@@ -1,6 +1,12 @@
// @ts-nocheck // @ts-nocheck
import { buildTelegramMessageContext } from "./bot-message-context.js"; import { buildTelegramMessageContext } from "./bot-message-context.js";
import { dispatchTelegramMessage } from "./bot-message-dispatch.js"; import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
import {
diagnosticLogger as diag,
logMessageProcessed,
logMessageQueued,
logSessionStateChange,
} from "../logging/diagnostic.js";
export const createTelegramMessageProcessor = (deps) => { export const createTelegramMessageProcessor = (deps) => {
const { const {
@@ -27,37 +33,122 @@ export const createTelegramMessageProcessor = (deps) => {
} = deps; } = deps;
return async (primaryCtx, allMedia, storeAllowFrom, options) => { return async (primaryCtx, allMedia, storeAllowFrom, options) => {
const context = await buildTelegramMessageContext({ const chatId = primaryCtx?.message?.chat?.id ?? primaryCtx?.chat?.id ?? "unknown";
primaryCtx, const messageId = primaryCtx?.message?.message_id ?? "unknown";
allMedia, const startTime = Date.now();
storeAllowFrom,
options, diag.info(
bot, `process message start: channel=telegram chatId=${chatId} messageId=${messageId} mediaCount=${
cfg, allMedia?.length ?? 0
account, }`,
historyLimit, );
groupHistories,
dmPolicy, let sessionKey: string | undefined;
allowFrom,
groupAllowFrom, try {
ackReactionScope, const context = await buildTelegramMessageContext({
logger, primaryCtx,
resolveGroupActivation, allMedia,
resolveGroupRequireMention, storeAllowFrom,
resolveTelegramGroupConfig, options,
}); bot,
if (!context) return; cfg,
await dispatchTelegramMessage({ account,
context, historyLimit,
bot, groupHistories,
cfg, dmPolicy,
runtime, allowFrom,
replyToMode, groupAllowFrom,
streamMode, ackReactionScope,
textLimit, logger,
telegramCfg, resolveGroupActivation,
opts, resolveGroupRequireMention,
resolveBotTopicsEnabled, resolveTelegramGroupConfig,
}); });
if (!context) {
const durationMs = Date.now() - startTime;
diag.debug(
`process message skipped: channel=telegram chatId=${chatId} messageId=${messageId} reason=no_context`,
);
logMessageProcessed({
channel: "telegram",
chatId,
messageId,
durationMs,
outcome: "skipped",
reason: "no_context",
});
return;
}
sessionKey = context?.route?.sessionKey;
diag.info(
`process message dispatching: channel=telegram chatId=${chatId} messageId=${messageId} sessionKey=${
sessionKey ?? "unknown"
}`,
);
if (sessionKey) {
logMessageQueued({ sessionKey, channel: "telegram", source: "telegram" });
}
await dispatchTelegramMessage({
context,
bot,
cfg,
runtime,
replyToMode,
streamMode,
textLimit,
telegramCfg,
opts,
resolveBotTopicsEnabled,
});
const durationMs = Date.now() - startTime;
logMessageProcessed({
channel: "telegram",
chatId,
messageId,
sessionKey,
durationMs,
outcome: "completed",
});
if (sessionKey) {
logSessionStateChange({
sessionKey,
state: "idle",
reason: "message_completed",
});
}
diag.info(
`process message complete: channel=telegram chatId=${chatId} messageId=${messageId} sessionKey=${
sessionKey ?? "unknown"
} durationMs=${durationMs}`,
);
} catch (err) {
const durationMs = Date.now() - startTime;
logMessageProcessed({
channel: "telegram",
chatId,
messageId,
sessionKey,
durationMs,
outcome: "error",
error: String(err),
});
if (sessionKey) {
logSessionStateChange({
sessionKey,
state: "idle",
reason: "message_error",
});
}
diag.error(
`process message error: channel=telegram chatId=${chatId} messageId=${messageId} durationMs=${durationMs} error="${String(
err,
)}"`,
);
throw err;
}
}; };
}; };
+13 -25
View File
@@ -2,7 +2,6 @@ import { createServer } from "node:http";
import { webhookCallback } from "grammy"; import { webhookCallback } from "grammy";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -35,7 +34,6 @@ export async function startTelegramWebhook(opts: {
const port = opts.port ?? 8787; const port = opts.port ?? 8787;
const host = opts.host ?? "0.0.0.0"; const host = opts.host ?? "0.0.0.0";
const runtime = opts.runtime ?? defaultRuntime; const runtime = opts.runtime ?? defaultRuntime;
const diagnosticsEnabled = isDiagnosticsEnabled(opts.config);
const bot = createTelegramBot({ const bot = createTelegramBot({
token: opts.token, token: opts.token,
runtime, runtime,
@@ -47,9 +45,7 @@ export async function startTelegramWebhook(opts: {
secretToken: opts.secret, secretToken: opts.secret,
}); });
if (diagnosticsEnabled) { startDiagnosticHeartbeat();
startDiagnosticHeartbeat();
}
const server = createServer((req, res) => { const server = createServer((req, res) => {
if (req.url === healthPath) { if (req.url === healthPath) {
@@ -63,30 +59,24 @@ export async function startTelegramWebhook(opts: {
return; return;
} }
const startTime = Date.now(); const startTime = Date.now();
if (diagnosticsEnabled) { logWebhookReceived({ channel: "telegram", updateType: "telegram-post" });
logWebhookReceived({ channel: "telegram", updateType: "telegram-post" });
}
const handled = handler(req, res); const handled = handler(req, res);
if (handled && typeof (handled as Promise<void>).catch === "function") { if (handled && typeof (handled as Promise<void>).catch === "function") {
void (handled as Promise<void>) void (handled as Promise<void>)
.then(() => { .then(() => {
if (diagnosticsEnabled) { logWebhookProcessed({
logWebhookProcessed({ channel: "telegram",
channel: "telegram", updateType: "telegram-post",
updateType: "telegram-post", durationMs: Date.now() - startTime,
durationMs: Date.now() - startTime, });
});
}
}) })
.catch((err) => { .catch((err) => {
const errMsg = formatErrorMessage(err); const errMsg = formatErrorMessage(err);
if (diagnosticsEnabled) { logWebhookError({
logWebhookError({ channel: "telegram",
channel: "telegram", updateType: "telegram-post",
updateType: "telegram-post", error: errMsg,
error: errMsg, });
});
}
runtime.log?.(`webhook handler failed: ${errMsg}`); runtime.log?.(`webhook handler failed: ${errMsg}`);
if (!res.headersSent) res.writeHead(500); if (!res.headersSent) res.writeHead(500);
res.end(); res.end();
@@ -108,9 +98,7 @@ export async function startTelegramWebhook(opts: {
const shutdown = () => { const shutdown = () => {
server.close(); server.close();
void bot.stop(); void bot.stop();
if (diagnosticsEnabled) { stopDiagnosticHeartbeat();
stopDiagnosticHeartbeat();
}
}; };
if (opts.abortSignal) { if (opts.abortSignal) {
opts.abortSignal.addEventListener("abort", shutdown, { once: true }); opts.abortSignal.addEventListener("abort", shutdown, { once: true });
+1 -8
View File
@@ -1,11 +1,4 @@
import { import { Editor, type EditorOptions, type EditorTheme, type TUI, Key, matchesKey } from "@mariozechner/pi-tui";
Editor,
type EditorOptions,
type EditorTheme,
type TUI,
Key,
matchesKey,
} from "@mariozechner/pi-tui";
export class CustomEditor extends Editor { export class CustomEditor extends Editor {
onEscape?: () => void; onEscape?: () => void;
+69 -69
View File
@@ -11,7 +11,7 @@ const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;
* Check if position is at a word boundary. * Check if position is at a word boundary.
*/ */
export function isWordBoundary(text: string, index: number): boolean { export function isWordBoundary(text: string, index: number): boolean {
return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? "");
} }
/** /**
@@ -19,17 +19,17 @@ export function isWordBoundary(text: string, index: number): boolean {
* Returns null if no match. * Returns null if no match.
*/ */
export function findWordBoundaryIndex(text: string, query: string): number | null { export function findWordBoundaryIndex(text: string, query: string): number | null {
if (!query) return null; if (!query) return null;
const textLower = text.toLowerCase(); const textLower = text.toLowerCase();
const queryLower = query.toLowerCase(); const queryLower = query.toLowerCase();
const maxIndex = textLower.length - queryLower.length; const maxIndex = textLower.length - queryLower.length;
if (maxIndex < 0) return null; if (maxIndex < 0) return null;
for (let i = 0; i <= maxIndex; i++) { for (let i = 0; i <= maxIndex; i++) {
if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
return i; return i;
} }
} }
return null; return null;
} }
/** /**
@@ -37,31 +37,31 @@ export function findWordBoundaryIndex(text: string, query: string): number | nul
* Returns score (lower = better) or null if no match. * Returns score (lower = better) or null if no match.
*/ */
export function fuzzyMatchLower(queryLower: string, textLower: string): number | null { export function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
if (queryLower.length === 0) return 0; if (queryLower.length === 0) return 0;
if (queryLower.length > textLower.length) return null; if (queryLower.length > textLower.length) return null;
let queryIndex = 0; let queryIndex = 0;
let score = 0; let score = 0;
let lastMatchIndex = -1; let lastMatchIndex = -1;
let consecutiveMatches = 0; let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) { if (textLower[i] === queryLower[queryIndex]) {
const isAtWordBoundary = isWordBoundary(textLower, i); const isAtWordBoundary = isWordBoundary(textLower, i);
if (lastMatchIndex === i - 1) { if (lastMatchIndex === i - 1) {
consecutiveMatches++; consecutiveMatches++;
score -= consecutiveMatches * 5; // Reward consecutive matches score -= consecutiveMatches * 5; // Reward consecutive matches
} else { } else {
consecutiveMatches = 0; consecutiveMatches = 0;
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps
} }
if (isAtWordBoundary) score -= 10; // Reward word boundary matches if (isAtWordBoundary) score -= 10; // Reward word boundary matches
score += i * 0.1; // Slight penalty for later matches score += i * 0.1; // Slight penalty for later matches
lastMatchIndex = i; lastMatchIndex = i;
queryIndex++; queryIndex++;
} }
} }
return queryIndex < queryLower.length ? null : score; return queryIndex < queryLower.length ? null : score;
} }
/** /**
@@ -69,46 +69,46 @@ export function fuzzyMatchLower(queryLower: string, textLower: string): number |
* Supports space-separated tokens (all must match). * Supports space-separated tokens (all must match).
*/ */
export function fuzzyFilterLower<T extends { searchTextLower?: string }>( export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
items: T[], items: T[],
queryLower: string, queryLower: string,
): T[] { ): T[] {
const trimmed = queryLower.trim(); const trimmed = queryLower.trim();
if (!trimmed) return items; if (!trimmed) return items;
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return items; if (tokens.length === 0) return items;
const results: { item: T; score: number }[] = []; const results: { item: T; score: number }[] = [];
for (const item of items) { for (const item of items) {
const text = item.searchTextLower ?? ""; const text = item.searchTextLower ?? "";
let totalScore = 0; let totalScore = 0;
let allMatch = true; let allMatch = true;
for (const token of tokens) { for (const token of tokens) {
const score = fuzzyMatchLower(token, text); const score = fuzzyMatchLower(token, text);
if (score !== null) { if (score !== null) {
totalScore += score; totalScore += score;
} else { } else {
allMatch = false; allMatch = false;
break; break;
} }
} }
if (allMatch) results.push({ item, score: totalScore }); if (allMatch) results.push({ item, score: totalScore });
} }
results.sort((a, b) => a.score - b.score); results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item); return results.map((r) => r.item);
} }
/** /**
* Prepare items for fuzzy filtering by pre-computing lowercase search text. * Prepare items for fuzzy filtering by pre-computing lowercase search text.
*/ */
export function prepareSearchItems< export function prepareSearchItems<T extends { label?: string; description?: string; searchText?: string }>(
T extends { label?: string; description?: string; searchText?: string }, items: T[],
>(items: T[]): (T & { searchTextLower: string })[] { ): (T & { searchTextLower: string })[] {
return items.map((item) => { return items.map((item) => {
const parts: string[] = []; const parts: string[] = [];
if (item.label) parts.push(item.label); if (item.label) parts.push(item.label);
if (item.description) parts.push(item.description); if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText); if (item.searchText) parts.push(item.searchText);
return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
}); });
} }
+4 -1
View File
@@ -5,7 +5,10 @@ import {
selectListTheme, selectListTheme,
settingsListTheme, settingsListTheme,
} from "../theme/theme.js"; } from "../theme/theme.js";
import { FilterableSelectList, type FilterableSelectItem } from "./filterable-select-list.js"; import {
FilterableSelectList,
type FilterableSelectItem,
} from "./filterable-select-list.js";
import { SearchableSelectList } from "./searchable-select-list.js"; import { SearchableSelectList } from "./searchable-select-list.js";
export function createSelectList(items: SelectItem[], maxVisible = 7) { export function createSelectList(items: SelectItem[], maxVisible = 7) {
+12 -12
View File
@@ -1,15 +1,15 @@
export function formatRelativeTime(timestamp: number): string { export function formatRelativeTime(timestamp: number): string {
const now = Date.now(); const now = Date.now();
const diff = now - timestamp; const diff = now - timestamp;
const seconds = Math.floor(diff / 1000); const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (seconds < 60) return "just now"; if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`; if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday"; if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`; if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
} }
-10
View File
@@ -62,7 +62,6 @@ export function applySettingsFromUrl(host: SettingsHost) {
const tokenRaw = params.get("token"); const tokenRaw = params.get("token");
const passwordRaw = params.get("password"); const passwordRaw = params.get("password");
const sessionRaw = params.get("session"); const sessionRaw = params.get("session");
const gatewayUrlRaw = params.get("gatewayUrl");
let shouldCleanUrl = false; let shouldCleanUrl = false;
if (tokenRaw != null) { if (tokenRaw != null) {
@@ -95,15 +94,6 @@ export function applySettingsFromUrl(host: SettingsHost) {
} }
} }
if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
applySettings(host, { ...host.settings, gatewayUrl });
}
params.delete("gatewayUrl");
shouldCleanUrl = true;
}
if (!shouldCleanUrl) return; if (!shouldCleanUrl) return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.search = params.toString(); url.search = params.toString();
+2 -1
View File
@@ -5,6 +5,7 @@ import { truncateText } from "./format";
marked.setOptions({ marked.setOptions({
gfm: true, gfm: true,
breaks: true, breaks: true,
headerIds: false,
mangle: false, mangle: false,
}); });
@@ -36,7 +37,7 @@ const allowedTags = [
"ul", "ul",
]; ];
const allowedAttrs = ["class", "href", "rel", "target", "title", "start"]; const allowedAttrs = ["class", "href", "rel", "target", "title"];
let hooksInstalled = false; let hooksInstalled = false;
const MARKDOWN_CHAR_LIMIT = 140_000; const MARKDOWN_CHAR_LIMIT = 140_000;