From 5035a6bf0a5c35b1d66195598d91d41e537c25a6 Mon Sep 17 00:00:00 2001 From: Jeremy Johnson Date: Sat, 31 Jan 2026 20:55:08 -0600 Subject: [PATCH 1/8] Add Claude Code workflow for @claude mentions in issues (#1) This workflow: - Triggers only when @claude is mentioned in issues or issue comments - Does NOT perform automatic code reviews on PRs - Uses CLAUDE_CODE_OAUTH_TOKEN for Claude Max/Pro subscription (no API key needed) To use: 1. Generate OAuth token: Run 'claude setup-token' in Claude Code CLI 2. Add token to repo secrets as CLAUDE_CODE_OAUTH_TOKEN 3. Install Claude GitHub App: https://github.com/apps/claude 4. Tag @claude in any issue to test --- .github/workflows/claude.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000000..ca24bf7aa211 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,30 @@ +name: Claude Code + +on: + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + +jobs: + claude: + runs-on: ubuntu-latest + # Only run when @claude is mentioned in issues/comments + if: contains(github.event.comment.body, '@claude') || contains(github.event.issue.body, '@claude') + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run Claude Code + uses: anthropics/claude-code-action@v1 + with: + prompt: | + ${{ github.event.comment.body || github.event.issue.body }} + claude_args: | + --max-turns 10 + env: + # Use OAuth token for Claude Max/Pro subscription (no API key needed) + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 6d3a092b32f50a08f978b05ab7f1e062d34448fc Mon Sep 17 00:00:00 2001 From: Jeremy Johnson Date: Fri, 6 Feb 2026 17:37:08 -0600 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20gateway=20stability=20=E2=80=94=20sh?= =?UTF-8?q?utdown=20timeout,=20crash=20backoff,=20error=20logging,=20safe?= =?UTF-8?q?=20JSON=20parsing,=20cron=20model=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase shutdown timeout from 5s to 15s to prevent incomplete cleanup (run-loop.ts) - Add ThrottleInterval=10 to launchd plist to prevent crash-loop respawning (launchd-plist.ts) - Replace silent .catch(() => {}) with logged warnings in gateway cleanup (server-close.ts) - Wrap JSON.parse in try-catch for Signal RPC responses (signal/client.ts) - Consolidate and safeguard JSON.parse in Feishu media download (feishu/download.ts) - Cron model override: warn and fall back to default instead of hard-failing the job (isolated-agent/run.ts) - Update test to match new warn-and-fallback behavior Co-Authored-By: Claude Opus 4.6 --- src/cli/gateway-cli/run-loop.ts | 2 +- ....uses-last-non-empty-agent-text-as.test.ts | 16 ++++++-- src/cron/isolated-agent/run.ts | 10 +++-- src/daemon/launchd-plist.ts | 2 +- src/gateway/server-close.ts | 39 ++++++++++++------- src/signal/client.ts | 7 +++- 6 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 9cdcf18652a1..42ab87c3da7d 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -39,7 +39,7 @@ export async function runGatewayLoop(params: { gatewayLog.error("shutdown timed out; exiting without full cleanup"); cleanupSignals(); params.runtime.exit(0); - }, 5000); + }, 15_000); void (async () => { try { diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 3ec1c935b08f..d9e5496647ac 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -479,7 +479,15 @@ describe("runCronIsolatedAgentTurn", () => { sendMessageSignal: vi.fn(), sendMessageIMessage: vi.fn(), }; - vi.mocked(runEmbeddedPiAgent).mockReset(); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { + durationMs: 5, + providerModel: "test/model", + inputTokens: 10, + outputTokens: 5, + }, + }); const res = await runCronIsolatedAgentTurn({ cfg: makeCfg(home, storePath), @@ -494,9 +502,9 @@ describe("runCronIsolatedAgentTurn", () => { lane: "cron", }); - expect(res.status).toBe("error"); - expect(res.error).toMatch("invalid model"); - expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); + // Invalid model override should warn and fall back to default, not fail + expect(res.status).toBe("ok"); + expect(vi.mocked(runEmbeddedPiAgent)).toHaveBeenCalled(); }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 3dd0cc416579..57b4bcf43710 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -200,10 +200,14 @@ export async function runCronIsolatedAgentTurn(params: { defaultModel: resolvedDefault.model, }); if ("error" in resolvedOverride) { - return { status: "error", error: resolvedOverride.error }; + // Fall through to the default model instead of failing the entire cron job + logWarn( + `[cron:${params.job.id}] model override "${modelOverrideRaw}" rejected (${resolvedOverride.error}); using default model`, + ); + } else { + provider = resolvedOverride.ref.provider; + model = resolvedOverride.ref.model; } - provider = resolvedOverride.ref.provider; - model = resolvedOverride.ref.model; } const now = Date.now(); const cronSession = resolveCronSession({ diff --git a/src/daemon/launchd-plist.ts b/src/daemon/launchd-plist.ts index e685cd9941cf..20f466072a82 100644 --- a/src/daemon/launchd-plist.ts +++ b/src/daemon/launchd-plist.ts @@ -106,5 +106,5 @@ export function buildLaunchAgentPlist({ ? `\n Comment\n ${plistEscape(comment.trim())}` : ""; const envXml = renderEnvDict(environment); - return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; + return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n ThrottleInterval\n 10\n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; } diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index ea0323587a98..8ab396e2f477 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -5,6 +5,9 @@ import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { stopGmailWatcher } from "../hooks/gmail-watcher.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("gateway"); export function createGatewayCloseHandler(params: { bonjourStop: (() => Promise) | null; @@ -40,8 +43,8 @@ export function createGatewayCloseHandler(params: { if (params.bonjourStop) { try { await params.bonjourStop(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: bonjour stop failed: ${String(err)}`); } } if (params.tailscaleCleanup) { @@ -50,22 +53,24 @@ export function createGatewayCloseHandler(params: { if (params.canvasHost) { try { await params.canvasHost.close(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: canvas host close failed: ${String(err)}`); } } if (params.canvasHostServer) { try { await params.canvasHostServer.close(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: canvas host server close failed: ${String(err)}`); } } for (const plugin of listChannelPlugins()) { await params.stopChannel(plugin.id); } if (params.pluginServices) { - await params.pluginServices.stop().catch(() => {}); + await params.pluginServices.stop().catch((err) => { + log.warn(`cleanup: plugin services stop failed: ${String(err)}`); + }); } await stopGmailWatcher(); params.cron.stop(); @@ -84,29 +89,33 @@ export function createGatewayCloseHandler(params: { if (params.agentUnsub) { try { params.agentUnsub(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: agent unsub failed: ${String(err)}`); } } if (params.heartbeatUnsub) { try { params.heartbeatUnsub(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: heartbeat unsub failed: ${String(err)}`); } } params.chatRunState.clear(); for (const c of params.clients) { try { c.socket.close(1012, "service restart"); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: client socket close failed: ${String(err)}`); } } params.clients.clear(); - await params.configReloader.stop().catch(() => {}); + await params.configReloader.stop().catch((err) => { + log.warn(`cleanup: config reloader stop failed: ${String(err)}`); + }); if (params.browserControl) { - await params.browserControl.stop().catch(() => {}); + await params.browserControl.stop().catch((err) => { + log.warn(`cleanup: browser control stop failed: ${String(err)}`); + }); } await new Promise((resolve) => params.wss.close(() => resolve())); const servers = diff --git a/src/signal/client.ts b/src/signal/client.ts index 1551183f141c..3a7f716e7289 100644 --- a/src/signal/client.ts +++ b/src/signal/client.ts @@ -81,7 +81,12 @@ export async function signalRpcRequest( if (!text) { throw new Error(`Signal RPC empty response (status ${res.status})`); } - const parsed = JSON.parse(text) as SignalRpcResponse; + let parsed: SignalRpcResponse; + try { + parsed = JSON.parse(text) as SignalRpcResponse; + } catch { + throw new Error(`Signal RPC malformed JSON (status ${res.status}): ${text.slice(0, 200)}`); + } if (parsed.error) { const code = parsed.error.code ?? "unknown"; const msg = parsed.error.message ?? "Signal RPC error"; From 8649983f8615a37ae63911b3052cf0288f8a0831 Mon Sep 17 00:00:00 2001 From: Jeremy Johnson Date: Fri, 6 Feb 2026 17:37:21 -0600 Subject: [PATCH 3/8] feat(ui): enrich config hints, restyle nested forms, and add missing dashboard features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uiHints enrichment: - Add 75+ FIELD_HELP entries covering tools, diagnostics, UI, browser, talk, skills, channels - Add 12 FIELD_PLACEHOLDERS for token/key inputs - Add 3 new FIELD_LABELS for gateway settings CSS restyling: - Nested config objects use left-border accent instead of full border - Reduced padding and title prominence for deeply nested forms - Add log severity row tinting (warn=yellow, error/fatal=red) - Add clickable stat card hover styles Missing features: - Cron job remove confirmation dialog - Token count formatting (200000 → "200K tokens") - Channel raw JSON wrapped in collapsible
- Overview stat cards link to their detail tabs - Nodes: security/ask mode descriptions, agent scope pill search/filter - Logs: severity-based row coloring Co-Authored-By: Claude Opus 4.6 --- src/config/schema.ts | 177 +++++++++++++++++++++++++++++++++++ ui/src/styles/components.css | 18 ++++ ui/src/styles/config.css | 42 ++++++++- ui/src/ui/app-render.ts | 1 + ui/src/ui/presenter.ts | 16 +++- ui/src/ui/views/channels.ts | 7 +- ui/src/ui/views/cron.ts | 4 +- ui/src/ui/views/logs.ts | 2 +- ui/src/ui/views/nodes.ts | 71 ++++++++++---- ui/src/ui/views/overview.ts | 23 ++++- 10 files changed, 336 insertions(+), 25 deletions(-) diff --git a/src/config/schema.ts b/src/config/schema.ts index a9c177c82490..fbcbce5f0280 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -125,6 +125,9 @@ const FIELD_LABELS: Record = { "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "agents.list.*.identity.avatar": "Identity Avatar", "agents.list.*.skills": "Agent Skill Filter", + "gateway.mode": "Gateway Mode", + "gateway.port": "Gateway Port", + "gateway.bind": "Bind Address", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", @@ -752,6 +755,168 @@ const FIELD_HELP: Record = { "Optional PluralKit token for resolving private systems or members.", "channels.slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', + + // --- gateway --- + "gateway.mode": + 'Gateway run mode ("local" for local-only, "remote" to connect to a remote gateway).', + "gateway.port": "Port for the gateway HTTP/WebSocket server (default: 18789).", + "gateway.bind": + 'Network interface to bind ("loopback" for 127.0.0.1, "all" for 0.0.0.0, or a specific address).', + + // --- diagnostics --- + "diagnostics.enabled": "Enable diagnostics subsystem (default: true).", + "diagnostics.otel.enabled": + "Enable OpenTelemetry export (traces, metrics, and/or logs). Requires an OTLP-compatible collector.", + "diagnostics.otel.endpoint": + "OTLP collector endpoint (e.g. http://localhost:4318 for HTTP or http://localhost:4317 for gRPC).", + "diagnostics.otel.protocol": 'OTLP transport protocol ("http/protobuf" or "grpc").', + "diagnostics.otel.headers": + "Extra headers sent with OTLP requests (key-value pairs for auth, routing, etc.).", + "diagnostics.otel.serviceName": + 'Service name reported to the collector (default: "openclaw-gateway").', + "diagnostics.otel.traces": + "Export distributed traces via OTLP (default: true when OTEL enabled).", + "diagnostics.otel.metrics": "Export metrics via OTLP (default: true when OTEL enabled).", + "diagnostics.otel.logs": "Export logs via OTLP (default: false).", + "diagnostics.otel.sampleRate": + "Trace sample rate (0.0-1.0). 1.0 traces everything; lower values reduce volume.", + "diagnostics.otel.flushIntervalMs": + "How often (ms) the OTLP exporter flushes buffered telemetry (default: 5000).", + + // --- tools.exec --- + "tools.exec.host": + 'Exec host environment ("local" runs commands on the gateway host; other values may route to sandboxes).', + "tools.exec.security": + 'Exec security mode ("elevated" allows all commands; "standard" restricts to safe bins and approved tools).', + "tools.exec.ask": + 'Exec ask/approval mode ("always" prompts before every exec; "auto" prompts only for unknown commands; "never" skips prompts).', + "tools.exec.node": "Node.js binary used for exec-based tools (default: auto-detected).", + "tools.exec.approvalRunningNoticeMs": + "Delay (ms) before showing a notice that an exec approval is pending (default: 5000).", + + // --- tools.profile / alsoAllow / byProvider --- + "tools.profile": + 'Active tool profile ("standard", "elevated", or "minimal"). Controls which tools are available by default.', + "tools.alsoAllow": + "Additional tools to allow on top of the active profile (array of tool names).", + "agents.list[].tools.profile": + 'Per-agent tool profile override ("standard", "elevated", or "minimal").', + "agents.list[].tools.alsoAllow": + "Per-agent additional tool allowlist (merged with the global alsoAllow).", + "tools.byProvider": + "Per-provider tool policy overrides (keyed by provider ID). Lets you enable/disable specific tools for certain providers.", + "agents.list[].tools.byProvider": "Per-agent, per-provider tool policy overrides.", + + // --- tools.media --- + "tools.media.image.enabled": + "Enable image understanding (vision). When enabled, images are analyzed and described for the agent.", + "tools.media.image.maxBytes": + "Maximum image file size (bytes) accepted for understanding (larger images are skipped).", + "tools.media.image.maxChars": + "Maximum characters in the image description returned to the agent.", + "tools.media.image.prompt": + "Custom prompt sent to the vision model when describing images (overrides the default).", + "tools.media.image.timeoutSeconds": "Timeout (seconds) for image understanding requests.", + "tools.media.image.attachments": + 'Attachment handling policy for images ("inline" sends image data with the message; "describe" sends only the text description; "skip" ignores).', + "tools.media.image.models": + "Model(s) to use for image understanding (overrides the shared tools.media.models).", + "tools.media.image.scope": + 'Channel scope for image understanding ("all", "direct", "group", or a channel ID list).', + "tools.media.models": + "Shared model list for all media understanding pipelines (image, audio, video). Per-type models override this.", + "tools.media.concurrency": + "Max concurrent media understanding jobs across all types (default: 2).", + "tools.media.audio.enabled": + "Enable audio understanding (speech-to-text transcription + analysis).", + "tools.media.audio.maxBytes": "Maximum audio file size (bytes) accepted for understanding.", + "tools.media.audio.maxChars": + "Maximum characters in the audio transcription returned to the agent.", + "tools.media.audio.prompt": + "Custom prompt sent to the audio model when transcribing audio (overrides the default).", + "tools.media.audio.timeoutSeconds": "Timeout (seconds) for audio understanding requests.", + "tools.media.audio.language": + 'Language hint for audio transcription (ISO 639-1 code, e.g. "en", "es", "zh").', + "tools.media.audio.attachments": + 'Attachment handling policy for audio ("inline", "describe", or "skip").', + "tools.media.audio.models": + "Model(s) to use for audio understanding (overrides the shared tools.media.models).", + "tools.media.audio.scope": + 'Channel scope for audio understanding ("all", "direct", "group", or a channel ID list).', + "tools.media.video.enabled": "Enable video understanding (frame extraction + analysis).", + "tools.media.video.maxBytes": "Maximum video file size (bytes) accepted for understanding.", + "tools.media.video.maxChars": + "Maximum characters in the video description returned to the agent.", + "tools.media.video.prompt": + "Custom prompt sent to the vision model when describing video frames (overrides the default).", + "tools.media.video.timeoutSeconds": "Timeout (seconds) for video understanding requests.", + "tools.media.video.attachments": + 'Attachment handling policy for videos ("inline", "describe", or "skip").', + "tools.media.video.models": + "Model(s) to use for video understanding (overrides the shared tools.media.models).", + "tools.media.video.scope": + 'Channel scope for video understanding ("all", "direct", "group", or a channel ID list).', + "tools.links.enabled": + "Enable link understanding (fetches URLs shared in messages and summarizes them).", + "tools.links.maxLinks": "Maximum number of links to process per message (default: 3).", + "tools.links.timeoutSeconds": "Timeout (seconds) for fetching and understanding links.", + "tools.links.models": "Model(s) to use for link understanding.", + "tools.links.scope": + 'Channel scope for link understanding ("all", "direct", "group", or a channel ID list).', + + // --- ui --- + "ui.seamColor": "Accent color used in the Control UI and CLI output (hex color code).", + "ui.assistant.name": "Display name for the assistant shown in the UI and message headers.", + "ui.assistant.avatar": "Avatar emoji or image URL for the assistant shown in the UI.", + + // --- browser --- + "browser.evaluateEnabled": + "Allow the browser_evaluate tool to run arbitrary JavaScript in the controlled browser (default: false).", + "browser.snapshotDefaults": + "Default settings for browser snapshots (screenshots and DOM captures).", + "browser.snapshotDefaults.mode": 'Default snapshot mode ("screenshot", "dom", or "hybrid").', + "browser.remoteCdpTimeoutMs": + "Timeout (ms) for connecting to a remote Chrome DevTools Protocol endpoint (default: 10000).", + "browser.remoteCdpHandshakeTimeoutMs": + "Timeout (ms) for the CDP WebSocket handshake after connection (default: 5000).", + + // --- talk --- + "talk.apiKey": + "API key for the Talk voice provider (e.g. OpenAI Realtime API key). Falls back to OPENAI_API_KEY env var.", + + // --- skills --- + "skills.load.watch": + "Watch skill directories for changes and hot-reload on save (default: true in dev).", + "skills.load.watchDebounceMs": + "Debounce window (ms) before reloading skills after a file change (default: 300).", + + // --- agents.defaults --- + "agents.defaults.workspace": + "Default workspace directory for agents (used for file operations and relative path resolution).", + "agents.defaults.memorySearch.enabled": + "Enable vector-based memory search for agents (default: true).", + "agents.defaults.memorySearch.model": + 'Embedding model for memory search (e.g. "text-embedding-3-small").', + "agents.defaults.memorySearch.chunking.tokens": + "Target chunk size in tokens for memory indexing (default: 256).", + "agents.defaults.memorySearch.chunking.overlap": + "Overlap tokens between consecutive memory chunks (default: 32).", + "agents.defaults.memorySearch.sync.onSessionStart": + "Trigger a memory reindex when a new session starts (default: true).", + "agents.defaults.memorySearch.sync.watchDebounceMs": + "Debounce window (ms) before reindexing after a memory file change (default: 1000).", + "agents.defaults.memorySearch.query.maxResults": + "Maximum number of memory search results returned per query (default: 5).", + "agents.defaults.memorySearch.query.minScore": + "Minimum similarity score (0-1) for memory search results to be included.", + + // --- channels (missing help) --- + "channels.telegram.capabilities.inlineButtons": + "Enable inline button support for Telegram messages (default: true). Disable to suppress inline keyboards.", + "channels.signal.account": + "Phone number registered with Signal for the bot (E.164 format, e.g. +15551234567). Used by signal-cli.", + "channels.imessage.cliPath": + "Path to the openclaw-imessage CLI binary used for sending/receiving iMessages.", }; const FIELD_PLACEHOLDERS: Record = { @@ -763,6 +928,18 @@ const FIELD_PLACEHOLDERS: Record = { "gateway.controlUi.allowedOrigins": "https://control.example.com", "channels.mattermost.baseUrl": "https://chat.example.com", "agents.list[].identity.avatar": "avatars/openclaw.png", + "channels.telegram.botToken": "123456:ABC-DEF1234...", + "channels.discord.token": "MTk...", + "channels.slack.botToken": "xoxb-...", + "channels.slack.appToken": "xapp-...", + "channels.signal.account": "+15551234567", + "channels.imessage.cliPath": "/usr/local/bin/openclaw-imessage", + "tools.web.search.apiKey": "BSA...", + "talk.apiKey": "sk-...", + "agents.defaults.model.primary": "anthropic/claude-sonnet-4-5-20250929", + "ui.assistant.name": "Mr. Fox", + "ui.assistant.avatar": "\u{1F98A}", + "ui.seamColor": "#6366f1", }; const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index f0438eeec2fb..9a0732a1ce81 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -91,6 +91,14 @@ gap: 6px; } +.stat-card-clickable { + transition: border-color var(--duration-fast) ease; +} + +.stat-card-clickable:hover { + border-color: var(--border-strong); +} + .note-title { font-weight: 600; letter-spacing: -0.01em; @@ -1042,6 +1050,16 @@ border-color: var(--danger-subtle); } +/* Severity row tinting — subtle background to distinguish warn/error rows */ +.log-row.log-row--warn { + background: color-mix(in srgb, var(--warn) 5%, transparent); +} + +.log-row.log-row--error, +.log-row.log-row--fatal { + background: color-mix(in srgb, var(--danger) 6%, transparent); +} + .log-subsystem { color: var(--muted); font-family: var(--mono); diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index ec4003a12444..025564969130 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -985,7 +985,7 @@ box-shadow: var(--focus-ring); } -/* Object (collapsible) */ +/* Object (collapsible) — top level */ .cfg-object { border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -993,10 +993,26 @@ overflow: hidden; } +/* Nested objects: lighter borders, left-accent instead of full border */ +.cfg-object .cfg-object { + border: none; + border-left: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); + border-radius: 0; + background: transparent; +} + +.cfg-object .cfg-object .cfg-object { + border-left-color: color-mix(in srgb, var(--accent) 20%, transparent); +} + :root[data-theme="light"] .cfg-object { background: white; } +:root[data-theme="light"] .cfg-object .cfg-object { + background: transparent; +} + .cfg-object__header { display: flex; align-items: center; @@ -1007,6 +1023,11 @@ transition: background var(--duration-fast) ease; } +/* Nested object headers: less padding */ +.cfg-object .cfg-object .cfg-object__header { + padding: 10px 14px; +} + .cfg-object__header:hover { background: var(--bg-hover); } @@ -1021,6 +1042,13 @@ color: var(--text); } +/* Nested object titles: smaller, less prominent */ +.cfg-object .cfg-object .cfg-object__title { + font-size: 13px; + font-weight: 500; + color: var(--muted); +} + .cfg-object__chevron { width: 18px; height: 18px; @@ -1044,12 +1072,24 @@ border-bottom: 1px solid var(--border); } +/* Nested help: no bottom border */ +.cfg-object .cfg-object .cfg-object__help { + border-bottom: none; + padding: 0 14px 10px; +} + .cfg-object__content { padding: 18px; display: grid; gap: 18px; } +/* Nested content: tighter spacing */ +.cfg-object .cfg-object .cfg-object__content { + padding: 10px 14px; + gap: 14px; +} + /* Array */ .cfg-array { border: 1px solid var(--border); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index d416bde447ef..b948b05301e5 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -236,6 +236,7 @@ export function renderApp(state: AppViewState) { }, onConnect: () => state.connect(), onRefresh: () => state.loadOverview(), + onNavigate: (tab) => state.setTab(tab), }) : nothing } diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index 7c99380a8612..9cd5e8e7ee26 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -21,13 +21,27 @@ export function formatNextRun(ms?: number | null) { return `${formatMs(ms)} (${formatAgo(ms)})`; } +function formatTokenCount(n: number): string { + if (n >= 1_000_000) { + const val = n / 1_000_000; + return val % 1 === 0 ? `${val}M` : `${val.toFixed(1)}M`; + } + if (n >= 1_000) { + const val = n / 1_000; + return val % 1 === 0 ? `${val}K` : `${val.toFixed(1)}K`; + } + return String(n); +} + export function formatSessionTokens(row: GatewaySessionRow) { if (row.totalTokens == null) { return "n/a"; } const total = row.totalTokens ?? 0; const ctx = row.contextTokens ?? 0; - return ctx ? `${total} / ${ctx}` : String(total); + return ctx + ? `${formatTokenCount(total)} / ${formatTokenCount(ctx)} tokens` + : `${formatTokenCount(total)} tokens`; } export function formatEventPayload(payload: unknown): string { diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index c1983fef076c..c5b44d3ba69b 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -82,9 +82,12 @@ export function renderChannels(props: ChannelsProps) { ` : nothing } -
+      
+ Show raw data +
 ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
-      
+
+
`; } diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 7b87826eaadd..9520224742cb 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -437,7 +437,9 @@ function renderJob(job: CronJob, props: CronProps) { ?disabled=${props.busy} @click=${(event: Event) => { event.stopPropagation(); - props.onRemove(job); + if (window.confirm("Are you sure you want to remove this cron job?")) { + props.onRemove(job); + } }} > Remove diff --git a/ui/src/ui/views/logs.ts b/ui/src/ui/views/logs.ts index c119c413c785..c28a9fc9438c 100644 --- a/ui/src/ui/views/logs.ts +++ b/ui/src/ui/views/logs.ts @@ -140,7 +140,7 @@ export function renderLogs(props: LogsProps) { ` : filtered.map( (entry) => html` -
+
${formatTime(entry.time)}
${entry.level ?? ""}
${entry.subsystem ?? ""}
diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index f48c925c9979..06ab57598f1b 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -292,16 +292,16 @@ type ExecApprovalsState = { const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__"; -const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [ - { value: "deny", label: "Deny" }, - { value: "allowlist", label: "Allowlist" }, - { value: "full", label: "Full" }, +const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string; desc: string }> = [ + { value: "deny", label: "Deny", desc: "Block all exec commands (safest)" }, + { value: "allowlist", label: "Allowlist", desc: "Only run commands matching allowlist patterns" }, + { value: "full", label: "Full", desc: "Allow all exec commands (use with caution)" }, ]; -const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [ - { value: "off", label: "Off" }, - { value: "on-miss", label: "On miss" }, - { value: "always", label: "Always" }, +const ASK_OPTIONS: Array<{ value: ExecAsk; label: string; desc: string }> = [ + { value: "off", label: "Off", desc: "Never prompt — use security mode directly" }, + { value: "on-miss", label: "On miss", desc: "Prompt when command is not in allowlist" }, + { value: "always", label: "Always", desc: "Always prompt before running any command" }, ]; function resolveBindingsState(props: NodesProps): BindingState { @@ -674,10 +674,41 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) { } function renderExecApprovalsTabs(state: ExecApprovalsState) { + const showSearch = state.agents.length > 10; return html` -
- Scope -
+
+
+ Scope + ${ + showSearch + ? html` + { + const input = event.target as HTMLInputElement; + const filter = input.value.toLowerCase().trim(); + const container = input.closest("div")?.parentElement?.querySelector(".scope-pills"); + if (!container) { + return; + } + for (const btn of container.querySelectorAll("[data-agent-id]")) { + const id = (btn as HTMLElement).dataset.agentId ?? ""; + const name = (btn as HTMLElement).dataset.agentName ?? ""; + const visible = + !filter || + id.toLowerCase().includes(filter) || + name.toLowerCase().includes(filter); + (btn as HTMLElement).style.display = visible ? "" : "none"; + } + }} + /> + ` + : nothing + } +
+
-
- - - - -
+
+ Filters +
+
+ + + + +
+
+
${ props.error From bc2822bb243c7997acb7938ade9bc429c95e992b Mon Sep 17 00:00:00 2001 From: Jeremy Johnson Date: Fri, 6 Feb 2026 22:42:11 -0600 Subject: [PATCH 6/8] Document channel config hints --- src/config/schema.test.ts | 6 + src/config/schema.ts | 234 ++++++++++++++++++++++++++ ui/src/styles/config.css | 24 +++ ui/src/ui/config-form.browser.test.ts | 115 +++++++++++++ ui/src/ui/views/channels.ts | 4 + ui/src/ui/views/config-form.node.ts | 145 +++++++++++++++- ui/src/ui/views/config-form.shared.ts | 135 ++++++++++++++- 7 files changed, 654 insertions(+), 9 deletions(-) diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index be4aa1b868c7..24b928fd70e5 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -120,5 +120,11 @@ describe("config schema", () => { expect(res.uiHints["channels.discord"]?.docsPath).toBe("/channels/discord"); expect(res.uiHints["channels.discord.actions.roles"]?.docsPath).toBe("/channels/discord"); expect(res.uiHints["channels.discord.actions.roles"]?.impacts?.[0]?.relation).toBe("requires"); + expect(res.uiHints["channels.*.allowBots"]?.docsPath).toBe("/gateway/configuration"); + expect(res.uiHints["channels.*.allowBots"]?.impacts?.[0]?.relation).toBe("risk"); + expect(res.uiHints["channels.*.dm.policy"]?.impacts?.[0]?.targetPath).toBe( + "channels.*.dm.allowFrom", + ); + expect(res.uiHints["channels.telegram.streamMode"]?.impacts?.[0]?.relation).toBe("conflicts"); }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index f92aa2debdfe..34d6baa1b326 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -534,6 +534,38 @@ const FIELD_HELP: Record = { "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", "channels.slack.allowBots": "Allow bot-authored messages to trigger Slack replies (default: false).", + "channels.*.allowBots": + "Allow replies to bot-authored messages for this channel (default: false). Keep mention/allowlist guardrails to avoid bot loops.", + "channels.*.accounts.*.allowBots": + "Allow replies to bot-authored messages for this account (default: false). Keep mention/allowlist guardrails to avoid bot loops.", + "channels.*.blockStreaming": + "Send completed reply blocks while the model is still generating for this channel/account.", + "channels.*.accounts.*.blockStreaming": + "Send completed reply blocks while the model is still generating for this account.", + "channels.*.capabilities": + "Optional runtime capability tags. Leave empty unless a feature/docs explicitly require a tag.", + "channels.*.accounts.*.capabilities": + "Optional runtime capability tags for this account. Leave empty unless a feature/docs explicitly require a tag.", + "channels.*.dm.policy": + 'Direct message access policy for this channel account. "open" requires allowFrom to include "*".', + "channels.*.accounts.*.dm.policy": + 'Direct message access policy for this account. "open" requires allowFrom to include "*".', + "channels.*.dmPolicy": + 'Direct message access policy for this channel. "open" requires allowFrom to include "*".', + "channels.*.accounts.*.dmPolicy": + 'Direct message access policy for this account. "open" requires allowFrom to include "*".', + "channels.discord.groupPolicy": + 'Guild message policy. "allowlist" requires guild entries under channels.discord.guilds.', + "channels.discord.accounts.*.groupPolicy": + 'Guild message policy for this account. "allowlist" requires guild entries under channels.discord.accounts..guilds.', + "channels.slack.groupPolicy": + 'Channel message policy. "allowlist" requires channel entries under channels.slack.channels.', + "channels.slack.accounts.*.groupPolicy": + 'Channel message policy for this account. "allowlist" requires channel entries under channels.slack.accounts..channels.', + "channels.googlechat.groupPolicy": + 'Space message policy. "allowlist" requires space entries under channels.googlechat.groups.', + "channels.googlechat.accounts.*.groupPolicy": + 'Space message policy for this account. "allowlist" requires space entries under channels.googlechat.accounts..groups.', "channels.slack.thread.historyScope": 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', "channels.slack.thread.inheritParent": @@ -1055,6 +1087,20 @@ const FIELD_DOCS: Record = { "gateway.mode": "/web/dashboard", "gateway.bind": "/web/dashboard", "gateway.controlUi.allowInsecureAuth": "/web/control-ui#insecure-http", + "channels.*.allowBots": "/gateway/configuration", + "channels.*.accounts.*.allowBots": "/gateway/configuration", + "channels.*.blockStreaming": "/concepts/streaming", + "channels.*.accounts.*.blockStreaming": "/concepts/streaming", + "channels.*.dm.policy": "/gateway/security", + "channels.*.accounts.*.dm.policy": "/gateway/security", + "channels.*.dmPolicy": "/gateway/security", + "channels.*.accounts.*.dmPolicy": "/gateway/security", + "channels.discord.groupPolicy": "/channels/discord", + "channels.discord.accounts.*.groupPolicy": "/channels/discord", + "channels.slack.groupPolicy": "/channels/slack", + "channels.slack.accounts.*.groupPolicy": "/channels/slack", + "channels.googlechat.groupPolicy": "/channels/googlechat", + "channels.googlechat.accounts.*.groupPolicy": "/channels/googlechat", "channels.discord.actions.roles": "/channels/discord", "channels.discord.actions.moderation": "/channels/discord", "channels.discord.accounts.*.actions.roles": "/channels/discord", @@ -1097,6 +1143,194 @@ const FIELD_IMPACTS: Record = { "Insecure auth allows token auth over plain HTTP. Prefer HTTPS (or loopback) for dashboard access.", }, ], + "channels.*.allowBots": [ + { + relation: "risk", + when: "truthy", + message: + "Allowing bot-authored messages can create bot-to-bot reply loops. Prefer mention/allowlist guardrails.", + docsPath: "/gateway/security", + }, + ], + "channels.*.accounts.*.allowBots": [ + { + relation: "risk", + when: "truthy", + message: + "Allowing bot-authored messages can create bot-to-bot reply loops. Prefer mention/allowlist guardrails.", + docsPath: "/gateway/security", + }, + ], + "channels.*.dm.policy": [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.dm.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + docsPath: "/gateway/security", + }, + ], + "channels.*.accounts.*.dm.policy": [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.accounts.*.dm.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + docsPath: "/gateway/security", + }, + ], + "channels.*.dmPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + docsPath: "/gateway/security", + }, + ], + "channels.*.accounts.*.dmPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.accounts.*.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + docsPath: "/gateway/security", + }, + ], + "channels.discord.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.discord.guilds", + targetWhen: "defined", + message: 'Discord "allowlist" policy needs at least one guild entry.', + docsPath: "/channels/discord", + }, + ], + "channels.discord.accounts.*.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.discord.accounts.*.guilds", + targetWhen: "defined", + message: 'Discord "allowlist" policy needs at least one guild entry for this account.', + docsPath: "/channels/discord", + }, + ], + "channels.slack.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.slack.channels", + targetWhen: "defined", + message: 'Slack "allowlist" policy needs at least one channel entry.', + docsPath: "/channels/slack", + }, + ], + "channels.slack.accounts.*.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.slack.accounts.*.channels", + targetWhen: "defined", + message: 'Slack "allowlist" policy needs at least one channel entry for this account.', + docsPath: "/channels/slack", + }, + ], + "channels.googlechat.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.googlechat.groups", + targetWhen: "defined", + message: 'Google Chat "allowlist" policy needs at least one space entry.', + docsPath: "/channels/googlechat", + }, + ], + "channels.googlechat.accounts.*.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.googlechat.accounts.*.groups", + targetWhen: "defined", + message: 'Google Chat "allowlist" policy needs at least one space entry for this account.', + docsPath: "/channels/googlechat", + }, + ], + "channels.telegram.streamMode": [ + { + relation: "conflicts", + when: "notEquals", + whenValue: "off", + targetPath: "channels.telegram.blockStreaming", + targetWhen: "truthy", + message: + "Telegram draft streaming can suppress block streaming for a reply. Set streamMode=off for block-only behavior.", + docsPath: "/concepts/streaming", + }, + ], + "channels.telegram.accounts.*.streamMode": [ + { + relation: "conflicts", + when: "notEquals", + whenValue: "off", + targetPath: "channels.telegram.accounts.*.blockStreaming", + targetWhen: "truthy", + message: + "Telegram draft streaming can suppress block streaming for a reply. Set streamMode=off for block-only behavior.", + docsPath: "/concepts/streaming", + }, + ], + "channels.telegram.blockStreaming": [ + { + relation: "conflicts", + when: "truthy", + targetPath: "channels.telegram.streamMode", + targetWhen: "notEquals", + targetValue: "off", + message: + "Telegram streamMode is enabled. Use streamMode=off if you want block streaming to drive replies.", + docsPath: "/concepts/streaming", + }, + ], + "channels.telegram.accounts.*.blockStreaming": [ + { + relation: "conflicts", + when: "truthy", + targetPath: "channels.telegram.accounts.*.streamMode", + targetWhen: "notEquals", + targetValue: "off", + message: + "Telegram streamMode is enabled for this account. Use streamMode=off for block-only behavior.", + docsPath: "/concepts/streaming", + }, + ], "channels.discord.actions.roles": [ { relation: "requires", diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 108df24dfbcb..58787ba69641 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -1275,6 +1275,18 @@ height: 100%; } +.cfg-array > .cfg-field__assist { + padding: 10px 18px; + border-bottom: 1px solid var(--border); +} + +.cfg-array__meta { + padding: 10px 18px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + .cfg-array__help { padding: 12px 18px; font-size: 12px; @@ -1411,6 +1423,18 @@ height: 100%; } +.cfg-map > .cfg-field__assist { + padding: 10px 18px; + border-bottom: 1px solid var(--border); +} + +.cfg-map__meta { + padding: 10px 18px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + .cfg-map__empty { padding: 28px 18px; text-align: center; diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index d250454bc162..6e165ca3e7d7 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -158,6 +158,121 @@ describe("config form renderer", () => { expect(onPatch).toHaveBeenCalledWith(["slack"], {}); }); + it("shows contextual map-key guidance for discord guild maps", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + channels: { + type: "object", + properties: { + discord: { + type: "object", + properties: { + guilds: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { channels: { discord: { guilds: { "123456789012345678": "on" } } } }, + onPatch, + }), + container, + ); + + const addButton = Array.from( + container.querySelectorAll(".cfg-map__add"), + ).find((button) => button.textContent?.includes("Add Guild")); + expect(addButton).toBeDefined(); + expect(container.textContent).toContain("Use Discord guild IDs"); + expect( + container + .querySelector(".cfg-map__item-key input") + ?.getAttribute("placeholder"), + ).toBe("123456789012345678"); + }); + + it("uses singular add labels for arrays with named hints", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + owners: { + type: "array", + items: { type: "string" }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { + owners: { label: "Owners" }, + }, + unsupportedPaths: analysis.unsupportedPaths, + value: { owners: [] }, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain("Add Owner"); + expect(container.textContent).toContain("Each owner expects text."); + }); + + it("applies fallback hints when backend uiHints are missing", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + channels: { + type: "object", + properties: { + discord: { + type: "object", + properties: { + allowBots: { type: "boolean" }, + }, + }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { channels: { discord: { allowBots: true } } }, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain("Allow replies to bot-authored messages"); + expect(container.textContent).toContain("bot-to-bot reply loops"); + const docsLink = container.querySelector("a.cfg-field__docs"); + expect(docsLink?.getAttribute("href")).toBe("https://docs.openclaw.ai/gateway/configuration"); + }); + it("supports wildcard uiHints for map entries", () => { const onPatch = vi.fn(); const container = document.createElement("div"); diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 11332d4795dc..155a8f8eacc9 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -79,6 +79,10 @@ export function renderChannels(props: ChannelsProps) {
${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}
+
+ If setup is unclear or replies are missing, run openclaw doctor and + openclaw channels status --probe to get targeted fixes. +
${ props.lastError ? html`
diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index e5ba582a9190..06b507326dc1 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -97,6 +97,121 @@ const icons = { `, }; +type MapFieldGuidance = { + addLabel: string; + keyPlaceholder: string; + keyHelp?: string; +}; + +function singularizeLabel(label: string): string { + const trimmed = label.trim(); + if (!trimmed) { + return "item"; + } + if (trimmed.endsWith("ies") && trimmed.length > 3) { + return `${trimmed.slice(0, -3)}y`; + } + if (trimmed.endsWith("s") && trimmed.length > 1) { + return trimmed.slice(0, -1); + } + return trimmed; +} + +function inferArrayItemTypeLabel(schema: JsonSchema): string { + const type = schemaType(schema); + if (type === "string") { + return "text"; + } + if (type === "number" || type === "integer") { + return "number"; + } + if (type === "boolean") { + return "true/false"; + } + if (type === "object") { + return "object"; + } + if (type === "array") { + return "list"; + } + if (schema.enum?.length) { + return "one of the listed options"; + } + return "value"; +} + +function inferMapFieldGuidance( + path: Array, + fallbackLabel: string, +): MapFieldGuidance { + const segments = path.filter((segment): segment is string => typeof segment === "string"); + const leaf = segments[segments.length - 1]?.toLowerCase() ?? ""; + const secondLast = segments[segments.length - 2]?.toLowerCase() ?? ""; + const channelId = segments[1]?.toLowerCase(); + + if (leaf === "accounts") { + return { + addLabel: "Add Account", + keyPlaceholder: "main", + keyHelp: "Use a stable account ID key (for example: main, work, or backup).", + }; + } + if (leaf === "guilds") { + return { + addLabel: "Add Guild", + keyPlaceholder: "123456789012345678", + keyHelp: "Use Discord guild IDs (or configured slugs).", + }; + } + if (leaf === "channels" && (channelId === "discord" || secondLast === "guilds")) { + return { + addLabel: "Add Channel", + keyPlaceholder: "123456789012345678", + keyHelp: "Use Discord channel IDs.", + }; + } + if (leaf === "channels" && channelId === "slack") { + return { + addLabel: "Add Channel", + keyPlaceholder: "C1234567890", + keyHelp: "Use Slack channel IDs (or canonical channel keys from your config).", + }; + } + if (leaf === "groups" && channelId === "googlechat") { + return { + addLabel: "Add Space", + keyPlaceholder: "spaces/AAAA1234", + keyHelp: "Use Google Chat space IDs.", + }; + } + if (leaf === "groups") { + return { + addLabel: "Add Group", + keyPlaceholder: "group-id", + keyHelp: "Use a group/chat ID key recognized by this channel.", + }; + } + if (leaf === "dms") { + return { + addLabel: "Add DM", + keyPlaceholder: "user-id", + keyHelp: "Use the sender/user ID for per-DM overrides.", + }; + } + if (leaf === "entries") { + return { + addLabel: "Add Entry", + keyPlaceholder: "plugin-id", + keyHelp: "Use a unique entry key (for example a plugin or provider ID).", + }; + } + const singular = singularizeLabel(fallbackLabel); + return { + addLabel: `Add ${humanize(singular)}`, + keyPlaceholder: "custom-key", + }; +} + function impactToneClass(relation: "requires" | "conflicts" | "recommends" | "risk"): string { if (relation === "requires" || relation === "conflicts") { return "danger"; @@ -825,6 +940,9 @@ function renderArray(params: { } const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : []; + const singularLabel = singularizeLabel(label); + const addLabel = `Add ${humanize(singularLabel)}`; + const itemTypeLabel = inferArrayItemTypeLabel(itemsSchema); return html`
@@ -841,15 +959,16 @@ function renderArray(params: { }} > ${icons.plus} - Add + ${addLabel}
${assist} +
Each ${singularLabel.toLowerCase()} expects ${itemTypeLabel}.
${ arr.length === 0 ? html` -
No items yet. Click "Add" to create one.
+
No ${label.toLowerCase()} configured yet.
` : html`
@@ -857,7 +976,7 @@ function renderArray(params: { (item, idx) => html`
- #${idx + 1} + #${idx + 1} ${singularLabel.toLowerCase()}
+ ${assist} + ${ + guidance.keyHelp + ? html`
${guidance.keyHelp}
` + : html`
Value type: ${mapValueType}.
` + } ${ entries.length === 0 ? html` -
No custom entries.
+
No ${label.toLowerCase()} configured yet.
` : html`
@@ -952,7 +1083,7 @@ function renderMapField(params: { { diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts index 6693ca9f45da..9771e0428afc 100644 --- a/ui/src/ui/views/config-form.shared.ts +++ b/ui/src/ui/views/config-form.shared.ts @@ -1,4 +1,4 @@ -import type { ConfigUiHintImpact, ConfigUiHints } from "../types.ts"; +import type { ConfigUiHint, ConfigUiHintImpact, ConfigUiHints } from "../types.ts"; export type JsonSchema = { type?: string | string[]; @@ -56,7 +56,122 @@ export function pathKey(path: Array): string { return path.filter((segment) => typeof segment === "string").join("."); } -export function hintForPath(path: Array, hints: ConfigUiHints) { +const FALLBACK_UI_HINTS: ConfigUiHints = { + "channels.*.allowBots": { + help: "Allow replies to bot-authored messages (default: off). Keep mention/allowlist guardrails to avoid bot loops.", + docsPath: "/gateway/configuration", + impacts: [ + { + relation: "risk", + when: "truthy", + message: + "Allowing bot-authored messages can create bot-to-bot reply loops. Keep mention/allowlist guardrails in place.", + docsPath: "/gateway/security", + }, + ], + }, + "channels.*.accounts.*.allowBots": { + help: "Allow replies to bot-authored messages for this account (default: off).", + docsPath: "/gateway/configuration", + impacts: [ + { + relation: "risk", + when: "truthy", + message: + "Allowing bot-authored messages can create bot-to-bot reply loops. Keep mention/allowlist guardrails in place.", + docsPath: "/gateway/security", + }, + ], + }, + "channels.*.blockStreaming": { + help: "Emit completed reply chunks while generating, instead of waiting for one final message.", + docsPath: "/concepts/streaming", + }, + "channels.*.accounts.*.blockStreaming": { + help: "Emit completed reply chunks while generating for this account.", + docsPath: "/concepts/streaming", + }, + "channels.*.capabilities": { + help: "Optional runtime capability tags. Leave empty unless channel docs explicitly require a tag.", + docsPath: "/gateway/configuration", + }, + "channels.*.accounts.*.capabilities": { + help: "Optional runtime capability tags for this account. Leave empty unless docs require one.", + docsPath: "/gateway/configuration", + }, + "channels.*.dm.policy": { + help: 'DM access policy. If set to "open", allowFrom should include "*".', + docsPath: "/gateway/security", + impacts: [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.dm.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + }, + ], + }, + "channels.*.accounts.*.dm.policy": { + help: 'DM access policy for this account. If set to "open", allowFrom should include "*".', + docsPath: "/gateway/security", + impacts: [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.accounts.*.dm.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + }, + ], + }, + "channels.*.dmPolicy": { + help: 'DM access policy. If set to "open", allowFrom should include "*".', + docsPath: "/gateway/security", + }, + "channels.*.accounts.*.dmPolicy": { + help: 'DM access policy for this account. If set to "open", allowFrom should include "*".', + docsPath: "/gateway/security", + }, + "channels.telegram.streamMode": { + docsPath: "/concepts/streaming", + impacts: [ + { + relation: "conflicts", + when: "notEquals", + whenValue: "off", + targetPath: "channels.telegram.blockStreaming", + targetWhen: "truthy", + message: + "Telegram draft streaming can suppress block streaming for a reply. Use streamMode=off for block-only behavior.", + }, + ], + }, + "channels.telegram.accounts.*.streamMode": { + docsPath: "/concepts/streaming", + impacts: [ + { + relation: "conflicts", + when: "notEquals", + whenValue: "off", + targetPath: "channels.telegram.accounts.*.blockStreaming", + targetWhen: "truthy", + message: + "Telegram draft streaming can suppress block streaming for a reply. Use streamMode=off for block-only behavior.", + }, + ], + }, +}; + +function resolveHint(path: Array, hints: ConfigUiHints): ConfigUiHint | undefined { const key = pathKey(path); const direct = hints[key]; if (direct) { @@ -85,6 +200,22 @@ export function hintForPath(path: Array, hints: ConfigUiHints) return undefined; } +export function hintForPath(path: Array, hints: ConfigUiHints) { + const fallback = resolveHint(path, FALLBACK_UI_HINTS); + const resolved = resolveHint(path, hints); + if (!fallback) { + return resolved; + } + if (!resolved) { + return fallback; + } + return { + ...fallback, + ...resolved, + impacts: [...(fallback.impacts ?? []), ...(resolved.impacts ?? [])], + }; +} + export function humanize(raw: string) { return raw .replace(/_/g, " ") From 69eed326df40d7b764fbdd2edd3a40b860c5b514 Mon Sep 17 00:00:00 2001 From: Jeremy Johnson Date: Fri, 6 Feb 2026 23:05:08 -0600 Subject: [PATCH 7/8] CI: skip upstream-only checks on forks --- .github/workflows/ci.yml | 2 +- .github/workflows/labeler.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 974670957b57..36d84299f1ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,7 +342,7 @@ jobs: run: ${{ matrix.command }} macos-app: - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.repository_owner == 'openclaw' runs-on: macos-latest strategy: fail-fast: false diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index f403c1030c02..1368bbaf45c5 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -10,6 +10,7 @@ permissions: {} jobs: label: + if: github.repository_owner == 'openclaw' && secrets.GH_APP_PRIVATE_KEY != '' permissions: contents: read pull-requests: write @@ -48,6 +49,7 @@ jobs: }); label-issues: + if: github.repository_owner == 'openclaw' && secrets.GH_APP_PRIVATE_KEY != '' permissions: issues: write runs-on: ubuntu-latest From 9506db5927a5db82617b0bc668b340766716cff5 Mon Sep 17 00:00:00 2001 From: Lucious Fox Date: Wed, 25 Feb 2026 11:51:55 -0600 Subject: [PATCH 8/8] fix: lazy-load LanceDB to avoid missing native module --- extensions/memory-lancedb/index.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index 5e4def80fa2e..47ecbb1cd563 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -6,8 +6,8 @@ * Provides seamless auto-recall and auto-capture via lifecycle hooks. */ +import type * as LanceDB from "@lancedb/lancedb"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import * as lancedb from "@lancedb/lancedb"; import { Type } from "@sinclair/typebox"; import { randomUUID } from "node:crypto"; import OpenAI from "openai"; @@ -44,8 +44,9 @@ type MemorySearchResult = { const TABLE_NAME = "memories"; class MemoryDB { - private db: lancedb.Connection | null = null; - private table: lancedb.Table | null = null; + private lancedb: typeof import("@lancedb/lancedb") | null = null; + private db: LanceDB.Connection | null = null; + private table: LanceDB.Table | null = null; private initPromise: Promise | null = null; constructor( @@ -65,7 +66,21 @@ class MemoryDB { return this.initPromise; } + private async getLanceDB(): Promise { + if (this.lancedb) return this.lancedb; + try { + this.lancedb = await import("@lancedb/lancedb"); + return this.lancedb; + } catch (err) { + // LanceDB currently ships platform-specific native deps. Fail gracefully on unsupported platforms. + throw new Error( + `memory-lancedb: LanceDB native module unavailable on ${process.platform}/${process.arch}: ${String(err)}`, + ); + } + } + private async doInitialize(): Promise { + const lancedb = await this.getLanceDB(); this.db = await lancedb.connect(this.dbPath); const tables = await this.db.tableNames();