Compare commits

...
Author SHA1 Message Date
Peter Steinberger 1b973caf7a fix: preserve cron edit delivery payloads (#1322) (thanks @KrauseFx) 2026-01-21 02:27:18 +00:00
ClawdFx ea775025c0 Run oxfmt formatting 2026-01-21 02:18:43 +00:00
ClawdFx 0b2830470c Fix: Preserve delivery settings when updating message via cron edit
- Add failing tests for delivery field preservation
- Fix register.cron-edit to conditionally build payload object
- Only include delivery fields (deliver, channel, to, bestEffortDeliver) when explicitly provided
- Previously undefined values were included, wiping out existing delivery settings
- Now --message alone preserves existing delivery config
- Tests verify both preservation and explicit override scenarios
2026-01-21 02:18:33 +00:00
Peter Steinberger e81ca7ab00 fix: tame invalid config logging 2026-01-21 01:58:47 +00:00
Peter Steinberger 27acfa59c5 chore: update a2ui bundle 2026-01-21 01:33:31 +00:00
Peter Steinberger b333c4a994 fix: address config settings viewbuilder updates 2026-01-21 01:33:31 +00:00
Peter Steinberger 23f7dd8b25 fix: pass threadId in restart sentinel 2026-01-21 01:26:15 +00:00
Peter Steinberger 77d9451712 docs: fix mdx in security cli doc 2026-01-21 01:24:29 +00:00
Peter Steinberger a4fc2b4536 fix: handle fetch streams safely 2026-01-21 01:24:29 +00:00
Peter Steinberger 20a7dd8a80 feat: add config subsections in control ui 2026-01-21 01:22:19 +00:00
Peter Steinberger 450d2d25e2 feat: add sectioned config layout in mac app 2026-01-21 01:22:19 +00:00
Peter Steinberger df024afc97 fix: note gatewayUrl param in control ui (#1342) (thanks @ameno-) 2026-01-21 01:21:57 +00:00
Peter Steinberger 12168dc64f Merge pull request #1337 from John-Rood/fix/restart-channel-routing
fix: preserve channel routing across gateway restart
2026-01-21 01:21:35 +00:00
Peter Steinberger 4232081fcb Merge pull request #1342 from ameno-/fix-webchat-gateway-url
fix(ui): parse gatewayUrl from URL params
2026-01-21 01:20:25 +00:00
Ameno OsmanandClaude Opus 4.5 c74551c2ae fix(ui): parse gatewayUrl from URL params for remote gateway access
Adds support for passing `gatewayUrl` as a URL parameter to the WebChat UI,
allowing the control-ui to connect to a remote gateway (e.g., VPS) instead
of defaulting to localhost.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 16:35:02 -08:00
23 changed files with 18284 additions and 15340 deletions

No files matched your search

+3
View File
@@ -26,13 +26,16 @@ Docs: https://docs.clawd.bot
- 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.
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
- 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: 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.
+320 -24
View File
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore
@State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
init(store: ChannelsStore = .shared) {
self.store = store
}
var body: some View {
ScrollView {
self.content
HStack(spacing: 16) {
self.sidebar
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
await self.store.loadConfigSchema()
await self.store.loadConfig()
}
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
}
}
}
extension ConfigSettings {
private var content: some View {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
Group {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
.disabled(self.isNixMode)
} else {
Text("Schema unavailable.")
private enum SubsectionSelection: Hashable {
case all
case key(String)
}
private struct ConfigSection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
var id: String { self.key }
}
private struct ConfigSubsection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
let path: ConfigPath
var id: String { self.key }
}
private var sections: [ConfigSection] {
guard let schema = self.store.configSchema else { return [] }
return self.resolveSections(schema)
}
private var activeSection: ConfigSection? {
self.sections.first { $0.key == self.activeSectionKey }
}
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
}
}
}
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configSchema != nil {
self.emptyDetail
} else {
Text("Schema unavailable.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text("Select a config section to view settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
}
@ViewBuilder
@@ -71,6 +158,18 @@ extension ConfigSettings {
.foregroundStyle(.secondary)
}
private func sectionHeader(_ section: ConfigSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.label)
.font(.title3.weight(.semibold))
if let help = section.help {
Text(help)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private var actionRow: some View {
HStack(spacing: 10) {
Button("Reload") {
@@ -85,6 +184,203 @@ extension ConfigSettings {
}
.buttonStyle(.bordered)
}
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key }) {
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
}()
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
.disabled(self.isNixMode)
}
private func ensureSelection() {
guard let schema = self.store.configSchema else { return }
let sections = self.resolveSections(schema)
guard !sections.isEmpty else { return }
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
if self.activeSectionKey != active.key {
self.activeSectionKey = active.key
}
self.ensureSubsection(for: active)
}
private func ensureSubsection(for section: ConfigSection) {
let subsections = self.resolveSubsections(for: section)
guard !subsections.isEmpty else {
self.activeSubsection = nil
return
}
switch self.activeSubsection {
case .all:
return
case let .key(key):
if subsections.contains(where: { $0.key == key }) { return }
case .none:
break
}
if let first = subsections.first {
self.activeSubsection = .key(first.key)
}
}
private func selectSection(_ section: ConfigSection) {
guard self.activeSectionKey != section.key else { return }
self.activeSectionKey = section.key
let subsections = self.resolveSubsections(for: section)
if let first = subsections.first {
self.activeSubsection = .key(first.key)
} else {
self.activeSubsection = nil
}
}
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
let node = self.resolvedSchemaNode(root)
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSection(key: key, label: label, help: help, node: child)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
let node = self.resolvedSchemaNode(section.node)
guard node.schemaType == "object" else { return [] }
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(section.key), .key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSubsection(
key: key,
label: label,
help: help,
node: child,
path: path)
}
}
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first { return only }
}
return node
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
struct ConfigSettings_Previews: PreviewProvider {
+1 -1
View File
@@ -21,4 +21,4 @@ clawdbot security audit --fix
```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
It also warns when small models (<=300B) are used without sandboxing and with web/browser tools enabled.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
+8 -7
View File
@@ -34,6 +34,10 @@ export type SkillInstallResult = {
code: number | null;
};
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function");
}
function summarizeInstallOutput(text: string): string | undefined {
const raw = text.trim();
if (!raw) return undefined;
@@ -154,13 +158,10 @@ async function downloadFile(
}
await ensureDir(path.dirname(destPath));
const file = fs.createWriteStream(destPath);
const body = response.body;
// Node's Readable.fromWeb expects the node:stream/web type; cast through unknown for DOM streams.
const nodeStream = body as unknown as NodeJS.ReadableStream;
const readable =
typeof nodeStream.pipe === "function"
? nodeStream
: Readable.fromWeb(body as unknown as NodeReadableStream);
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 };
+1 -1
View File
@@ -1 +1 @@
c1cdb1b463e70d87976d88abf13e373e774c057e6796c947227c56b459af9d77
d9a36b111dfd93cbbb629fddf075800690ce0ae32a3a2ef201b365f4f6d6f5d5
File diff suppressed because it is too large. Load diff
+124
View File
@@ -246,4 +246,128 @@ describe("cron cli", () => {
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.deliver).toBe(false);
});
it("does not include undefined delivery fields when updating message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
// Update message without delivery flags - should NOT include undefined delivery fields
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
from: "user",
});
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
bestEffortDeliver?: boolean;
};
};
};
// Should include the new message
expect(patch?.patch?.payload?.message).toBe("Updated message");
// Should NOT include delivery fields at all (to preserve existing values)
expect(patch?.patch?.payload).not.toHaveProperty("deliver");
expect(patch?.patch?.payload).not.toHaveProperty("channel");
expect(patch?.patch?.payload).not.toHaveProperty("to");
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
});
it("includes delivery fields when explicitly provided with message", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
// Update message AND delivery - should include both
await program.parseAsync(
[
"cron",
"edit",
"job-1",
"--message",
"Updated message",
"--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?: {
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
};
};
};
// Should include everything
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.deliver).toBe(true);
expect(patch?.patch?.payload?.channel).toBe("telegram");
expect(patch?.patch?.payload?.to).toBe("19098680");
});
it("includes best-effort delivery when provided with 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", "--message", "Updated message", "--best-effort-deliver"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
};
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(true);
});
it("includes no-best-effort delivery when provided with 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", "--message", "Updated message", "--no-best-effort-deliver"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
};
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(false);
});
});
+22 -12
View File
@@ -10,6 +10,15 @@ import {
warnIfCronSchedulerDisabled,
} from "./shared.js";
const assignIf = (
target: Record<string, unknown>,
key: string,
value: unknown,
shouldAssign: boolean,
) => {
if (shouldAssign) target[key] = value;
};
export function registerCronEditCommand(cron: Command) {
addGatewayClientOptions(
cron
@@ -136,18 +145,19 @@ export function registerCronEditCommand(cron: Command) {
};
} 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;
}
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
assignIf(payload, "model", model, Boolean(model));
assignIf(payload, "thinking", thinking, Boolean(thinking));
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean");
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string");
assignIf(payload, "to", opts.to, typeof opts.to === "string");
assignIf(
payload,
"bestEffortDeliver",
opts.bestEffortDeliver,
typeof opts.bestEffortDeliver === "boolean",
);
patch.payload = payload;
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { normalizeEnv } from "../infra/env.js";
import { isMainModule } from "../infra/is-main.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { tryRouteCli } from "./route.js";
@@ -42,7 +43,7 @@ export async function runCli(argv: string[] = process.argv) {
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1);
});
+9 -2
View File
@@ -57,6 +57,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
];
const CONFIG_BACKUP_COUNT = 5;
const loggedInvalidConfigs = new Set<string>();
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
@@ -244,8 +245,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const details = validated.issues
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
.join("\n");
deps.logger.error(`Invalid config:\\n${details}`);
throw new Error("Invalid config");
if (!loggedInvalidConfigs.has(configPath)) {
loggedInvalidConfigs.add(configPath);
deps.logger.error(`Invalid config:\\n${details}`);
}
const error = new Error("Invalid config");
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
(error as { code?: string; details?: string }).details = details;
throw error;
}
if (validated.warnings.length > 0) {
const details = validated.warnings
+1 -1
View File
@@ -87,7 +87,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
deliver: true,
bestEffortDeliver: true,
messageChannel: channel,
replyToId: threadId,
threadId,
},
defaultRuntime,
params.deps,
+3 -5
View File
@@ -28,6 +28,7 @@ import {
PortInUseError,
} from "./infra/ports.js";
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
import { formatUncaughtError } from "./infra/errors.js";
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
import { enableConsoleCapture } from "./logging.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js";
@@ -82,15 +83,12 @@ if (isMain) {
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1);
});
void program.parseAsync(process.argv).catch((err) => {
console.error(
"[clawdbot] CLI failed:",
err instanceof Error ? (err.stack ?? err.message) : err,
);
console.error("[clawdbot] CLI failed:", formatUncaughtError(err));
process.exit(1);
});
}
+10
View File
@@ -20,3 +20,13 @@ export function formatErrorMessage(err: unknown): string {
return Object.prototype.toString.call(err);
}
}
export function formatUncaughtError(err: unknown): string {
if (extractErrorCode(err) === "INVALID_CONFIG") {
return formatErrorMessage(err);
}
if (err instanceof Error) {
return err.stack ?? err.message ?? err.name;
}
return formatErrorMessage(err);
}
+3 -4
View File
@@ -1,5 +1,7 @@
import process from "node:process";
import { formatUncaughtError } from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set<UnhandledRejectionHandler>();
@@ -28,10 +30,7 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
export function installUnhandledRejectionHandler(): void {
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return;
console.error(
"[clawdbot] Unhandled promise rejection:",
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
);
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
process.exit(1);
});
}
+2 -1
View File
@@ -55,6 +55,7 @@ async function main() {
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
assertSupportedRuntime();
const { formatUncaughtError } = await import("../infra/errors.js");
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
const { buildProgram } = await import("../cli/program.js");
@@ -63,7 +64,7 @@ async function main() {
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[clawdbot] Uncaught exception:", error.stack ?? error.message);
console.error("[clawdbot] Uncaught exception:", formatUncaughtError(error));
process.exit(1);
});
+100
View File
@@ -358,6 +358,98 @@
color: var(--ok);
}
/* Section Hero */
.config-section-hero {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: rgba(0, 0, 0, 0.04);
}
:root[data-theme="light"] .config-section-hero {
background: rgba(0, 0, 0, 0.015);
}
.config-section-hero__icon {
width: 28px;
height: 28px;
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
}
.config-section-hero__icon svg {
width: 100%;
height: 100%;
stroke: currentColor;
fill: none;
}
.config-section-hero__text {
display: grid;
gap: 2px;
min-width: 0;
}
.config-section-hero__title {
font-size: 15px;
font-weight: 600;
}
.config-section-hero__desc {
font-size: 12px;
color: var(--muted);
}
/* Subsection Nav */
.config-subnav {
display: flex;
gap: 8px;
padding: 10px 20px 12px;
border-bottom: 1px solid var(--border);
background: rgba(0, 0, 0, 0.03);
overflow-x: auto;
}
:root[data-theme="light"] .config-subnav {
background: rgba(0, 0, 0, 0.02);
}
.config-subnav__item {
border: 1px solid transparent;
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
background: rgba(0, 0, 0, 0.12);
cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
white-space: nowrap;
}
:root[data-theme="light"] .config-subnav__item {
background: rgba(0, 0, 0, 0.06);
}
.config-subnav__item:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
}
:root[data-theme="light"] .config-subnav__item:hover {
background: rgba(0, 0, 0, 0.08);
}
.config-subnav__item.active {
color: var(--accent);
border-color: rgba(245, 159, 74, 0.4);
background: rgba(245, 159, 74, 0.12);
}
/* Content Area */
.config-content {
flex: 1;
@@ -1243,6 +1335,14 @@
justify-content: center;
}
.config-section-hero {
padding: 12px 16px;
}
.config-subnav {
padding: 8px 16px 10px;
}
.config-content {
padding: 16px;
}
+6 -1
View File
@@ -485,11 +485,16 @@ export function renderApp(state: AppViewState) {
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => (state.configRaw = next),
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => (state.configActiveSection = section),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
+10
View File
@@ -62,6 +62,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
const tokenRaw = params.get("token");
const passwordRaw = params.get("password");
const sessionRaw = params.get("session");
const gatewayUrlRaw = params.get("gatewayUrl");
let shouldCleanUrl = false;
if (tokenRaw != null) {
@@ -94,6 +95,15 @@ export function applySettingsFromUrl(host: SettingsHost) {
}
}
if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
applySettings(host, { ...host.settings, gatewayUrl });
}
params.delete("gatewayUrl");
shouldCleanUrl = true;
}
if (!shouldCleanUrl) return;
const url = new URL(window.location.href);
url.search = params.toString();
+1
View File
@@ -152,6 +152,7 @@ export class ClawdbotApp extends LitElement {
@state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = "";
@state() configActiveSection: string | null = null;
@state() configActiveSubsection: string | null = null;
@state() channelsLoading = false;
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
+1
View File
@@ -33,6 +33,7 @@ export type ConfigState = {
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
lastError: string | null;
};
+97 -33
View File
@@ -1,6 +1,11 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
import { renderNode } from "./config-form.node";
export type ConfigFormProps = {
@@ -11,6 +16,7 @@ export type ConfigFormProps = {
unsupportedPaths?: string[];
searchQuery?: string;
activeSection?: string | null;
activeSubsection?: string | null;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
@@ -146,6 +152,7 @@ export function renderConfigForm(props: ConfigFormProps) {
const properties = schema.properties;
const searchQuery = props.searchQuery ?? "";
const activeSection = props.activeSection;
const activeSubsection = props.activeSubsection ?? null;
// Filter and sort entries
let entries = Object.entries(properties);
@@ -168,6 +175,25 @@ export function renderConfigForm(props: ConfigFormProps) {
return a[0].localeCompare(b[0]);
});
let subsectionContext:
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
| null = null;
if (activeSection && activeSubsection && entries.length === 1) {
const sectionSchema = entries[0]?.[1];
if (
sectionSchema &&
schemaType(sectionSchema) === "object" &&
sectionSchema.properties &&
sectionSchema.properties[activeSubsection]
) {
subsectionContext = {
sectionKey: activeSection,
subsectionKey: activeSubsection,
schema: sectionSchema.properties[activeSubsection],
};
}
}
if (entries.length === 0) {
return html`
<div class="config-empty">
@@ -183,38 +209,76 @@ export function renderConfigForm(props: ConfigFormProps) {
return html`
<div class="config-form config-form--modern">
${entries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? ""
};
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description ? html`
<p class="config-section-card__desc">${meta.description}</p>
` : nothing}
</div>
</div>
<div class="config-section-card__content">
${renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</section>
`;
})}
${subsectionContext
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
<section class="config-section-card" id=${id}>
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${label}</h3>
${description
? html`<p class="config-section-card__desc">${description}</p>`
: nothing}
</div>
</div>
<div class="config-section-card__content">
${renderNode({
schema: node,
value: scopedValue,
path: [sectionKey, subsectionKey],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</section>
`;
})()
: entries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing}
</div>
</div>
<div class="config-section-card__content">
${renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</section>
`;
})}
</div>
`;
}
+7
View File
@@ -21,13 +21,20 @@ describe("config view", () => {
uiHints: {},
formMode: "form" as const,
formValue: {},
originalValue: {},
searchQuery: "",
activeSection: null,
activeSubsection: null,
onRawChange: vi.fn(),
onFormModeChange: vi.fn(),
onFormPatch: vi.fn(),
onSearchChange: vi.fn(),
onSectionChange: vi.fn(),
onReload: vi.fn(),
onSave: vi.fn(),
onApply: vi.fn(),
onUpdate: vi.fn(),
onSubsectionChange: vi.fn(),
});
it("disables save when form is unsafe", () => {
+113
View File
@@ -1,6 +1,12 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
export type ConfigProps = {
raw: string;
@@ -19,11 +25,13 @@ export type ConfigProps = {
originalValue: Record<string, unknown> | null;
searchQuery: string;
activeSection: string | null;
activeSubsection: string | null;
onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
onSearchChange: (query: string) => void;
onSectionChange: (section: string | null) => void;
onSubsectionChange: (section: string | null) => void;
onReload: () => void;
onSave: () => void;
onApply: () => void;
@@ -80,10 +88,49 @@ const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "wizard", label: "Setup Wizard" },
];
type SubsectionEntry = {
key: string;
label: string;
description?: string;
order: number;
};
const ALL_SUBSECTION = "__all__";
function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
}
function resolveSectionMeta(key: string, schema?: JsonSchema): {
label: string;
description?: string;
} {
const meta = SECTION_META[key];
if (meta) return meta;
return {
label: schema?.title ?? humanize(key),
description: schema?.description ?? "",
};
}
function resolveSubsections(params: {
key: string;
schema: JsonSchema | undefined;
uiHints: ConfigUiHints;
}): SubsectionEntry[] {
const { key, schema, uiHints } = params;
if (!schema || schemaType(schema) !== "object" || !schema.properties) return [];
const entries = Object.entries(schema.properties).map(([subKey, node]) => {
const hint = hintForPath([key, subKey], uiHints);
const label = hint?.label ?? node.title ?? humanize(subKey);
const description = hint?.help ?? node.description ?? "";
const order = hint?.order ?? 50;
return { key: subKey, label, description, order };
});
entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key)));
return entries;
}
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null
@@ -164,6 +211,31 @@ export function renderConfig(props: ConfigProps) {
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections];
const activeSectionSchema =
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined)
: undefined;
const activeSectionMeta = props.activeSection
? resolveSectionMeta(props.activeSection, activeSectionSchema)
: null;
const subsections = props.activeSection
? resolveSubsections({
key: props.activeSection,
schema: activeSectionSchema,
uiHints: props.uiHints,
})
: [];
const allowSubnav =
props.formMode === "form" &&
Boolean(props.activeSection) &&
subsections.length > 0;
const isAllSubsection = props.activeSubsection === ALL_SUBSECTION;
const effectiveSubsection = props.searchQuery
? null
: isAllSubsection
? null
: props.activeSubsection ?? (subsections[0]?.key ?? null);
// Compute diff for showing changes
const diff = props.formMode === "form"
@@ -304,6 +376,46 @@ export function renderConfig(props: ConfigProps) {
</details>
` : nothing}
${activeSectionMeta && props.formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div>
<div class="config-section-hero__text">
<div class="config-section-hero__title">${activeSectionMeta.label}</div>
${activeSectionMeta.description
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
: nothing}
</div>
</div>
`
: nothing}
${allowSubnav
? html`
<div class="config-subnav">
<button
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
>
All
</button>
${subsections.map(
(entry) => html`
<button
class="config-subnav__item ${
effectiveSubsection === entry.key ? "active" : ""
}"
title=${entry.description || entry.label}
@click=${() => props.onSubsectionChange(entry.key)}
>
${entry.label}
</button>
`,
)}
</div>
`
: nothing}
<!-- Form content -->
<div class="config-content">
${props.formMode === "form"
@@ -322,6 +434,7 @@ export function renderConfig(props: ConfigProps) {
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
})}
${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;">