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