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. - Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh. - Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled. - Security: warn when <=300B models run without sandboxing and with web tools enabled.
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
### Fixes ### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco. - Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. - Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry. - Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk. - Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner. - Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. - Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: 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: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl. - TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) - Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander. - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) - Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
+320 -24
View File
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore @Bindable var store: ChannelsStore
@State private var hasLoaded = false @State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
init(store: ChannelsStore = .shared) { init(store: ChannelsStore = .shared) {
self.store = store self.store = store
} }
var body: some View { var body: some View {
ScrollView { HStack(spacing: 16) {
self.content self.sidebar
self.detail
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task { .task {
guard !self.hasLoaded else { return } guard !self.hasLoaded else { return }
guard !self.isPreview else { return } guard !self.isPreview else { return }
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
await self.store.loadConfigSchema() await self.store.loadConfigSchema()
await self.store.loadConfig() await self.store.loadConfig()
} }
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
}
} }
} }
extension ConfigSettings { extension ConfigSettings {
private var content: some View { private enum SubsectionSelection: Hashable {
VStack(alignment: .leading, spacing: 16) { case all
self.header case key(String)
if let status = self.store.configStatus { }
Text(status)
.font(.callout) private struct ConfigSection: Identifiable {
.foregroundStyle(.secondary) let key: String
} let label: String
self.actionRow let help: String?
Group { let node: ConfigSchemaNode
if self.store.configSchemaLoading {
ProgressView().controlSize(.small) var id: String { self.key }
} else if let schema = self.store.configSchema { }
ConfigSchemaForm(store: self.store, schema: schema, path: [])
.disabled(self.isNixMode) private struct ConfigSubsection: Identifiable {
} else { let key: String
Text("Schema unavailable.") 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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
}
} }
} }
if self.store.configDirty, !self.isNixMode { .padding(.vertical, 10)
Text("Unsaved changes") .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) .font(.caption)
.foregroundStyle(.secondary) .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(.horizontal, 24)
.padding(.vertical, 18) .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 @ViewBuilder
@@ -71,6 +158,18 @@ extension ConfigSettings {
.foregroundStyle(.secondary) .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 { private var actionRow: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Button("Reload") { Button("Reload") {
@@ -85,6 +184,203 @@ extension ConfigSettings {
} }
.buttonStyle(.bordered) .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 { struct ConfigSettings_Previews: PreviewProvider {
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String? agentid: String?,
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String? agentid: String?,
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int public let ts: Int
public let channelorder: [String] public let channelorder: [String]
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable] public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable] public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable]
@@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int, ts: Int,
channelorder: [String], channelorder: [String],
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable], channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable], channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable] channeldefaultaccountid: [String: AnyCodable]
@@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts self.ts = ts
self.channelorder = channelorder self.channelorder = channelorder
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts case ts
case channelorder = "channelOrder" case channelorder = "channelOrder"
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"
+5
View File
@@ -286,6 +286,11 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- CLI: `clawdbot message send --media <mp4> --gif-playback` - CLI: `clawdbot message send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true` - Gateway: `send` params include `gifPlayback: true`
## Voice notes (PTT audio)
WhatsApp sends audio as **voice notes** (PTT bubble).
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
## Media limits + optimization ## Media limits + optimization
- Default outbound cap: 5 MB (per media item). - Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`. - Override: `agents.defaults.mediaMaxMb`.
+13
View File
@@ -14,3 +14,16 @@ Related:
Tip: run `clawdbot cron --help` for the full command surface. Tip: run `clawdbot cron --help` for the full command surface.
## Common edits
Update delivery settings without changing the message:
```bash
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
```
Disable delivery for an isolated job:
```bash
clawdbot cron edit <job-id> --no-deliver
```
+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. 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. safely retry; the server keeps a short‑lived dedupe cache.
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`. - Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
## Pairing + local trust
- All WS clients (operators + nodes) include a **device identity** on `connect`.
- New device IDs require pairing approval; the Gateway issues a **device token**
for subsequent connects.
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
auto‑approved to keep same‑host UX smooth.
- **Non‑local** connects must sign the `connect.challenge` nonce and require
explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
[Security](/gateway/security).
## Protocol typing and codegen ## Protocol typing and codegen
- TypeBox schemas define the protocol. - TypeBox schemas define the protocol.
+2
View File
@@ -195,6 +195,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role. - Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval - Pairing approvals are required for new device IDs unless local auto-approval
is enabled. is enabled.
- **Local** connects include loopback and the gateway host’s own tailnet address
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node). - All WS clients must include `device` identity during `connect` (operator + node).
- Non-local connections must sign the server-provided `connect.challenge` nonce. - Non-local connections must sign the server-provided `connect.challenge` nonce.
+6
View File
@@ -270,6 +270,12 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access. protect local WS access.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Local device pairing:
- Device pairing is auto‑approved for **local** connects (loopback or the
gateway host’s own tailnet address) to keep same‑host clients smooth.
- Other tailnet peers are **not** treated as local; they still need pairing
approval.
Auth modes: Auth modes:
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups). - `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`). - `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
+51
View File
@@ -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 clawdbot doctor
``` ```
## Advanced: expose WSL services over LAN (portproxy)
WSL has its own virtual network. If another machine needs to reach a service
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
so you may need to refresh the forwarding rule.
Example (PowerShell **as Administrator**):
```powershell
$Distro = "Ubuntu-24.04"
$ListenPort = 2222
$TargetPort = 22
$WslIp = (wsl -d $Distro -- hostname -I).Trim().Split(" ")[0]
if (-not $WslIp) { throw "WSL IP not found." }
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
connectaddress=$WslIp connectport=$TargetPort
```
Allow the port through Windows Firewall (one-time):
```powershell
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
-Protocol TCP -LocalPort $ListenPort -Action Allow
```
Refresh the portproxy after WSL restarts:
```powershell
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
connectaddress=$WslIp connectport=$TargetPort | Out-Null
```
Notes:
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
`clawdbot status --all` to confirm.
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
- If you want this automatic, register a Scheduled Task to run the refresh
step at login.
## Step-by-step WSL2 install ## Step-by-step WSL2 install
### 1) Install WSL2 + Ubuntu ### 1) Install WSL2 + Ubuntu
+1
View File
@@ -32,6 +32,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Core concepts ## Core concepts
- [Architecture](/concepts/architecture) - [Architecture](/concepts/architecture)
- [Network hub](/network)
- [Agent runtime](/concepts/agent) - [Agent runtime](/concepts/agent)
- [Agent workspace](/concepts/agent-workspace) - [Agent workspace](/concepts/agent-workspace)
- [Memory](/concepts/memory) - [Memory](/concepts/memory)
+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.env` — list; env var must exist **or** be provided in config.
- `requires.config` — list of `clawdbot.json` paths that must be truthy. - `requires.config` — list of `clawdbot.json` paths that must be truthy.
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`. - `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv). - `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download).
Note on sandboxing: Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time. - `requires.bins` is checked on the **host** at skill load time.
@@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
Notes: Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). - If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts.
- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform.
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun). - Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
This only affects **skill installs**; the Gateway runtime should still be Node This only affects **skill installs**; the Gateway runtime should still be Node
(Bun is not recommended for WhatsApp/Telegram). (Bun is not recommended for WhatsApp/Telegram).
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible. - Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
If no `metadata.clawdbot` is present, the skill is always eligible (unless If no `metadata.clawdbot` is present, the skill is always eligible (unless
disabled in config or blocked by `skills.allowBundled` for bundled skills). disabled in config or blocked by `skills.allowBundled` for bundled skills).
+49
View File
@@ -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 fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { Readable } from "node:stream";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { pipeline } from "node:stream/promises";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveBrewExecutable } from "../infra/brew.js"; import { resolveBrewExecutable } from "../infra/brew.js";
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js"; import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js";
import { import {
hasBinary, hasBinary,
loadWorkspaceSkillEntries, loadWorkspaceSkillEntries,
@@ -13,6 +16,7 @@ import {
type SkillInstallSpec, type SkillInstallSpec,
type SkillsInstallPreferences, type SkillsInstallPreferences,
} from "./skills.js"; } from "./skills.js";
import { resolveSkillKey } from "./skills/frontmatter.js";
export type SkillInstallRequest = { export type SkillInstallRequest = {
workspaceDir: string; workspaceDir: string;
@@ -30,6 +34,10 @@ export type SkillInstallResult = {
code: number | null; 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 { function summarizeInstallOutput(text: string): string | undefined {
const raw = text.trim(); const raw = text.trim();
if (!raw) return undefined; if (!raw) return undefined;
@@ -112,11 +120,162 @@ function buildInstallCommand(
if (!spec.package) return { argv: null, error: "missing uv package" }; if (!spec.package) return { argv: null, error: "missing uv package" };
return { argv: ["uv", "tool", "install", spec.package] }; return { argv: ["uv", "tool", "install", spec.package] };
} }
case "download": {
return { argv: null, error: "download install handled separately" };
}
default: default:
return { argv: null, error: "unsupported installer" }; return { argv: null, error: "unsupported installer" };
} }
} }
function resolveDownloadTargetDir(entry: SkillEntry, spec: SkillInstallSpec): string {
if (spec.targetDir?.trim()) return resolveUserPath(spec.targetDir);
const key = resolveSkillKey(entry.skill, entry);
return path.join(CONFIG_DIR, "tools", key);
}
function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | undefined {
const explicit = spec.archive?.trim().toLowerCase();
if (explicit) return explicit;
const lower = filename.toLowerCase();
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tar.gz";
if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) return "tar.bz2";
if (lower.endsWith(".zip")) return "zip";
return undefined;
}
async function downloadFile(
url: string,
destPath: string,
timeoutMs: number,
): Promise<{ bytes: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs));
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok || !response.body) {
throw new Error(`Download failed (${response.status} ${response.statusText})`);
}
await ensureDir(path.dirname(destPath));
const file = fs.createWriteStream(destPath);
const body = response.body 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> { async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> {
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable()); const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
if (!exe) return undefined; if (!exe) return undefined;
@@ -167,6 +326,9 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
code: null, code: null,
}; };
} }
if (spec.kind === "download") {
return await installDownloadSpec({ entry, spec, timeoutMs });
}
const prefs = resolveSkillsInstallPreferences(params.config); const prefs = resolveSkillsInstallPreferences(params.config);
const command = buildInstallCommand(spec, prefs); const command = buildInstallCommand(spec, prefs);
+40 -27
View File
@@ -100,36 +100,49 @@ function normalizeInstallOptions(
): SkillInstallOption[] { ): SkillInstallOption[] {
const install = entry.clawdbot?.install ?? []; const install = entry.clawdbot?.install ?? [];
if (install.length === 0) return []; if (install.length === 0) return [];
const preferred = selectPreferredInstallSpec(install, prefs);
if (!preferred) return []; const platform = process.platform;
const { spec, index } = preferred; const filtered = install.filter((spec) => {
const id = (spec.id ?? `${spec.kind}-${index}`).trim(); const osList = spec.os ?? [];
const bins = spec.bins ?? []; return osList.length === 0 || osList.includes(platform);
let label = (spec.label ?? "").trim(); });
if (spec.kind === "node" && spec.package) { if (filtered.length === 0) return [];
label = `Install ${spec.package} (${prefs.nodeManager})`;
} const toOption = (spec: SkillInstallSpec, index: number): SkillInstallOption => {
if (!label) { const id = (spec.id ?? `${spec.kind}-${index}`).trim();
if (spec.kind === "brew" && spec.formula) { const bins = spec.bins ?? [];
label = `Install ${spec.formula} (brew)`; let label = (spec.label ?? "").trim();
} else if (spec.kind === "node" && spec.package) { if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`; 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 [
{ const preferred = selectPreferredInstallSpec(filtered, prefs);
id, if (!preferred) return [];
kind: spec.kind, return [toOption(preferred.spec, preferred.index)];
label,
bins,
},
];
} }
function buildSkillStatus( 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 = const kindRaw =
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : ""; typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
const kind = kindRaw.trim().toLowerCase(); const kind = kindRaw.trim().toLowerCase();
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") { if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") {
return undefined; return undefined;
} }
@@ -47,9 +47,16 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
if (typeof raw.label === "string") spec.label = raw.label; if (typeof raw.label === "string") spec.label = raw.label;
const bins = normalizeStringList(raw.bins); const bins = normalizeStringList(raw.bins);
if (bins.length > 0) spec.bins = bins; if (bins.length > 0) spec.bins = bins;
const osList = normalizeStringList(raw.os);
if (osList.length > 0) spec.os = osList;
if (typeof raw.formula === "string") spec.formula = raw.formula; if (typeof raw.formula === "string") spec.formula = raw.formula;
if (typeof raw.package === "string") spec.package = raw.package; if (typeof raw.package === "string") spec.package = raw.package;
if (typeof raw.module === "string") spec.module = raw.module; if (typeof raw.module === "string") spec.module = raw.module;
if (typeof raw.url === "string") spec.url = raw.url;
if (typeof raw.archive === "string") spec.archive = raw.archive;
if (typeof raw.extract === "boolean") spec.extract = raw.extract;
if (typeof raw.stripComponents === "number") spec.stripComponents = raw.stripComponents;
if (typeof raw.targetDir === "string") spec.targetDir = raw.targetDir;
return spec; return spec;
} }
+7 -1
View File
@@ -2,12 +2,18 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
export type SkillInstallSpec = { export type SkillInstallSpec = {
id?: string; id?: string;
kind: "brew" | "node" | "go" | "uv"; kind: "brew" | "node" | "go" | "uv" | "download";
label?: string; label?: string;
bins?: string[]; bins?: string[];
os?: string[];
formula?: string; formula?: string;
package?: string; package?: string;
module?: string; module?: string;
url?: string;
archive?: string;
extract?: boolean;
stripComponents?: number;
targetDir?: string;
}; };
export type ClawdbotSkillMetadata = { export type ClawdbotSkillMetadata = {
+2 -2
View File
@@ -65,7 +65,7 @@ describe("cron tool", () => {
data: { data: {
name: "wake-up", name: "wake-up",
schedule: { atMs: 123 }, schedule: { atMs: 123 },
payload: { text: "hello" }, payload: { kind: "systemEvent", text: "hello" },
}, },
}, },
}); });
@@ -105,7 +105,7 @@ describe("cron tool", () => {
job: { job: {
name: "reminder", name: "reminder",
schedule: { atMs: 123 }, 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 } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js"; 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 { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { import {
formatDoctorNonInteractiveHint, formatDoctorNonInteractiveHint,
@@ -77,11 +79,42 @@ export function createGatewayTool(opts?: {
: undefined; : undefined;
const note = const note =
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; 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 = { const payload: RestartSentinelPayload = {
kind: "restart", kind: "restart",
status: "ok", status: "ok",
ts: Date.now(), ts: Date.now(),
sessionKey, sessionKey,
deliveryContext,
threadId,
message: note ?? reason ?? null, message: note ?? reason ?? null,
doctorHint: formatDoctorNonInteractiveHint(), doctorHint: formatDoctorNonInteractiveHint(),
stats: { stats: {
@@ -13,6 +13,11 @@ const mocks = vi.hoisted(() => ({
aborted: false, aborted: false,
})), })),
})); }));
const diagnosticMocks = vi.hoisted(() => ({
logMessageQueued: vi.fn(),
logMessageProcessed: vi.fn(),
logSessionStateChange: vi.fn(),
}));
vi.mock("./route-reply.js", () => ({ vi.mock("./route-reply.js", () => ({
isRoutableChannel: (channel: string | undefined) => isRoutableChannel: (channel: string | undefined) =>
@@ -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 { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
const { resetInboundDedupe } = await import("./inbound-dedupe.js"); const { resetInboundDedupe } = await import("./inbound-dedupe.js");
@@ -50,6 +61,9 @@ function createDispatcher(): ReplyDispatcher {
describe("dispatchReplyFromConfig", () => { describe("dispatchReplyFromConfig", () => {
beforeEach(() => { beforeEach(() => {
resetInboundDedupe(); resetInboundDedupe();
diagnosticMocks.logMessageQueued.mockReset();
diagnosticMocks.logMessageProcessed.mockReset();
diagnosticMocks.logSessionStateChange.mockReset();
}); });
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => { it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({ mocks.tryFastAbortFromMessage.mockResolvedValue({
@@ -186,4 +200,74 @@ describe("dispatchReplyFromConfig", () => {
expect(replyResolver).toHaveBeenCalledTimes(1); expect(replyResolver).toHaveBeenCalledTimes(1);
}); });
it("emits diagnostics when enabled", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "slack",
Surface: "slack",
SessionKey: "agent:main:main",
MessageSid: "msg-1",
To: "slack:C123",
});
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1);
expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
state: "processing",
reason: "message_start",
});
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
outcome: "completed",
sessionKey: "agent:main:main",
}),
);
});
it("marks diagnostics skipped for duplicate inbound messages", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
const cfg = { diagnostics: { enabled: true } } as ClawdbotConfig;
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550123",
MessageSid: "msg-dup",
});
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
await dispatchReplyFromConfig({
ctx,
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
await dispatchReplyFromConfig({
ctx,
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
outcome: "skipped",
reason: "duplicate",
}),
);
});
}); });
+147 -82
View File
@@ -1,5 +1,11 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import {
logMessageProcessed,
logMessageQueued,
logSessionStateChange,
} from "../../logging/diagnostic.js";
import { getReplyFromConfig } from "../reply.js"; import { getReplyFromConfig } from "../reply.js";
import type { FinalizedMsgContext } from "../templating.js"; import type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -21,8 +27,55 @@ export async function dispatchReplyFromConfig(params: {
replyResolver?: typeof getReplyFromConfig; replyResolver?: typeof getReplyFromConfig;
}): Promise<DispatchFromConfigResult> { }): Promise<DispatchFromConfigResult> {
const { ctx, cfg, dispatcher } = params; const { ctx, cfg, dispatcher } = params;
const diagnosticsEnabled = isDiagnosticsEnabled(cfg);
const channel = String(ctx.Surface ?? ctx.Provider ?? "unknown").toLowerCase();
const chatId = ctx.To ?? ctx.From;
const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
const sessionKey = ctx.SessionKey;
const startTime = diagnosticsEnabled ? Date.now() : 0;
const canTrackSession = diagnosticsEnabled && Boolean(sessionKey);
const recordProcessed = (
outcome: "completed" | "skipped" | "error",
opts?: {
reason?: string;
error?: string;
},
) => {
if (!diagnosticsEnabled) return;
logMessageProcessed({
channel,
chatId,
messageId,
sessionKey,
durationMs: Date.now() - startTime,
outcome,
reason: opts?.reason,
error: opts?.error,
});
};
const markProcessing = () => {
if (!canTrackSession || !sessionKey) return;
logMessageQueued({ sessionKey, channel, source: "dispatch" });
logSessionStateChange({
sessionKey,
state: "processing",
reason: "message_start",
});
};
const markIdle = (reason: string) => {
if (!canTrackSession || !sessionKey) return;
logSessionStateChange({
sessionKey,
state: "idle",
reason,
});
};
if (shouldSkipDuplicateInbound(ctx)) { if (shouldSkipDuplicateInbound(ctx)) {
recordProcessed("skipped", { reason: "duplicate" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
} }
@@ -68,95 +121,107 @@ export async function dispatchReplyFromConfig(params: {
} }
}; };
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg }); markProcessing();
if (fastAbort.handled) {
const payload = { try {
text: formatAbortReplyText(fastAbort.stoppedSubagents), const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
} satisfies ReplyPayload; 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 queuedFinal = false;
let routedFinalCount = 0; let routedFinalCount = 0;
if (shouldRouteToOriginating && originatingChannel && originatingTo) { for (const reply of replies) {
const result = await routeReply({ if (shouldRouteToOriginating && originatingChannel && originatingTo) {
payload, // Route final reply to originating channel.
channel: originatingChannel, const result = await routeReply({
to: originatingTo, payload: reply,
sessionKey: ctx.SessionKey, channel: originatingChannel,
accountId: ctx.AccountId, to: originatingTo,
threadId: ctx.MessageThreadId, sessionKey: ctx.SessionKey,
cfg, accountId: ctx.AccountId,
}); threadId: ctx.MessageThreadId,
queuedFinal = result.ok; cfg,
if (result.ok) routedFinalCount += 1; });
if (!result.ok) { if (!result.ok) {
logVerbose( logVerbose(
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`, `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(); await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts(); const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount; counts.final += routedFinalCount;
recordProcessed("completed");
markIdle("message_completed");
return { queuedFinal, counts }; return { queuedFinal, counts };
} catch (err) {
recordProcessed("error", { error: String(err) });
markIdle("message_error");
throw err;
} }
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
if (shouldRouteToOriginating) {
// Fire-and-forget for streaming tool results when routing.
void sendPayloadAsync(payload);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendToolResult(payload);
}
},
onBlockReply: (payload: ReplyPayload, context) => {
if (shouldRouteToOriginating) {
// Await routed sends so upstream can enforce ordering/timeouts.
return sendPayloadAsync(payload, context?.abortSignal);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendBlockReply(payload);
}
},
},
cfg,
);
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
let queuedFinal = false;
let routedFinalCount = 0;
for (const reply of replies) {
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
// Route final reply to originating channel.
const result = await routeReply({
payload: reply,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`,
);
}
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
} else {
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
}
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
return { queuedFinal, counts };
} }
+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 type { Command } from "commander";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { import {
channelsAddCommand, channelsAddCommand,
channelsCapabilitiesCommand, channelsCapabilitiesCommand,
@@ -12,11 +13,9 @@ import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveCliChannelOptions } from "./channel-options.js";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
import { runCommandWithRuntime } from "./cli-utils.js"; import { runCommandWithRuntime } from "./cli-utils.js";
import { hasExplicitOptions } from "./command-options.js"; import { hasExplicitOptions } from "./command-options.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
const optionNamesAdd = [ const optionNamesAdd = [
"channel", "channel",
@@ -59,7 +58,9 @@ function runChannelsCommandWithDanger(action: () => Promise<void>, label: string
} }
export function registerChannelsCli(program: Command) { export function registerChannelsCli(program: Command) {
const channelNames = resolveCliChannelOptions().join("|"); const channelNames = listChannelPlugins()
.map((plugin) => plugin.id)
.join("|");
const channels = program const channels = program
.command("channels") .command("channels")
.description("Manage chat channel accounts") .description("Manage chat channel accounts")
@@ -71,7 +72,6 @@ export function registerChannelsCli(program: Command) {
"docs.clawd.bot/cli/channels", "docs.clawd.bot/cli/channels",
)}\n`, )}\n`,
); );
markCommandRequiresPluginRegistry(channels);
channels channels
.command("list") .command("list")
+59 -3
View File
@@ -173,7 +173,7 @@ describe("cron cli", () => {
expect(clearPatch?.patch?.agentId).toBeNull(); 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(); callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js"); 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 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( .option(
"--deliver", "--deliver",
"Deliver agent output (required when using last-route delivery without --to)", "Deliver agent output (required when using last-route delivery without --to)",
false,
) )
.option("--no-deliver", "Disable delivery")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`) .option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option( .option(
"--to <dest>", "--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
) )
.option("--best-effort-deliver", "Do not fail job if delivery fails", 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") .option("--post-prefix <prefix>", "Prefix for summary system event")
.action(async (id, opts) => { .action(async (id, opts) => {
try { try {
@@ -105,35 +106,49 @@ export function registerCronEditCommand(cron: Command) {
}; };
} }
const payloadChosen = [opts.systemEvent, opts.message].filter(Boolean).length; const hasSystemEventPatch = typeof opts.systemEvent === "string";
if (payloadChosen > 1) throw new Error("Choose at most one payload change"); const model =
if (opts.systemEvent) { 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 = { patch.payload = {
kind: "systemEvent", kind: "systemEvent",
text: String(opts.systemEvent), text: String(opts.systemEvent),
}; };
} else if (opts.message) { } else if (hasAgentTurnPatch) {
const model = const payload: Record<string, unknown> = { kind: "agentTurn" };
typeof opts.model === "string" && opts.model.trim() ? opts.model.trim() : undefined; if (typeof opts.message === "string") payload.message = String(opts.message);
const thinking = if (model) payload.model = model;
typeof opts.thinking === "string" && opts.thinking.trim() if (thinking) payload.thinking = thinking;
? opts.thinking.trim() if (hasTimeoutSeconds) {
: undefined; payload.timeoutSeconds = timeoutSeconds;
const timeoutSeconds = opts.timeoutSeconds }
? Number.parseInt(String(opts.timeoutSeconds), 10) if (typeof opts.deliver === "boolean") payload.deliver = opts.deliver;
: undefined; if (typeof opts.channel === "string") payload.channel = opts.channel;
patch.payload = { if (typeof opts.to === "string") payload.to = opts.to;
kind: "agentTurn", if (typeof opts.bestEffortDeliver === "boolean") {
message: String(opts.message), payload.bestEffortDeliver = opts.bestEffortDeliver;
model, }
thinking, patch.payload = payload;
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
deliver: opts.deliver ? true : undefined,
channel: typeof opts.channel === "string" ? opts.channel : undefined,
to: typeof opts.to === "string" ? opts.to : undefined,
bestEffortDeliver: opts.bestEffortDeliver ? true : undefined,
};
} }
if (typeof opts.postPrefix === "string") { if (typeof opts.postPrefix === "string") {
+3 -2
View File
@@ -1,12 +1,13 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import type { CronJob, CronSchedule } from "../../cron/types.js"; import type { CronJob, CronSchedule } from "../../cron/types.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import { resolveCliChannelOptions } from "../channel-options.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { callGatewayFromCli } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js";
export const getCronChannelOptions = () => ["last", ...resolveCliChannelOptions()].join("|"); export const getCronChannelOptions = () =>
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
try { try {
-2
View File
@@ -8,7 +8,6 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
function parseLimit(value: unknown): number | null { function parseLimit(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === "number" && Number.isFinite(value)) {
@@ -43,7 +42,6 @@ export function registerDirectoryCli(program: Command) {
.action(() => { .action(() => {
directory.help({ error: true }); directory.help({ error: true });
}); });
markCommandRequiresPluginRegistry(directory);
const withChannel = (cmd: Command) => const withChannel = (cmd: Command) =>
cmd cmd
+4 -12
View File
@@ -34,16 +34,12 @@ vi.mock("../channels/plugins/index.js", () => ({
normalizeChannelId, normalizeChannelId,
})); }));
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../config/config.js", () => ({
const actual = await importOriginal<typeof import("../config/config.js")>(); loadConfig: vi.fn().mockReturnValue({}),
return { }));
...actual,
loadConfig: vi.fn().mockReturnValue({}),
};
});
describe("pairing cli", () => { 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(); listPairingChannels.mockClear();
const { registerPairingCli } = await import("./pairing-cli.js"); const { registerPairingCli } = await import("./pairing-cli.js");
@@ -53,10 +49,6 @@ describe("pairing cli", () => {
program.name("test"); program.name("test");
registerPairingCli(program); registerPairingCli(program);
expect(listPairingChannels).not.toHaveBeenCalled();
listChannelPairingRequests.mockResolvedValueOnce([]);
await program.parseAsync(["pairing", "list", "telegram"], { from: "user" });
expect(listPairingChannels).toHaveBeenCalledTimes(1); expect(listPairingChannels).toHaveBeenCalledTimes(1);
}); });
+4 -11
View File
@@ -10,9 +10,7 @@ import {
} from "../pairing/pairing-store.js"; } from "../pairing/pairing-store.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveCliChannelOptions } from "./channel-options.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
/** Parse channel, allowing extension channels not in core registry. */ /** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel { function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
@@ -46,9 +44,7 @@ async function notifyApproved(channel: PairingChannel, id: string) {
} }
export function registerPairingCli(program: Command) { export function registerPairingCli(program: Command) {
const channelOptions = resolveCliChannelOptions(); const channels = listPairingChannels();
const channelHint =
channelOptions.length > 0 ? `Channel (${channelOptions.join(", ")})` : "Channel";
const pairing = program const pairing = program
.command("pairing") .command("pairing")
.description("Secure DM pairing (approve inbound requests)") .description("Secure DM pairing (approve inbound requests)")
@@ -57,16 +53,14 @@ export function registerPairingCli(program: Command) {
() => () =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`, `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`,
); );
markCommandRequiresPluginRegistry(pairing);
pairing pairing
.command("list") .command("list")
.description("List pending pairing requests") .description("List pending pairing requests")
.option("--channel <channel>", channelHint) .option("--channel <channel>", `Channel (${channels.join(", ")})`)
.argument("[channel]", channelHint) .argument("[channel]", `Channel (${channels.join(", ")})`)
.option("--json", "Print JSON", false) .option("--json", "Print JSON", false)
.action(async (channelArg, opts) => { .action(async (channelArg, opts) => {
const channels = listPairingChannels();
const channelRaw = opts.channel ?? channelArg; const channelRaw = opts.channel ?? channelArg;
if (!channelRaw) { if (!channelRaw) {
throw new Error( throw new Error(
@@ -93,12 +87,11 @@ export function registerPairingCli(program: Command) {
pairing pairing
.command("approve") .command("approve")
.description("Approve a pairing code and allow that sender") .description("Approve a pairing code and allow that sender")
.option("--channel <channel>", channelHint) .option("--channel <channel>", `Channel (${channels.join(", ")})`)
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)") .argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)") .argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
.option("--notify", "Notify the requester on the same channel", false) .option("--notify", "Notify the requester on the same channel", false)
.action(async (codeOrChannel, code, opts) => { .action(async (codeOrChannel, code, opts) => {
const channels = listPairingChannels();
const channelRaw = opts.channel ?? codeOrChannel; const channelRaw = opts.channel ?? codeOrChannel;
const resolvedCode = opts.channel ? codeOrChannel : code; const resolvedCode = opts.channel ? codeOrChannel : code;
if (!opts.channel && !code) { if (!opts.channel && !code) {
-24
View File
@@ -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 = { const routeAgentsList: RouteSpec = {
match: (path) => path[0] === "agents" && path[1] === "list", match: (path) => path[0] === "agents" && path[1] === "list",
loadPlugins: true,
run: async (argv) => { run: async (argv) => {
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings"); const bindings = hasFlag(argv, "--bindings");
-56
View File
@@ -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 { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js"; import { emitCliBanner } from "../banner.js";
import { getCommandPath, hasHelpOrVersion } from "../argv.js"; import { getCommandPath, hasHelpOrVersion } from "../argv.js";
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
import { commandRequiresPluginRegistry } from "./command-metadata.js";
import { ensureConfigReady } from "./config-guard.js"; import { ensureConfigReady } from "./config-guard.js";
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
function setProcessTitleForCommand(actionCommand: Command) { function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand; let current: Command = actionCommand;
@@ -16,17 +15,20 @@ function setProcessTitleForCommand(actionCommand: Command) {
process.title = `clawdbot-${name}`; process.title = `clawdbot-${name}`;
} }
// Commands that need channel plugins loaded
const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]);
export function registerPreActionHooks(program: Command, programVersion: string) { export function registerPreActionHooks(program: Command, programVersion: string) {
program.hook("preAction", async (_thisCommand, actionCommand) => { program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand); setProcessTitleForCommand(actionCommand);
emitCliBanner(programVersion); emitCliBanner(programVersion);
const argv = process.argv; const argv = process.argv;
if (hasHelpOrVersion(argv)) return; if (hasHelpOrVersion(argv)) return;
const needsPlugins = commandRequiresPluginRegistry(actionCommand);
const commandPath = getCommandPath(argv, 2); const commandPath = getCommandPath(argv, 2);
if (commandPath[0] === "doctor") return; if (commandPath[0] === "doctor") return;
await ensureConfigReady({ runtime: defaultRuntime, commandPath }); await ensureConfigReady({ runtime: defaultRuntime, commandPath });
if (needsPlugins) { // Load plugins for commands that need channel access
if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
ensurePluginRegistryLoaded(); ensurePluginRegistryLoaded();
} }
}); });
+1 -4
View File
@@ -14,11 +14,10 @@ import { theme } from "../../terminal/theme.js";
import { hasExplicitOptions } from "../command-options.js"; import { hasExplicitOptions } from "../command-options.js";
import { createDefaultDeps } from "../deps.js"; import { createDefaultDeps } from "../deps.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { collectOption } from "./helpers.js"; import { collectOption } from "./helpers.js";
export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) { export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) {
const agent = program program
.command("agent") .command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)") .description("Run an agent turn via the Gateway (use --local for embedded)")
.requiredOption("-m, --message <text>", "Message body for the agent") .requiredOption("-m, --message <text>", "Message body for the agent")
@@ -68,7 +67,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
await agentCliCommand(opts, defaultRuntime, deps); await agentCliCommand(opts, defaultRuntime, deps);
}); });
}); });
markCommandRequiresPluginRegistry(agent);
const agents = program const agents = program
.command("agents") .command("agents")
@@ -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`, `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agents", "docs.clawd.bot/cli/agents")}\n`,
); );
markCommandRequiresPluginRegistry(agents);
agents agents
.command("list") .command("list")
+1 -3
View File
@@ -8,10 +8,9 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
export function registerConfigureCommand(program: Command) { export function registerConfigureCommand(program: Command) {
const configure = program program
.command("configure") .command("configure")
.description("Interactive prompt to set up credentials, devices, and agent defaults") .description("Interactive prompt to set up credentials, devices, and agent defaults")
.addHelpText( .addHelpText(
@@ -49,5 +48,4 @@ export function registerConfigureCommand(program: Command) {
await configureCommandWithSections(sections as never, defaultRuntime); 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 { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import type { ProgramContext } from "./context.js"; import type { ProgramContext } from "./context.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { createMessageCliHelpers } from "./message/helpers.js"; import { createMessageCliHelpers } from "./message/helpers.js";
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js"; import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
import { import {
@@ -40,7 +39,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/mes
.action(() => { .action(() => {
message.help({ error: true }); message.help({ error: true });
}); });
markCommandRequiresPluginRegistry(message);
const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions); const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions);
registerMessageSendCommand(message, helpers); registerMessageSendCommand(message, helpers);
+1 -3
View File
@@ -12,7 +12,6 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
function resolveInstallDaemonFlag( function resolveInstallDaemonFlag(
command: unknown, command: unknown,
@@ -33,7 +32,7 @@ function resolveInstallDaemonFlag(
} }
export function registerOnboardCommand(program: Command) { export function registerOnboardCommand(program: Command) {
const onboard = program program
.command("onboard") .command("onboard")
.description("Interactive wizard to set up the gateway, workspace, and skills") .description("Interactive wizard to set up the gateway, workspace, and skills")
.addHelpText( .addHelpText(
@@ -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 { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js"; import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { markCommandRequiresPluginRegistry } from "./command-metadata.js";
import { parsePositiveIntOrUndefined } from "./helpers.js"; import { parsePositiveIntOrUndefined } from "./helpers.js";
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean { function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
@@ -25,7 +24,7 @@ function parseTimeoutMs(timeout: unknown): number | null | undefined {
} }
export function registerStatusHealthSessionsCommands(program: Command) { export function registerStatusHealthSessionsCommands(program: Command) {
const status = program program
.command("status") .command("status")
.description("Show channel health and recent session recipients") .description("Show channel health and recent session recipients")
.option("--json", "Output JSON instead of text", false) .option("--json", "Output JSON instead of text", false)
@@ -73,9 +72,8 @@ Examples:
); );
}); });
}); });
markCommandRequiresPluginRegistry(status);
const health = program program
.command("health") .command("health")
.description("Fetch health from the running gateway") .description("Fetch health from the running gateway")
.option("--json", "Output JSON instead of text", false) .option("--json", "Output JSON instead of text", false)
@@ -105,7 +103,6 @@ Examples:
); );
}); });
}); });
markCommandRequiresPluginRegistry(health);
program program
.command("sessions") .command("sessions")
+2 -1
View File
@@ -7,6 +7,7 @@ import { normalizeEnv } from "../infra/env.js";
import { isMainModule } from "../infra/is-main.js"; import { isMainModule } from "../infra/is-main.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js"; import { enableConsoleCapture } from "../logging.js";
import { tryRouteCli } from "./route.js"; import { tryRouteCli } from "./route.js";
@@ -42,7 +43,7 @@ export async function runCli(argv: string[] = process.argv) {
installUnhandledRejectionHandler(); installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message); console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1); process.exit(1);
}); });
+1 -3
View File
@@ -5,7 +5,6 @@ import { sandboxExplainCommand } from "../commands/sandbox-explain.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
// --- Types --- // --- Types ---
@@ -143,7 +142,7 @@ export function registerSandboxCli(program: Command) {
// --- Explain Command --- // --- Explain Command ---
const explain = sandbox sandbox
.command("explain") .command("explain")
.description("Explain effective sandbox/tool policy for a session/agent") .description("Explain effective sandbox/tool policy for a session/agent")
.option("--session <key>", "Session key to inspect (defaults to agent main)") .option("--session <key>", "Session key to inspect (defaults to agent main)")
@@ -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 { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js"; import { isRich, theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js";
type SecurityAuditOptions = { type SecurityAuditOptions = {
json?: boolean; json?: boolean;
@@ -37,7 +36,6 @@ export function registerSecurityCli(program: Command) {
() => () =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.clawd.bot/cli/security")}\n`, `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.clawd.bot/cli/security")}\n`,
); );
markCommandRequiresPluginRegistry(security);
security security
.command("audit") .command("audit")
+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 { 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. */ /** Image content block for Claude API multimodal messages. */
export type ImageContent = { export type ImageContent = {
+9 -2
View File
@@ -57,6 +57,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
]; ];
const CONFIG_BACKUP_COUNT = 5; const CONFIG_BACKUP_COUNT = 5;
const loggedInvalidConfigs = new Set<string>();
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: 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 const details = validated.issues
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`) .map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
.join("\n"); .join("\n");
deps.logger.error(`Invalid config:\\n${details}`); if (!loggedInvalidConfigs.has(configPath)) {
throw new Error("Invalid config"); 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) { if (validated.warnings.length > 0) {
const details = validated.warnings const details = validated.warnings
-6
View File
@@ -51,12 +51,6 @@ function coerceSchedule(schedule: UnknownRecord) {
function coercePayload(payload: UnknownRecord) { function coercePayload(payload: UnknownRecord) {
const next: UnknownRecord = { ...payload }; const next: UnknownRecord = { ...payload };
const kind = typeof payload.kind === "string" ? payload.kind : undefined;
if (!kind) {
if (typeof payload.text === "string") next.kind = "systemEvent";
else if (typeof payload.message === "string") next.kind = "agentTurn";
}
// Back-compat: older configs used `provider` for delivery channel. // Back-compat: older configs used `provider` for delivery channel.
migrateLegacyCronPayload(next); migrateLegacyCronPayload(next);
return next; return next;
+64 -2
View File
@@ -1,7 +1,13 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { computeNextRunAtMs } from "../schedule.js"; import { computeNextRunAtMs } from "../schedule.js";
import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js"; import type {
CronJob,
CronJobCreate,
CronJobPatch,
CronPayload,
CronPayloadPatch,
} from "../types.js";
import { import {
normalizeOptionalAgentId, normalizeOptionalAgentId,
normalizeOptionalText, normalizeOptionalText,
@@ -103,7 +109,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
if (patch.schedule) job.schedule = patch.schedule; if (patch.schedule) job.schedule = patch.schedule;
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget; if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
if (patch.wakeMode) job.wakeMode = patch.wakeMode; if (patch.wakeMode) job.wakeMode = patch.wakeMode;
if (patch.payload) job.payload = patch.payload; if (patch.payload) job.payload = mergeCronPayload(job.payload, patch.payload);
if (patch.isolation) job.isolation = patch.isolation; if (patch.isolation) job.isolation = patch.isolation;
if (patch.state) job.state = { ...job.state, ...patch.state }; if (patch.state) job.state = { ...job.state, ...patch.state };
if ("agentId" in patch) { if ("agentId" in patch) {
@@ -112,6 +118,62 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
assertSupportedJobSpec(job); assertSupportedJobSpec(job);
} }
function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload {
if (patch.kind !== existing.kind) {
return buildPayloadFromPatch(patch);
}
if (patch.kind === "systemEvent") {
if (existing.kind !== "systemEvent") {
return buildPayloadFromPatch(patch);
}
const text = typeof patch.text === "string" ? patch.text : existing.text;
return { kind: "systemEvent", text };
}
if (existing.kind !== "agentTurn") {
return buildPayloadFromPatch(patch);
}
const next: Extract<CronPayload, { kind: "agentTurn" }> = { ...existing };
if (typeof patch.message === "string") next.message = patch.message;
if (typeof patch.model === "string") next.model = patch.model;
if (typeof patch.thinking === "string") next.thinking = patch.thinking;
if (typeof patch.timeoutSeconds === "number") next.timeoutSeconds = patch.timeoutSeconds;
if (typeof patch.deliver === "boolean") next.deliver = patch.deliver;
if (typeof patch.channel === "string") next.channel = patch.channel;
if (typeof patch.to === "string") next.to = patch.to;
if (typeof patch.bestEffortDeliver === "boolean") {
next.bestEffortDeliver = patch.bestEffortDeliver;
}
return next;
}
function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
if (patch.kind === "systemEvent") {
if (typeof patch.text !== "string" || patch.text.length === 0) {
throw new Error('cron.update payload.kind="systemEvent" requires text');
}
return { kind: "systemEvent", text: patch.text };
}
if (typeof patch.message !== "string" || patch.message.length === 0) {
throw new Error('cron.update payload.kind="agentTurn" requires message');
}
return {
kind: "agentTurn",
message: patch.message,
model: patch.model,
thinking: patch.thinking,
timeoutSeconds: patch.timeoutSeconds,
deliver: patch.deliver,
channel: patch.channel,
to: patch.to,
bestEffortDeliver: patch.bestEffortDeliver,
};
}
export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) { export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) {
if (opts.forced) return true; if (opts.forced) return true;
return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs; return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs;
+18 -3
View File
@@ -25,6 +25,20 @@ export type CronPayload =
bestEffortDeliver?: boolean; bestEffortDeliver?: boolean;
}; };
export type CronPayloadPatch =
| { kind: "systemEvent"; text?: string }
| {
kind: "agentTurn";
message?: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
deliver?: boolean;
channel?: CronMessageChannel;
to?: string;
bestEffortDeliver?: boolean;
};
export type CronIsolation = { export type CronIsolation = {
postToMainPrefix?: string; postToMainPrefix?: string;
/** /**
@@ -72,6 +86,7 @@ export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" |
state?: Partial<CronJobState>; state?: Partial<CronJobState>;
}; };
export type CronJobPatch = Partial< export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "state" | "payload">> & {
Omit<CronJob, "id" | "createdAtMs" | "state"> & { state: CronJobState } 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 net from "node:net";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
export function isLoopbackAddress(ip: string | undefined): boolean { export function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false; if (!ip) return false;
@@ -11,6 +11,22 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
return false; return false;
} }
function normalizeIPv4MappedAddress(ip: string): string {
if (ip.startsWith("::ffff:")) return ip.slice("::ffff:".length);
return ip;
}
export function isLocalGatewayAddress(ip: string | undefined): boolean {
if (isLoopbackAddress(ip)) return true;
if (!ip) return false;
const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase());
const tailnetIPv4 = pickPrimaryTailnetIPv4();
if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) return true;
const tailnetIPv6 = pickPrimaryTailnetIPv6();
if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) return true;
return false;
}
/** /**
* Resolves gateway bind host with fallback strategy. * Resolves gateway bind host with fallback strategy.
* *
+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( export const CronIsolationSchema = Type.Object(
{ {
postToMainPrefix: Type.Optional(Type.String()), postToMainPrefix: Type.Optional(Type.String()),
@@ -120,18 +144,35 @@ export const CronAddParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const CronJobPatchSchema = Type.Object(
{
name: Type.Optional(NonEmptyString),
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
description: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
schedule: Type.Optional(CronScheduleSchema),
sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])),
wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
payload: Type.Optional(CronPayloadPatchSchema),
isolation: Type.Optional(CronIsolationSchema),
state: Type.Optional(Type.Partial(CronJobStateSchema)),
},
{ additionalProperties: false },
);
export const CronUpdateParamsSchema = Type.Union([ export const CronUpdateParamsSchema = Type.Union([
Type.Object( Type.Object(
{ {
id: NonEmptyString, id: NonEmptyString,
patch: Type.Partial(CronAddParamsSchema), patch: CronJobPatchSchema,
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
Type.Object( Type.Object(
{ {
jobId: NonEmptyString, jobId: NonEmptyString,
patch: Type.Partial(CronAddParamsSchema), patch: CronJobPatchSchema,
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
+73 -73
View File
@@ -3,92 +3,92 @@ import { Type } from "@sinclair/typebox";
import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js";
export const SessionsListParamsSchema = Type.Object( export const SessionsListParamsSchema = Type.Object(
{ {
limit: Type.Optional(Type.Integer({ minimum: 1 })), limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
/** /**
* Read first 8KB of each session transcript to derive title from first user message. * Read first 8KB of each session transcript to derive title from first user message.
* Performs a file read per session - use `limit` to bound result set on large stores. * Performs a file read per session - use `limit` to bound result set on large stores.
*/ */
includeDerivedTitles: Type.Optional(Type.Boolean()), includeDerivedTitles: Type.Optional(Type.Boolean()),
/** /**
* Read last 16KB of each session transcript to extract most recent message preview. * Read last 16KB of each session transcript to extract most recent message preview.
* Performs a file read per session - use `limit` to bound result set on large stores. * Performs a file read per session - use `limit` to bound result set on large stores.
*/ */
includeLastMessage: Type.Optional(Type.Boolean()), includeLastMessage: Type.Optional(Type.Boolean()),
label: Type.Optional(SessionLabelString), label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString),
search: Type.Optional(Type.String()), search: Type.Optional(Type.String()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsResolveParamsSchema = Type.Object( export const SessionsResolveParamsSchema = Type.Object(
{ {
key: Type.Optional(NonEmptyString), key: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString), label: Type.Optional(SessionLabelString),
agentId: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsPatchParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
responseUsage: Type.Optional( responseUsage: Type.Optional(
Type.Union([ Type.Union([
Type.Literal("off"), Type.Literal("off"),
Type.Literal("tokens"), Type.Literal("tokens"),
Type.Literal("full"), Type.Literal("full"),
// Backward compat with older clients/stores. // Backward compat with older clients/stores.
Type.Literal("on"), Type.Literal("on"),
Type.Null(), Type.Null(),
]), ]),
), ),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional( sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
), ),
groupActivation: Type.Optional( groupActivation: Type.Optional(
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
), ),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsResetParamsSchema = Type.Object( export const SessionsResetParamsSchema = Type.Object(
{ key: NonEmptyString }, { key: NonEmptyString },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsDeleteParamsSchema = Type.Object( export const SessionsDeleteParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
deleteTranscript: Type.Optional(Type.Boolean()), deleteTranscript: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsCompactParamsSchema = Type.Object( export const SessionsCompactParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
maxLines: Type.Optional(Type.Integer({ minimum: 1 })), maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
+2 -1
View File
@@ -3,6 +3,7 @@ import type { WebSocketServer } from "ws";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js"; import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { stopGmailWatcher } from "../hooks/gmail-watcher.js"; import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import type { PluginServicesHandle } from "../plugins/services.js"; import type { PluginServicesHandle } from "../plugins/services.js";
export function createGatewayCloseHandler(params: { export function createGatewayCloseHandler(params: {
@@ -13,7 +14,7 @@ export function createGatewayCloseHandler(params: {
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>; stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
pluginServices: PluginServicesHandle | null; pluginServices: PluginServicesHandle | null;
cron: { stop: () => void }; cron: { stop: () => void };
heartbeatRunner: { stop: () => void }; heartbeatRunner: HeartbeatRunner;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>; nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
tickInterval: ReturnType<typeof setInterval>; tickInterval: ReturnType<typeof setInterval>;
+3 -4
View File
@@ -1,7 +1,7 @@
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js"; import type { loadConfig } from "../config/config.js";
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js"; import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
import { import {
authorizeGatewaySigusr1Restart, authorizeGatewaySigusr1Restart,
@@ -18,7 +18,7 @@ import { buildGatewayCronService, type GatewayCronState } from "./server-cron.js
type GatewayHotReloadState = { type GatewayHotReloadState = {
hooksConfig: ReturnType<typeof resolveHooksConfig>; hooksConfig: ReturnType<typeof resolveHooksConfig>;
heartbeatRunner: { stop: () => void }; heartbeatRunner: HeartbeatRunner;
cronState: GatewayCronState; cronState: GatewayCronState;
browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> | null; browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> | null;
}; };
@@ -57,8 +57,7 @@ export function createGatewayReloadHandlers(params: {
} }
if (plan.restartHeartbeat) { if (plan.restartHeartbeat) {
state.heartbeatRunner.stop(); nextState.heartbeatRunner.updateConfig(nextConfig);
nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig });
} }
resetDirectoryCache(); resetDirectoryCache();
+29 -2
View File
@@ -28,9 +28,30 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
return; 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 { cfg, entry } = loadSessionEntry(sessionKey);
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey); const parsedTarget = resolveAnnounceTargetFromKey(baseSessionKey);
const origin = mergeDeliveryContext(deliveryContextFromSession(entry), parsedTarget ?? undefined);
// 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 channelRaw = origin?.channel;
const channel = channelRaw ? normalizeChannelId(channelRaw) : null; const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
const to = origin?.to; const to = origin?.to;
@@ -51,6 +72,11 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
return; return;
} }
const threadId =
payload.threadId ??
sessionThreadId ??
(origin?.threadId != null ? String(origin.threadId) : undefined);
try { try {
await agentCommand( await agentCommand(
{ {
@@ -61,6 +87,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
deliver: true, deliver: true,
bestEffortDeliver: true, bestEffortDeliver: true,
messageChannel: channel, messageChannel: channel,
threadId,
}, },
defaultRuntime, defaultRuntime,
params.deps, 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 () => { test("accepts password auth when configured", async () => {
testState.gatewayAuth = { mode: "password", password: "secret" }; testState.gatewayAuth = { mode: "password", password: "secret" };
const port = await getFreePort(); const port = await getFreePort();
+93 -3
View File
@@ -122,7 +122,7 @@ describe("gateway server cron", () => {
data: { data: {
name: "wrapped", name: "wrapped",
schedule: { atMs }, schedule: { atMs },
payload: { text: "hello" }, payload: { kind: "systemEvent", text: "hello" },
}, },
}); });
expect(addRes.ok).toBe(true); expect(addRes.ok).toBe(true);
@@ -166,7 +166,7 @@ describe("gateway server cron", () => {
id: jobId, id: jobId,
patch: { patch: {
schedule: { atMs }, schedule: { atMs },
payload: { text: "updated" }, payload: { kind: "systemEvent", text: "updated" },
}, },
}); });
expect(updateRes.ok).toBe(true); expect(updateRes.ok).toBe(true);
@@ -182,6 +182,96 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
}); });
test("merges agentTurn payload patches", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "patch merge",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello", model: "opus" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: {
payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" },
},
});
expect(updateRes.ok).toBe(true);
const updated = updateRes.payload as
| {
payload?: {
kind?: unknown;
message?: unknown;
model?: unknown;
deliver?: unknown;
channel?: unknown;
to?: unknown;
};
}
| undefined;
expect(updated?.payload?.kind).toBe("agentTurn");
expect(updated?.payload?.message).toBe("hello");
expect(updated?.payload?.model).toBe("opus");
expect(updated?.payload?.deliver).toBe(true);
expect(updated?.payload?.channel).toBe("telegram");
expect(updated?.payload?.to).toBe("19098680");
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("rejects payload kind changes without required fields", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "patch reject",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: {
payload: { kind: "agentTurn", deliver: true },
},
});
expect(updateRes.ok).toBe(false);
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("accepts jobId for cron.update", async () => { test("accepts jobId for cron.update", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.cronStorePath = path.join(dir, "cron", "jobs.json");
@@ -210,7 +300,7 @@ describe("gateway server cron", () => {
jobId, jobId,
patch: { patch: {
schedule: { atMs }, schedule: { atMs },
payload: { text: "updated" }, payload: { kind: "systemEvent", text: "updated" },
}, },
}); });
expect(updateRes.ok).toBe(true); expect(updateRes.ok).toBe(true);
+14 -1
View File
@@ -13,6 +13,7 @@ import {
readConfigFileSnapshot, readConfigFileSnapshot,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
@@ -26,6 +27,7 @@ import {
} from "../infra/skills-remote.js"; } from "../infra/skills-remote.js";
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js"; import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js";
import type { PluginServicesHandle } from "../plugins/services.js"; import type { PluginServicesHandle } from "../plugins/services.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -198,6 +200,10 @@ export async function startGatewayServer(
} }
const cfgAtStart = loadConfig(); const cfgAtStart = loadConfig();
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
}
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
initSubagentRegistry(); initSubagentRegistry();
const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
@@ -533,5 +539,12 @@ export async function startGatewayServer(
httpServer, 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 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 startGmailWatcher = vi.fn(async () => ({ started: true }));
const stopGmailWatcher = vi.fn(async () => {}); const stopGmailWatcher = vi.fn(async () => {});
@@ -116,6 +120,7 @@ const hoisted = vi.hoisted(() => {
browserStop, browserStop,
startBrowserControlServerIfEnabled, startBrowserControlServerIfEnabled,
heartbeatStop, heartbeatStop,
heartbeatUpdateConfig,
startHeartbeatRunner, startHeartbeatRunner,
startGmailWatcher, startGmailWatcher,
stopGmailWatcher, stopGmailWatcher,
@@ -237,8 +242,9 @@ describe("gateway hot reload", () => {
expect(hoisted.browserStop).toHaveBeenCalledTimes(1); expect(hoisted.browserStop).toHaveBeenCalledTimes(1);
expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2); expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2);
expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(2); expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1);
expect(hoisted.heartbeatStop).toHaveBeenCalledTimes(1); expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledTimes(1);
expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledWith(nextConfig);
expect(hoisted.cronInstances.length).toBe(2); expect(hoisted.cronInstances.length).toBe(2);
expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1); expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1);
@@ -25,7 +25,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js"; import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js"; import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js"; import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress } from "../../net.js"; import { isLocalGatewayAddress } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { import {
type ConnectParams, type ConnectParams,
@@ -290,9 +290,18 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role; connectParams.role = role;
connectParams.scopes = scopes; 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; const device = connectParams.device;
let devicePublicKey: string | null = null; let devicePublicKey: string | null = null;
if (!device) { if (!device && !allowsDeviceOptional) {
setHandshakeState("failed"); setHandshakeState("failed");
setCloseCause("device-required", { setCloseCause("device-required", {
client: connectParams.client.id, client: connectParams.client.id,
@@ -347,7 +356,7 @@ export function attachGatewayWsMessageHandler(params: {
close(1008, "device signature expired"); close(1008, "device signature expired");
return; return;
} }
const nonceRequired = !isLoopbackAddress(remoteAddr); const nonceRequired = !isLocalGatewayAddress(remoteAddr);
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (nonceRequired && !providedNonce) { if (nonceRequired && !providedNonce) {
setHandshakeState("failed"); setHandshakeState("failed");
@@ -458,14 +467,7 @@ export function attachGatewayWsMessageHandler(params: {
} }
} }
const authResult = await authorizeGatewayConnect({ if (!authOk && connectParams.auth?.token && device) {
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
if (!authOk && connectParams.auth?.token) {
const tokenCheck = await verifyDeviceToken({ const tokenCheck = await verifyDeviceToken({
deviceId: device.id, deviceId: device.id,
token: connectParams.auth.token, token: connectParams.auth.token,
@@ -524,7 +526,7 @@ export function attachGatewayWsMessageHandler(params: {
role, role,
scopes, scopes,
remoteIp: remoteAddr, remoteIp: remoteAddr,
silent: isLoopbackAddress(remoteAddr), silent: isLocalGatewayAddress(remoteAddr),
}); });
const context = buildRequestContext(); const context = buildRequestContext();
if (pairing.request.silent === true) { if (pairing.request.silent === true) {
@@ -656,7 +658,7 @@ export function attachGatewayWsMessageHandler(params: {
if (presenceKey) { if (presenceKey) {
upsertPresence(presenceKey, { upsertPresence(presenceKey, {
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(), host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr,
version: connectParams.client.version, version: connectParams.client.version,
platform: connectParams.client.platform, platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily, deviceFamily: connectParams.client.deviceFamily,
+2 -1
View File
@@ -280,7 +280,7 @@ export async function connectReq(
signature: string; signature: string;
signedAt: number; signedAt: number;
nonce?: string; nonce?: string;
}; } | null;
}, },
): Promise<ConnectResponse> { ): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto"); const { randomUUID } = await import("node:crypto");
@@ -294,6 +294,7 @@ export async function connectReq(
const role = opts?.role ?? "operator"; const role = opts?.role ?? "operator";
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : []; const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => { const device = (() => {
if (opts?.device === null) return undefined;
if (opts?.device) return opts.device; if (opts?.device) return opts.device;
const identity = loadOrCreateDeviceIdentity(); const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now(); const signedAtMs = Date.now();
+3 -5
View File
@@ -28,6 +28,7 @@ import {
PortInUseError, PortInUseError,
} from "./infra/ports.js"; } from "./infra/ports.js";
import { assertSupportedRuntime } from "./infra/runtime-guard.js"; import { assertSupportedRuntime } from "./infra/runtime-guard.js";
import { formatUncaughtError } from "./infra/errors.js";
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js"; import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
import { enableConsoleCapture } from "./logging.js"; import { enableConsoleCapture } from "./logging.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js"; import { runCommandWithTimeout, runExec } from "./process/exec.js";
@@ -82,15 +83,12 @@ if (isMain) {
installUnhandledRejectionHandler(); installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message); console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1); process.exit(1);
}); });
void program.parseAsync(process.argv).catch((err) => { void program.parseAsync(process.argv).catch((err) => {
console.error( console.error("[clawdbot] CLI failed:", formatUncaughtError(err));
"[clawdbot] CLI failed:",
err instanceof Error ? (err.stack ?? err.message) : err,
);
process.exit(1); process.exit(1);
}); });
} }
+7 -7
View File
@@ -141,9 +141,11 @@ export type DiagnosticEventPayload =
| DiagnosticRunAttemptEvent | DiagnosticRunAttemptEvent
| DiagnosticHeartbeatEvent; | DiagnosticHeartbeatEvent;
type DiagnosticEventInput<T extends DiagnosticEventPayload = DiagnosticEventPayload> = export type DiagnosticEventInput = DiagnosticEventPayload extends infer Event
T extends DiagnosticEventPayload ? Omit<T, "seq" | "ts"> : never; ? Event extends DiagnosticEventPayload
? Omit<Event, "seq" | "ts">
: never
: never;
let seq = 0; let seq = 0;
const listeners = new Set<(evt: DiagnosticEventPayload) => void>(); const listeners = new Set<(evt: DiagnosticEventPayload) => void>();
@@ -151,14 +153,12 @@ export function isDiagnosticsEnabled(config?: ClawdbotConfig): boolean {
return config?.diagnostics?.enabled === true; return config?.diagnostics?.enabled === true;
} }
export function emitDiagnosticEvent<T extends DiagnosticEventPayload>( export function emitDiagnosticEvent(event: DiagnosticEventInput) {
event: DiagnosticEventInput<T>,
) {
const enriched = { const enriched = {
...event, ...event,
seq: (seq += 1), seq: (seq += 1),
ts: Date.now(), ts: Date.now(),
} as DiagnosticEventPayload; } satisfies DiagnosticEventPayload;
for (const listener of listeners) { for (const listener of listeners) {
try { try {
listener(enriched); listener(enriched);
+10
View File
@@ -20,3 +20,13 @@ export function formatErrorMessage(err: unknown): string {
return Object.prototype.toString.call(err); 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"; const DEFAULT_HEARTBEAT_TARGET = "last";
type HeartbeatAgentState = {
agentId: string;
heartbeat?: HeartbeatConfig;
intervalMs: number;
lastRunMs?: number;
nextDueMs: number;
};
export type HeartbeatRunner = {
stop: () => void;
updateConfig: (cfg: ClawdbotConfig) => void;
};
function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) { function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
const list = cfg.agents?.list ?? []; const list = cfg.agents?.list ?? [];
return list.some((entry) => Boolean(entry?.heartbeat)); return list.some((entry) => Boolean(entry?.heartbeat));
@@ -539,24 +552,97 @@ export function startHeartbeatRunner(opts: {
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
}) { runOnce?: typeof runHeartbeatOnce;
const cfg = opts.cfg ?? loadConfig(); }): HeartbeatRunner {
const heartbeatAgents = resolveHeartbeatAgents(cfg);
const intervals = heartbeatAgents
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
.filter((value): value is number => typeof value === "number");
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
if (!intervalMs) {
log.info("heartbeat: disabled", { enabled: false });
}
const runtime = opts.runtime ?? defaultRuntime; const runtime = opts.runtime ?? defaultRuntime;
const 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) => { const run: HeartbeatWakeHandler = async (params) => {
if (!heartbeatsEnabled) { if (!heartbeatsEnabled) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
} }
if (heartbeatAgents.length === 0) { if (state.agents.size === 0) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
} }
@@ -566,52 +652,44 @@ export function startHeartbeatRunner(opts: {
const now = startedAt; const now = startedAt;
let ran = false; let ran = false;
for (const agent of heartbeatAgents) { for (const agent of state.agents.values()) {
const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat); if (isInterval && now < agent.nextDueMs) {
if (!agentIntervalMs) continue;
const lastRun = lastRunByAgent.get(agent.agentId);
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
continue; continue;
} }
const res = await runHeartbeatOnce({ const res = await runOnce({
cfg, cfg: state.cfg,
agentId: agent.agentId, agentId: agent.agentId,
heartbeat: agent.heartbeat, heartbeat: agent.heartbeat,
reason, reason,
deps: { runtime }, deps: { runtime: state.runtime },
}); });
if (res.status === "skipped" && res.reason === "requests-in-flight") { if (res.status === "skipped" && res.reason === "requests-in-flight") {
return res; return res;
} }
if (res.status !== "skipped" || res.reason !== "disabled") { if (res.status !== "skipped" || res.reason !== "disabled") {
lastRunByAgent.set(agent.agentId, now); agent.lastRunMs = now;
agent.nextDueMs = now + agent.intervalMs;
} }
if (res.status === "ran") ran = true; if (res.status === "ran") ran = true;
} }
scheduleNext();
if (ran) return { status: "ran", durationMs: Date.now() - startedAt }; if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" }; return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
}; };
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason })); setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
updateConfig(state.cfg);
let timer: NodeJS.Timeout | null = null;
if (intervalMs) {
timer = setInterval(() => {
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
}, intervalMs);
timer.unref?.();
log.info("heartbeat: started", { intervalMs });
}
const cleanup = () => { const cleanup = () => {
state.stopped = true;
setHeartbeatWakeHandler(null); setHeartbeatWakeHandler(null);
if (timer) clearInterval(timer); if (state.timer) clearTimeout(state.timer);
timer = null; state.timer = null;
}; };
opts.abortSignal?.addEventListener("abort", cleanup, { once: true }); 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"; status: "ok" | "error" | "skipped";
ts: number; ts: number;
sessionKey?: string; 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; message?: string | null;
doctorHint?: string | null; doctorHint?: string | null;
stats?: RestartSentinelStats | null; stats?: RestartSentinelStats | null;
+3 -4
View File
@@ -1,5 +1,7 @@
import process from "node:process"; import process from "node:process";
import { formatUncaughtError } from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean; type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set<UnhandledRejectionHandler>(); const handlers = new Set<UnhandledRejectionHandler>();
@@ -28,10 +30,7 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
export function installUnhandledRejectionHandler(): void { export function installUnhandledRejectionHandler(): void {
process.on("unhandledRejection", (reason, _promise) => { process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return; if (isUnhandledRejectionHandled(reason)) return;
console.error( console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
"[clawdbot] Unhandled promise rejection:",
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
);
process.exit(1); process.exit(1);
}); });
} }
+1
View File
@@ -338,6 +338,7 @@ export function startDiagnosticHeartbeat() {
} }
} }
}, 30_000); }, 30_000);
heartbeatInterval.unref?.();
} }
export function stopDiagnosticHeartbeat() { export function stopDiagnosticHeartbeat() {
+2 -1
View File
@@ -55,6 +55,7 @@ async function main() {
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js"); const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
assertSupportedRuntime(); assertSupportedRuntime();
const { formatUncaughtError } = await import("../infra/errors.js");
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js"); const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
const { buildProgram } = await import("../cli/program.js"); const { buildProgram } = await import("../cli/program.js");
@@ -63,7 +64,7 @@ async function main() {
installUnhandledRejectionHandler(); installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message); console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1); 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 buildTelegramMessageContext = vi.hoisted(() => vi.fn());
const dispatchTelegramMessage = vi.hoisted(() => vi.fn()); const dispatchTelegramMessage = vi.hoisted(() => vi.fn());
const logMessageQueued = vi.hoisted(() => vi.fn());
const logMessageProcessed = vi.hoisted(() => vi.fn());
const logSessionStateChange = vi.hoisted(() => vi.fn());
const diagnosticLogger = vi.hoisted(() => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}));
vi.mock("./bot-message-context.js", () => ({ vi.mock("./bot-message-context.js", () => ({
buildTelegramMessageContext, buildTelegramMessageContext,
@@ -19,25 +11,12 @@ vi.mock("./bot-message-dispatch.js", () => ({
dispatchTelegramMessage, dispatchTelegramMessage,
})); }));
vi.mock("../logging/diagnostic.js", () => ({
diagnosticLogger,
logMessageQueued,
logMessageProcessed,
logSessionStateChange,
}));
import { createTelegramMessageProcessor } from "./bot-message.js"; import { createTelegramMessageProcessor } from "./bot-message.js";
describe("telegram bot message diagnostics", () => { describe("telegram bot message processor", () => {
beforeEach(() => { beforeEach(() => {
buildTelegramMessageContext.mockReset(); buildTelegramMessageContext.mockReset();
dispatchTelegramMessage.mockReset(); dispatchTelegramMessage.mockReset();
logMessageQueued.mockReset();
logMessageProcessed.mockReset();
logSessionStateChange.mockReset();
diagnosticLogger.info.mockReset();
diagnosticLogger.debug.mockReset();
diagnosticLogger.error.mockReset();
}); });
const baseDeps = { const baseDeps = {
@@ -63,39 +42,19 @@ describe("telegram bot message diagnostics", () => {
resolveBotTopicsEnabled: () => false, resolveBotTopicsEnabled: () => false,
}; };
it("decrements queue depth after successful processing", async () => { it("dispatches when context is available", async () => {
buildTelegramMessageContext.mockResolvedValue({ buildTelegramMessageContext.mockResolvedValue({ route: { sessionKey: "agent:main:main" } });
route: { sessionKey: "agent:main:main" },
});
const processMessage = createTelegramMessageProcessor(baseDeps); const processMessage = createTelegramMessageProcessor(baseDeps);
await processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {}); await processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {});
expect(logMessageQueued).toHaveBeenCalledTimes(1); expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1);
expect(logSessionStateChange).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
state: "idle",
reason: "message_completed",
});
}); });
it("decrements queue depth after processing error", async () => { it("skips dispatch when no context is produced", async () => {
buildTelegramMessageContext.mockResolvedValue({ buildTelegramMessageContext.mockResolvedValue(null);
route: { sessionKey: "agent:main:main" },
});
dispatchTelegramMessage.mockRejectedValue(new Error("boom"));
const processMessage = createTelegramMessageProcessor(baseDeps); const processMessage = createTelegramMessageProcessor(baseDeps);
await processMessage({ message: { chat: { id: 123 }, message_id: 456 } }, [], [], {});
await expect( expect(dispatchTelegramMessage).not.toHaveBeenCalled();
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",
});
}); });
}); });
+32 -123
View File
@@ -1,12 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { buildTelegramMessageContext } from "./bot-message-context.js"; import { buildTelegramMessageContext } from "./bot-message-context.js";
import { dispatchTelegramMessage } from "./bot-message-dispatch.js"; import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
import {
diagnosticLogger as diag,
logMessageProcessed,
logMessageQueued,
logSessionStateChange,
} from "../logging/diagnostic.js";
export const createTelegramMessageProcessor = (deps) => { export const createTelegramMessageProcessor = (deps) => {
const { const {
@@ -33,122 +27,37 @@ export const createTelegramMessageProcessor = (deps) => {
} = deps; } = deps;
return async (primaryCtx, allMedia, storeAllowFrom, options) => { return async (primaryCtx, allMedia, storeAllowFrom, options) => {
const chatId = primaryCtx?.message?.chat?.id ?? primaryCtx?.chat?.id ?? "unknown"; const context = await buildTelegramMessageContext({
const messageId = primaryCtx?.message?.message_id ?? "unknown"; primaryCtx,
const startTime = Date.now(); allMedia,
storeAllowFrom,
diag.info( options,
`process message start: channel=telegram chatId=${chatId} messageId=${messageId} mediaCount=${ bot,
allMedia?.length ?? 0 cfg,
}`, account,
); historyLimit,
groupHistories,
let sessionKey: string | undefined; dmPolicy,
allowFrom,
try { groupAllowFrom,
const context = await buildTelegramMessageContext({ ackReactionScope,
primaryCtx, logger,
allMedia, resolveGroupActivation,
storeAllowFrom, resolveGroupRequireMention,
options, resolveTelegramGroupConfig,
bot, });
cfg, if (!context) return;
account, await dispatchTelegramMessage({
historyLimit, context,
groupHistories, bot,
dmPolicy, cfg,
allowFrom, runtime,
groupAllowFrom, replyToMode,
ackReactionScope, streamMode,
logger, textLimit,
resolveGroupActivation, telegramCfg,
resolveGroupRequireMention, opts,
resolveTelegramGroupConfig, resolveBotTopicsEnabled,
}); });
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;
}
}; };
}; };
+25 -13
View File
@@ -2,6 +2,7 @@ import { createServer } from "node:http";
import { webhookCallback } from "grammy"; import { webhookCallback } from "grammy";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -34,6 +35,7 @@ export async function startTelegramWebhook(opts: {
const port = opts.port ?? 8787; const port = opts.port ?? 8787;
const host = opts.host ?? "0.0.0.0"; const host = opts.host ?? "0.0.0.0";
const runtime = opts.runtime ?? defaultRuntime; const runtime = opts.runtime ?? defaultRuntime;
const diagnosticsEnabled = isDiagnosticsEnabled(opts.config);
const bot = createTelegramBot({ const bot = createTelegramBot({
token: opts.token, token: opts.token,
runtime, runtime,
@@ -45,7 +47,9 @@ export async function startTelegramWebhook(opts: {
secretToken: opts.secret, secretToken: opts.secret,
}); });
startDiagnosticHeartbeat(); if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
}
const server = createServer((req, res) => { const server = createServer((req, res) => {
if (req.url === healthPath) { if (req.url === healthPath) {
@@ -59,24 +63,30 @@ export async function startTelegramWebhook(opts: {
return; return;
} }
const startTime = Date.now(); const startTime = Date.now();
logWebhookReceived({ channel: "telegram", updateType: "telegram-post" }); if (diagnosticsEnabled) {
logWebhookReceived({ channel: "telegram", updateType: "telegram-post" });
}
const handled = handler(req, res); const handled = handler(req, res);
if (handled && typeof (handled as Promise<void>).catch === "function") { if (handled && typeof (handled as Promise<void>).catch === "function") {
void (handled as Promise<void>) void (handled as Promise<void>)
.then(() => { .then(() => {
logWebhookProcessed({ if (diagnosticsEnabled) {
channel: "telegram", logWebhookProcessed({
updateType: "telegram-post", channel: "telegram",
durationMs: Date.now() - startTime, updateType: "telegram-post",
}); durationMs: Date.now() - startTime,
});
}
}) })
.catch((err) => { .catch((err) => {
const errMsg = formatErrorMessage(err); const errMsg = formatErrorMessage(err);
logWebhookError({ if (diagnosticsEnabled) {
channel: "telegram", logWebhookError({
updateType: "telegram-post", channel: "telegram",
error: errMsg, updateType: "telegram-post",
}); error: errMsg,
});
}
runtime.log?.(`webhook handler failed: ${errMsg}`); runtime.log?.(`webhook handler failed: ${errMsg}`);
if (!res.headersSent) res.writeHead(500); if (!res.headersSent) res.writeHead(500);
res.end(); res.end();
@@ -98,7 +108,9 @@ export async function startTelegramWebhook(opts: {
const shutdown = () => { const shutdown = () => {
server.close(); server.close();
void bot.stop(); void bot.stop();
stopDiagnosticHeartbeat(); if (diagnosticsEnabled) {
stopDiagnosticHeartbeat();
}
}; };
if (opts.abortSignal) { if (opts.abortSignal) {
opts.abortSignal.addEventListener("abort", shutdown, { once: true }); 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 { export class CustomEditor extends Editor {
onEscape?: () => void; onEscape?: () => void;
+69 -69
View File
@@ -11,7 +11,7 @@ const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;
* Check if position is at a word boundary. * Check if position is at a word boundary.
*/ */
export function isWordBoundary(text: string, index: number): boolean { export function isWordBoundary(text: string, index: number): boolean {
return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? "");
} }
/** /**
@@ -19,17 +19,17 @@ export function isWordBoundary(text: string, index: number): boolean {
* Returns null if no match. * Returns null if no match.
*/ */
export function findWordBoundaryIndex(text: string, query: string): number | null { export function findWordBoundaryIndex(text: string, query: string): number | null {
if (!query) return null; if (!query) return null;
const textLower = text.toLowerCase(); const textLower = text.toLowerCase();
const queryLower = query.toLowerCase(); const queryLower = query.toLowerCase();
const maxIndex = textLower.length - queryLower.length; const maxIndex = textLower.length - queryLower.length;
if (maxIndex < 0) return null; if (maxIndex < 0) return null;
for (let i = 0; i <= maxIndex; i++) { for (let i = 0; i <= maxIndex; i++) {
if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
return i; return i;
} }
} }
return null; return null;
} }
/** /**
@@ -37,31 +37,31 @@ export function findWordBoundaryIndex(text: string, query: string): number | nul
* Returns score (lower = better) or null if no match. * Returns score (lower = better) or null if no match.
*/ */
export function fuzzyMatchLower(queryLower: string, textLower: string): number | null { export function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
if (queryLower.length === 0) return 0; if (queryLower.length === 0) return 0;
if (queryLower.length > textLower.length) return null; if (queryLower.length > textLower.length) return null;
let queryIndex = 0; let queryIndex = 0;
let score = 0; let score = 0;
let lastMatchIndex = -1; let lastMatchIndex = -1;
let consecutiveMatches = 0; let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) { if (textLower[i] === queryLower[queryIndex]) {
const isAtWordBoundary = isWordBoundary(textLower, i); const isAtWordBoundary = isWordBoundary(textLower, i);
if (lastMatchIndex === i - 1) { if (lastMatchIndex === i - 1) {
consecutiveMatches++; consecutiveMatches++;
score -= consecutiveMatches * 5; // Reward consecutive matches score -= consecutiveMatches * 5; // Reward consecutive matches
} else { } else {
consecutiveMatches = 0; consecutiveMatches = 0;
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps
} }
if (isAtWordBoundary) score -= 10; // Reward word boundary matches if (isAtWordBoundary) score -= 10; // Reward word boundary matches
score += i * 0.1; // Slight penalty for later matches score += i * 0.1; // Slight penalty for later matches
lastMatchIndex = i; lastMatchIndex = i;
queryIndex++; queryIndex++;
} }
} }
return queryIndex < queryLower.length ? null : score; return queryIndex < queryLower.length ? null : score;
} }
/** /**
@@ -69,46 +69,46 @@ export function fuzzyMatchLower(queryLower: string, textLower: string): number |
* Supports space-separated tokens (all must match). * Supports space-separated tokens (all must match).
*/ */
export function fuzzyFilterLower<T extends { searchTextLower?: string }>( export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
items: T[], items: T[],
queryLower: string, queryLower: string,
): T[] { ): T[] {
const trimmed = queryLower.trim(); const trimmed = queryLower.trim();
if (!trimmed) return items; if (!trimmed) return items;
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return items; if (tokens.length === 0) return items;
const results: { item: T; score: number }[] = []; const results: { item: T; score: number }[] = [];
for (const item of items) { for (const item of items) {
const text = item.searchTextLower ?? ""; const text = item.searchTextLower ?? "";
let totalScore = 0; let totalScore = 0;
let allMatch = true; let allMatch = true;
for (const token of tokens) { for (const token of tokens) {
const score = fuzzyMatchLower(token, text); const score = fuzzyMatchLower(token, text);
if (score !== null) { if (score !== null) {
totalScore += score; totalScore += score;
} else { } else {
allMatch = false; allMatch = false;
break; break;
} }
} }
if (allMatch) results.push({ item, score: totalScore }); if (allMatch) results.push({ item, score: totalScore });
} }
results.sort((a, b) => a.score - b.score); results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item); return results.map((r) => r.item);
} }
/** /**
* Prepare items for fuzzy filtering by pre-computing lowercase search text. * Prepare items for fuzzy filtering by pre-computing lowercase search text.
*/ */
export function prepareSearchItems<T extends { label?: string; description?: string; searchText?: string }>( export function prepareSearchItems<
items: T[], T extends { label?: string; description?: string; searchText?: string },
): (T & { searchTextLower: string })[] { >(items: T[]): (T & { searchTextLower: string })[] {
return items.map((item) => { return items.map((item) => {
const parts: string[] = []; const parts: string[] = [];
if (item.label) parts.push(item.label); if (item.label) parts.push(item.label);
if (item.description) parts.push(item.description); if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText); if (item.searchText) parts.push(item.searchText);
return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
}); });
} }
+1 -4
View File
@@ -5,10 +5,7 @@ import {
selectListTheme, selectListTheme,
settingsListTheme, settingsListTheme,
} from "../theme/theme.js"; } from "../theme/theme.js";
import { import { FilterableSelectList, type FilterableSelectItem } from "./filterable-select-list.js";
FilterableSelectList,
type FilterableSelectItem,
} from "./filterable-select-list.js";
import { SearchableSelectList } from "./searchable-select-list.js"; import { SearchableSelectList } from "./searchable-select-list.js";
export function createSelectList(items: SelectItem[], maxVisible = 7) { export function createSelectList(items: SelectItem[], maxVisible = 7) {
+12 -12
View File
@@ -1,15 +1,15 @@
export function formatRelativeTime(timestamp: number): string { export function formatRelativeTime(timestamp: number): string {
const now = Date.now(); const now = Date.now();
const diff = now - timestamp; const diff = now - timestamp;
const seconds = Math.floor(diff / 1000); const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (seconds < 60) return "just now"; if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`; if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday"; if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`; if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
} }
+100
View File
@@ -358,6 +358,98 @@
color: var(--ok); 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 */ /* Content Area */
.config-content { .config-content {
flex: 1; flex: 1;
@@ -1243,6 +1335,14 @@
justify-content: center; justify-content: center;
} }
.config-section-hero {
padding: 12px 16px;
}
.config-subnav {
padding: 8px 16px 10px;
}
.config-content { .config-content {
padding: 16px; padding: 16px;
} }
+6 -1
View File
@@ -485,11 +485,16 @@ export function renderApp(state: AppViewState) {
originalValue: state.configFormOriginal, originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery, searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection, activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => (state.configRaw = next), onRawChange: (next) => (state.configRaw = next),
onFormModeChange: (mode) => (state.configFormMode = mode), onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query), 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), onReload: () => loadConfig(state),
onSave: () => saveConfig(state), onSave: () => saveConfig(state),
onApply: () => applyConfig(state), onApply: () => applyConfig(state),
+10
View File
@@ -62,6 +62,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
const tokenRaw = params.get("token"); const tokenRaw = params.get("token");
const passwordRaw = params.get("password"); const passwordRaw = params.get("password");
const sessionRaw = params.get("session"); const sessionRaw = params.get("session");
const gatewayUrlRaw = params.get("gatewayUrl");
let shouldCleanUrl = false; let shouldCleanUrl = false;
if (tokenRaw != null) { if (tokenRaw != null) {
@@ -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; if (!shouldCleanUrl) return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.search = params.toString(); url.search = params.toString();
+1
View File
@@ -152,6 +152,7 @@ export class ClawdbotApp extends LitElement {
@state() configFormMode: "form" | "raw" = "form"; @state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = ""; @state() configSearchQuery = "";
@state() configActiveSection: string | null = null; @state() configActiveSection: string | null = null;
@state() configActiveSubsection: string | null = null;
@state() channelsLoading = false; @state() channelsLoading = false;
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null; @state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
+1
View File
@@ -33,6 +33,7 @@ export type ConfigState = {
configFormMode: "form" | "raw"; configFormMode: "form" | "raw";
configSearchQuery: string; configSearchQuery: string;
configActiveSection: string | null; configActiveSection: string | null;
configActiveSubsection: string | null;
lastError: string | null; lastError: string | null;
}; };
+1 -2
View File
@@ -5,7 +5,6 @@ import { truncateText } from "./format";
marked.setOptions({ marked.setOptions({
gfm: true, gfm: true,
breaks: true, breaks: true,
headerIds: false,
mangle: false, mangle: false,
}); });
@@ -37,7 +36,7 @@ const allowedTags = [
"ul", "ul",
]; ];
const allowedAttrs = ["class", "href", "rel", "target", "title"]; const allowedAttrs = ["class", "href", "rel", "target", "title", "start"];
let hooksInstalled = false; let hooksInstalled = false;
const MARKDOWN_CHAR_LIMIT = 140_000; const MARKDOWN_CHAR_LIMIT = 140_000;
+97 -33
View File
@@ -1,6 +1,11 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types"; 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"; import { renderNode } from "./config-form.node";
export type ConfigFormProps = { export type ConfigFormProps = {
@@ -11,6 +16,7 @@ export type ConfigFormProps = {
unsupportedPaths?: string[]; unsupportedPaths?: string[];
searchQuery?: string; searchQuery?: string;
activeSection?: string | null; activeSection?: string | null;
activeSubsection?: string | null;
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}; };
@@ -146,6 +152,7 @@ export function renderConfigForm(props: ConfigFormProps) {
const properties = schema.properties; const properties = schema.properties;
const searchQuery = props.searchQuery ?? ""; const searchQuery = props.searchQuery ?? "";
const activeSection = props.activeSection; const activeSection = props.activeSection;
const activeSubsection = props.activeSubsection ?? null;
// Filter and sort entries // Filter and sort entries
let entries = Object.entries(properties); let entries = Object.entries(properties);
@@ -168,6 +175,25 @@ export function renderConfigForm(props: ConfigFormProps) {
return a[0].localeCompare(b[0]); 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) { if (entries.length === 0) {
return html` return html`
<div class="config-empty"> <div class="config-empty">
@@ -183,38 +209,76 @@ export function renderConfigForm(props: ConfigFormProps) {
return html` return html`
<div class="config-form config-form--modern"> <div class="config-form config-form--modern">
${entries.map(([key, node]) => { ${subsectionContext
const meta = SECTION_META[key] ?? { ? (() => {
label: key.charAt(0).toUpperCase() + key.slice(1), const { sectionKey, subsectionKey, schema: node } = subsectionContext;
description: node.description ?? "" const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
}; const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
return html` const sectionValue = (value as Record<string, unknown>)[sectionKey];
<section class="config-section-card" id="config-section-${key}"> const scopedValue =
<div class="config-section-card__header"> sectionValue && typeof sectionValue === "object"
<span class="config-section-card__icon">${getSectionIcon(key)}</span> ? (sectionValue as Record<string, unknown>)[subsectionKey]
<div class="config-section-card__titles"> : undefined;
<h3 class="config-section-card__title">${meta.label}</h3> const id = `config-section-${sectionKey}-${subsectionKey}`;
${meta.description ? html` return html`
<p class="config-section-card__desc">${meta.description}</p> <section class="config-section-card" id=${id}>
` : nothing} <div class="config-section-card__header">
</div> <span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
</div> <div class="config-section-card__titles">
<div class="config-section-card__content"> <h3 class="config-section-card__title">${label}</h3>
${renderNode({ ${description
schema: node, ? html`<p class="config-section-card__desc">${description}</p>`
value: (value as Record<string, unknown>)[key], : nothing}
path: [key], </div>
hints: props.uiHints, </div>
unsupported, <div class="config-section-card__content">
disabled: props.disabled ?? false, ${renderNode({
showLabel: false, schema: node,
onPatch: props.onPatch, value: scopedValue,
})} path: [sectionKey, subsectionKey],
</div> hints: props.uiHints,
</section> 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> </div>
`; `;
} }
+7
View File
@@ -21,13 +21,20 @@ describe("config view", () => {
uiHints: {}, uiHints: {},
formMode: "form" as const, formMode: "form" as const,
formValue: {}, formValue: {},
originalValue: {},
searchQuery: "",
activeSection: null,
activeSubsection: null,
onRawChange: vi.fn(), onRawChange: vi.fn(),
onFormModeChange: vi.fn(), onFormModeChange: vi.fn(),
onFormPatch: vi.fn(), onFormPatch: vi.fn(),
onSearchChange: vi.fn(),
onSectionChange: vi.fn(),
onReload: vi.fn(), onReload: vi.fn(),
onSave: vi.fn(), onSave: vi.fn(),
onApply: vi.fn(), onApply: vi.fn(),
onUpdate: vi.fn(), onUpdate: vi.fn(),
onSubsectionChange: vi.fn(),
}); });
it("disables save when form is unsafe", () => { it("disables save when form is unsafe", () => {
+113
View File
@@ -1,6 +1,12 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm } from "./config-form"; import { analyzeConfigSchema, renderConfigForm } from "./config-form";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
export type ConfigProps = { export type ConfigProps = {
raw: string; raw: string;
@@ -19,11 +25,13 @@ export type ConfigProps = {
originalValue: Record<string, unknown> | null; originalValue: Record<string, unknown> | null;
searchQuery: string; searchQuery: string;
activeSection: string | null; activeSection: string | null;
activeSubsection: string | null;
onRawChange: (next: string) => void; onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void; onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void; onFormPatch: (path: Array<string | number>, value: unknown) => void;
onSearchChange: (query: string) => void; onSearchChange: (query: string) => void;
onSectionChange: (section: string | null) => void; onSectionChange: (section: string | null) => void;
onSubsectionChange: (section: string | null) => void;
onReload: () => void; onReload: () => void;
onSave: () => void; onSave: () => void;
onApply: () => void; onApply: () => void;
@@ -80,10 +88,49 @@ const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "wizard", label: "Setup Wizard" }, { key: "wizard", label: "Setup Wizard" },
]; ];
type SubsectionEntry = {
key: string;
label: string;
description?: string;
order: number;
};
const ALL_SUBSECTION = "__all__";
function getSectionIcon(key: string) { function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default; 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( function computeDiff(
original: Record<string, unknown> | null, original: Record<string, unknown> | null,
current: 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) })); .map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections]; 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 // Compute diff for showing changes
const diff = props.formMode === "form" const diff = props.formMode === "form"
@@ -304,6 +376,46 @@ export function renderConfig(props: ConfigProps) {
</details> </details>
` : nothing} ` : 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 --> <!-- Form content -->
<div class="config-content"> <div class="config-content">
${props.formMode === "form" ${props.formMode === "form"
@@ -322,6 +434,7 @@ export function renderConfig(props: ConfigProps) {
onPatch: props.onFormPatch, onPatch: props.onFormPatch,
searchQuery: props.searchQuery, searchQuery: props.searchQuery,
activeSection: props.activeSection, activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
})} })}
${formUnsafe ${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">