Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce267400c | ||
|
|
d353db42ba | ||
|
|
e81ca7ab00 | ||
|
|
27acfa59c5 | ||
|
|
b333c4a994 | ||
|
|
23f7dd8b25 | ||
|
|
77d9451712 | ||
|
|
a4fc2b4536 | ||
|
|
20a7dd8a80 | ||
|
|
450d2d25e2 | ||
|
|
df024afc97 | ||
|
|
12168dc64f | ||
|
|
4232081fcb | ||
|
|
17f3635109 | ||
|
|
c74551c2ae |
No files matched your search
@@ -29,7 +29,10 @@ Docs: https://docs.clawd.bot
|
|||||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||||
|
- Gateway: allow token-auth Control UI connections without device identity. (#1314) — thanks @dbhurley.
|
||||||
|
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
|
||||||
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||||
|
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
||||||
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
|
- UI: 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-.
|
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
|
|||||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||||
@Bindable var store: ChannelsStore
|
@Bindable var store: ChannelsStore
|
||||||
@State private var hasLoaded = false
|
@State private var hasLoaded = false
|
||||||
|
@State private var activeSectionKey: String?
|
||||||
|
@State private var activeSubsection: SubsectionSelection?
|
||||||
|
|
||||||
init(store: ChannelsStore = .shared) {
|
init(store: ChannelsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
HStack(spacing: 16) {
|
||||||
self.content
|
self.sidebar
|
||||||
|
self.detail
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task {
|
.task {
|
||||||
guard !self.hasLoaded else { return }
|
guard !self.hasLoaded else { return }
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
|
|||||||
await self.store.loadConfigSchema()
|
await self.store.loadConfigSchema()
|
||||||
await self.store.loadConfig()
|
await self.store.loadConfig()
|
||||||
}
|
}
|
||||||
|
.onAppear { self.ensureSelection() }
|
||||||
|
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||||
|
if !loading { self.ensureSelection() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConfigSettings {
|
extension ConfigSettings {
|
||||||
private var content: some View {
|
private enum SubsectionSelection: Hashable {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
case all
|
||||||
self.header
|
case key(String)
|
||||||
if let status = self.store.configStatus {
|
}
|
||||||
Text(status)
|
|
||||||
.font(.callout)
|
private struct ConfigSection: Identifiable {
|
||||||
.foregroundStyle(.secondary)
|
let key: String
|
||||||
}
|
let label: String
|
||||||
self.actionRow
|
let help: String?
|
||||||
Group {
|
let node: ConfigSchemaNode
|
||||||
if self.store.configSchemaLoading {
|
|
||||||
ProgressView().controlSize(.small)
|
var id: String { self.key }
|
||||||
} else if let schema = self.store.configSchema {
|
}
|
||||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
|
||||||
.disabled(self.isNixMode)
|
private struct ConfigSubsection: Identifiable {
|
||||||
} else {
|
let key: String
|
||||||
Text("Schema unavailable.")
|
let label: String
|
||||||
|
let help: String?
|
||||||
|
let node: ConfigSchemaNode
|
||||||
|
let path: ConfigPath
|
||||||
|
|
||||||
|
var id: String { self.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sections: [ConfigSection] {
|
||||||
|
guard let schema = self.store.configSchema else { return [] }
|
||||||
|
return self.resolveSections(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeSection: ConfigSection? {
|
||||||
|
self.sections.first { $0.key == self.activeSectionKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebar: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
if self.sections.isEmpty {
|
||||||
|
Text("No config sections available.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
ForEach(self.sections) { section in
|
||||||
|
self.sidebarRow(section)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.store.configDirty, !self.isNixMode {
|
.padding(.vertical, 10)
|
||||||
Text("Unsaved changes")
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if self.store.configSchemaLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else if let section = self.activeSection {
|
||||||
|
self.sectionDetail(section)
|
||||||
|
} else if self.store.configSchema != nil {
|
||||||
|
self.emptyDetail
|
||||||
|
} else {
|
||||||
|
Text("Schema unavailable.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
self.header
|
||||||
|
Text("Select a config section to view settings.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
}
|
||||||
|
|
||||||
|
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.header
|
||||||
|
if let status = self.store.configStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
self.actionRow
|
||||||
|
self.sectionHeader(section)
|
||||||
|
self.subsectionNav(section)
|
||||||
|
self.sectionForm(section)
|
||||||
|
if self.store.configDirty, !self.isNixMode {
|
||||||
|
Text("Unsaved changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -71,6 +158,18 @@ extension ConfigSettings {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ section: ConfigSection) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(section.label)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
if let help = section.help {
|
||||||
|
Text(help)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var actionRow: some View {
|
private var actionRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button("Reload") {
|
Button("Reload") {
|
||||||
@@ -85,6 +184,203 @@ extension ConfigSettings {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sidebarRow(_ section: ConfigSection) -> some View {
|
||||||
|
let isSelected = self.activeSectionKey == section.key
|
||||||
|
return Button {
|
||||||
|
self.selectSection(section)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(section.label)
|
||||||
|
if let help = section.help {
|
||||||
|
Text(help)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.background(Color.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func subsectionNav(_ section: ConfigSection) -> some View {
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
if subsections.isEmpty {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
self.subsectionButton(
|
||||||
|
title: "All",
|
||||||
|
isSelected: self.activeSubsection == .all)
|
||||||
|
{
|
||||||
|
self.activeSubsection = .all
|
||||||
|
}
|
||||||
|
ForEach(subsections) { subsection in
|
||||||
|
self.subsectionButton(
|
||||||
|
title: subsection.label,
|
||||||
|
isSelected: self.activeSubsection == .key(subsection.key))
|
||||||
|
{
|
||||||
|
self.activeSubsection = .key(subsection.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subsectionButton(
|
||||||
|
title: String,
|
||||||
|
isSelected: Bool,
|
||||||
|
action: @escaping () -> Void) -> some View
|
||||||
|
{
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.foregroundStyle(isSelected ? Color.accentColor : .primary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||||
|
let subsection = self.activeSubsection
|
||||||
|
let defaultPath: ConfigPath = [.key(section.key)]
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||||
|
if case let .key(key) = subsection,
|
||||||
|
let match = subsections.first(where: { $0.key == key }) {
|
||||||
|
return (match.node, match.path)
|
||||||
|
}
|
||||||
|
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||||
|
.disabled(self.isNixMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSelection() {
|
||||||
|
guard let schema = self.store.configSchema else { return }
|
||||||
|
let sections = self.resolveSections(schema)
|
||||||
|
guard !sections.isEmpty else { return }
|
||||||
|
|
||||||
|
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||||
|
if self.activeSectionKey != active.key {
|
||||||
|
self.activeSectionKey = active.key
|
||||||
|
}
|
||||||
|
self.ensureSubsection(for: active)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSubsection(for section: ConfigSection) {
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
guard !subsections.isEmpty else {
|
||||||
|
self.activeSubsection = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.activeSubsection {
|
||||||
|
case .all:
|
||||||
|
return
|
||||||
|
case let .key(key):
|
||||||
|
if subsections.contains(where: { $0.key == key }) { return }
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if let first = subsections.first {
|
||||||
|
self.activeSubsection = .key(first.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectSection(_ section: ConfigSection) {
|
||||||
|
guard self.activeSectionKey != section.key else { return }
|
||||||
|
self.activeSectionKey = section.key
|
||||||
|
let subsections = self.resolveSubsections(for: section)
|
||||||
|
if let first = subsections.first {
|
||||||
|
self.activeSubsection = .key(first.key)
|
||||||
|
} else {
|
||||||
|
self.activeSubsection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||||
|
let node = self.resolvedSchemaNode(root)
|
||||||
|
let hints = self.store.configUiHints
|
||||||
|
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||||
|
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||||
|
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||||
|
if orderA != orderB { return orderA < orderB }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.compactMap { key in
|
||||||
|
guard let child = node.properties[key] else { return nil }
|
||||||
|
let path: ConfigPath = [.key(key)]
|
||||||
|
let hint = hintForPath(path, hints: hints)
|
||||||
|
let label = hint?.label
|
||||||
|
?? child.title
|
||||||
|
?? self.humanize(key)
|
||||||
|
let help = hint?.help ?? child.description
|
||||||
|
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||||
|
let node = self.resolvedSchemaNode(section.node)
|
||||||
|
guard node.schemaType == "object" else { return [] }
|
||||||
|
let hints = self.store.configUiHints
|
||||||
|
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||||
|
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||||
|
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||||
|
if orderA != orderB { return orderA < orderB }
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.compactMap { key in
|
||||||
|
guard let child = node.properties[key] else { return nil }
|
||||||
|
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||||
|
let hint = hintForPath(path, hints: hints)
|
||||||
|
let label = hint?.label
|
||||||
|
?? child.title
|
||||||
|
?? self.humanize(key)
|
||||||
|
let help = hint?.help ?? child.description
|
||||||
|
return ConfigSubsection(
|
||||||
|
key: key,
|
||||||
|
label: label,
|
||||||
|
help: help,
|
||||||
|
node: child,
|
||||||
|
path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||||
|
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||||
|
if !variants.isEmpty {
|
||||||
|
let nonNull = variants.filter { !$0.isNullSchema }
|
||||||
|
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
private func humanize(_ key: String) -> String {
|
||||||
|
key.replacingOccurrences(of: "_", with: " ")
|
||||||
|
.replacingOccurrences(of: "-", with: " ")
|
||||||
|
.capitalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConfigSettings_Previews: PreviewProvider {
|
struct ConfigSettings_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ clawdbot security audit --fix
|
|||||||
```
|
```
|
||||||
|
|
||||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
||||||
It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
|
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||||
@@ -34,6 +34,10 @@ export type SkillInstallResult = {
|
|||||||
code: number | null;
|
code: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
|
||||||
|
return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function");
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeInstallOutput(text: string): string | undefined {
|
function summarizeInstallOutput(text: string): string | undefined {
|
||||||
const raw = text.trim();
|
const raw = text.trim();
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
@@ -154,13 +158,10 @@ async function downloadFile(
|
|||||||
}
|
}
|
||||||
await ensureDir(path.dirname(destPath));
|
await ensureDir(path.dirname(destPath));
|
||||||
const file = fs.createWriteStream(destPath);
|
const file = fs.createWriteStream(destPath);
|
||||||
const body = response.body;
|
const body = response.body as unknown;
|
||||||
// Node's Readable.fromWeb expects the node:stream/web type; cast through unknown for DOM streams.
|
const readable = isNodeReadableStream(body)
|
||||||
const nodeStream = body as unknown as NodeJS.ReadableStream;
|
? body
|
||||||
const readable =
|
: Readable.fromWeb(body as NodeReadableStream);
|
||||||
typeof nodeStream.pipe === "function"
|
|
||||||
? nodeStream
|
|
||||||
: Readable.fromWeb(body as unknown as NodeReadableStream);
|
|
||||||
await pipeline(readable, file);
|
await pipeline(readable, file);
|
||||||
const stat = await fs.promises.stat(destPath);
|
const stat = await fs.promises.stat(destPath);
|
||||||
return { bytes: stat.size };
|
return { bytes: stat.size };
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import crypto from "node:crypto";
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { loadConfig } from "../../config/io.js";
|
||||||
|
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||||
import {
|
import {
|
||||||
formatDoctorNonInteractiveHint,
|
formatDoctorNonInteractiveHint,
|
||||||
@@ -77,11 +79,42 @@ export function createGatewayTool(opts?: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const note =
|
const note =
|
||||||
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
|
||||||
|
// Extract channel + threadId for routing after restart
|
||||||
|
let deliveryContext: { channel?: string; to?: string; accountId?: string } | undefined;
|
||||||
|
let threadId: string | undefined;
|
||||||
|
if (sessionKey) {
|
||||||
|
const threadMarker = ":thread:";
|
||||||
|
const threadIndex = sessionKey.lastIndexOf(threadMarker);
|
||||||
|
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
|
||||||
|
const threadIdRaw =
|
||||||
|
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
|
||||||
|
threadId = threadIdRaw?.trim() || undefined;
|
||||||
|
try {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
let entry = store[sessionKey];
|
||||||
|
if (!entry?.deliveryContext && threadIndex !== -1 && baseSessionKey) {
|
||||||
|
entry = store[baseSessionKey];
|
||||||
|
}
|
||||||
|
if (entry?.deliveryContext) {
|
||||||
|
deliveryContext = {
|
||||||
|
channel: entry.deliveryContext.channel,
|
||||||
|
to: entry.deliveryContext.to,
|
||||||
|
accountId: entry.deliveryContext.accountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore: best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
const payload: RestartSentinelPayload = {
|
const payload: RestartSentinelPayload = {
|
||||||
kind: "restart",
|
kind: "restart",
|
||||||
status: "ok",
|
status: "ok",
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
deliveryContext,
|
||||||
|
threadId,
|
||||||
message: note ?? reason ?? null,
|
message: note ?? reason ?? null,
|
||||||
doctorHint: formatDoctorNonInteractiveHint(),
|
doctorHint: formatDoctorNonInteractiveHint(),
|
||||||
stats: {
|
stats: {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
c1cdb1b463e70d87976d88abf13e373e774c057e6796c947227c56b459af9d77
|
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
|
||||||
+17440
-15247
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -7,6 +7,7 @@ import { normalizeEnv } from "../infra/env.js";
|
|||||||
import { isMainModule } from "../infra/is-main.js";
|
import { isMainModule } from "../infra/is-main.js";
|
||||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||||
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||||
|
import { formatUncaughtError } from "../infra/errors.js";
|
||||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||||
import { enableConsoleCapture } from "../logging.js";
|
import { enableConsoleCapture } from "../logging.js";
|
||||||
import { tryRouteCli } from "./route.js";
|
import { tryRouteCli } from "./route.js";
|
||||||
@@ -42,7 +43,7 @@ export async function runCli(argv: string[] = process.argv) {
|
|||||||
installUnhandledRejectionHandler();
|
installUnhandledRejectionHandler();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
|
||||||
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
|
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
|
||||||
|
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||||
|
|
||||||
/** Image content block for Claude API multimodal messages. */
|
/** Image content block for Claude API multimodal messages. */
|
||||||
export type ImageContent = {
|
export type ImageContent = {
|
||||||
|
|||||||
+9
-2
@@ -57,6 +57,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const CONFIG_BACKUP_COUNT = 5;
|
const CONFIG_BACKUP_COUNT = 5;
|
||||||
|
const loggedInvalidConfigs = new Set<string>();
|
||||||
|
|
||||||
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
|
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
|
||||||
|
|
||||||
@@ -244,8 +245,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
const details = validated.issues
|
const details = validated.issues
|
||||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
deps.logger.error(`Invalid config:\\n${details}`);
|
if (!loggedInvalidConfigs.has(configPath)) {
|
||||||
throw new Error("Invalid config");
|
loggedInvalidConfigs.add(configPath);
|
||||||
|
deps.logger.error(`Invalid config:\\n${details}`);
|
||||||
|
}
|
||||||
|
const error = new Error("Invalid config");
|
||||||
|
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
|
||||||
|
(error as { code?: string; details?: string }).details = details;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
if (validated.warnings.length > 0) {
|
if (validated.warnings.length > 0) {
|
||||||
const details = validated.warnings
|
const details = validated.warnings
|
||||||
|
|||||||
@@ -28,9 +28,30 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const threadMarker = ":thread:";
|
||||||
|
const threadIndex = sessionKey.lastIndexOf(threadMarker);
|
||||||
|
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
|
||||||
|
const threadIdRaw =
|
||||||
|
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
|
||||||
|
const sessionThreadId = threadIdRaw?.trim() || undefined;
|
||||||
|
|
||||||
const { cfg, entry } = loadSessionEntry(sessionKey);
|
const { cfg, entry } = loadSessionEntry(sessionKey);
|
||||||
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey);
|
const parsedTarget = resolveAnnounceTargetFromKey(baseSessionKey);
|
||||||
const origin = mergeDeliveryContext(deliveryContextFromSession(entry), parsedTarget ?? undefined);
|
|
||||||
|
// Prefer delivery context from sentinel (captured at restart) over session store
|
||||||
|
// Handles race condition where store wasn't flushed before restart
|
||||||
|
const sentinelContext = payload.deliveryContext;
|
||||||
|
let sessionDeliveryContext = deliveryContextFromSession(entry);
|
||||||
|
if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) {
|
||||||
|
const { entry: baseEntry } = loadSessionEntry(baseSessionKey);
|
||||||
|
sessionDeliveryContext = deliveryContextFromSession(baseEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = mergeDeliveryContext(
|
||||||
|
sentinelContext,
|
||||||
|
mergeDeliveryContext(sessionDeliveryContext, parsedTarget ?? undefined),
|
||||||
|
);
|
||||||
|
|
||||||
const channelRaw = origin?.channel;
|
const channelRaw = origin?.channel;
|
||||||
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
|
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
|
||||||
const to = origin?.to;
|
const to = origin?.to;
|
||||||
@@ -51,6 +72,11 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const threadId =
|
||||||
|
payload.threadId ??
|
||||||
|
sessionThreadId ??
|
||||||
|
(origin?.threadId != null ? String(origin.threadId) : undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agentCommand(
|
await agentCommand(
|
||||||
{
|
{
|
||||||
@@ -61,6 +87,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
|||||||
deliver: true,
|
deliver: true,
|
||||||
bestEffortDeliver: true,
|
bestEffortDeliver: true,
|
||||||
messageChannel: channel,
|
messageChannel: channel,
|
||||||
|
threadId,
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
params.deps,
|
params.deps,
|
||||||
|
|||||||
@@ -102,6 +102,33 @@ describe("gateway server auth/connect", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts token auth without device identity", async () => {
|
||||||
|
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||||
|
const res = await connectReq(ws, { token: "secret", device: null });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
if (prevToken === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires device identity when auth mode is none", async () => {
|
||||||
|
const { server, ws, prevToken } = await startServerWithClient();
|
||||||
|
const res = await connectReq(ws, { device: null });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.message ?? "").toContain("device identity required");
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
if (prevToken === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("accepts password auth when configured", async () => {
|
test("accepts password auth when configured", async () => {
|
||||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|||||||
@@ -290,9 +290,18 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
connectParams.role = role;
|
connectParams.role = role;
|
||||||
connectParams.scopes = scopes;
|
connectParams.scopes = scopes;
|
||||||
|
|
||||||
|
const authResult = await authorizeGatewayConnect({
|
||||||
|
auth: resolvedAuth,
|
||||||
|
connectAuth: connectParams.auth,
|
||||||
|
req: upgradeReq,
|
||||||
|
});
|
||||||
|
let authOk = authResult.ok;
|
||||||
|
let authMethod = authResult.method ?? "none";
|
||||||
|
const allowsDeviceOptional = authOk && authMethod === "token";
|
||||||
|
|
||||||
const device = connectParams.device;
|
const device = connectParams.device;
|
||||||
let devicePublicKey: string | null = null;
|
let devicePublicKey: string | null = null;
|
||||||
if (!device) {
|
if (!device && !allowsDeviceOptional) {
|
||||||
setHandshakeState("failed");
|
setHandshakeState("failed");
|
||||||
setCloseCause("device-required", {
|
setCloseCause("device-required", {
|
||||||
client: connectParams.client.id,
|
client: connectParams.client.id,
|
||||||
@@ -458,14 +467,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authResult = await authorizeGatewayConnect({
|
if (!authOk && connectParams.auth?.token && device) {
|
||||||
auth: resolvedAuth,
|
|
||||||
connectAuth: connectParams.auth,
|
|
||||||
req: upgradeReq,
|
|
||||||
});
|
|
||||||
let authOk = authResult.ok;
|
|
||||||
let authMethod = authResult.method ?? "none";
|
|
||||||
if (!authOk && connectParams.auth?.token) {
|
|
||||||
const tokenCheck = await verifyDeviceToken({
|
const tokenCheck = await verifyDeviceToken({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
token: connectParams.auth.token,
|
token: connectParams.auth.token,
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export async function connectReq(
|
|||||||
signature: string;
|
signature: string;
|
||||||
signedAt: number;
|
signedAt: number;
|
||||||
nonce?: string;
|
nonce?: string;
|
||||||
};
|
} | null;
|
||||||
},
|
},
|
||||||
): Promise<ConnectResponse> {
|
): Promise<ConnectResponse> {
|
||||||
const { randomUUID } = await import("node:crypto");
|
const { randomUUID } = await import("node:crypto");
|
||||||
@@ -294,6 +294,7 @@ export async function connectReq(
|
|||||||
const role = opts?.role ?? "operator";
|
const role = opts?.role ?? "operator";
|
||||||
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
||||||
const device = (() => {
|
const device = (() => {
|
||||||
|
if (opts?.device === null) return undefined;
|
||||||
if (opts?.device) return opts.device;
|
if (opts?.device) return opts.device;
|
||||||
const identity = loadOrCreateDeviceIdentity();
|
const identity = loadOrCreateDeviceIdentity();
|
||||||
const signedAtMs = Date.now();
|
const signedAtMs = Date.now();
|
||||||
|
|||||||
+3
-5
@@ -28,6 +28,7 @@ import {
|
|||||||
PortInUseError,
|
PortInUseError,
|
||||||
} from "./infra/ports.js";
|
} from "./infra/ports.js";
|
||||||
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
|
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
|
||||||
|
import { formatUncaughtError } from "./infra/errors.js";
|
||||||
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
|
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
|
||||||
import { enableConsoleCapture } from "./logging.js";
|
import { enableConsoleCapture } from "./logging.js";
|
||||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||||
@@ -82,15 +83,12 @@ if (isMain) {
|
|||||||
installUnhandledRejectionHandler();
|
installUnhandledRejectionHandler();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
void program.parseAsync(process.argv).catch((err) => {
|
void program.parseAsync(process.argv).catch((err) => {
|
||||||
console.error(
|
console.error("[clawdbot] CLI failed:", formatUncaughtError(err));
|
||||||
"[clawdbot] CLI failed:",
|
|
||||||
err instanceof Error ? (err.stack ?? err.message) : err,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -20,3 +20,13 @@ export function formatErrorMessage(err: unknown): string {
|
|||||||
return Object.prototype.toString.call(err);
|
return Object.prototype.toString.call(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatUncaughtError(err: unknown): string {
|
||||||
|
if (extractErrorCode(err) === "INVALID_CONFIG") {
|
||||||
|
return formatErrorMessage(err);
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.stack ?? err.message ?? err.name;
|
||||||
|
}
|
||||||
|
return formatErrorMessage(err);
|
||||||
|
}
|
||||||
@@ -33,6 +33,14 @@ export type RestartSentinelPayload = {
|
|||||||
status: "ok" | "error" | "skipped";
|
status: "ok" | "error" | "skipped";
|
||||||
ts: number;
|
ts: number;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
/** Delivery context captured at restart time to ensure channel routing survives restart. */
|
||||||
|
deliveryContext?: {
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
accountId?: string;
|
||||||
|
};
|
||||||
|
/** Thread ID for reply threading (e.g., Slack thread_ts). */
|
||||||
|
threadId?: string;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
doctorHint?: string | null;
|
doctorHint?: string | null;
|
||||||
stats?: RestartSentinelStats | null;
|
stats?: RestartSentinelStats | null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
|
||||||
|
import { formatUncaughtError } from "./errors.js";
|
||||||
|
|
||||||
type UnhandledRejectionHandler = (reason: unknown) => boolean;
|
type UnhandledRejectionHandler = (reason: unknown) => boolean;
|
||||||
|
|
||||||
const handlers = new Set<UnhandledRejectionHandler>();
|
const handlers = new Set<UnhandledRejectionHandler>();
|
||||||
@@ -28,10 +30,7 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
|
|||||||
export function installUnhandledRejectionHandler(): void {
|
export function installUnhandledRejectionHandler(): void {
|
||||||
process.on("unhandledRejection", (reason, _promise) => {
|
process.on("unhandledRejection", (reason, _promise) => {
|
||||||
if (isUnhandledRejectionHandled(reason)) return;
|
if (isUnhandledRejectionHandled(reason)) return;
|
||||||
console.error(
|
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
|
||||||
"[clawdbot] Unhandled promise rejection:",
|
|
||||||
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+2
-1
@@ -55,6 +55,7 @@ async function main() {
|
|||||||
|
|
||||||
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
|
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
|
||||||
assertSupportedRuntime();
|
assertSupportedRuntime();
|
||||||
|
const { formatUncaughtError } = await import("../infra/errors.js");
|
||||||
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
|
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
|
||||||
|
|
||||||
const { buildProgram } = await import("../cli/program.js");
|
const { buildProgram } = await import("../cli/program.js");
|
||||||
@@ -63,7 +64,7 @@ async function main() {
|
|||||||
installUnhandledRejectionHandler();
|
installUnhandledRejectionHandler();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
|
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -358,6 +358,98 @@
|
|||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section Hero */
|
||||||
|
.config-section-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .config-section-hero {
|
||||||
|
background: rgba(0, 0, 0, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section-hero__icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section-hero__icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section-hero__text {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section-hero__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section-hero__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subsection Nav */
|
||||||
|
.config-subnav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .config-subnav {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subnav__item {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .config-subnav__item {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subnav__item:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .config-subnav__item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subnav__item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(245, 159, 74, 0.4);
|
||||||
|
background: rgba(245, 159, 74, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
/* Content Area */
|
/* Content Area */
|
||||||
.config-content {
|
.config-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1243,6 +1335,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-section-hero {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subnav {
|
||||||
|
padding: 8px 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.config-content {
|
.config-content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,11 +485,16 @@ export function renderApp(state: AppViewState) {
|
|||||||
originalValue: state.configFormOriginal,
|
originalValue: state.configFormOriginal,
|
||||||
searchQuery: state.configSearchQuery,
|
searchQuery: state.configSearchQuery,
|
||||||
activeSection: state.configActiveSection,
|
activeSection: state.configActiveSection,
|
||||||
|
activeSubsection: state.configActiveSubsection,
|
||||||
onRawChange: (next) => (state.configRaw = next),
|
onRawChange: (next) => (state.configRaw = next),
|
||||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||||
onSectionChange: (section) => (state.configActiveSection = section),
|
onSectionChange: (section) => {
|
||||||
|
state.configActiveSection = section;
|
||||||
|
state.configActiveSubsection = null;
|
||||||
|
},
|
||||||
|
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||||
onReload: () => loadConfig(state),
|
onReload: () => loadConfig(state),
|
||||||
onSave: () => saveConfig(state),
|
onSave: () => saveConfig(state),
|
||||||
onApply: () => applyConfig(state),
|
onApply: () => applyConfig(state),
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() configFormMode: "form" | "raw" = "form";
|
@state() configFormMode: "form" | "raw" = "form";
|
||||||
@state() configSearchQuery = "";
|
@state() configSearchQuery = "";
|
||||||
@state() configActiveSection: string | null = null;
|
@state() configActiveSection: string | null = null;
|
||||||
|
@state() configActiveSubsection: string | null = null;
|
||||||
|
|
||||||
@state() channelsLoading = false;
|
@state() channelsLoading = false;
|
||||||
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type ConfigState = {
|
|||||||
configFormMode: "form" | "raw";
|
configFormMode: "form" | "raw";
|
||||||
configSearchQuery: string;
|
configSearchQuery: string;
|
||||||
configActiveSection: string | null;
|
configActiveSection: string | null;
|
||||||
|
configActiveSubsection: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { ConfigUiHints } from "../types";
|
import type { ConfigUiHints } from "../types";
|
||||||
import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared";
|
import {
|
||||||
|
hintForPath,
|
||||||
|
humanize,
|
||||||
|
schemaType,
|
||||||
|
type JsonSchema,
|
||||||
|
} from "./config-form.shared";
|
||||||
import { renderNode } from "./config-form.node";
|
import { renderNode } from "./config-form.node";
|
||||||
|
|
||||||
export type ConfigFormProps = {
|
export type ConfigFormProps = {
|
||||||
@@ -11,6 +16,7 @@ export type ConfigFormProps = {
|
|||||||
unsupportedPaths?: string[];
|
unsupportedPaths?: string[];
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
activeSection?: string | null;
|
activeSection?: string | null;
|
||||||
|
activeSubsection?: string | null;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,6 +152,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
|||||||
const properties = schema.properties;
|
const properties = schema.properties;
|
||||||
const searchQuery = props.searchQuery ?? "";
|
const searchQuery = props.searchQuery ?? "";
|
||||||
const activeSection = props.activeSection;
|
const activeSection = props.activeSection;
|
||||||
|
const activeSubsection = props.activeSubsection ?? null;
|
||||||
|
|
||||||
// Filter and sort entries
|
// Filter and sort entries
|
||||||
let entries = Object.entries(properties);
|
let entries = Object.entries(properties);
|
||||||
@@ -168,6 +175,25 @@ export function renderConfigForm(props: ConfigFormProps) {
|
|||||||
return a[0].localeCompare(b[0]);
|
return a[0].localeCompare(b[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let subsectionContext:
|
||||||
|
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
|
||||||
|
| null = null;
|
||||||
|
if (activeSection && activeSubsection && entries.length === 1) {
|
||||||
|
const sectionSchema = entries[0]?.[1];
|
||||||
|
if (
|
||||||
|
sectionSchema &&
|
||||||
|
schemaType(sectionSchema) === "object" &&
|
||||||
|
sectionSchema.properties &&
|
||||||
|
sectionSchema.properties[activeSubsection]
|
||||||
|
) {
|
||||||
|
subsectionContext = {
|
||||||
|
sectionKey: activeSection,
|
||||||
|
subsectionKey: activeSubsection,
|
||||||
|
schema: sectionSchema.properties[activeSubsection],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return html`
|
return html`
|
||||||
<div class="config-empty">
|
<div class="config-empty">
|
||||||
@@ -183,38 +209,76 @@ export function renderConfigForm(props: ConfigFormProps) {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="config-form config-form--modern">
|
<div class="config-form config-form--modern">
|
||||||
${entries.map(([key, node]) => {
|
${subsectionContext
|
||||||
const meta = SECTION_META[key] ?? {
|
? (() => {
|
||||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
|
||||||
description: node.description ?? ""
|
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
|
||||||
};
|
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
|
||||||
|
const description = hint?.help ?? node.description ?? "";
|
||||||
return html`
|
const sectionValue = (value as Record<string, unknown>)[sectionKey];
|
||||||
<section class="config-section-card" id="config-section-${key}">
|
const scopedValue =
|
||||||
<div class="config-section-card__header">
|
sectionValue && typeof sectionValue === "object"
|
||||||
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
|
? (sectionValue as Record<string, unknown>)[subsectionKey]
|
||||||
<div class="config-section-card__titles">
|
: undefined;
|
||||||
<h3 class="config-section-card__title">${meta.label}</h3>
|
const id = `config-section-${sectionKey}-${subsectionKey}`;
|
||||||
${meta.description ? html`
|
return html`
|
||||||
<p class="config-section-card__desc">${meta.description}</p>
|
<section class="config-section-card" id=${id}>
|
||||||
` : nothing}
|
<div class="config-section-card__header">
|
||||||
</div>
|
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
|
||||||
</div>
|
<div class="config-section-card__titles">
|
||||||
<div class="config-section-card__content">
|
<h3 class="config-section-card__title">${label}</h3>
|
||||||
${renderNode({
|
${description
|
||||||
schema: node,
|
? html`<p class="config-section-card__desc">${description}</p>`
|
||||||
value: (value as Record<string, unknown>)[key],
|
: nothing}
|
||||||
path: [key],
|
</div>
|
||||||
hints: props.uiHints,
|
</div>
|
||||||
unsupported,
|
<div class="config-section-card__content">
|
||||||
disabled: props.disabled ?? false,
|
${renderNode({
|
||||||
showLabel: false,
|
schema: node,
|
||||||
onPatch: props.onPatch,
|
value: scopedValue,
|
||||||
})}
|
path: [sectionKey, subsectionKey],
|
||||||
</div>
|
hints: props.uiHints,
|
||||||
</section>
|
unsupported,
|
||||||
`;
|
disabled: props.disabled ?? false,
|
||||||
})}
|
showLabel: false,
|
||||||
|
onPatch: props.onPatch,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
})()
|
||||||
|
: entries.map(([key, node]) => {
|
||||||
|
const meta = SECTION_META[key] ?? {
|
||||||
|
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||||
|
description: node.description ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<section class="config-section-card" id="config-section-${key}">
|
||||||
|
<div class="config-section-card__header">
|
||||||
|
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
|
||||||
|
<div class="config-section-card__titles">
|
||||||
|
<h3 class="config-section-card__title">${meta.label}</h3>
|
||||||
|
${meta.description
|
||||||
|
? html`<p class="config-section-card__desc">${meta.description}</p>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-section-card__content">
|
||||||
|
${renderNode({
|
||||||
|
schema: node,
|
||||||
|
value: (value as Record<string, unknown>)[key],
|
||||||
|
path: [key],
|
||||||
|
hints: props.uiHints,
|
||||||
|
unsupported,
|
||||||
|
disabled: props.disabled ?? false,
|
||||||
|
showLabel: false,
|
||||||
|
onPatch: props.onPatch,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -21,13 +21,20 @@ describe("config view", () => {
|
|||||||
uiHints: {},
|
uiHints: {},
|
||||||
formMode: "form" as const,
|
formMode: "form" as const,
|
||||||
formValue: {},
|
formValue: {},
|
||||||
|
originalValue: {},
|
||||||
|
searchQuery: "",
|
||||||
|
activeSection: null,
|
||||||
|
activeSubsection: null,
|
||||||
onRawChange: vi.fn(),
|
onRawChange: vi.fn(),
|
||||||
onFormModeChange: vi.fn(),
|
onFormModeChange: vi.fn(),
|
||||||
onFormPatch: vi.fn(),
|
onFormPatch: vi.fn(),
|
||||||
|
onSearchChange: vi.fn(),
|
||||||
|
onSectionChange: vi.fn(),
|
||||||
onReload: vi.fn(),
|
onReload: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onApply: vi.fn(),
|
onApply: vi.fn(),
|
||||||
onUpdate: vi.fn(),
|
onUpdate: vi.fn(),
|
||||||
|
onSubsectionChange: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables save when form is unsafe", () => {
|
it("disables save when form is unsafe", () => {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { ConfigUiHints } from "../types";
|
import type { ConfigUiHints } from "../types";
|
||||||
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
||||||
|
import {
|
||||||
|
hintForPath,
|
||||||
|
humanize,
|
||||||
|
schemaType,
|
||||||
|
type JsonSchema,
|
||||||
|
} from "./config-form.shared";
|
||||||
|
|
||||||
export type ConfigProps = {
|
export type ConfigProps = {
|
||||||
raw: string;
|
raw: string;
|
||||||
@@ -19,11 +25,13 @@ export type ConfigProps = {
|
|||||||
originalValue: Record<string, unknown> | null;
|
originalValue: Record<string, unknown> | null;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
activeSection: string | null;
|
activeSection: string | null;
|
||||||
|
activeSubsection: string | null;
|
||||||
onRawChange: (next: string) => void;
|
onRawChange: (next: string) => void;
|
||||||
onFormModeChange: (mode: "form" | "raw") => void;
|
onFormModeChange: (mode: "form" | "raw") => void;
|
||||||
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
onSectionChange: (section: string | null) => void;
|
onSectionChange: (section: string | null) => void;
|
||||||
|
onSubsectionChange: (section: string | null) => void;
|
||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
@@ -80,10 +88,49 @@ const SECTIONS: Array<{ key: string; label: string }> = [
|
|||||||
{ key: "wizard", label: "Setup Wizard" },
|
{ key: "wizard", label: "Setup Wizard" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type SubsectionEntry = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_SUBSECTION = "__all__";
|
||||||
|
|
||||||
function getSectionIcon(key: string) {
|
function getSectionIcon(key: string) {
|
||||||
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
|
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSectionMeta(key: string, schema?: JsonSchema): {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
} {
|
||||||
|
const meta = SECTION_META[key];
|
||||||
|
if (meta) return meta;
|
||||||
|
return {
|
||||||
|
label: schema?.title ?? humanize(key),
|
||||||
|
description: schema?.description ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSubsections(params: {
|
||||||
|
key: string;
|
||||||
|
schema: JsonSchema | undefined;
|
||||||
|
uiHints: ConfigUiHints;
|
||||||
|
}): SubsectionEntry[] {
|
||||||
|
const { key, schema, uiHints } = params;
|
||||||
|
if (!schema || schemaType(schema) !== "object" || !schema.properties) return [];
|
||||||
|
const entries = Object.entries(schema.properties).map(([subKey, node]) => {
|
||||||
|
const hint = hintForPath([key, subKey], uiHints);
|
||||||
|
const label = hint?.label ?? node.title ?? humanize(subKey);
|
||||||
|
const description = hint?.help ?? node.description ?? "";
|
||||||
|
const order = hint?.order ?? 50;
|
||||||
|
return { key: subKey, label, description, order };
|
||||||
|
});
|
||||||
|
entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key)));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
function computeDiff(
|
function computeDiff(
|
||||||
original: Record<string, unknown> | null,
|
original: Record<string, unknown> | null,
|
||||||
current: Record<string, unknown> | null
|
current: Record<string, unknown> | null
|
||||||
@@ -164,6 +211,31 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
|
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
|
||||||
|
|
||||||
const allSections = [...availableSections, ...extraSections];
|
const allSections = [...availableSections, ...extraSections];
|
||||||
|
|
||||||
|
const activeSectionSchema =
|
||||||
|
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
|
||||||
|
? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined)
|
||||||
|
: undefined;
|
||||||
|
const activeSectionMeta = props.activeSection
|
||||||
|
? resolveSectionMeta(props.activeSection, activeSectionSchema)
|
||||||
|
: null;
|
||||||
|
const subsections = props.activeSection
|
||||||
|
? resolveSubsections({
|
||||||
|
key: props.activeSection,
|
||||||
|
schema: activeSectionSchema,
|
||||||
|
uiHints: props.uiHints,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const allowSubnav =
|
||||||
|
props.formMode === "form" &&
|
||||||
|
Boolean(props.activeSection) &&
|
||||||
|
subsections.length > 0;
|
||||||
|
const isAllSubsection = props.activeSubsection === ALL_SUBSECTION;
|
||||||
|
const effectiveSubsection = props.searchQuery
|
||||||
|
? null
|
||||||
|
: isAllSubsection
|
||||||
|
? null
|
||||||
|
: props.activeSubsection ?? (subsections[0]?.key ?? null);
|
||||||
|
|
||||||
// Compute diff for showing changes
|
// Compute diff for showing changes
|
||||||
const diff = props.formMode === "form"
|
const diff = props.formMode === "form"
|
||||||
@@ -304,6 +376,46 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
</details>
|
</details>
|
||||||
` : nothing}
|
` : nothing}
|
||||||
|
|
||||||
|
${activeSectionMeta && props.formMode === "form"
|
||||||
|
? html`
|
||||||
|
<div class="config-section-hero">
|
||||||
|
<div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div>
|
||||||
|
<div class="config-section-hero__text">
|
||||||
|
<div class="config-section-hero__title">${activeSectionMeta.label}</div>
|
||||||
|
${activeSectionMeta.description
|
||||||
|
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
${allowSubnav
|
||||||
|
? html`
|
||||||
|
<div class="config-subnav">
|
||||||
|
<button
|
||||||
|
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
|
||||||
|
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
${subsections.map(
|
||||||
|
(entry) => html`
|
||||||
|
<button
|
||||||
|
class="config-subnav__item ${
|
||||||
|
effectiveSubsection === entry.key ? "active" : ""
|
||||||
|
}"
|
||||||
|
title=${entry.description || entry.label}
|
||||||
|
@click=${() => props.onSubsectionChange(entry.key)}
|
||||||
|
>
|
||||||
|
${entry.label}
|
||||||
|
</button>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
<!-- Form content -->
|
<!-- Form content -->
|
||||||
<div class="config-content">
|
<div class="config-content">
|
||||||
${props.formMode === "form"
|
${props.formMode === "form"
|
||||||
@@ -322,6 +434,7 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
onPatch: props.onFormPatch,
|
onPatch: props.onFormPatch,
|
||||||
searchQuery: props.searchQuery,
|
searchQuery: props.searchQuery,
|
||||||
activeSection: props.activeSection,
|
activeSection: props.activeSection,
|
||||||
|
activeSubsection: effectiveSubsection,
|
||||||
})}
|
})}
|
||||||
${formUnsafe
|
${formUnsafe
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||||
|
|||||||
Reference in new issue
Block a user