Compare commits

..
Author SHA1 Message Date
Peter Steinberger 2ce267400c fix: allow token auth without device identity (#1314) (thanks @dbhurley) 2026-01-21 02:44:28 +00:00
David HurleyandClaude Opus 4.5 d353db42ba fix: allow token auth to bypass device identity requirement
The device identity check was rejecting connections before token
authentication could be attempted. This broke the control-ui (web UI)
which uses token-based authentication via URL parameter.

Changes:
- Skip device identity requirement when a token is provided
- Guard device token verification to only run when device is present

Fixes control-ui showing "device identity required" error when
connecting with a valid token.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 02:20:56 +00:00
Peter Steinberger e81ca7ab00 fix: tame invalid config logging 2026-01-21 01:58:47 +00:00
Peter Steinberger 27acfa59c5 chore: update a2ui bundle 2026-01-21 01:33:31 +00:00
Peter Steinberger b333c4a994 fix: address config settings viewbuilder updates 2026-01-21 01:33:31 +00:00
Peter Steinberger 23f7dd8b25 fix: pass threadId in restart sentinel 2026-01-21 01:26:15 +00:00
Peter Steinberger 77d9451712 docs: fix mdx in security cli doc 2026-01-21 01:24:29 +00:00
Peter Steinberger a4fc2b4536 fix: handle fetch streams safely 2026-01-21 01:24:29 +00:00
Peter Steinberger 20a7dd8a80 feat: add config subsections in control ui 2026-01-21 01:22:19 +00:00
Peter Steinberger 450d2d25e2 feat: add sectioned config layout in mac app 2026-01-21 01:22:19 +00:00
Peter Steinberger df024afc97 fix: note gatewayUrl param in control ui (#1342) (thanks @ameno-) 2026-01-21 01:21:57 +00:00
Peter Steinberger 12168dc64f Merge pull request #1337 from John-Rood/fix/restart-channel-routing
fix: preserve channel routing across gateway restart
2026-01-21 01:21:35 +00:00
Peter Steinberger 4232081fcb Merge pull request #1342 from ameno-/fix-webchat-gateway-url
fix(ui): parse gatewayUrl from URL params
2026-01-21 01:20:25 +00:00
17f3635109 fix: preserve restart routing + thread replies (#1337) (thanks @John-Rood)
Co-authored-by: John-Rood <John-Rood@users.noreply.github.com>
Co-authored-by: Outdoor <outdoor@users.noreply.github.com>
2026-01-21 01:20:25 +00:00
Peter Steinberger 9206d21c76 fix: narrow cron payload merge types 2026-01-21 01:14:24 +00:00
Peter SteinbergerandFelix Krause 96be166bd6 fix: refactor cron edit payload patches
Co-authored-by: Felix Krause <869950+KrauseFx@users.noreply.github.com>
2026-01-21 01:14:24 +00:00
Peter SteinbergerandSeb Slight d8abd53a1d fix: reschedule heartbeat on hot reload
Co-authored-by: Seb Slight <sebslight@users.noreply.github.com>
2026-01-21 00:53:54 +00:00
Peter Steinberger eff292eda4 Merge pull request #1338 from MaudeCode/fix/message-command-plugins
fix(cli): load plugin registry for message/channels commands
2026-01-21 00:49:04 +00:00
Ameno OsmanandClaude Opus 4.5 c74551c2ae fix(ui): parse gatewayUrl from URL params for remote gateway access
Adds support for passing `gatewayUrl` as a URL parameter to the WebChat UI,
allowing the control-ui to connect to a remote gateway (e.g., VPS) instead
of defaulting to localhost.

Usage: http://localhost:5173/?gatewayUrl=ws://<vps-ip>:18789&token=<token>

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 16:35:02 -08:00
Peter Steinberger 48b0d08493 fix: resolve ReadableStream cast in skills install 2026-01-21 00:33:22 +00:00
Peter Steinberger dd38185e6c docs: add diagnostics changelog entry 2026-01-21 00:30:59 +00:00
Peter Steinberger ec01e5c7e6 fix: emit diagnostics across channels 2026-01-21 00:30:34 +00:00
Peter Steinberger e447233533 chore: sync protocol outputs 2026-01-21 00:19:53 +00:00
Peter Steinberger 00bcb01bb4 docs: clarify whatsapp voice notes and wsl portproxy 2026-01-21 00:14:36 +00:00
Peter Steinberger 458850483a feat: add sherpa-onnx-tts skill 2026-01-21 00:14:36 +00:00
Peter Steinberger 76bae8da40 feat: add download installs for skills 2026-01-21 00:14:36 +00:00
Peter Steinberger c33c0629ec fix: treat tailnet host as local for pairing 2026-01-21 00:14:15 +00:00
Peter Steinberger e5ea8a0d22 docs: add network hub + pairing locality 2026-01-21 00:14:14 +00:00
Peter SteinbergerandBradley Priest e083f678fd fix(ui): preserve ordered list numbering (#1341) - thanks @bradleypriest
Co-authored-by: Bradley Priest <bradleypriest@gmail.com>
2026-01-21 00:12:21 +00:00
Maude Bot 4ad9622efb 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 16:41:15 -05:00
90 changed files with 19982 additions and 16057 deletions

No files matched your search

+9 -1
View File
@@ -18,20 +18,28 @@ Docs: https://docs.clawd.bot
- 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.
- 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
- 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: 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: 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.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: allow token-auth Control UI connections without device identity. (#1314) — thanks @dbhurley.
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- 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).
- 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.
- 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)
- 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)
+320 -24
View File
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore
@State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
init(store: ChannelsStore = .shared) {
self.store = store
}
var body: some View {
ScrollView {
self.content
HStack(spacing: 16) {
self.sidebar
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
await self.store.loadConfigSchema()
await self.store.loadConfig()
}
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
}
}
}
extension ConfigSettings {
private var content: some View {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
Group {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
.disabled(self.isNixMode)
} else {
Text("Schema unavailable.")
private enum SubsectionSelection: Hashable {
case all
case key(String)
}
private struct ConfigSection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
var id: String { self.key }
}
private struct ConfigSubsection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
let path: ConfigPath
var id: String { self.key }
}
private var sections: [ConfigSection] {
guard let schema = self.store.configSchema else { return [] }
return self.resolveSections(schema)
}
private var activeSection: ConfigSection? {
self.sections.first { $0.key == self.activeSectionKey }
}
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
}
}
}
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configSchema != nil {
self.emptyDetail
} else {
Text("Schema unavailable.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text("Select a config section to view settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
}
@ViewBuilder
@@ -71,6 +158,18 @@ extension ConfigSettings {
.foregroundStyle(.secondary)
}
private func sectionHeader(_ section: ConfigSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.label)
.font(.title3.weight(.semibold))
if let help = section.help {
Text(help)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private var actionRow: some View {
HStack(spacing: 10) {
Button("Reload") {
@@ -85,6 +184,203 @@ extension ConfigSettings {
}
.buttonStyle(.bordered)
}
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key }) {
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
}()
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
.disabled(self.isNixMode)
}
private func ensureSelection() {
guard let schema = self.store.configSchema else { return }
let sections = self.resolveSections(schema)
guard !sections.isEmpty else { return }
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
if self.activeSectionKey != active.key {
self.activeSectionKey = active.key
}
self.ensureSubsection(for: active)
}
private func ensureSubsection(for section: ConfigSection) {
let subsections = self.resolveSubsections(for: section)
guard !subsections.isEmpty else {
self.activeSubsection = nil
return
}
switch self.activeSubsection {
case .all:
return
case let .key(key):
if subsections.contains(where: { $0.key == key }) { return }
case .none:
break
}
if let first = subsections.first {
self.activeSubsection = .key(first.key)
}
}
private func selectSection(_ section: ConfigSection) {
guard self.activeSectionKey != section.key else { return }
self.activeSectionKey = section.key
let subsections = self.resolveSubsections(for: section)
if let first = subsections.first {
self.activeSubsection = .key(first.key)
} else {
self.activeSubsection = nil
}
}
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
let node = self.resolvedSchemaNode(root)
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSection(key: key, label: label, help: help, node: child)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
let node = self.resolvedSchemaNode(section.node)
guard node.schemaType == "object" else { return [] }
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(section.key), .key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSubsection(
key: key,
label: label,
help: help,
node: child,
path: path)
}
}
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first { return only }
}
return node
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
struct ConfigSettings_Previews: PreviewProvider {
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String?
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?,
accountid: String?,
replyaccountid: String?,
threadid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel"
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
public let spawnedby: String?
public let agentid: String?
public let search: String?
public init(
limit: Int?,
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
spawnedby: String?,
agentid: String?
agentid: String?,
search: String?
) {
self.limit = limit
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
self.spawnedby = spawnedby
self.agentid = agentid
self.search = search
}
private enum CodingKeys: String, CodingKey {
case limit
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
case spawnedby = "spawnedBy"
case agentid = "agentId"
case search
}
}
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String?
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?,
accountid: String?,
replyaccountid: String?,
threadid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel"
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
public let spawnedby: String?
public let agentid: String?
public let search: String?
public init(
limit: Int?,
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
spawnedby: String?,
agentid: String?
agentid: String?,
search: String?
) {
self.limit = limit
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
self.spawnedby = spawnedby
self.agentid = agentid
self.search = search
}
private enum CodingKeys: String, CodingKey {
case limit
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
case spawnedby = "spawnedBy"
case agentid = "agentId"
case search
}
}
@@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int
public let channelorder: [String]
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 channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable]
@@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int,
channelorder: [String],
channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable]
@@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts
self.channelorder = channelorder
self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels
self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid
@@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts
case channelorder = "channelOrder"
case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels
case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId"
+5
View File
@@ -286,6 +286,11 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- CLI: `clawdbot message send --media <mp4> --gif-playback`
- 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
- Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`.
+13
View File
@@ -14,3 +14,16 @@ Related:
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
```
+1 -1
View File
@@ -21,4 +21,4 @@ clawdbot security audit --fix
```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
+15
View File
@@ -77,6 +77,21 @@ Client Gateway
safely retry; the server keeps a short‑lived dedupe cache.
- 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
- TypeBox schemas define the protocol.
+2
View File
@@ -195,6 +195,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval
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).
- Non-local connections must sign the server-provided `connect.challenge` nonce.
+6
View File
@@ -270,6 +270,12 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access.
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:
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
+51
View File
@@ -0,0 +1,51 @@
---
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,6 +49,50 @@ Repair/migrate:
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
### 1) Install WSL2 + Ubuntu
+1
View File
@@ -32,6 +32,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Core concepts
- [Architecture](/concepts/architecture)
- [Network hub](/network)
- [Agent runtime](/concepts/agent)
- [Agent workspace](/concepts/agent-workspace)
- [Memory](/concepts/memory)
+4 -1
View File
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
- `requires.env` — list; env var must exist **or** be provided in config.
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
- `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).
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download).
Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time.
@@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
Notes:
- 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).
This only affects **skill installs**; the Gateway runtime should still be Node
(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.
- 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
disabled in config or blocked by `skills.allowBundled` for bundled skills).
+49
View File
@@ -0,0 +1,49 @@
---
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
@@ -0,0 +1,178 @@
#!/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);
+163 -1
View File
@@ -1,10 +1,13 @@
import fs from "node:fs";
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 { resolveBrewExecutable } from "../infra/brew.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js";
import {
hasBinary,
loadWorkspaceSkillEntries,
@@ -13,6 +16,7 @@ import {
type SkillInstallSpec,
type SkillsInstallPreferences,
} from "./skills.js";
import { resolveSkillKey } from "./skills/frontmatter.js";
export type SkillInstallRequest = {
workspaceDir: string;
@@ -30,6 +34,10 @@ export type SkillInstallResult = {
code: number | null;
};
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function");
}
function summarizeInstallOutput(text: string): string | undefined {
const raw = text.trim();
if (!raw) return undefined;
@@ -112,11 +120,162 @@ function buildInstallCommand(
if (!spec.package) return { argv: null, error: "missing uv package" };
return { argv: ["uv", "tool", "install", spec.package] };
}
case "download": {
return { argv: null, error: "download install handled separately" };
}
default:
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 as unknown;
const readable = isNodeReadableStream(body)
? body
: Readable.fromWeb(body 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> {
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
if (!exe) return undefined;
@@ -167,6 +326,9 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
code: null,
};
}
if (spec.kind === "download") {
return await installDownloadSpec({ entry, spec, timeoutMs });
}
const prefs = resolveSkillsInstallPreferences(params.config);
const command = buildInstallCommand(spec, prefs);
+40 -27
View File
@@ -100,36 +100,49 @@ function normalizeInstallOptions(
): SkillInstallOption[] {
const install = entry.clawdbot?.install ?? [];
if (install.length === 0) return [];
const preferred = selectPreferredInstallSpec(install, prefs);
if (!preferred) return [];
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) {
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})`;
} 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";
}
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));
}
return [
{
id,
kind: spec.kind,
label,
bins,
},
];
const preferred = selectPreferredInstallSpec(filtered, prefs);
if (!preferred) return [];
return [toOption(preferred.spec, preferred.index)];
}
function buildSkillStatus(
@@ -109,4 +109,33 @@ 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([]);
}
});
});
+8 -1
View File
@@ -35,7 +35,7 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
const kindRaw =
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
const kind = kindRaw.trim().toLowerCase();
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") {
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") {
return undefined;
}
@@ -47,9 +47,16 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
if (typeof raw.label === "string") spec.label = raw.label;
const bins = normalizeStringList(raw.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.package === "string") spec.package = raw.package;
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;
}
+7 -1
View File
@@ -2,12 +2,18 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
export type SkillInstallSpec = {
id?: string;
kind: "brew" | "node" | "go" | "uv";
kind: "brew" | "node" | "go" | "uv" | "download";
label?: string;
bins?: string[];
os?: string[];
formula?: string;
package?: string;
module?: string;
url?: string;
archive?: string;
extract?: boolean;
stripComponents?: number;
targetDir?: string;
};
export type ClawdbotSkillMetadata = {
+2 -2
View File
@@ -65,7 +65,7 @@ describe("cron tool", () => {
data: {
name: "wake-up",
schedule: { atMs: 123 },
payload: { text: "hello" },
payload: { kind: "systemEvent", text: "hello" },
},
},
});
@@ -105,7 +105,7 @@ describe("cron tool", () => {
job: {
name: "reminder",
schedule: { atMs: 123 },
payload: { text: "Reminder: the thing." },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
+33
View File
@@ -3,6 +3,8 @@ import crypto from "node:crypto";
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/io.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import {
formatDoctorNonInteractiveHint,
@@ -77,11 +79,42 @@ export function createGatewayTool(opts?: {
: undefined;
const note =
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
// Extract channel + threadId for routing after restart
let deliveryContext: { channel?: string; to?: string; accountId?: string } | undefined;
let threadId: string | undefined;
if (sessionKey) {
const threadMarker = ":thread:";
const threadIndex = sessionKey.lastIndexOf(threadMarker);
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
const threadIdRaw =
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
threadId = threadIdRaw?.trim() || undefined;
try {
const cfg = loadConfig();
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
let entry = store[sessionKey];
if (!entry?.deliveryContext && threadIndex !== -1 && baseSessionKey) {
entry = store[baseSessionKey];
}
if (entry?.deliveryContext) {
deliveryContext = {
channel: entry.deliveryContext.channel,
to: entry.deliveryContext.to,
accountId: entry.deliveryContext.accountId,
};
}
} catch {
// ignore: best-effort
}
}
const payload: RestartSentinelPayload = {
kind: "restart",
status: "ok",
ts: Date.now(),
sessionKey,
deliveryContext,
threadId,
message: note ?? reason ?? null,
doctorHint: formatDoctorNonInteractiveHint(),
stats: {
@@ -13,6 +13,11 @@ const mocks = vi.hoisted(() => ({
aborted: false,
})),
}));
const diagnosticMocks = vi.hoisted(() => ({
logMessageQueued: vi.fn(),
logMessageProcessed: vi.fn(),
logSessionStateChange: vi.fn(),
}));
vi.mock("./route-reply.js", () => ({
isRoutableChannel: (channel: string | undefined) =>
@@ -34,6 +39,12 @@ 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 { resetInboundDedupe } = await import("./inbound-dedupe.js");
@@ -50,6 +61,9 @@ function createDispatcher(): ReplyDispatcher {
describe("dispatchReplyFromConfig", () => {
beforeEach(() => {
resetInboundDedupe();
diagnosticMocks.logMessageQueued.mockReset();
diagnosticMocks.logMessageProcessed.mockReset();
diagnosticMocks.logSessionStateChange.mockReset();
});
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
@@ -186,4 +200,74 @@ describe("dispatchReplyFromConfig", () => {
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",
}),
);
});
});
+147 -82
View File
@@ -1,5 +1,11 @@
import type { ClawdbotConfig } from "../../config/config.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 type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -21,8 +27,55 @@ export async function dispatchReplyFromConfig(params: {
replyResolver?: typeof getReplyFromConfig;
}): Promise<DispatchFromConfigResult> {
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)) {
recordProcessed("skipped", { reason: "duplicate" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
@@ -68,95 +121,107 @@ export async function dispatchReplyFromConfig(params: {
}
};
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
if (fastAbort.handled) {
const payload = {
text: formatAbortReplyText(fastAbort.stoppedSubagents),
} satisfies ReplyPayload;
markProcessing();
try {
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
if (fastAbort.handled) {
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 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"}`,
);
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;
}
} else {
queuedFinal = dispatcher.sendFinalReply(payload);
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
recordProcessed("completed");
markIdle("message_completed");
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 };
}
+1 -1
View File
@@ -1 +1 @@
c1cdb1b463e70d87976d88abf13e373e774c057e6796c947227c56b459af9d77
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,4 +1,5 @@
import type { Command } from "commander";
import { listChannelPlugins } from "../channels/plugins/index.js";
import {
channelsAddCommand,
channelsCapabilitiesCommand,
@@ -12,11 +13,9 @@ import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { resolveCliChannelOptions } from "./channel-options.js";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { hasExplicitOptions } from "./command-options.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
const optionNamesAdd = [
"channel",
@@ -59,7 +58,9 @@ function runChannelsCommandWithDanger(action: () => Promise<void>, label: string
}
export function registerChannelsCli(program: Command) {
const channelNames = resolveCliChannelOptions().join("|");
const channelNames = listChannelPlugins()
.map((plugin) => plugin.id)
.join("|");
const channels = program
.command("channels")
.description("Manage chat channel accounts")
@@ -71,7 +72,6 @@ export function registerChannelsCli(program: Command) {
"docs.clawd.bot/cli/channels",
)}\n`,
);
markCommandRequiresPluginRegistry(channels);
channels
.command("list")
+59 -3
View File
@@ -173,7 +173,7 @@ describe("cron cli", () => {
expect(clearPatch?.patch?.agentId).toBeNull();
});
it("does not include model/thinking when no payload change is requested", async () => {
it("allows model/thinking updates without --message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
@@ -186,8 +186,64 @@ describe("cron cli", () => {
});
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as { patch?: { payload?: unknown } };
const patch = updateCall?.[2] as {
patch?: { payload?: { kind?: string; model?: string; thinking?: string } };
};
expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
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);
});
});
+42 -27
View File
@@ -38,14 +38,15 @@ export function registerCronEditCommand(cron: Command) {
.option(
"--deliver",
"Deliver agent output (required when using last-route delivery without --to)",
false,
)
.option("--no-deliver", "Disable delivery")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option(
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option("--best-effort-deliver", "Do not fail job if delivery fails", false)
.option("--best-effort-deliver", "Do not fail job if delivery fails")
.option("--no-best-effort-deliver", "Fail job when delivery fails")
.option("--post-prefix <prefix>", "Prefix for summary system event")
.action(async (id, opts) => {
try {
@@ -105,35 +106,49 @@ export function registerCronEditCommand(cron: Command) {
};
}
const payloadChosen = [opts.systemEvent, opts.message].filter(Boolean).length;
if (payloadChosen > 1) throw new Error("Choose at most one payload change");
if (opts.systemEvent) {
const hasSystemEventPatch = typeof opts.systemEvent === "string";
const model =
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined;
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 = {
kind: "systemEvent",
text: String(opts.systemEvent),
};
} else if (opts.message) {
const model =
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined;
const thinking =
typeof opts.thinking === "string" && opts.thinking.trim()
? opts.thinking.trim()
: undefined;
const timeoutSeconds = opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined;
patch.payload = {
kind: "agentTurn",
message: String(opts.message),
model,
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,
};
} else if (hasAgentTurnPatch) {
const payload: Record<string, unknown> = { kind: "agentTurn" };
if (typeof opts.message === "string") payload.message = String(opts.message);
if (model) payload.model = model;
if (thinking) payload.thinking = thinking;
if (hasTimeoutSeconds) {
payload.timeoutSeconds = timeoutSeconds;
}
if (typeof opts.deliver === "boolean") payload.deliver = opts.deliver;
if (typeof opts.channel === "string") payload.channel = opts.channel;
if (typeof opts.to === "string") payload.to = opts.to;
if (typeof opts.bestEffortDeliver === "boolean") {
payload.bestEffortDeliver = opts.bestEffortDeliver;
}
patch.payload = payload;
}
if (typeof opts.postPrefix === "string") {
+3 -2
View File
@@ -1,12 +1,13 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import type { CronJob, CronSchedule } from "../../cron/types.js";
import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { resolveCliChannelOptions } from "../channel-options.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { callGatewayFromCli } from "../gateway-rpc.js";
export const getCronChannelOptions = () => ["last", ...resolveCliChannelOptions()].join("|");
export const getCronChannelOptions = () =>
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
try {
-2
View File
@@ -8,7 +8,6 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
function parseLimit(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
@@ -43,7 +42,6 @@ export function registerDirectoryCli(program: Command) {
.action(() => {
directory.help({ error: true });
});
markCommandRequiresPluginRegistry(directory);
const withChannel = (cmd: Command) =>
cmd
+4 -12
View File
@@ -34,16 +34,12 @@ vi.mock("../channels/plugins/index.js", () => ({
normalizeChannelId,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn().mockReturnValue({}),
};
});
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn().mockReturnValue({}),
}));
describe("pairing cli", () => {
it("defers pairing channel lookup until command execution", async () => {
it("evaluates pairing channels when registering the CLI (not at import)", async () => {
listPairingChannels.mockClear();
const { registerPairingCli } = await import("./pairing-cli.js");
@@ -53,10 +49,6 @@ describe("pairing cli", () => {
program.name("test");
registerPairingCli(program);
expect(listPairingChannels).not.toHaveBeenCalled();
listChannelPairingRequests.mockResolvedValueOnce([]);
await program.parseAsync(["pairing", "list", "telegram"], { from: "user" });
expect(listPairingChannels).toHaveBeenCalledTimes(1);
});
+4 -11
View File
@@ -10,9 +10,7 @@ import {
} from "../pairing/pairing-store.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { resolveCliChannelOptions } from "./channel-options.js";
import { formatCliCommand } from "./command-format.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
/** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
@@ -46,9 +44,7 @@ async function notifyApproved(channel: PairingChannel, id: string) {
}
export function registerPairingCli(program: Command) {
const channelOptions = resolveCliChannelOptions();
const channelHint =
channelOptions.length > 0 ? `Channel (${channelOptions.join(", ")})` : "Channel";
const channels = listPairingChannels();
const pairing = program
.command("pairing")
.description("Secure DM pairing (approve inbound requests)")
@@ -57,16 +53,14 @@ export function registerPairingCli(program: Command) {
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`,
);
markCommandRequiresPluginRegistry(pairing);
pairing
.command("list")
.description("List pending pairing requests")
.option("--channel <channel>", channelHint)
.argument("[channel]", channelHint)
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
.argument("[channel]", `Channel (${channels.join(", ")})`)
.option("--json", "Print JSON", false)
.action(async (channelArg, opts) => {
const channels = listPairingChannels();
const channelRaw = opts.channel ?? channelArg;
if (!channelRaw) {
throw new Error(
@@ -93,12 +87,11 @@ export function registerPairingCli(program: Command) {
pairing
.command("approve")
.description("Approve a pairing code and allow that sender")
.option("--channel <channel>", channelHint)
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
.option("--notify", "Notify the requester on the same channel", false)
.action(async (codeOrChannel, code, opts) => {
const channels = listPairingChannels();
const channelRaw = opts.channel ?? codeOrChannel;
const resolvedCode = opts.channel ? codeOrChannel : code;
if (!opts.channel && !code) {
-24
View File
@@ -1,24 +0,0 @@
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
@@ -1,21 +0,0 @@
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,7 +86,6 @@ const routeSessions: RouteSpec = {
const routeAgentsList: RouteSpec = {
match: (path) => path[0] === "agents" && path[1] === "list",
loadPlugins: true,
run: async (argv) => {
const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings");
-56
View File
@@ -1,56 +0,0 @@
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);
});
});
+6 -4
View File
@@ -2,9 +2,8 @@ import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js";
import { getCommandPath, hasHelpOrVersion } from "../argv.js";
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
import { commandRequiresPluginRegistry } from "./command-metadata.js";
import { ensureConfigReady } from "./config-guard.js";
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand;
@@ -16,17 +15,20 @@ function setProcessTitleForCommand(actionCommand: Command) {
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) {
program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand);
emitCliBanner(programVersion);
const argv = process.argv;
if (hasHelpOrVersion(argv)) return;
const needsPlugins = commandRequiresPluginRegistry(actionCommand);
const commandPath = getCommandPath(argv, 2);
if (commandPath[0] === "doctor") return;
await ensureConfigReady({ runtime: defaultRuntime, commandPath });
if (needsPlugins) {
// Load plugins for commands that need channel access
if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
ensurePluginRegistryLoaded();
}
});
+1 -4
View File
@@ -14,11 +14,10 @@ import { theme } from "../../terminal/theme.js";
import { hasExplicitOptions } from "../command-options.js";
import { createDefaultDeps } from "../deps.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { collectOption } from "./helpers.js";
export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) {
const agent = program
program
.command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)")
.requiredOption("-m, --message <text>", "Message body for the agent")
@@ -68,7 +67,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
await agentCliCommand(opts, defaultRuntime, deps);
});
});
markCommandRequiresPluginRegistry(agent);
const agents = program
.command("agents")
@@ -78,7 +76,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agents", "docs.clawd.bot/cli/agents")}\n`,
);
markCommandRequiresPluginRegistry(agents);
agents
.command("list")
+1 -3
View File
@@ -8,10 +8,9 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
export function registerConfigureCommand(program: Command) {
const configure = program
program
.command("configure")
.description("Interactive prompt to set up credentials, devices, and agent defaults")
.addHelpText(
@@ -49,5 +48,4 @@ export function registerConfigureCommand(program: Command) {
await configureCommandWithSections(sections as never, defaultRuntime);
});
});
markCommandRequiresPluginRegistry(configure);
}
-2
View File
@@ -2,7 +2,6 @@ import type { Command } from "commander";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import type { ProgramContext } from "./context.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { createMessageCliHelpers } from "./message/helpers.js";
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
import {
@@ -40,7 +39,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/mes
.action(() => {
message.help({ error: true });
});
markCommandRequiresPluginRegistry(message);
const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions);
registerMessageSendCommand(message, helpers);
+1 -3
View File
@@ -12,7 +12,6 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
function resolveInstallDaemonFlag(
command: unknown,
@@ -33,7 +32,7 @@ function resolveInstallDaemonFlag(
}
export function registerOnboardCommand(program: Command) {
const onboard = program
program
.command("onboard")
.description("Interactive wizard to set up the gateway, workspace, and skills")
.addHelpText(
@@ -151,5 +150,4 @@ export function registerOnboardCommand(program: Command) {
);
});
});
markCommandRequiresPluginRegistry(onboard);
}
@@ -7,7 +7,6 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { parsePositiveIntOrUndefined } from "./helpers.js";
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
@@ -25,7 +24,7 @@ function parseTimeoutMs(timeout: unknown): number | null | undefined {
}
export function registerStatusHealthSessionsCommands(program: Command) {
const status = program
program
.command("status")
.description("Show channel health and recent session recipients")
.option("--json", "Output JSON instead of text", false)
@@ -73,9 +72,8 @@ Examples:
);
});
});
markCommandRequiresPluginRegistry(status);
const health = program
program
.command("health")
.description("Fetch health from the running gateway")
.option("--json", "Output JSON instead of text", false)
@@ -105,7 +103,6 @@ Examples:
);
});
});
markCommandRequiresPluginRegistry(health);
program
.command("sessions")
+2 -1
View File
@@ -7,6 +7,7 @@ import { normalizeEnv } from "../infra/env.js";
import { isMainModule } from "../infra/is-main.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { tryRouteCli } from "./route.js";
@@ -42,7 +43,7 @@ export async function runCli(argv: string[] = process.argv) {
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1);
});
+1 -3
View File
@@ -5,7 +5,6 @@ import { sandboxExplainCommand } from "../commands/sandbox-explain.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
// --- Types ---
@@ -143,7 +142,7 @@ export function registerSandboxCli(program: Command) {
// --- Explain Command ---
const explain = sandbox
sandbox
.command("explain")
.description("Explain effective sandbox/tool policy for a session/agent")
.option("--session <key>", "Session key to inspect (defaults to agent main)")
@@ -162,5 +161,4 @@ export function registerSandboxCli(program: Command) {
),
),
);
markCommandRequiresPluginRegistry(explain);
}
-2
View File
@@ -8,7 +8,6 @@ import { fixSecurityFootguns } from "../security/fix.js";
import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
type SecurityAuditOptions = {
json?: boolean;
@@ -37,7 +36,6 @@ export function registerSecurityCli(program: Command) {
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.clawd.bot/cli/security")}\n`,
);
markCommandRequiresPluginRegistry(security);
security
.command("audit")
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
/** Image content block for Claude API multimodal messages. */
export type ImageContent = {
+9 -2
View File
@@ -57,6 +57,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
];
const CONFIG_BACKUP_COUNT = 5;
const loggedInvalidConfigs = new Set<string>();
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
@@ -244,8 +245,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const details = validated.issues
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
.join("\n");
deps.logger.error(`Invalid config:\\n${details}`);
throw new Error("Invalid config");
if (!loggedInvalidConfigs.has(configPath)) {
loggedInvalidConfigs.add(configPath);
deps.logger.error(`Invalid config:\\n${details}`);
}
const error = new Error("Invalid config");
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
(error as { code?: string; details?: string }).details = details;
throw error;
}
if (validated.warnings.length > 0) {
const details = validated.warnings
-6
View File
@@ -51,12 +51,6 @@ function coerceSchedule(schedule: UnknownRecord) {
function coercePayload(payload: UnknownRecord) {
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.
migrateLegacyCronPayload(next);
return next;
+64 -2
View File
@@ -1,7 +1,13 @@
import crypto from "node:crypto";
import { computeNextRunAtMs } from "../schedule.js";
import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js";
import type {
CronJob,
CronJobCreate,
CronJobPatch,
CronPayload,
CronPayloadPatch,
} from "../types.js";
import {
normalizeOptionalAgentId,
normalizeOptionalText,
@@ -103,7 +109,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
if (patch.schedule) job.schedule = patch.schedule;
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
if (patch.wakeMode) job.wakeMode = patch.wakeMode;
if (patch.payload) job.payload = patch.payload;
if (patch.payload) job.payload = mergeCronPayload(job.payload, patch.payload);
if (patch.isolation) job.isolation = patch.isolation;
if (patch.state) job.state = { ...job.state, ...patch.state };
if ("agentId" in patch) {
@@ -112,6 +118,62 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
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 }) {
if (opts.forced) return true;
return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs;
+18 -3
View File
@@ -25,6 +25,20 @@ export type CronPayload =
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 = {
postToMainPrefix?: string;
/**
@@ -72,6 +86,7 @@ export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" |
state?: Partial<CronJobState>;
};
export type CronJobPatch = Partial<
Omit<CronJob, "id" | "createdAtMs" | "state"> & { state: CronJobState }
>;
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "state" | "payload">> & {
payload?: CronPayloadPatch;
state?: Partial<CronJobState>;
};
+41
View File
@@ -0,0 +1,41 @@
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);
});
});
+17 -1
View File
@@ -1,6 +1,6 @@
import net from "node:net";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
export function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false;
@@ -11,6 +11,22 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
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.
*
+43 -2
View File
@@ -52,6 +52,30 @@ 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(
{
postToMainPrefix: Type.Optional(Type.String()),
@@ -120,18 +144,35 @@ export const CronAddParamsSchema = Type.Object(
{ 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([
Type.Object(
{
id: NonEmptyString,
patch: Type.Partial(CronAddParamsSchema),
patch: CronJobPatchSchema,
},
{ additionalProperties: false },
),
Type.Object(
{
jobId: NonEmptyString,
patch: Type.Partial(CronAddParamsSchema),
patch: CronJobPatchSchema,
},
{ additionalProperties: false },
),
+73 -73
View File
@@ -3,92 +3,92 @@ import { Type } from "@sinclair/typebox";
import { NonEmptyString, SessionLabelString } from "./primitives.js";
export const SessionsListParamsSchema = Type.Object(
{
limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
/**
* 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.
*/
includeDerivedTitles: Type.Optional(Type.Boolean()),
/**
* 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.
*/
includeLastMessage: Type.Optional(Type.Boolean()),
label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
search: Type.Optional(Type.String()),
},
{ additionalProperties: false },
{
limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
/**
* 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.
*/
includeDerivedTitles: Type.Optional(Type.Boolean()),
/**
* 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.
*/
includeLastMessage: Type.Optional(Type.Boolean()),
label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
search: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const SessionsResolveParamsSchema = Type.Object(
{
key: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString),
agentId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString),
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
{
key: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString),
agentId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString),
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const SessionsPatchParamsSchema = Type.Object(
{
key: NonEmptyString,
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
responseUsage: Type.Optional(
Type.Union([
Type.Literal("off"),
Type.Literal("tokens"),
Type.Literal("full"),
// Backward compat with older clients/stores.
Type.Literal("on"),
Type.Null(),
]),
),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
),
groupActivation: Type.Optional(
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
),
},
{ additionalProperties: false },
{
key: NonEmptyString,
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
responseUsage: Type.Optional(
Type.Union([
Type.Literal("off"),
Type.Literal("tokens"),
Type.Literal("full"),
// Backward compat with older clients/stores.
Type.Literal("on"),
Type.Null(),
]),
),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
),
groupActivation: Type.Optional(
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
),
},
{ additionalProperties: false },
);
export const SessionsResetParamsSchema = Type.Object(
{ key: NonEmptyString },
{ additionalProperties: false },
{ key: NonEmptyString },
{ additionalProperties: false },
);
export const SessionsDeleteParamsSchema = Type.Object(
{
key: NonEmptyString,
deleteTranscript: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
{
key: NonEmptyString,
deleteTranscript: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const SessionsCompactParamsSchema = Type.Object(
{
key: NonEmptyString,
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
{
key: NonEmptyString,
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
);
+2 -1
View File
@@ -3,6 +3,7 @@ import type { WebSocketServer } from "ws";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import type { PluginServicesHandle } from "../plugins/services.js";
export function createGatewayCloseHandler(params: {
@@ -13,7 +14,7 @@ export function createGatewayCloseHandler(params: {
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
pluginServices: PluginServicesHandle | null;
cron: { stop: () => void };
heartbeatRunner: { stop: () => void };
heartbeatRunner: HeartbeatRunner;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
tickInterval: ReturnType<typeof setInterval>;
+3 -4
View File
@@ -1,7 +1,7 @@
import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js";
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
import {
authorizeGatewaySigusr1Restart,
@@ -18,7 +18,7 @@ import { buildGatewayCronService, type GatewayCronState } from "./server-cron.js
type GatewayHotReloadState = {
hooksConfig: ReturnType<typeof resolveHooksConfig>;
heartbeatRunner: { stop: () => void };
heartbeatRunner: HeartbeatRunner;
cronState: GatewayCronState;
browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> | null;
};
@@ -57,8 +57,7 @@ export function createGatewayReloadHandlers(params: {
}
if (plan.restartHeartbeat) {
state.heartbeatRunner.stop();
nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig });
nextState.heartbeatRunner.updateConfig(nextConfig);
}
resetDirectoryCache();
+29 -2
View File
@@ -28,9 +28,30 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
return;
}
const threadMarker = ":thread:";
const threadIndex = sessionKey.lastIndexOf(threadMarker);
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
const threadIdRaw =
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
const sessionThreadId = threadIdRaw?.trim() || undefined;
const { cfg, entry } = loadSessionEntry(sessionKey);
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey);
const origin = mergeDeliveryContext(deliveryContextFromSession(entry), parsedTarget ?? undefined);
const parsedTarget = resolveAnnounceTargetFromKey(baseSessionKey);
// Prefer delivery context from sentinel (captured at restart) over session store
// Handles race condition where store wasn't flushed before restart
const sentinelContext = payload.deliveryContext;
let sessionDeliveryContext = deliveryContextFromSession(entry);
if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) {
const { entry: baseEntry } = loadSessionEntry(baseSessionKey);
sessionDeliveryContext = deliveryContextFromSession(baseEntry);
}
const origin = mergeDeliveryContext(
sentinelContext,
mergeDeliveryContext(sessionDeliveryContext, parsedTarget ?? undefined),
);
const channelRaw = origin?.channel;
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
const to = origin?.to;
@@ -51,6 +72,11 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
return;
}
const threadId =
payload.threadId ??
sessionThreadId ??
(origin?.threadId != null ? String(origin.threadId) : undefined);
try {
await agentCommand(
{
@@ -61,6 +87,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
deliver: true,
bestEffortDeliver: true,
messageChannel: channel,
threadId,
},
defaultRuntime,
params.deps,
+27
View File
@@ -102,6 +102,33 @@ describe("gateway server auth/connect", () => {
}
});
test("accepts token auth without device identity", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "secret", device: null });
expect(res.ok).toBe(true);
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("requires device identity when auth mode is none", async () => {
const { server, ws, prevToken } = await startServerWithClient();
const res = await connectReq(ws, { device: null });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("device identity required");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts password auth when configured", async () => {
testState.gatewayAuth = { mode: "password", password: "secret" };
const port = await getFreePort();
+93 -3
View File
@@ -122,7 +122,7 @@ describe("gateway server cron", () => {
data: {
name: "wrapped",
schedule: { atMs },
payload: { text: "hello" },
payload: { kind: "systemEvent", text: "hello" },
},
});
expect(addRes.ok).toBe(true);
@@ -166,7 +166,7 @@ describe("gateway server cron", () => {
id: jobId,
patch: {
schedule: { atMs },
payload: { text: "updated" },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(updateRes.ok).toBe(true);
@@ -182,6 +182,96 @@ describe("gateway server cron", () => {
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 () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
@@ -210,7 +300,7 @@ describe("gateway server cron", () => {
jobId,
patch: {
schedule: { atMs },
payload: { text: "updated" },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(updateRes.ok).toBe(true);
+14 -1
View File
@@ -13,6 +13,7 @@ import {
readConfigFileSnapshot,
writeConfigFile,
} from "../config/config.js";
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
@@ -26,6 +27,7 @@ import {
} from "../infra/skills-remote.js";
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js";
import type { PluginServicesHandle } from "../plugins/services.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -198,6 +200,10 @@ export async function startGatewayServer(
}
const cfgAtStart = loadConfig();
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
}
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
initSubagentRegistry();
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
@@ -533,5 +539,12 @@ export async function startGatewayServer(
httpServer,
});
return { close };
return {
close: async (opts) => {
if (diagnosticsEnabled) {
stopDiagnosticHeartbeat();
}
await close(opts);
},
};
}
+9 -3
View File
@@ -21,7 +21,11 @@ const hoisted = vi.hoisted(() => {
}));
const heartbeatStop = vi.fn();
const startHeartbeatRunner = vi.fn(() => ({ stop: heartbeatStop }));
const heartbeatUpdateConfig = vi.fn();
const startHeartbeatRunner = vi.fn(() => ({
stop: heartbeatStop,
updateConfig: heartbeatUpdateConfig,
}));
const startGmailWatcher = vi.fn(async () => ({ started: true }));
const stopGmailWatcher = vi.fn(async () => {});
@@ -116,6 +120,7 @@ const hoisted = vi.hoisted(() => {
browserStop,
startBrowserControlServerIfEnabled,
heartbeatStop,
heartbeatUpdateConfig,
startHeartbeatRunner,
startGmailWatcher,
stopGmailWatcher,
@@ -237,8 +242,9 @@ describe("gateway hot reload", () => {
expect(hoisted.browserStop).toHaveBeenCalledTimes(1);
expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2);
expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(2);
expect(hoisted.heartbeatStop).toHaveBeenCalledTimes(1);
expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1);
expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledTimes(1);
expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledWith(nextConfig);
expect(hoisted.cronInstances.length).toBe(2);
expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1);
@@ -25,7 +25,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress } from "../../net.js";
import { isLocalGatewayAddress } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import {
type ConnectParams,
@@ -290,9 +290,18 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role;
connectParams.scopes = scopes;
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
const allowsDeviceOptional = authOk && authMethod === "token";
const device = connectParams.device;
let devicePublicKey: string | null = null;
if (!device) {
if (!device && !allowsDeviceOptional) {
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
@@ -347,7 +356,7 @@ export function attachGatewayWsMessageHandler(params: {
close(1008, "device signature expired");
return;
}
const nonceRequired = !isLoopbackAddress(remoteAddr);
const nonceRequired = !isLocalGatewayAddress(remoteAddr);
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (nonceRequired && !providedNonce) {
setHandshakeState("failed");
@@ -458,14 +467,7 @@ export function attachGatewayWsMessageHandler(params: {
}
}
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
if (!authOk && connectParams.auth?.token) {
if (!authOk && connectParams.auth?.token && device) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
token: connectParams.auth.token,
@@ -524,7 +526,7 @@ export function attachGatewayWsMessageHandler(params: {
role,
scopes,
remoteIp: remoteAddr,
silent: isLoopbackAddress(remoteAddr),
silent: isLocalGatewayAddress(remoteAddr),
});
const context = buildRequestContext();
if (pairing.request.silent === true) {
@@ -656,7 +658,7 @@ export function attachGatewayWsMessageHandler(params: {
if (presenceKey) {
upsertPresence(presenceKey, {
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr,
version: connectParams.client.version,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
+2 -1
View File
@@ -280,7 +280,7 @@ export async function connectReq(
signature: string;
signedAt: number;
nonce?: string;
};
} | null;
},
): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto");
@@ -294,6 +294,7 @@ export async function connectReq(
const role = opts?.role ?? "operator";
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => {
if (opts?.device === null) return undefined;
if (opts?.device) return opts.device;
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
+3 -5
View File
@@ -28,6 +28,7 @@ import {
PortInUseError,
} from "./infra/ports.js";
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
import { formatUncaughtError } from "./infra/errors.js";
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
import { enableConsoleCapture } from "./logging.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js";
@@ -82,15 +83,12 @@ if (isMain) {
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1);
});
void program.parseAsync(process.argv).catch((err) => {
console.error(
"[clawdbot] CLI failed:",
err instanceof Error ? (err.stack ?? err.message) : err,
);
console.error("[clawdbot] CLI failed:", formatUncaughtError(err));
process.exit(1);
});
}
+7 -7
View File
@@ -141,9 +141,11 @@ export type DiagnosticEventPayload =
| DiagnosticRunAttemptEvent
| DiagnosticHeartbeatEvent;
type DiagnosticEventInput<T extends DiagnosticEventPayload = DiagnosticEventPayload> =
T extends DiagnosticEventPayload ? Omit<T, "seq" | "ts"> : never;
export type DiagnosticEventInput = DiagnosticEventPayload extends infer Event
? Event extends DiagnosticEventPayload
? Omit<Event, "seq" | "ts">
: never
: never;
let seq = 0;
const listeners = new Set<(evt: DiagnosticEventPayload) => void>();
@@ -151,14 +153,12 @@ export function isDiagnosticsEnabled(config?: ClawdbotConfig): boolean {
return config?.diagnostics?.enabled === true;
}
export function emitDiagnosticEvent<T extends DiagnosticEventPayload>(
event: DiagnosticEventInput<T>,
) {
export function emitDiagnosticEvent(event: DiagnosticEventInput) {
const enriched = {
...event,
seq: (seq += 1),
ts: Date.now(),
} as DiagnosticEventPayload;
} satisfies DiagnosticEventPayload;
for (const listener of listeners) {
try {
listener(enriched);
+10
View File
@@ -20,3 +20,13 @@ export function formatErrorMessage(err: unknown): string {
return Object.prototype.toString.call(err);
}
}
export function formatUncaughtError(err: unknown): string {
if (extractErrorCode(err) === "INVALID_CONFIG") {
return formatErrorMessage(err);
}
if (err instanceof Error) {
return err.stack ?? err.message ?? err.name;
}
return formatErrorMessage(err);
}
@@ -0,0 +1,57 @@
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();
});
});
+112 -34
View File
@@ -70,6 +70,19 @@ export type HeartbeatSummary = {
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) {
const list = cfg.agents?.list ?? [];
return list.some((entry) => Boolean(entry?.heartbeat));
@@ -539,24 +552,97 @@ export function startHeartbeatRunner(opts: {
cfg?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
}) {
const cfg = opts.cfg ?? loadConfig();
const heartbeatAgents = resolveHeartbeatAgents(cfg);
const intervals = heartbeatAgents
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
.filter((value): value is number => typeof value === "number");
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
if (!intervalMs) {
log.info("heartbeat: disabled", { enabled: false });
}
runOnce?: typeof runHeartbeatOnce;
}): HeartbeatRunner {
const runtime = opts.runtime ?? defaultRuntime;
const lastRunByAgent = new Map<string, number>();
const runOnce = opts.runOnce ?? runHeartbeatOnce;
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) => {
if (!heartbeatsEnabled) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
}
if (heartbeatAgents.length === 0) {
if (state.agents.size === 0) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
}
@@ -566,52 +652,44 @@ export function startHeartbeatRunner(opts: {
const now = startedAt;
let ran = false;
for (const agent of heartbeatAgents) {
const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
if (!agentIntervalMs) continue;
const lastRun = lastRunByAgent.get(agent.agentId);
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
for (const agent of state.agents.values()) {
if (isInterval && now < agent.nextDueMs) {
continue;
}
const res = await runHeartbeatOnce({
cfg,
const res = await runOnce({
cfg: state.cfg,
agentId: agent.agentId,
heartbeat: agent.heartbeat,
reason,
deps: { runtime },
deps: { runtime: state.runtime },
});
if (res.status === "skipped" && res.reason === "requests-in-flight") {
return res;
}
if (res.status !== "skipped" || res.reason !== "disabled") {
lastRunByAgent.set(agent.agentId, now);
agent.lastRunMs = now;
agent.nextDueMs = now + agent.intervalMs;
}
if (res.status === "ran") ran = true;
}
scheduleNext();
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
};
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
let timer: NodeJS.Timeout | null = null;
if (intervalMs) {
timer = setInterval(() => {
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
}, intervalMs);
timer.unref?.();
log.info("heartbeat: started", { intervalMs });
}
updateConfig(state.cfg);
const cleanup = () => {
state.stopped = true;
setHeartbeatWakeHandler(null);
if (timer) clearInterval(timer);
timer = null;
if (state.timer) clearTimeout(state.timer);
state.timer = null;
};
opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
return { stop: cleanup };
return { stop: cleanup, updateConfig };
}
+8
View File
@@ -33,6 +33,14 @@ export type RestartSentinelPayload = {
status: "ok" | "error" | "skipped";
ts: number;
sessionKey?: string;
/** Delivery context captured at restart time to ensure channel routing survives restart. */
deliveryContext?: {
channel?: string;
to?: string;
accountId?: string;
};
/** Thread ID for reply threading (e.g., Slack thread_ts). */
threadId?: string;
message?: string | null;
doctorHint?: string | null;
stats?: RestartSentinelStats | null;
+3 -4
View File
@@ -1,5 +1,7 @@
import process from "node:process";
import { formatUncaughtError } from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set<UnhandledRejectionHandler>();
@@ -28,10 +30,7 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
export function installUnhandledRejectionHandler(): void {
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return;
console.error(
"[clawdbot] Unhandled promise rejection:",
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
);
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
process.exit(1);
});
}
+1
View File
@@ -338,6 +338,7 @@ export function startDiagnosticHeartbeat() {
}
}
}, 30_000);
heartbeatInterval.unref?.();
}
export function stopDiagnosticHeartbeat() {
+2 -1
View File
@@ -55,6 +55,7 @@ async function main() {
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
assertSupportedRuntime();
const { formatUncaughtError } = await import("../infra/errors.js");
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
const { buildProgram } = await import("../cli/program.js");
@@ -63,7 +64,7 @@ async function main() {
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1);
});
+8 -49
View File
@@ -2,14 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const buildTelegramMessageContext = 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", () => ({
buildTelegramMessageContext,
@@ -19,25 +11,12 @@ vi.mock("./bot-message-dispatch.js", () => ({
dispatchTelegramMessage,
}));
vi.mock("../logging/diagnostic.js", () => ({
diagnosticLogger,
logMessageQueued,
logMessageProcessed,
logSessionStateChange,
}));
import { createTelegramMessageProcessor } from "./bot-message.js";
describe("telegram bot message diagnostics", () => {
describe("telegram bot message processor", () => {
beforeEach(() => {
buildTelegramMessageContext.mockReset();
dispatchTelegramMessage.mockReset();
logMessageQueued.mockReset();
logMessageProcessed.mockReset();
logSessionStateChange.mockReset();
diagnosticLogger.info.mockReset();
diagnosticLogger.debug.mockReset();
diagnosticLogger.error.mockReset();
});
const baseDeps = {
@@ -63,39 +42,19 @@ describe("telegram bot message diagnostics", () => {
resolveBotTopicsEnabled: () => false,
};
it("decrements queue depth after successful processing", async () => {
buildTelegramMessageContext.mockResolvedValue({
route: { sessionKey: "agent:main:main" },
});
it("dispatches when context is available", async () => {
buildTelegramMessageContext.mockResolvedValue({ route: { sessionKey: "agent:main:main" } });
const processMessage = createTelegramMessageProcessor(baseDeps);
await processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {});
expect(logMessageQueued).toHaveBeenCalledTimes(1);
expect(logSessionStateChange).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
state: "idle",
reason: "message_completed",
});
expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1);
});
it("decrements queue depth after processing error", async () => {
buildTelegramMessageContext.mockResolvedValue({
route: { sessionKey: "agent:main:main" },
});
dispatchTelegramMessage.mockRejectedValue(new Error("boom"));
it("skips dispatch when no context is produced", async () => {
buildTelegramMessageContext.mockResolvedValue(null);
const processMessage = createTelegramMessageProcessor(baseDeps);
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",
});
await processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {});
expect(dispatchTelegramMessage).not.toHaveBeenCalled();
});
});
+32 -123
View File
@@ -1,12 +1,6 @@
// @ts-nocheck
import { buildTelegramMessageContext } from "./bot-message-context.js";
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
import {
diagnosticLogger as diag,
logMessageProcessed,
logMessageQueued,
logSessionStateChange,
} from "../logging/diagnostic.js";
export const createTelegramMessageProcessor = (deps) => {
const {
@@ -33,122 +27,37 @@ export const createTelegramMessageProcessor = (deps) => {
} = deps;
return async (primaryCtx, allMedia, storeAllowFrom, options) => {
const chatId = primaryCtx?.message?.chat?.id ?? primaryCtx?.chat?.id ?? "unknown";
const messageId = primaryCtx?.message?.message_id ?? "unknown";
const startTime = Date.now();
diag.info(
`process message start: channel=telegram chatId=${chatId} messageId=${messageId} mediaCount=${
allMedia?.length ?? 0
}`,
);
let sessionKey: string | undefined;
try {
const context = await buildTelegramMessageContext({
primaryCtx,
allMedia,
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
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;
}
const context = await buildTelegramMessageContext({
primaryCtx,
allMedia,
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
});
if (!context) return;
await dispatchTelegramMessage({
context,
bot,
cfg,
runtime,
replyToMode,
streamMode,
textLimit,
telegramCfg,
opts,
resolveBotTopicsEnabled,
});
};
};
+25 -13
View File
@@ -2,6 +2,7 @@ import { createServer } from "node:http";
import { webhookCallback } from "grammy";
import type { ClawdbotConfig } from "../config/config.js";
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -34,6 +35,7 @@ export async function startTelegramWebhook(opts: {
const port = opts.port ?? 8787;
const host = opts.host ?? "0.0.0.0";
const runtime = opts.runtime ?? defaultRuntime;
const diagnosticsEnabled = isDiagnosticsEnabled(opts.config);
const bot = createTelegramBot({
token: opts.token,
runtime,
@@ -45,7 +47,9 @@ export async function startTelegramWebhook(opts: {
secretToken: opts.secret,
});
startDiagnosticHeartbeat();
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
}
const server = createServer((req, res) => {
if (req.url === healthPath) {
@@ -59,24 +63,30 @@ export async function startTelegramWebhook(opts: {
return;
}
const startTime = Date.now();
logWebhookReceived({ channel: "telegram", updateType: "telegram-post" });
if (diagnosticsEnabled) {
logWebhookReceived({ channel: "telegram", updateType: "telegram-post" });
}
const handled = handler(req, res);
if (handled && typeof (handled as Promise<void>).catch === "function") {
void (handled as Promise<void>)
.then(() => {
logWebhookProcessed({
channel: "telegram",
updateType: "telegram-post",
durationMs: Date.now() - startTime,
});
if (diagnosticsEnabled) {
logWebhookProcessed({
channel: "telegram",
updateType: "telegram-post",
durationMs: Date.now() - startTime,
});
}
})
.catch((err) => {
const errMsg = formatErrorMessage(err);
logWebhookError({
channel: "telegram",
updateType: "telegram-post",
error: errMsg,
});
if (diagnosticsEnabled) {
logWebhookError({
channel: "telegram",
updateType: "telegram-post",
error: errMsg,
});
}
runtime.log?.(`webhook handler failed: ${errMsg}`);
if (!res.headersSent) res.writeHead(500);
res.end();
@@ -98,7 +108,9 @@ export async function startTelegramWebhook(opts: {
const shutdown = () => {
server.close();
void bot.stop();
stopDiagnosticHeartbeat();
if (diagnosticsEnabled) {
stopDiagnosticHeartbeat();
}
};
if (opts.abortSignal) {
opts.abortSignal.addEventListener("abort", shutdown, { once: true });
+8 -1
View File
@@ -1,4 +1,11 @@
import { Editor, type EditorOptions, type EditorTheme, type TUI, Key, matchesKey } from "@mariozechner/pi-tui";
import {
Editor,
type EditorOptions,
type EditorTheme,
type TUI,
Key,
matchesKey,
} from "@mariozechner/pi-tui";
export class CustomEditor extends Editor {
onEscape?: () => void;
+69 -69
View File
@@ -11,7 +11,7 @@ const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;
* Check if position is at a word boundary.
*/
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.
*/
export function findWordBoundaryIndex(text: string, query: string): number | null {
if (!query) return null;
const textLower = text.toLowerCase();
const queryLower = query.toLowerCase();
const maxIndex = textLower.length - queryLower.length;
if (maxIndex < 0) return null;
for (let i = 0; i <= maxIndex; i++) {
if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
return i;
}
}
return null;
if (!query) return null;
const textLower = text.toLowerCase();
const queryLower = query.toLowerCase();
const maxIndex = textLower.length - queryLower.length;
if (maxIndex < 0) return null;
for (let i = 0; i <= maxIndex; i++) {
if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
return i;
}
}
return null;
}
/**
@@ -37,31 +37,31 @@ export function findWordBoundaryIndex(text: string, query: string): number | nul
* Returns score (lower = better) or null if no match.
*/
export function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
if (queryLower.length === 0) return 0;
if (queryLower.length > textLower.length) return null;
if (queryLower.length === 0) return 0;
if (queryLower.length > textLower.length) return null;
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
const isAtWordBoundary = isWordBoundary(textLower, i);
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5; // Reward consecutive matches
} else {
consecutiveMatches = 0;
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps
}
if (isAtWordBoundary) score -= 10; // Reward word boundary matches
score += i * 0.1; // Slight penalty for later matches
lastMatchIndex = i;
queryIndex++;
}
}
return queryIndex < queryLower.length ? null : score;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
const isAtWordBoundary = isWordBoundary(textLower, i);
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5; // Reward consecutive matches
} else {
consecutiveMatches = 0;
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps
}
if (isAtWordBoundary) score -= 10; // Reward word boundary matches
score += i * 0.1; // Slight penalty for later matches
lastMatchIndex = i;
queryIndex++;
}
}
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).
*/
export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
items: T[],
queryLower: string,
items: T[],
queryLower: string,
): T[] {
const trimmed = queryLower.trim();
if (!trimmed) return items;
const trimmed = queryLower.trim();
if (!trimmed) return items;
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return items;
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return items;
const results: { item: T; score: number }[] = [];
for (const item of items) {
const text = item.searchTextLower ?? "";
let totalScore = 0;
let allMatch = true;
for (const token of tokens) {
const score = fuzzyMatchLower(token, text);
if (score !== null) {
totalScore += score;
} else {
allMatch = false;
break;
}
}
if (allMatch) results.push({ item, score: totalScore });
}
results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item);
const results: { item: T; score: number }[] = [];
for (const item of items) {
const text = item.searchTextLower ?? "";
let totalScore = 0;
let allMatch = true;
for (const token of tokens) {
const score = fuzzyMatchLower(token, text);
if (score !== null) {
totalScore += score;
} else {
allMatch = false;
break;
}
}
if (allMatch) results.push({ item, score: totalScore });
}
results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item);
}
/**
* Prepare items for fuzzy filtering by pre-computing lowercase search text.
*/
export function prepareSearchItems<T extends { label?: string; description?: string; searchText?: string }>(
items: T[],
): (T & { searchTextLower: string })[] {
return items.map((item) => {
const parts: string[] = [];
if (item.label) parts.push(item.label);
if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText);
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
});
export function prepareSearchItems<
T extends { label?: string; description?: string; searchText?: string },
>(items: T[]): (T & { searchTextLower: string })[] {
return items.map((item) => {
const parts: string[] = [];
if (item.label) parts.push(item.label);
if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText);
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
});
}
+1 -4
View File
@@ -5,10 +5,7 @@ import {
selectListTheme,
settingsListTheme,
} 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";
export function createSelectList(items: SelectItem[], maxVisible = 7) {
+12 -12
View File
@@ -1,15 +1,15 @@
export function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
+100
View File
@@ -358,6 +358,98 @@
color: var(--ok);
}
/* Section Hero */
.config-section-hero {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: rgba(0, 0, 0, 0.04);
}
:root[data-theme="light"] .config-section-hero {
background: rgba(0, 0, 0, 0.015);
}
.config-section-hero__icon {
width: 28px;
height: 28px;
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
}
.config-section-hero__icon svg {
width: 100%;
height: 100%;
stroke: currentColor;
fill: none;
}
.config-section-hero__text {
display: grid;
gap: 2px;
min-width: 0;
}
.config-section-hero__title {
font-size: 15px;
font-weight: 600;
}
.config-section-hero__desc {
font-size: 12px;
color: var(--muted);
}
/* Subsection Nav */
.config-subnav {
display: flex;
gap: 8px;
padding: 10px 20px 12px;
border-bottom: 1px solid var(--border);
background: rgba(0, 0, 0, 0.03);
overflow-x: auto;
}
:root[data-theme="light"] .config-subnav {
background: rgba(0, 0, 0, 0.02);
}
.config-subnav__item {
border: 1px solid transparent;
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
background: rgba(0, 0, 0, 0.12);
cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
white-space: nowrap;
}
:root[data-theme="light"] .config-subnav__item {
background: rgba(0, 0, 0, 0.06);
}
.config-subnav__item:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
}
:root[data-theme="light"] .config-subnav__item:hover {
background: rgba(0, 0, 0, 0.08);
}
.config-subnav__item.active {
color: var(--accent);
border-color: rgba(245, 159, 74, 0.4);
background: rgba(245, 159, 74, 0.12);
}
/* Content Area */
.config-content {
flex: 1;
@@ -1243,6 +1335,14 @@
justify-content: center;
}
.config-section-hero {
padding: 12px 16px;
}
.config-subnav {
padding: 8px 16px 10px;
}
.config-content {
padding: 16px;
}
+6 -1
View File
@@ -485,11 +485,16 @@ export function renderApp(state: AppViewState) {
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => (state.configRaw = next),
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => (state.configActiveSection = section),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
+10
View File
@@ -62,6 +62,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
const tokenRaw = params.get("token");
const passwordRaw = params.get("password");
const sessionRaw = params.get("session");
const gatewayUrlRaw = params.get("gatewayUrl");
let shouldCleanUrl = false;
if (tokenRaw != null) {
@@ -94,6 +95,15 @@ 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;
const url = new URL(window.location.href);
url.search = params.toString();
+1
View File
@@ -152,6 +152,7 @@ export class ClawdbotApp extends LitElement {
@state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = "";
@state() configActiveSection: string | null = null;
@state() configActiveSubsection: string | null = null;
@state() channelsLoading = false;
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
+1
View File
@@ -33,6 +33,7 @@ export type ConfigState = {
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
lastError: string | null;
};
+1 -2
View File
@@ -5,7 +5,6 @@ import { truncateText } from "./format";
marked.setOptions({
gfm: true,
breaks: true,
headerIds: false,
mangle: false,
});
@@ -37,7 +36,7 @@ const allowedTags = [
"ul",
];
const allowedAttrs = ["class", "href", "rel", "target", "title"];
const allowedAttrs = ["class", "href", "rel", "target", "title", "start"];
let hooksInstalled = false;
const MARKDOWN_CHAR_LIMIT = 140_000;
+97 -33
View File
@@ -1,6 +1,11 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
import { renderNode } from "./config-form.node";
export type ConfigFormProps = {
@@ -11,6 +16,7 @@ export type ConfigFormProps = {
unsupportedPaths?: string[];
searchQuery?: string;
activeSection?: string | null;
activeSubsection?: string | null;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
@@ -146,6 +152,7 @@ export function renderConfigForm(props: ConfigFormProps) {
const properties = schema.properties;
const searchQuery = props.searchQuery ?? "";
const activeSection = props.activeSection;
const activeSubsection = props.activeSubsection ?? null;
// Filter and sort entries
let entries = Object.entries(properties);
@@ -168,6 +175,25 @@ export function renderConfigForm(props: ConfigFormProps) {
return a[0].localeCompare(b[0]);
});
let subsectionContext:
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
| null = null;
if (activeSection && activeSubsection && entries.length === 1) {
const sectionSchema = entries[0]?.[1];
if (
sectionSchema &&
schemaType(sectionSchema) === "object" &&
sectionSchema.properties &&
sectionSchema.properties[activeSubsection]
) {
subsectionContext = {
sectionKey: activeSection,
subsectionKey: activeSubsection,
schema: sectionSchema.properties[activeSubsection],
};
}
}
if (entries.length === 0) {
return html`
<div class="config-empty">
@@ -183,38 +209,76 @@ export function renderConfigForm(props: ConfigFormProps) {
return html`
<div class="config-form config-form--modern">
${entries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? ""
};
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description ? html`
<p class="config-section-card__desc">${meta.description}</p>
` : nothing}
</div>
</div>
<div class="config-section-card__content">
${renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</section>
`;
})}
${subsectionContext
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
<section class="config-section-card" id=${id}>
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${label}</h3>
${description
? html`<p class="config-section-card__desc">${description}</p>`
: nothing}
</div>
</div>
<div class="config-section-card__content">
${renderNode({
schema: node,
value: scopedValue,
path: [sectionKey, subsectionKey],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</section>
`;
})()
: entries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing}
</div>
</div>
<div class="config-section-card__content">
${renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</section>
`;
})}
</div>
`;
}
+7
View File
@@ -21,13 +21,20 @@ describe("config view", () => {
uiHints: {},
formMode: "form" as const,
formValue: {},
originalValue: {},
searchQuery: "",
activeSection: null,
activeSubsection: null,
onRawChange: vi.fn(),
onFormModeChange: vi.fn(),
onFormPatch: vi.fn(),
onSearchChange: vi.fn(),
onSectionChange: vi.fn(),
onReload: vi.fn(),
onSave: vi.fn(),
onApply: vi.fn(),
onUpdate: vi.fn(),
onSubsectionChange: vi.fn(),
});
it("disables save when form is unsafe", () => {
+113
View File
@@ -1,6 +1,12 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
export type ConfigProps = {
raw: string;
@@ -19,11 +25,13 @@ export type ConfigProps = {
originalValue: Record<string, unknown> | null;
searchQuery: string;
activeSection: string | null;
activeSubsection: string | null;
onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
onSearchChange: (query: string) => void;
onSectionChange: (section: string | null) => void;
onSubsectionChange: (section: string | null) => void;
onReload: () => void;
onSave: () => void;
onApply: () => void;
@@ -80,10 +88,49 @@ const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "wizard", label: "Setup Wizard" },
];
type SubsectionEntry = {
key: string;
label: string;
description?: string;
order: number;
};
const ALL_SUBSECTION = "__all__";
function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
}
function resolveSectionMeta(key: string, schema?: JsonSchema): {
label: string;
description?: string;
} {
const meta = SECTION_META[key];
if (meta) return meta;
return {
label: schema?.title ?? humanize(key),
description: schema?.description ?? "",
};
}
function resolveSubsections(params: {
key: string;
schema: JsonSchema | undefined;
uiHints: ConfigUiHints;
}): SubsectionEntry[] {
const { key, schema, uiHints } = params;
if (!schema || schemaType(schema) !== "object" || !schema.properties) return [];
const entries = Object.entries(schema.properties).map(([subKey, node]) => {
const hint = hintForPath([key, subKey], uiHints);
const label = hint?.label ?? node.title ?? humanize(subKey);
const description = hint?.help ?? node.description ?? "";
const order = hint?.order ?? 50;
return { key: subKey, label, description, order };
});
entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key)));
return entries;
}
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null
@@ -164,6 +211,31 @@ export function renderConfig(props: ConfigProps) {
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections];
const activeSectionSchema =
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined)
: undefined;
const activeSectionMeta = props.activeSection
? resolveSectionMeta(props.activeSection, activeSectionSchema)
: null;
const subsections = props.activeSection
? resolveSubsections({
key: props.activeSection,
schema: activeSectionSchema,
uiHints: props.uiHints,
})
: [];
const allowSubnav =
props.formMode === "form" &&
Boolean(props.activeSection) &&
subsections.length > 0;
const isAllSubsection = props.activeSubsection === ALL_SUBSECTION;
const effectiveSubsection = props.searchQuery
? null
: isAllSubsection
? null
: props.activeSubsection ?? (subsections[0]?.key ?? null);
// Compute diff for showing changes
const diff = props.formMode === "form"
@@ -304,6 +376,46 @@ export function renderConfig(props: ConfigProps) {
</details>
` : nothing}
${activeSectionMeta && props.formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div>
<div class="config-section-hero__text">
<div class="config-section-hero__title">${activeSectionMeta.label}</div>
${activeSectionMeta.description
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
: nothing}
</div>
</div>
`
: nothing}
${allowSubnav
? html`
<div class="config-subnav">
<button
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
>
All
</button>
${subsections.map(
(entry) => html`
<button
class="config-subnav__item ${
effectiveSubsection === entry.key ? "active" : ""
}"
title=${entry.description || entry.label}
@click=${() => props.onSubsectionChange(entry.key)}
>
${entry.label}
</button>
`,
)}
</div>
`
: nothing}
<!-- Form content -->
<div class="config-content">
${props.formMode === "form"
@@ -322,6 +434,7 @@ export function renderConfig(props: ConfigProps) {
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
})}
${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;">