From 33f56ecf156e696cdccb5b8073315ff4458ddcb6 Mon Sep 17 00:00:00 2001 From: Marc Terns Date: Wed, 7 Jan 2026 21:21:32 -0600 Subject: [PATCH 01/37] UI: improve skill install feedback --- ui/src/ui/app-render.ts | 3 +++ ui/src/ui/app.ts | 2 ++ ui/src/ui/controllers/skills.ts | 45 ++++++++++++++++++++++++++++++--- ui/src/ui/views/skills.ts | 22 ++++++++++++++-- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 9cc9e5b17..eadc0cc52 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -57,6 +57,7 @@ import { saveSkillApiKey, updateSkillEdit, updateSkillEnabled, + type SkillMessage, } from "./controllers/skills"; import { loadNodes } from "./controllers/nodes"; import { loadChatHistory } from "./controllers/chat"; @@ -162,6 +163,7 @@ export type AppViewState = { skillsError: string | null; skillsFilter: string; skillEdits: Record; + skillMessages: Record; skillsBusyKey: string | null; debugLoading: boolean; debugStatus: StatusSummary | null; @@ -377,6 +379,7 @@ export function renderApp(state: AppViewState) { error: state.skillsError, filter: state.skillsFilter, edits: state.skillEdits, + messages: state.skillMessages, busyKey: state.skillsBusyKey, onFilterChange: (next) => (state.skillsFilter = next), onRefresh: () => loadSkills(state), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 702cf69ee..1a13da3a4 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -75,6 +75,7 @@ import { } from "./controllers/cron"; import { loadSkills, + type SkillMessage, } from "./controllers/skills"; import { loadDebug } from "./controllers/debug"; @@ -332,6 +333,7 @@ export class ClawdbotApp extends LitElement { @state() skillsFilter = ""; @state() skillEdits: Record = {}; @state() skillsBusyKey: string | null = null; + @state() skillMessages: Record = {}; @state() debugLoading = false; @state() debugStatus: StatusSummary | null = null; diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts index fe3329956..688c54fb4 100644 --- a/ui/src/ui/controllers/skills.ts +++ b/ui/src/ui/controllers/skills.ts @@ -9,8 +9,24 @@ export type SkillsState = { skillsError: string | null; skillsBusyKey: string | null; skillEdits: Record; + skillMessages: SkillMessageMap; }; +export type SkillMessage = { + kind: "success" | "error"; + message: string; +}; + +export type SkillMessageMap = Record; + +function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) { + if (!key.trim()) return; + const next = { ...state.skillMessages }; + if (message) next[key] = message; + else delete next[key]; + state.skillMessages = next; +} + export async function loadSkills(state: SkillsState) { if (!state.client || !state.connected) return; if (state.skillsLoading) return; @@ -47,8 +63,16 @@ export async function updateSkillEnabled( try { await state.client.request("skills.update", { skillKey, enabled }); await loadSkills(state); + setSkillMessage(state, skillKey, { + kind: "success", + message: enabled ? "Skill enabled" : "Skill disabled", + }); } catch (err) { state.skillsError = String(err); + setSkillMessage(state, skillKey, { + kind: "error", + message: String(err), + }); } finally { state.skillsBusyKey = null; } @@ -62,8 +86,16 @@ export async function saveSkillApiKey(state: SkillsState, skillKey: string) { const apiKey = state.skillEdits[skillKey] ?? ""; await state.client.request("skills.update", { skillKey, apiKey }); await loadSkills(state); + setSkillMessage(state, skillKey, { + kind: "success", + message: "API key saved", + }); } catch (err) { state.skillsError = String(err); + setSkillMessage(state, skillKey, { + kind: "error", + message: String(err), + }); } finally { state.skillsBusyKey = null; } @@ -78,16 +110,23 @@ export async function installSkill( state.skillsBusyKey = name; state.skillsError = null; try { - await state.client.request("skills.install", { + const result = (await state.client.request("skills.install", { name, installId, timeoutMs: 120000, - }); + })) as { ok?: boolean; message?: string }; await loadSkills(state); + setSkillMessage(state, name, { + kind: "success", + message: result?.message ?? "Installed", + }); } catch (err) { state.skillsError = String(err); + setSkillMessage(state, name, { + kind: "error", + message: String(err), + }); } finally { state.skillsBusyKey = null; } } - diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 50861fee1..f10aae6dc 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { clampText } from "../format"; import type { SkillStatusEntry, SkillStatusReport } from "../types"; +import type { SkillMessageMap } from "../controllers/skills"; export type SkillsProps = { loading: boolean; @@ -10,6 +11,7 @@ export type SkillsProps = { filter: string; edits: Record; busyKey: string | null; + messages: SkillMessageMap; onFilterChange: (next: string) => void; onRefresh: () => void; onToggle: (skillKey: string, enabled: boolean) => void; @@ -73,6 +75,10 @@ export function renderSkills(props: SkillsProps) { function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name; const apiKey = props.edits[skill.skillKey] ?? ""; + const message = + props.messages[skill.skillKey] ?? props.messages[skill.name] ?? null; + const canInstall = + skill.install.length > 0 && skill.missing.bins.length > 0; const missing = [ ...skill.missing.bins.map((b) => `bin:${b}`), ...skill.missing.env.map((e) => `env:${e}`), @@ -120,16 +126,28 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { > ${skill.disabled ? "Enable" : "Disable"} - ${skill.install.length > 0 + ${canInstall ? html`` : nothing} + ${message + ? html`
+ ${message.message} +
` + : nothing} ${skill.primaryEnv ? html`
From c0ae11f5d841fcf9b1d45e3a3385301c5e28e561 Mon Sep 17 00:00:00 2001 From: gupsammy Date: Thu, 8 Jan 2026 15:51:07 +0530 Subject: [PATCH 02/37] fix(macos): prevent crash from missing ClawdbotKit resources and Swift library The macOS app was crashing in two scenarios: 1. Bundle.module crash (fixes #213): When the first tool event arrived, ToolDisplayRegistry tried to load config via ClawdbotKitResources.bundle, which used Bundle.module directly. In packaged apps, Bundle.module couldn't find the resource bundle at the expected path, causing a fatal assertion failure after ~40-80 minutes of runtime. 2. dyld crash (fixes #417): Swift 6.2 requires libswiftCompatibilitySpan.dylib but SwiftPM doesn't bundle it automatically, causing immediate crash on launch with "Library not loaded" error. Changes: - ClawdbotKitResources.swift: Replace direct Bundle.module access with a safe locator that checks multiple paths and falls back gracefully - package-mac-app.sh: Copy ClawdbotKit_ClawdbotKit.bundle to Resources - package-mac-app.sh: Copy libswiftCompatibilitySpan.dylib from Xcode toolchain to Frameworks Tested on macOS 26.2 with Swift 6.2 - app launches and runs without crashes. Co-Authored-By: Claude Opus 4.5 --- .../ClawdbotKit/ClawdbotKitResources.swift | 72 ++++++++++++++++++- scripts/package-mac-app.sh | 18 +++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift index 8e5ee5f63..d990e2cb2 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift @@ -1,5 +1,75 @@ import Foundation public enum ClawdbotKitResources { - public static let bundle: Bundle = .module + /// Resource bundle for ClawdbotKit. + /// + /// Locates the SwiftPM-generated resource bundle, checking multiple locations: + /// 1. Inside Bundle.main (packaged apps) + /// 2. Bundle.module (SwiftPM development/tests) + /// 3. Falls back to Bundle.main if not found (resource lookups will return nil) + /// + /// This avoids a fatal crash when Bundle.module can't locate its resources + /// in packaged .app bundles where the resource bundle path differs from + /// SwiftPM's expectations. + public static let bundle: Bundle = locateBundle() + + private static let bundleName = "ClawdbotKit_ClawdbotKit" + + private static func locateBundle() -> Bundle { + // 1. Check inside Bundle.main (packaged apps copy resources here) + if let mainResourceURL = Bundle.main.resourceURL { + let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: bundleURL) { + return bundle + } + } + + // 2. Check Bundle.main directly for embedded resources + if Bundle.main.url(forResource: "tool-display", withExtension: "json") != nil { + return Bundle.main + } + + // 3. Try Bundle.module (works in SwiftPM development/tests) + // Wrap in a function to defer the fatalError until actually called + if let moduleBundle = loadModuleBundleSafely() { + return moduleBundle + } + + // 4. Fallback: return Bundle.main (resource lookups will return nil gracefully) + return Bundle.main + } + + private static func loadModuleBundleSafely() -> Bundle? { + // Bundle.module is generated by SwiftPM and will fatalError if not found. + // We check likely locations manually to avoid the crash. + let candidates: [URL?] = [ + Bundle.main.resourceURL, + Bundle.main.bundleURL, + Bundle(for: BundleLocator.self).resourceURL, + Bundle(for: BundleLocator.self).bundleURL, + ] + + for candidate in candidates { + guard let baseURL = candidate else { continue } + + // Direct path + let directURL = baseURL.appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: directURL) { + return bundle + } + + // Inside Resources/ + let resourcesURL = baseURL + .appendingPathComponent("Resources") + .appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: resourcesURL) { + return bundle + } + } + + return nil + } } + +// Helper class for bundle lookup via Bundle(for:) +private final class BundleLocator {} diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index aed72a4f1..b5467f60b 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -221,6 +221,15 @@ if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework" fi +echo "📦 Copying Swift 6.2 compatibility libraries" +SWIFT_COMPAT_LIB="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-6.2/macosx/libswiftCompatibilitySpan.dylib" +if [ -f "$SWIFT_COMPAT_LIB" ]; then + cp "$SWIFT_COMPAT_LIB" "$APP_ROOT/Contents/Frameworks/" + chmod +x "$APP_ROOT/Contents/Frameworks/libswiftCompatibilitySpan.dylib" +else + echo "WARN: Swift compatibility library not found at $SWIFT_COMPAT_LIB (continuing)" >&2 +fi + echo "🖼 Copying app icon" cp "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/Clawdbot.icns" "$APP_ROOT/Contents/Resources/Clawdbot.icns" @@ -228,6 +237,15 @@ echo "📦 Copying device model resources" rm -rf "$APP_ROOT/Contents/Resources/DeviceModels" cp -R "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels" +echo "📦 Copying ClawdbotKit resources" +CLAWDBOTKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/ClawdbotKit_ClawdbotKit.bundle" +if [ -d "$CLAWDBOTKIT_BUNDLE" ]; then + rm -rf "$APP_ROOT/Contents/Resources/ClawdbotKit_ClawdbotKit.bundle" + cp -R "$CLAWDBOTKIT_BUNDLE" "$APP_ROOT/Contents/Resources/ClawdbotKit_ClawdbotKit.bundle" +else + echo "WARN: ClawdbotKit resource bundle not found at $CLAWDBOTKIT_BUNDLE (continuing)" >&2 +fi + RELAY_DIR="$APP_ROOT/Contents/Resources/Relay" if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then From bf061b3c0be8cc17e29d36dfbc421fef239a1cb2 Mon Sep 17 00:00:00 2001 From: Azade Date: Thu, 8 Jan 2026 11:20:05 +0000 Subject: [PATCH 03/37] fix(daemon): align systemd unit with documentation Align generated systemd service file with docs: https://docs.clawd.bot/gateway#supervision-systemd-user-unit Adds: - After=network-online.target - Wants=network-online.target - RestartSec=5 --- src/daemon/systemd.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index c0a8b495b..2b659ec77 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -141,10 +141,13 @@ function buildSystemdUnit({ return [ "[Unit]", "Description=Clawdbot Gateway", + "After=network-online.target", + "Wants=network-online.target", "", "[Service]", `ExecStart=${execStart}`, "Restart=always", + "RestartSec=5", workingDirLine, ...envLines, "", From ed93faba37a9838acf9892ddaba9ac7272388ee1 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Thu, 8 Jan 2026 16:52:50 +0100 Subject: [PATCH 04/37] Chunking: avoid splits inside parentheses --- src/auto-reply/chunk.test.ts | 25 ++++++++++++++++ src/auto-reply/chunk.ts | 57 ++++++++++++++++++++++++------------ 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index a6218fbfa..5d04e6aa2 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -170,4 +170,29 @@ describe("chunkMarkdownText", () => { expect(nonFenceLines.join("\n").trim()).not.toBe(""); } }); + + it("keeps parenthetical phrases together", () => { + const text = "Heads up now (Though now I'm curious)ok"; + const chunks = chunkMarkdownText(text, 35); + expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]); + }); + + it("handles nested parentheses", () => { + const text = "Hello (outer (inner) end) world"; + const chunks = chunkMarkdownText(text, 26); + expect(chunks).toEqual(["Hello (outer (inner) end)", "world"]); + }); + + it("hard-breaks when a parenthetical exceeds the limit", () => { + const text = `(${"a".repeat(80)})`; + const chunks = chunkMarkdownText(text, 20); + expect(chunks[0]?.length).toBe(20); + expect(chunks.join("")).toBe(text); + }); + + it("ignores unmatched closing parentheses", () => { + const text = "Hello) world (ok)"; + const chunks = chunkMarkdownText(text, 12); + expect(chunks).toEqual(["Hello)", "world (ok)"]); + }); }); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index fb2174d1d..d2f846f09 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -60,18 +60,27 @@ export function chunkText(text: string, limit: number): string[] { while (remaining.length > limit) { const window = remaining.slice(0, limit); - // 1) Prefer a newline break inside the window. - let breakIdx = window.lastIndexOf("\n"); + // 1) Prefer a newline break inside the window (outside parentheses). + let lastNewline = -1; + let lastWhitespace = -1; + let depth = 0; + for (let i = 0; i < window.length; i++) { + const char = window[i]; + if (char === "(") { + depth += 1; + continue; + } + if (char === ")" && depth > 0) { + depth -= 1; + continue; + } + if (depth !== 0) continue; + if (char === "\n") lastNewline = i; + else if (/\s/.test(char)) lastWhitespace = i; + } // 2) Otherwise prefer the last whitespace (word boundary) inside the window. - if (breakIdx <= 0) { - for (let i = window.length - 1; i >= 0; i--) { - if (/\s/.test(window[i])) { - breakIdx = i; - break; - } - } - } + let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace; // 3) Fallback: hard break exactly at the limit. if (breakIdx <= 0) breakIdx = limit; @@ -204,15 +213,27 @@ function pickSafeBreakIndex( window: string, spans: ReturnType, ): number { - let newlineIdx = window.lastIndexOf("\n"); - while (newlineIdx > 0) { - if (isSafeFenceBreak(spans, newlineIdx)) return newlineIdx; - newlineIdx = window.lastIndexOf("\n", newlineIdx - 1); - } - - for (let i = window.length - 1; i > 0; i--) { - if (/\s/.test(window[i]) && isSafeFenceBreak(spans, i)) return i; + let lastNewline = -1; + let lastWhitespace = -1; + let depth = 0; + + for (let i = 0; i < window.length; i++) { + if (!isSafeFenceBreak(spans, i)) continue; + const char = window[i]; + if (char === "(") { + depth += 1; + continue; + } + if (char === ")" && depth > 0) { + depth -= 1; + continue; + } + if (depth !== 0) continue; + if (char === "\n") lastNewline = i; + else if (/\s/.test(char)) lastWhitespace = i; } + if (lastNewline > 0) return lastNewline; + if (lastWhitespace > 0) return lastWhitespace; return -1; } From b5adb7943861090cc020d9fe4ab0d7092e749ddf Mon Sep 17 00:00:00 2001 From: Gregor's Bot Date: Thu, 8 Jan 2026 18:24:00 +0100 Subject: [PATCH 05/37] fix: rename ClaudeBot to Clawdbot in system prompt (#502) --- src/agents/system-prompt.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 64d876b10..f393188d9 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -44,7 +44,7 @@ export function buildAgentSystemPrompt(params: { nodes: "List/describe/notify/camera/screen on paired nodes", cron: "Manage cron jobs and wake events", gateway: - "Restart, apply config, or run updates on the running ClaudeBot process", + "Restart, apply config, or run updates on the running Clawdbot process", agents_list: "List agent ids allowed for sessions_spawn", sessions_list: "List other sessions (incl. sub-agents) with filters/last", sessions_history: "Fetch history for another session/sub-agent", @@ -129,7 +129,7 @@ export function buildAgentSystemPrompt(params: { const runtimeInfo = params.runtimeInfo; const lines = [ - "You are a personal assistant running inside ClaudeBot.", + "You are a personal assistant running inside Clawdbot.", "", "## Tooling", "Tool availability (filtered by policy):", @@ -157,13 +157,13 @@ export function buildAgentSystemPrompt(params: { "## Skills", `Skills provide task-specific instructions. Use \`read\` to load from ${params.workspaceDir}/skills//SKILL.md when needed.`, "", - hasGateway ? "## ClaudeBot Self-Update" : "", + hasGateway ? "## Clawdbot Self-Update" : "", hasGateway ? [ "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", "Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).", - "After restart, ClaudeBot pings the last active session automatically.", + "After restart, Clawdbot pings the last active session automatically.", ].join("\n") : "", hasGateway ? "" : "", @@ -212,7 +212,7 @@ export function buildAgentSystemPrompt(params: { ownerLine ?? "", ownerLine ? "" : "", "## Workspace Files (injected)", - "These user-editable files are loaded by ClaudeBot and included below in Project Context.", + "These user-editable files are loaded by Clawdbot and included below in Project Context.", "", userTimezone || userTime ? `Time: assume UTC unless stated. User TZ=${userTimezone ?? "unknown"}. Current user time (converted)=${userTime ?? "unknown"}.` @@ -251,7 +251,7 @@ export function buildAgentSystemPrompt(params: { heartbeatPromptLine, "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:", "HEARTBEAT_OK", - 'ClaudeBot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).', + 'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', "", "## Runtime", From 898136e47244ae92a69e1d0298c1c6ea8d42d372 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Thu, 8 Jan 2026 19:30:24 +0100 Subject: [PATCH 06/37] =?UTF-8?q?=F0=9F=A4=96=20codex:=20fix=20block=20rep?= =?UTF-8?q?ly=20ordering=20(#503)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: serialize block reply sends, make typing non-blocking, add timeout fallback + abort-aware routing, and add regression tests. Why: prevent out-of-order streamed blocks while keeping final fallback on timeouts. Tests: ./node_modules/.bin/vitest run src/auto-reply/reply.block-streaming.test.ts src/auto-reply/reply/route-reply.test.ts Tests: corepack pnpm lint && corepack pnpm build (pass). corepack pnpm test (ran locally; failure observed during run). Co-authored-by: Josh Palmer --- CHANGELOG.md | 1 + src/auto-reply/reply.block-streaming.test.ts | 110 +++++++++++++++++++ src/auto-reply/reply/agent-runner.ts | 73 ++++++++++-- src/auto-reply/reply/dispatch-from-config.ts | 13 ++- src/auto-reply/reply/route-reply.test.ts | 16 +++ src/auto-reply/reply/route-reply.ts | 13 ++- src/auto-reply/types.ts | 12 +- 7 files changed, 224 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e887663b0..d7af201c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). - Control UI: logs tab opens at the newest entries (bottom). diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index caeedc120..15dca24e1 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -103,6 +103,61 @@ describe("block streaming", () => { }); }); + it("preserves block reply ordering when typing start is slow", async () => { + await withTempHome(async (home) => { + let releaseTyping: (() => void) | undefined; + const typingGate = new Promise((resolve) => { + releaseTyping = resolve; + }); + const onReplyStart = vi.fn(() => typingGate); + const seen: string[] = []; + const onBlockReply = vi.fn(async (payload) => { + seen.push(payload.text ?? ""); + }); + + vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => { + void params.onBlockReply?.({ text: "first" }); + void params.onBlockReply?.({ text: "second" }); + return { + payloads: [{ text: "first" }, { text: "second" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }; + }); + + const replyPromise = getReplyFromConfig( + { + Body: "ping", + From: "+1004", + To: "+2000", + MessageSid: "msg-125", + Provider: "telegram", + }, + { + onReplyStart, + onBlockReply, + }, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + telegram: { allowFrom: ["*"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + await waitForCalls(() => onReplyStart.mock.calls.length, 1); + releaseTyping?.(); + + const res = await replyPromise; + expect(res).toBeUndefined(); + expect(seen).toEqual(["first", "second"]); + }); + }); + it("drops final payloads when block replies streamed", async () => { await withTempHome(async (home) => { const onBlockReply = vi.fn().mockResolvedValue(undefined); @@ -143,4 +198,59 @@ describe("block streaming", () => { expect(onBlockReply).toHaveBeenCalledTimes(1); }); }); + + it("falls back to final payloads when block reply send times out", async () => { + await withTempHome(async (home) => { + let sawAbort = false; + const onBlockReply = vi.fn((_, context) => { + return new Promise((resolve) => { + context?.abortSignal?.addEventListener( + "abort", + () => { + sawAbort = true; + resolve(); + }, + { once: true }, + ); + }); + }); + + vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => { + void params.onBlockReply?.({ text: "streamed" }); + return { + payloads: [{ text: "final" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }; + }); + + const replyPromise = getReplyFromConfig( + { + Body: "ping", + From: "+1004", + To: "+2000", + MessageSid: "msg-126", + Provider: "telegram", + }, + { + onBlockReply, + blockReplyTimeoutMs: 10, + }, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + telegram: { allowFrom: ["*"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const res = await replyPromise; + expect(res).toMatchObject({ text: "final" }); + expect(sawAbort).toBe(true); + }); + }); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 76ba0d040..1225491b2 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -47,6 +47,7 @@ import type { TypingController } from "./typing.js"; import { createTypingSignaler } from "./typing-mode.js"; const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; +const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000; const isBunFetchSocketError = (message?: string) => Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message)); @@ -61,6 +62,23 @@ const formatBunFetchSocketError = (message: string) => { ].join("\n"); }; +const withTimeout = async ( + promise: Promise, + timeoutMs: number, + timeoutError: Error, +): Promise => { + if (!timeoutMs || timeoutMs <= 0) return promise; + let timer: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError), timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timer) clearTimeout(timer); + } +}; + export async function runReplyAgent(params: { commandBody: string; followupRun: FollowupRun; @@ -144,7 +162,12 @@ export async function runReplyAgent(params: { const pendingStreamedPayloadKeys = new Set(); const pendingBlockTasks = new Set>(); const pendingToolTasks = new Set>(); + let blockReplyChain: Promise = Promise.resolve(); + let blockReplyAborted = false; + let didLogBlockReplyAbort = false; let didStreamBlockReply = false; + const blockReplyTimeoutMs = + opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS; const buildPayloadKey = (payload: ReplyPayload) => { const text = payload.text?.trim() ?? ""; const mediaList = payload.mediaUrls?.length @@ -367,16 +390,49 @@ export async function runReplyAgent(params: { ) { return; } + if (blockReplyAborted) return; pendingStreamedPayloadKeys.add(payloadKey); - const task = (async () => { - await typingSignals.signalTextDelta(taggedPayload.text); - await opts.onBlockReply?.(blockPayload); - })() - .then(() => { + void typingSignals + .signalTextDelta(taggedPayload.text) + .catch((err) => { + logVerbose( + `block reply typing signal failed: ${String(err)}`, + ); + }); + const timeoutError = new Error( + `block reply delivery timed out after ${blockReplyTimeoutMs}ms`, + ); + const abortController = new AbortController(); + blockReplyChain = blockReplyChain + .then(async () => { + if (blockReplyAborted) return false; + await withTimeout( + opts.onBlockReply?.(blockPayload, { + abortSignal: abortController.signal, + timeoutMs: blockReplyTimeoutMs, + }) ?? Promise.resolve(), + blockReplyTimeoutMs, + timeoutError, + ); + return true; + }) + .then((didSend) => { + if (!didSend) return; streamedPayloadKeys.add(payloadKey); didStreamBlockReply = true; }) .catch((err) => { + if (err === timeoutError) { + abortController.abort(); + blockReplyAborted = true; + if (!didLogBlockReplyAbort) { + didLogBlockReplyAbort = true; + logVerbose( + `block reply delivery timed out after ${blockReplyTimeoutMs}ms; skipping remaining block replies to preserve ordering`, + ); + } + return; + } logVerbose( `block reply delivery failed: ${String(err)}`, ); @@ -384,6 +440,7 @@ export async function runReplyAgent(params: { .finally(() => { pendingStreamedPayloadKeys.delete(payloadKey); }); + const task = blockReplyChain; pendingBlockTasks.add(task); void task.finally(() => pendingBlockTasks.delete(task)); } @@ -546,10 +603,10 @@ export async function runReplyAgent(params: { }) .filter(isRenderablePayload); - // Drop final payloads if block streaming is enabled and we already streamed - // block replies. Tool-sent duplicates are filtered below. + // Drop final payloads only when block streaming succeeded end-to-end. + // If streaming aborted (e.g., timeout), fall back to final payloads. const shouldDropFinalPayloads = - blockStreamingEnabled && didStreamBlockReply; + blockStreamingEnabled && didStreamBlockReply && !blockReplyAborted; const messagingToolSentTexts = runResult.messagingToolSentTexts ?? []; const messagingToolSentTargets = runResult.messagingToolSentTargets ?? []; const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 26961ad7c..1bf1fbfce 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -41,10 +41,14 @@ export async function dispatchReplyFromConfig(params: { * Note: Only called when shouldRouteToOriginating is true, so * originatingChannel and originatingTo are guaranteed to be defined. */ - const sendPayloadAsync = async (payload: ReplyPayload): Promise => { + const sendPayloadAsync = async ( + payload: ReplyPayload, + abortSignal?: AbortSignal, + ): Promise => { // TypeScript doesn't narrow these from the shouldRouteToOriginating check, // but they're guaranteed non-null when this function is called. if (!originatingChannel || !originatingTo) return; + if (abortSignal?.aborted) return; const result = await routeReply({ payload, channel: originatingChannel, @@ -52,6 +56,7 @@ export async function dispatchReplyFromConfig(params: { accountId: ctx.AccountId, threadId: ctx.MessageThreadId, cfg, + abortSignal, }); if (!result.ok) { logVerbose( @@ -73,10 +78,10 @@ export async function dispatchReplyFromConfig(params: { dispatcher.sendToolResult(payload); } }, - onBlockReply: (payload: ReplyPayload) => { + onBlockReply: (payload: ReplyPayload, context) => { if (shouldRouteToOriginating) { - // Fire-and-forget for streaming block replies when routing. - void sendPayloadAsync(payload); + // Await routed sends so upstream can enforce ordering/timeouts. + return sendPayloadAsync(payload, context?.abortSignal); } else { // Synchronous dispatch to preserve callback timing. dispatcher.sendBlockReply(payload); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index cc40383c9..9571e292f 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -31,6 +31,22 @@ vi.mock("../../web/outbound.js", () => ({ const { routeReply } = await import("./route-reply.js"); describe("routeReply", () => { + it("skips sends when abort signal is already aborted", async () => { + mocks.sendMessageSlack.mockClear(); + const controller = new AbortController(); + controller.abort(); + const res = await routeReply({ + payload: { text: "hi" }, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + abortSignal: controller.signal, + }); + expect(res.ok).toBe(false); + expect(res.error).toContain("aborted"); + expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + }); + it("no-ops on empty payload", async () => { mocks.sendMessageSlack.mockClear(); const res = await routeReply({ diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index c02ce1c77..f7529c8cf 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -30,6 +30,8 @@ export type RouteReplyParams = { threadId?: number; /** Config for provider-specific settings. */ cfg: ClawdbotConfig; + /** Optional abort signal for cooperative cancellation. */ + abortSignal?: AbortSignal; }; export type RouteReplyResult = { @@ -52,7 +54,7 @@ export type RouteReplyResult = { export async function routeReply( params: RouteReplyParams, ): Promise { - const { payload, channel, to, accountId, threadId } = params; + const { payload, channel, to, accountId, threadId, abortSignal } = params; // Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts` const text = payload.text ?? ""; @@ -72,6 +74,9 @@ export async function routeReply( text: string; mediaUrl?: string; }): Promise => { + if (abortSignal?.aborted) { + return { ok: false, error: "Reply routing aborted" }; + } const { text, mediaUrl } = params; switch (channel) { case "telegram": { @@ -148,12 +153,18 @@ export async function routeReply( }; try { + if (abortSignal?.aborted) { + return { ok: false, error: "Reply routing aborted" }; + } if (mediaUrls.length === 0) { return await sendOne({ text }); } let last: RouteReplyResult | undefined; for (let i = 0; i < mediaUrls.length; i++) { + if (abortSignal?.aborted) { + return { ok: false, error: "Reply routing aborted" }; + } const mediaUrl = mediaUrls[i]; const caption = i === 0 ? text : ""; last = await sendOne({ text: caption, mediaUrl }); diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 0bd7671d7..7f69aeff9 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -1,14 +1,24 @@ import type { TypingController } from "./reply/typing.js"; +export type BlockReplyContext = { + abortSignal?: AbortSignal; + timeoutMs?: number; +}; + export type GetReplyOptions = { onReplyStart?: () => Promise | void; onTypingController?: (typing: TypingController) => void; isHeartbeat?: boolean; onPartialReply?: (payload: ReplyPayload) => Promise | void; onReasoningStream?: (payload: ReplyPayload) => Promise | void; - onBlockReply?: (payload: ReplyPayload) => Promise | void; + onBlockReply?: ( + payload: ReplyPayload, + context?: BlockReplyContext, + ) => Promise | void; onToolResult?: (payload: ReplyPayload) => Promise | void; disableBlockStreaming?: boolean; + /** Timeout for block reply delivery (ms). */ + blockReplyTimeoutMs?: number; /** If provided, only load these skills for this session (empty = no skills). */ skillFilter?: string[]; }; From 26d00d8fe5941151e72ceaa2b47dd2d05c9aa8cf Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Thu, 8 Jan 2026 19:31:06 +0100 Subject: [PATCH 07/37] fix(telegram): resolve fetch type errors with grammY Bot constructor Add proper type assertion for ApiClientOptions["fetch"] to resolve TypeScript compilation errors when passing fetch implementation to grammY's Bot constructor. This follows the same pattern already used in bot.ts. Fixes #465 --- src/telegram/send.ts | 17 +++++++++-------- src/telegram/webhook-set.ts | 17 +++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/telegram/send.ts b/src/telegram/send.ts index d15fa0616..d0715c22c 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -1,4 +1,5 @@ import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types"; +import type { ApiClientOptions } from "grammy"; import { Bot, InputFile } from "grammy"; import { loadConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -113,10 +114,10 @@ export async function sendMessageTelegram( // Use provided api or create a new Bot instance. The nullish coalescing // operator ensures api is always defined (Bot.api is always non-null). const fetchImpl = resolveTelegramFetch(); - const api = - opts.api ?? - new Bot(token, fetchImpl ? { client: { fetch: fetchImpl } } : undefined) - .api; + const client: ApiClientOptions | undefined = fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : undefined; + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const mediaUrl = opts.mediaUrl?.trim(); // Build optional params for forum topics and reply threading. @@ -271,10 +272,10 @@ export async function reactMessageTelegram( const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); const fetchImpl = resolveTelegramFetch(); - const api = - opts.api ?? - new Bot(token, fetchImpl ? { client: { fetch: fetchImpl } } : undefined) - .api; + const client: ApiClientOptions | undefined = fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : undefined; + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const request = createTelegramRetryRunner({ retry: opts.retry, configRetry: account.config.retry, diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts index fc81c1106..78abb9adb 100644 --- a/src/telegram/webhook-set.ts +++ b/src/telegram/webhook-set.ts @@ -1,3 +1,4 @@ +import type { ApiClientOptions } from "grammy"; import { Bot } from "grammy"; import { resolveTelegramFetch } from "./fetch.js"; @@ -8,10 +9,10 @@ export async function setTelegramWebhook(opts: { dropPendingUpdates?: boolean; }) { const fetchImpl = resolveTelegramFetch(); - const bot = new Bot( - opts.token, - fetchImpl ? { client: { fetch: fetchImpl } } : undefined, - ); + const client: ApiClientOptions | undefined = fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : undefined; + const bot = new Bot(opts.token, client ? { client } : undefined); await bot.api.setWebhook(opts.url, { secret_token: opts.secret, drop_pending_updates: opts.dropPendingUpdates ?? false, @@ -20,9 +21,9 @@ export async function setTelegramWebhook(opts: { export async function deleteTelegramWebhook(opts: { token: string }) { const fetchImpl = resolveTelegramFetch(); - const bot = new Bot( - opts.token, - fetchImpl ? { client: { fetch: fetchImpl } } : undefined, - ); + const client: ApiClientOptions | undefined = fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : undefined; + const bot = new Bot(opts.token, client ? { client } : undefined); await bot.api.deleteWebhook(); } From 18c572f7639da420ad004c017204975fdd053616 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Thu, 8 Jan 2026 19:59:06 +0100 Subject: [PATCH 08/37] =?UTF-8?q?=F0=9F=A4=96=20codex:=20handle=20discord?= =?UTF-8?q?=20gateway=20error=20events=20(#504)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + src/discord/monitor.gateway.test.ts | 49 +++++++++++++++++ src/discord/monitor.ts | 84 ++++++++++++++++++++++++----- 3 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 src/discord/monitor.gateway.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d7af201c1..324bd54e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#504) - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). diff --git a/src/discord/monitor.gateway.test.ts b/src/discord/monitor.gateway.test.ts new file mode 100644 index 000000000..fc7d62784 --- /dev/null +++ b/src/discord/monitor.gateway.test.ts @@ -0,0 +1,49 @@ +import { EventEmitter } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { waitForDiscordGatewayStop } from "./monitor.js"; + +describe("waitForDiscordGatewayStop", () => { + it("resolves on abort and disconnects gateway", async () => { + const emitter = new EventEmitter(); + const disconnect = vi.fn(); + const abort = new AbortController(); + + const promise = waitForDiscordGatewayStop({ + gateway: { emitter, disconnect }, + abortSignal: abort.signal, + }); + + expect(emitter.listenerCount("error")).toBe(1); + abort.abort(); + + await expect(promise).resolves.toBeUndefined(); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(emitter.listenerCount("error")).toBe(0); + }); + + it("rejects on gateway error and disconnects", async () => { + const emitter = new EventEmitter(); + const disconnect = vi.fn(); + const onGatewayError = vi.fn(); + const abort = new AbortController(); + const err = new Error("boom"); + + const promise = waitForDiscordGatewayStop({ + gateway: { emitter, disconnect }, + abortSignal: abort.signal, + onGatewayError, + }); + + emitter.emit("error", err); + + await expect(promise).rejects.toThrow("boom"); + expect(onGatewayError).toHaveBeenCalledWith(err); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(emitter.listenerCount("error")).toBe(0); + + abort.abort(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 27baa3c66..5d845fe2e 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,3 +1,5 @@ +import type { EventEmitter } from "node:events"; + import { ChannelType, Client, @@ -82,6 +84,62 @@ type DiscordMediaInfo = { placeholder: string; }; +type DiscordGatewayHandle = { + emitter?: Pick; + disconnect?: () => void; +}; + +export async function waitForDiscordGatewayStop(params: { + gateway?: DiscordGatewayHandle; + abortSignal?: AbortSignal; + onGatewayError?: (err: unknown) => void; +}): Promise { + const { gateway, abortSignal, onGatewayError } = params; + const emitter = gateway?.emitter; + return await new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + abortSignal?.removeEventListener("abort", onAbort); + emitter?.removeListener("error", onGatewayErrorEvent); + }; + const finishResolve = () => { + if (settled) return; + settled = true; + cleanup(); + try { + gateway?.disconnect?.(); + } finally { + resolve(); + } + }; + const finishReject = (err: unknown) => { + if (settled) return; + settled = true; + cleanup(); + try { + gateway?.disconnect?.(); + } finally { + reject(err); + } + }; + const onAbort = () => { + finishResolve(); + }; + const onGatewayErrorEvent = (err: unknown) => { + onGatewayError?.(err); + finishReject(err); + }; + + if (abortSignal?.aborted) { + onAbort(); + return; + } + + abortSignal?.addEventListener("abort", onAbort, { once: true }); + emitter?.on("error", onGatewayErrorEvent); + }); +} + type DiscordHistoryEntry = { sender: string; body: string; @@ -402,18 +460,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); - await new Promise((resolve) => { - const onAbort = async () => { - try { - const gateway = client.getPlugin("gateway"); - gateway?.disconnect(); - } finally { - resolve(); - } - }; - opts.abortSignal?.addEventListener("abort", () => { - void onAbort(); - }); + const gateway = client.getPlugin("gateway"); + const gatewayEmitter = (gateway as unknown as { emitter?: EventEmitter }) + ?.emitter; + await waitForDiscordGatewayStop({ + gateway: gateway + ? { + emitter: gatewayEmitter, + disconnect: () => gateway.disconnect(), + } + : undefined, + abortSignal: opts.abortSignal, + onGatewayError: (err) => { + runtime.error?.(danger(`discord gateway error: ${String(err)}`)); + }, }); } From 29eafc61bda4d3ade1d73d529260314ea06f37e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:08:27 +0100 Subject: [PATCH 09/37] fix: update changelog + prompt test --- CHANGELOG.md | 2 +- src/agents/system-prompt.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 324bd54e3..cd232f2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#504) +- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 4c7fc1f58..17e38efc8 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -78,7 +78,7 @@ describe("buildAgentSystemPrompt", () => { toolNames: ["gateway", "bash"], }); - expect(prompt).toContain("## ClaudeBot Self-Update"); + expect(prompt).toContain("## Clawdbot Self-Update"); expect(prompt).toContain("config.apply"); expect(prompt).toContain("update.run"); }); From e7dad1a01ac914151c78f4c3a8590eccb9035624 Mon Sep 17 00:00:00 2001 From: Keith the Silly Goose Date: Fri, 9 Jan 2026 06:48:28 +1300 Subject: [PATCH 10/37] fix(status): include provider prefix in model display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /status command was showing 'anthropic/claude-sonnet-4-5' even when using 'google-antigravity/claude-sonnet-4-5' because buildStatusMessage received only the model name without the provider prefix. When resolveConfiguredModelRef parsed a model string without a slash, it fell back to DEFAULT_PROVIDER ('anthropic'), causing the misleading display. Fix: Pass the full 'provider/model' string to buildStatusMessage so the provider is correctly extracted and displayed. 🪿 Investigated and submitted by Keith the Silly Goose --- src/auto-reply/reply/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 78bc7010f..5c80076c4 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -444,7 +444,7 @@ export async function handleCommands(params: { ...cfg.agent, model: { ...cfg.agent?.model, - primary: model, + primary: `${provider}/${model}`, }, contextTokens, thinkingDefault: cfg.agent?.thinkingDefault, From 6c101e43bd59770677b55216a49ea44b75e2242e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:13:45 +0100 Subject: [PATCH 11/37] docs: add changelog for #506 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd232f2e3..98f64ab49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 +- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). - Control UI: logs tab opens at the newest entries (bottom). From 69d490d136b46ef22f227ec8f31322ff7db24c74 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:18:17 +0100 Subject: [PATCH 12/37] fix(telegram): resolve grammY fetch type mismatch (#512) Co-authored-by: Yuri Chukhlib --- CHANGELOG.md | 1 + src/telegram/send.ts | 17 +++++++++-------- src/telegram/webhook-set.ts | 17 +++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f64ab49..f3ed751ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Control UI: logs tab opens at the newest entries (bottom). - Control UI: add Docs link, remove chat composer divider, and add New session button. - Telegram: retry long-polling conflicts with backoff to avoid fatal exits. +- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - Agent system prompt: avoid automatic self-updates unless explicitly requested. - Onboarding: tighten QuickStart hint copy for configuring later. - Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic. diff --git a/src/telegram/send.ts b/src/telegram/send.ts index d15fa0616..d0715c22c 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -1,4 +1,5 @@ import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types"; +import type { ApiClientOptions } from "grammy"; import { Bot, InputFile } from "grammy"; import { loadConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -113,10 +114,10 @@ export async function sendMessageTelegram( // Use provided api or create a new Bot instance. The nullish coalescing // operator ensures api is always defined (Bot.api is always non-null). const fetchImpl = resolveTelegramFetch(); - const api = - opts.api ?? - new Bot(token, fetchImpl ? { client: { fetch: fetchImpl } } : undefined) - .api; + const client: ApiClientOptions | undefined = fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : undefined; + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const mediaUrl = opts.mediaUrl?.trim(); // Build optional params for forum topics and reply threading. @@ -271,10 +272,10 @@ export async function reactMessageTelegram( const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); const fetchImpl = resolveTelegramFetch(); - const api = - opts.api ?? - new Bot(token, fetchImpl ? { client: { fetch: fetchImpl } } : undefined) - .api; + const client: ApiClientOptions | undefined = fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : undefined; + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const request = createTelegramRetryRunner({ retry: opts.retry, configRetry: account.config.retry, diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts index fc81c1106..78abb9adb 100644 --- a/src/telegram/webhook-set.ts +++ b/src/telegram/webhook-set.ts @@ -1,3 +1,4 @@ +import type { ApiClientOptions } from "grammy"; import { Bot } from "grammy"; import { resolveTelegramFetch } from "./fetch.js"; @@ -8,10 +9,10 @@ export async function setTelegramWebhook(opts: { dropPendingUpdates?: boolean; }) { const fetchImpl = resolveTelegramFetch(); - const bot = new Bot( - opts.token, - fetchImpl ? { client: { fetch: fetchImpl } } : undefined, - ); + const client: ApiClientOptions | undefined = fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : undefined; + const bot = new Bot(opts.token, client ? { client } : undefined); await bot.api.setWebhook(opts.url, { secret_token: opts.secret, drop_pending_updates: opts.dropPendingUpdates ?? false, @@ -20,9 +21,9 @@ export async function setTelegramWebhook(opts: { export async function deleteTelegramWebhook(opts: { token: string }) { const fetchImpl = resolveTelegramFetch(); - const bot = new Bot( - opts.token, - fetchImpl ? { client: { fetch: fetchImpl } } : undefined, - ); + const client: ApiClientOptions | undefined = fetchImpl + ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } + : undefined; + const bot = new Bot(opts.token, client ? { client } : undefined); await bot.api.deleteWebhook(); } From fceeeb312e65753cb441d1ad348ab1bfb5013f54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:23:06 +0100 Subject: [PATCH 13/37] refactor: split discord gateway helpers --- src/discord/monitor.gateway.test.ts | 14 ++++++- src/discord/monitor.gateway.ts | 63 ++++++++++++++++++++++++++++ src/discord/monitor.ts | 65 +++-------------------------- 3 files changed, 81 insertions(+), 61 deletions(-) create mode 100644 src/discord/monitor.gateway.ts diff --git a/src/discord/monitor.gateway.test.ts b/src/discord/monitor.gateway.test.ts index fc7d62784..26685da72 100644 --- a/src/discord/monitor.gateway.test.ts +++ b/src/discord/monitor.gateway.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; -import { waitForDiscordGatewayStop } from "./monitor.js"; +import { waitForDiscordGatewayStop } from "./monitor.gateway.js"; describe("waitForDiscordGatewayStop", () => { it("resolves on abort and disconnects gateway", async () => { @@ -46,4 +46,16 @@ describe("waitForDiscordGatewayStop", () => { abort.abort(); expect(disconnect).toHaveBeenCalledTimes(1); }); + + it("resolves on abort without a gateway", async () => { + const abort = new AbortController(); + + const promise = waitForDiscordGatewayStop({ + abortSignal: abort.signal, + }); + + abort.abort(); + + await expect(promise).resolves.toBeUndefined(); + }); }); diff --git a/src/discord/monitor.gateway.ts b/src/discord/monitor.gateway.ts new file mode 100644 index 000000000..d09df288b --- /dev/null +++ b/src/discord/monitor.gateway.ts @@ -0,0 +1,63 @@ +import type { EventEmitter } from "node:events"; + +export type DiscordGatewayHandle = { + emitter?: Pick; + disconnect?: () => void; +}; + +export function getDiscordGatewayEmitter( + gateway?: unknown, +): EventEmitter | undefined { + return (gateway as { emitter?: EventEmitter } | undefined)?.emitter; +} + +export async function waitForDiscordGatewayStop(params: { + gateway?: DiscordGatewayHandle; + abortSignal?: AbortSignal; + onGatewayError?: (err: unknown) => void; +}): Promise { + const { gateway, abortSignal, onGatewayError } = params; + const emitter = gateway?.emitter; + return await new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + abortSignal?.removeEventListener("abort", onAbort); + emitter?.removeListener("error", onGatewayErrorEvent); + }; + const finishResolve = () => { + if (settled) return; + settled = true; + cleanup(); + try { + gateway?.disconnect?.(); + } finally { + resolve(); + } + }; + const finishReject = (err: unknown) => { + if (settled) return; + settled = true; + cleanup(); + try { + gateway?.disconnect?.(); + } finally { + reject(err); + } + }; + const onAbort = () => { + finishResolve(); + }; + const onGatewayErrorEvent = (err: unknown) => { + onGatewayError?.(err); + finishReject(err); + }; + + if (abortSignal?.aborted) { + onAbort(); + return; + } + + abortSignal?.addEventListener("abort", onAbort, { once: true }); + emitter?.on("error", onGatewayErrorEvent); + }); +} diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 5d845fe2e..754d5f302 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,5 +1,3 @@ -import type { EventEmitter } from "node:events"; - import { ChannelType, Client, @@ -63,6 +61,10 @@ import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { chunkDiscordText } from "./chunk.js"; +import { + getDiscordGatewayEmitter, + waitForDiscordGatewayStop, +} from "./monitor.gateway.js"; import { fetchDiscordApplicationId } from "./probe.js"; import { reactMessageDiscord, sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; @@ -84,62 +86,6 @@ type DiscordMediaInfo = { placeholder: string; }; -type DiscordGatewayHandle = { - emitter?: Pick; - disconnect?: () => void; -}; - -export async function waitForDiscordGatewayStop(params: { - gateway?: DiscordGatewayHandle; - abortSignal?: AbortSignal; - onGatewayError?: (err: unknown) => void; -}): Promise { - const { gateway, abortSignal, onGatewayError } = params; - const emitter = gateway?.emitter; - return await new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - abortSignal?.removeEventListener("abort", onAbort); - emitter?.removeListener("error", onGatewayErrorEvent); - }; - const finishResolve = () => { - if (settled) return; - settled = true; - cleanup(); - try { - gateway?.disconnect?.(); - } finally { - resolve(); - } - }; - const finishReject = (err: unknown) => { - if (settled) return; - settled = true; - cleanup(); - try { - gateway?.disconnect?.(); - } finally { - reject(err); - } - }; - const onAbort = () => { - finishResolve(); - }; - const onGatewayErrorEvent = (err: unknown) => { - onGatewayError?.(err); - finishReject(err); - }; - - if (abortSignal?.aborted) { - onAbort(); - return; - } - - abortSignal?.addEventListener("abort", onAbort, { once: true }); - emitter?.on("error", onGatewayErrorEvent); - }); -} - type DiscordHistoryEntry = { sender: string; body: string; @@ -461,8 +407,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); const gateway = client.getPlugin("gateway"); - const gatewayEmitter = (gateway as unknown as { emitter?: EventEmitter }) - ?.emitter; + const gatewayEmitter = getDiscordGatewayEmitter(gateway); await waitForDiscordGatewayStop({ gateway: gateway ? { From 7c45f269afe4b99245bec5d56edea61546877623 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:23:38 +0100 Subject: [PATCH 14/37] test: cover status model provider prefix --- src/auto-reply/status.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 2e9f5ba80..36ef4e848 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -102,6 +102,18 @@ describe("buildStatusMessage", () => { expect(text).toContain("🧠 Model: openai/gpt-4.1-mini"); }); + it("keeps provider prefix from configured model", () => { + const text = buildStatusMessage({ + agent: { + model: "google-antigravity/claude-sonnet-4-5", + }, + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + }); + + expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5"); + }); + it("handles missing agent config gracefully", () => { const text = buildStatusMessage({ agent: {}, From 135fe4a148e562863814617f31af931e2d21067e Mon Sep 17 00:00:00 2001 From: gupsammy Date: Thu, 8 Jan 2026 15:51:07 +0530 Subject: [PATCH 15/37] fix(macos): prevent crash from missing ClawdbotKit resources and Swift library The macOS app was crashing in two scenarios: 1. Bundle.module crash (fixes #213): When the first tool event arrived, ToolDisplayRegistry tried to load config via ClawdbotKitResources.bundle, which used Bundle.module directly. In packaged apps, Bundle.module couldn't find the resource bundle at the expected path, causing a fatal assertion failure after ~40-80 minutes of runtime. 2. dyld crash (fixes #417): Swift 6.2 requires libswiftCompatibilitySpan.dylib but SwiftPM doesn't bundle it automatically, causing immediate crash on launch with "Library not loaded" error. Changes: - ClawdbotKitResources.swift: Replace direct Bundle.module access with a safe locator that checks multiple paths and falls back gracefully - package-mac-app.sh: Copy ClawdbotKit_ClawdbotKit.bundle to Resources - package-mac-app.sh: Copy libswiftCompatibilitySpan.dylib from Xcode toolchain to Frameworks Tested on macOS 26.2 with Swift 6.2 - app launches and runs without crashes. Co-Authored-By: Claude Opus 4.5 --- .../ClawdbotKit/ClawdbotKitResources.swift | 72 ++++++++++++++++++- scripts/package-mac-app.sh | 18 +++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift index 8e5ee5f63..d990e2cb2 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift @@ -1,5 +1,75 @@ import Foundation public enum ClawdbotKitResources { - public static let bundle: Bundle = .module + /// Resource bundle for ClawdbotKit. + /// + /// Locates the SwiftPM-generated resource bundle, checking multiple locations: + /// 1. Inside Bundle.main (packaged apps) + /// 2. Bundle.module (SwiftPM development/tests) + /// 3. Falls back to Bundle.main if not found (resource lookups will return nil) + /// + /// This avoids a fatal crash when Bundle.module can't locate its resources + /// in packaged .app bundles where the resource bundle path differs from + /// SwiftPM's expectations. + public static let bundle: Bundle = locateBundle() + + private static let bundleName = "ClawdbotKit_ClawdbotKit" + + private static func locateBundle() -> Bundle { + // 1. Check inside Bundle.main (packaged apps copy resources here) + if let mainResourceURL = Bundle.main.resourceURL { + let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: bundleURL) { + return bundle + } + } + + // 2. Check Bundle.main directly for embedded resources + if Bundle.main.url(forResource: "tool-display", withExtension: "json") != nil { + return Bundle.main + } + + // 3. Try Bundle.module (works in SwiftPM development/tests) + // Wrap in a function to defer the fatalError until actually called + if let moduleBundle = loadModuleBundleSafely() { + return moduleBundle + } + + // 4. Fallback: return Bundle.main (resource lookups will return nil gracefully) + return Bundle.main + } + + private static func loadModuleBundleSafely() -> Bundle? { + // Bundle.module is generated by SwiftPM and will fatalError if not found. + // We check likely locations manually to avoid the crash. + let candidates: [URL?] = [ + Bundle.main.resourceURL, + Bundle.main.bundleURL, + Bundle(for: BundleLocator.self).resourceURL, + Bundle(for: BundleLocator.self).bundleURL, + ] + + for candidate in candidates { + guard let baseURL = candidate else { continue } + + // Direct path + let directURL = baseURL.appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: directURL) { + return bundle + } + + // Inside Resources/ + let resourcesURL = baseURL + .appendingPathComponent("Resources") + .appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: resourcesURL) { + return bundle + } + } + + return nil + } } + +// Helper class for bundle lookup via Bundle(for:) +private final class BundleLocator {} diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index aed72a4f1..b5467f60b 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -221,6 +221,15 @@ if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework" fi +echo "📦 Copying Swift 6.2 compatibility libraries" +SWIFT_COMPAT_LIB="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-6.2/macosx/libswiftCompatibilitySpan.dylib" +if [ -f "$SWIFT_COMPAT_LIB" ]; then + cp "$SWIFT_COMPAT_LIB" "$APP_ROOT/Contents/Frameworks/" + chmod +x "$APP_ROOT/Contents/Frameworks/libswiftCompatibilitySpan.dylib" +else + echo "WARN: Swift compatibility library not found at $SWIFT_COMPAT_LIB (continuing)" >&2 +fi + echo "🖼 Copying app icon" cp "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/Clawdbot.icns" "$APP_ROOT/Contents/Resources/Clawdbot.icns" @@ -228,6 +237,15 @@ echo "📦 Copying device model resources" rm -rf "$APP_ROOT/Contents/Resources/DeviceModels" cp -R "$ROOT_DIR/apps/macos/Sources/Clawdbot/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels" +echo "📦 Copying ClawdbotKit resources" +CLAWDBOTKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/ClawdbotKit_ClawdbotKit.bundle" +if [ -d "$CLAWDBOTKIT_BUNDLE" ]; then + rm -rf "$APP_ROOT/Contents/Resources/ClawdbotKit_ClawdbotKit.bundle" + cp -R "$CLAWDBOTKIT_BUNDLE" "$APP_ROOT/Contents/Resources/ClawdbotKit_ClawdbotKit.bundle" +else + echo "WARN: ClawdbotKit resource bundle not found at $CLAWDBOTKIT_BUNDLE (continuing)" >&2 +fi + RELAY_DIR="$APP_ROOT/Contents/Resources/Relay" if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then From d4e62f5d80df4e15b2eb5a8bed19b12ebf7f2e23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 19:24:01 +0000 Subject: [PATCH 16/37] =?UTF-8?q?fix:=20land=20macos=20resource=20bundle?= =?UTF-8?q?=20guard=20(#473)=20=E2=80=94=20thanks=20@gupsammy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../ToolDisplayRegistryTests.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ToolDisplayRegistryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ed751ad..4de6bcb0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj +- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). - Control UI: logs tab opens at the newest entries (bottom). diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ToolDisplayRegistryTests.swift b/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ToolDisplayRegistryTests.swift new file mode 100644 index 000000000..54e68fe00 --- /dev/null +++ b/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ToolDisplayRegistryTests.swift @@ -0,0 +1,16 @@ +import ClawdbotKit +import Foundation +import Testing + +@Suite struct ToolDisplayRegistryTests { + @Test func loadsToolDisplayConfigFromBundle() { + let url = ClawdbotKitResources.bundle.url(forResource: "tool-display", withExtension: "json") + #expect(url != nil) + } + + @Test func resolvesKnownToolFromConfig() { + let summary = ToolDisplayRegistry.resolve(name: "bash", args: nil) + #expect(summary.emoji == "🛠️") + #expect(summary.title == "Bash") + } +} From 024a1c67979457b1fcd8f6e839076f272fbc4e7e Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Thu, 8 Jan 2026 20:28:03 +0100 Subject: [PATCH 17/37] fix(cron): use jobId parameter instead of id for AI tool schema Fixes parameter mismatch between AI tool schema and internal validation. The TypeBox schema now uses `jobId` for update/remove/run/runs actions, matching what users expect based on the returned job objects. Changes: - Changed parameter from `id` to `jobId` in TypeBox schema for update/remove/run/runs - Updated execute function to read `jobId` parameter - Updated tests to use `jobId` in input parameters The gateway protocol still uses `id` internally - the tool now maps `jobId` from the AI to `id` for the gateway call. Fixes #185 Co-Authored-By: Claude --- src/agents/tools/cron-tool.test.ts | 8 ++++---- src/agents/tools/cron-tool.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 0cc248d1c..8becd0f33 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -16,12 +16,12 @@ describe("cron tool", () => { it.each([ [ "update", - { action: "update", id: "job-1", patch: { foo: "bar" } }, + { action: "update", jobId: "job-1", patch: { foo: "bar" } }, { id: "job-1", patch: { foo: "bar" } }, ], - ["remove", { action: "remove", id: "job-1" }, { id: "job-1" }], - ["run", { action: "run", id: "job-1" }, { id: "job-1" }], - ["runs", { action: "runs", id: "job-1" }, { id: "job-1" }], + ["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }], + ["run", { action: "run", jobId: "job-1" }, { id: "job-1" }], + ["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }], ])("%s sends id to gateway", async (action, args, expectedParams) => { const tool = createCronTool(); await tool.execute("call1", args); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 95f7a68ba..c070f2864 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -47,7 +47,7 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - id: Type.String(), + jobId: Type.String(), patch: Type.Object({}, { additionalProperties: true }), }), Type.Object({ @@ -55,21 +55,21 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - id: Type.String(), + jobId: Type.String(), }), Type.Object({ action: Type.Literal("run"), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - id: Type.String(), + jobId: Type.String(), }), Type.Object({ action: Type.Literal("runs"), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - id: Type.String(), + jobId: Type.String(), }), Type.Object({ action: Type.Literal("wake"), @@ -121,7 +121,7 @@ export function createCronTool(): AnyAgentTool { ); } case "update": { - const id = readStringParam(params, "id", { required: true }); + const id = readStringParam(params, "jobId", { required: true }); if (!params.patch || typeof params.patch !== "object") { throw new Error("patch required"); } @@ -134,19 +134,19 @@ export function createCronTool(): AnyAgentTool { ); } case "remove": { - const id = readStringParam(params, "id", { required: true }); + const id = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.remove", gatewayOpts, { id }), ); } case "run": { - const id = readStringParam(params, "id", { required: true }); + const id = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.run", gatewayOpts, { id }), ); } case "runs": { - const id = readStringParam(params, "id", { required: true }); + const id = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.runs", gatewayOpts, { id }), ); From 9a39cca3a6736c9b41161a68065af0a6ecfe8a7a Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Thu, 8 Jan 2026 16:52:50 +0100 Subject: [PATCH 18/37] Chunking: avoid splits inside parentheses --- src/auto-reply/chunk.test.ts | 25 ++++++++++++++++ src/auto-reply/chunk.ts | 57 ++++++++++++++++++++++++------------ 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index f4462cfc9..c385d270d 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -184,4 +184,29 @@ describe("chunkMarkdownText", () => { expect(nonFenceLines.join("\n").trim()).not.toBe(""); } }); + + it("keeps parenthetical phrases together", () => { + const text = "Heads up now (Though now I'm curious)ok"; + const chunks = chunkMarkdownText(text, 35); + expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]); + }); + + it("handles nested parentheses", () => { + const text = "Hello (outer (inner) end) world"; + const chunks = chunkMarkdownText(text, 26); + expect(chunks).toEqual(["Hello (outer (inner) end)", "world"]); + }); + + it("hard-breaks when a parenthetical exceeds the limit", () => { + const text = `(${"a".repeat(80)})`; + const chunks = chunkMarkdownText(text, 20); + expect(chunks[0]?.length).toBe(20); + expect(chunks.join("")).toBe(text); + }); + + it("ignores unmatched closing parentheses", () => { + const text = "Hello) world (ok)"; + const chunks = chunkMarkdownText(text, 12); + expect(chunks).toEqual(["Hello)", "world (ok)"]); + }); }); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index b362eab2f..b64bbe5bf 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -90,18 +90,27 @@ export function chunkText(text: string, limit: number): string[] { while (remaining.length > limit) { const window = remaining.slice(0, limit); - // 1) Prefer a newline break inside the window. - let breakIdx = window.lastIndexOf("\n"); + // 1) Prefer a newline break inside the window (outside parentheses). + let lastNewline = -1; + let lastWhitespace = -1; + let depth = 0; + for (let i = 0; i < window.length; i++) { + const char = window[i]; + if (char === "(") { + depth += 1; + continue; + } + if (char === ")" && depth > 0) { + depth -= 1; + continue; + } + if (depth !== 0) continue; + if (char === "\n") lastNewline = i; + else if (/\s/.test(char)) lastWhitespace = i; + } // 2) Otherwise prefer the last whitespace (word boundary) inside the window. - if (breakIdx <= 0) { - for (let i = window.length - 1; i >= 0; i--) { - if (/\s/.test(window[i])) { - breakIdx = i; - break; - } - } - } + let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace; // 3) Fallback: hard break exactly at the limit. if (breakIdx <= 0) breakIdx = limit; @@ -234,15 +243,27 @@ function pickSafeBreakIndex( window: string, spans: ReturnType, ): number { - let newlineIdx = window.lastIndexOf("\n"); - while (newlineIdx > 0) { - if (isSafeFenceBreak(spans, newlineIdx)) return newlineIdx; - newlineIdx = window.lastIndexOf("\n", newlineIdx - 1); - } - - for (let i = window.length - 1; i > 0; i--) { - if (/\s/.test(window[i]) && isSafeFenceBreak(spans, i)) return i; + let lastNewline = -1; + let lastWhitespace = -1; + let depth = 0; + + for (let i = 0; i < window.length; i++) { + if (!isSafeFenceBreak(spans, i)) continue; + const char = window[i]; + if (char === "(") { + depth += 1; + continue; + } + if (char === ")" && depth > 0) { + depth -= 1; + continue; + } + if (depth !== 0) continue; + if (char === "\n") lastNewline = i; + else if (/\s/.test(char)) lastWhitespace = i; } + if (lastNewline > 0) return lastNewline; + if (lastWhitespace > 0) return lastWhitespace; return -1; } From 44e6f7fdfaf556b94dff09ca5d7c39141a1a4ed7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:39:28 +0100 Subject: [PATCH 19/37] =?UTF-8?q?fix:=20avoid=20splitting=20outbound=20chu?= =?UTF-8?q?nks=20inside=20parentheses=20(#499)=20=E2=80=94=20thanks=20@phi?= =?UTF-8?q?lipp-spiess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + src/auto-reply/chunk.test.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de6bcb0e..717b268cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 +- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index c385d270d..335576b3c 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -67,6 +67,12 @@ describe("chunkText", () => { const chunks = chunkText(text, 10); expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]); }); + + it("keeps parenthetical phrases together", () => { + const text = "Heads up now (Though now I'm curious)ok"; + const chunks = chunkText(text, 35); + expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]); + }); }); describe("resolveTextChunkLimit", () => { From 0e0bd9cd16c507d7db3db31cc39f5092a25c8a3e Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Thu, 8 Jan 2026 20:40:00 +0100 Subject: [PATCH 20/37] fix(heartbeat): pass accountId for Telegram delivery Heartbeat Telegram delivery was failing when the bot token was configured only via telegram.botToken in config (without TELEGRAM_BOT_TOKEN environment variable). Root cause: deliverOutboundPayloads was called without accountId parameter, so sendMessageTelegram couldn't determine which account to use and couldn't find the token from config. Fix: Resolve default Telegram accountId when provider is "telegram" and pass it to deliverOutboundPayloads. This follows the same pattern used elsewhere in the codebase (e.g., cron uses resolveTelegramToken). Changes: - Added import for resolveDefaultTelegramAccountId - Added accountId resolution for telegram provider - Updated deliverOutboundPayloads call to include accountId Fixes #318 Co-Authored-By: Claude --- src/infra/heartbeat-runner.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b015a0896..25b5aab8e 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -22,6 +22,7 @@ import { createSubsystemLogger } from "../logging.js"; import { getQueueSize } from "../process/command-queue.js"; import { webAuthExists } from "../providers/web/index.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { resolveDefaultTelegramAccountId } from "../telegram/accounts.js"; import { getActiveWebListener } from "../web/active-listener.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { @@ -315,10 +316,17 @@ export async function runHeartbeatOnce(opts: { } } + // Resolve accountId for providers that support multiple accounts + const accountId = + delivery.provider === "telegram" + ? resolveDefaultTelegramAccountId(cfg) + : undefined; + await deliverOutboundPayloads({ cfg, provider: delivery.provider, to: delivery.to, + accountId, payloads: [ { text: normalized.text, From db93ec04652661fcbf7667c34e12471408791a86 Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Thu, 8 Jan 2026 20:28:03 +0100 Subject: [PATCH 21/37] fix(cron): use jobId parameter instead of id for AI tool schema Fixes parameter mismatch between AI tool schema and internal validation. The TypeBox schema now uses `jobId` for update/remove/run/runs actions, matching what users expect based on the returned job objects. Changes: - Changed parameter from `id` to `jobId` in TypeBox schema for update/remove/run/runs - Updated execute function to read `jobId` parameter - Updated tests to use `jobId` in input parameters The gateway protocol still uses `id` internally - the tool now maps `jobId` from the AI to `id` for the gateway call. Fixes #185 Co-Authored-By: Claude --- src/agents/tools/cron-tool.test.ts | 8 ++++---- src/agents/tools/cron-tool.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 0cc248d1c..8becd0f33 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -16,12 +16,12 @@ describe("cron tool", () => { it.each([ [ "update", - { action: "update", id: "job-1", patch: { foo: "bar" } }, + { action: "update", jobId: "job-1", patch: { foo: "bar" } }, { id: "job-1", patch: { foo: "bar" } }, ], - ["remove", { action: "remove", id: "job-1" }, { id: "job-1" }], - ["run", { action: "run", id: "job-1" }, { id: "job-1" }], - ["runs", { action: "runs", id: "job-1" }, { id: "job-1" }], + ["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }], + ["run", { action: "run", jobId: "job-1" }, { id: "job-1" }], + ["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }], ])("%s sends id to gateway", async (action, args, expectedParams) => { const tool = createCronTool(); await tool.execute("call1", args); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 95f7a68ba..c070f2864 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -47,7 +47,7 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - id: Type.String(), + jobId: Type.String(), patch: Type.Object({}, { additionalProperties: true }), }), Type.Object({ @@ -55,21 +55,21 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - id: Type.String(), + jobId: Type.String(), }), Type.Object({ action: Type.Literal("run"), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - id: Type.String(), + jobId: Type.String(), }), Type.Object({ action: Type.Literal("runs"), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - id: Type.String(), + jobId: Type.String(), }), Type.Object({ action: Type.Literal("wake"), @@ -121,7 +121,7 @@ export function createCronTool(): AnyAgentTool { ); } case "update": { - const id = readStringParam(params, "id", { required: true }); + const id = readStringParam(params, "jobId", { required: true }); if (!params.patch || typeof params.patch !== "object") { throw new Error("patch required"); } @@ -134,19 +134,19 @@ export function createCronTool(): AnyAgentTool { ); } case "remove": { - const id = readStringParam(params, "id", { required: true }); + const id = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.remove", gatewayOpts, { id }), ); } case "run": { - const id = readStringParam(params, "id", { required: true }); + const id = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.run", gatewayOpts, { id }), ); } case "runs": { - const id = readStringParam(params, "id", { required: true }); + const id = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.runs", gatewayOpts, { id }), ); From 2f817543eb844dcf44b6218fcfaf4b3af8eabf12 Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Thu, 8 Jan 2026 20:40:00 +0100 Subject: [PATCH 22/37] fix(heartbeat): pass accountId for Telegram delivery Heartbeat Telegram delivery was failing when the bot token was configured only via telegram.botToken in config (without TELEGRAM_BOT_TOKEN environment variable). Root cause: deliverOutboundPayloads was called without accountId parameter, so sendMessageTelegram couldn't determine which account to use and couldn't find the token from config. Fix: Resolve default Telegram accountId when provider is "telegram" and pass it to deliverOutboundPayloads. This follows the same pattern used elsewhere in the codebase (e.g., cron uses resolveTelegramToken). Changes: - Added import for resolveDefaultTelegramAccountId - Added accountId resolution for telegram provider - Updated deliverOutboundPayloads call to include accountId Fixes #318 Co-Authored-By: Claude --- src/infra/heartbeat-runner.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b015a0896..25b5aab8e 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -22,6 +22,7 @@ import { createSubsystemLogger } from "../logging.js"; import { getQueueSize } from "../process/command-queue.js"; import { webAuthExists } from "../providers/web/index.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { resolveDefaultTelegramAccountId } from "../telegram/accounts.js"; import { getActiveWebListener } from "../web/active-listener.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { @@ -315,10 +316,17 @@ export async function runHeartbeatOnce(opts: { } } + // Resolve accountId for providers that support multiple accounts + const accountId = + delivery.provider === "telegram" + ? resolveDefaultTelegramAccountId(cfg) + : undefined; + await deliverOutboundPayloads({ cfg, provider: delivery.provider, to: delivery.to, + accountId, payloads: [ { text: normalized.text, From 6ddfb1d0e1ab4b0b4b9b22bae08dc1b830e08e24 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:46:58 +0100 Subject: [PATCH 23/37] fix(heartbeat): telegram accountId + cron jobId compat (#516, thanks @YuriNachos) --- CHANGELOG.md | 1 + src/agents/tools/cron-tool.test.ts | 22 ++++++++++++++ src/agents/tools/cron-tool.ts | 46 ++++++++++++++++++++++++------ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 717b268cb..08751d4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 8becd0f33..6e65acb83 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -19,9 +19,17 @@ describe("cron tool", () => { { action: "update", jobId: "job-1", patch: { foo: "bar" } }, { id: "job-1", patch: { foo: "bar" } }, ], + [ + "update", + { action: "update", id: "job-2", patch: { foo: "bar" } }, + { id: "job-2", patch: { foo: "bar" } }, + ], ["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }], + ["remove", { action: "remove", id: "job-2" }, { id: "job-2" }], ["run", { action: "run", jobId: "job-1" }, { id: "job-1" }], + ["run", { action: "run", id: "job-2" }, { id: "job-2" }], ["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }], + ["runs", { action: "runs", id: "job-2" }, { id: "job-2" }], ])("%s sends id to gateway", async (action, args, expectedParams) => { const tool = createCronTool(); await tool.execute("call1", args); @@ -35,6 +43,20 @@ describe("cron tool", () => { expect(call.params).toEqual(expectedParams); }); + it("prefers jobId over id when both are provided", async () => { + const tool = createCronTool(); + await tool.execute("call1", { + action: "run", + jobId: "job-primary", + id: "job-legacy", + }); + + const call = callGatewayMock.mock.calls[0]?.[0] as { + params?: unknown; + }; + expect(call?.params).toEqual({ id: "job-primary" }); + }); + it("normalizes cron.add job payloads", async () => { const tool = createCronTool(); await tool.execute("call2", { diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index c070f2864..519b707cd 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -47,7 +47,8 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - jobId: Type.String(), + jobId: Type.Optional(Type.String()), + id: Type.Optional(Type.String()), patch: Type.Object({}, { additionalProperties: true }), }), Type.Object({ @@ -55,21 +56,24 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - jobId: Type.String(), + jobId: Type.Optional(Type.String()), + id: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("run"), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - jobId: Type.String(), + jobId: Type.Optional(Type.String()), + id: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("runs"), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - jobId: Type.String(), + jobId: Type.Optional(Type.String()), + id: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("wake"), @@ -88,7 +92,7 @@ export function createCronTool(): AnyAgentTool { label: "Cron", name: "cron", description: - "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.", + "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility.", parameters: CronToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -121,7 +125,13 @@ export function createCronTool(): AnyAgentTool { ); } case "update": { - const id = readStringParam(params, "jobId", { required: true }); + const id = + readStringParam(params, "jobId") ?? readStringParam(params, "id"); + if (!id) { + throw new Error( + "jobId required (id accepted for backward compatibility)", + ); + } if (!params.patch || typeof params.patch !== "object") { throw new Error("patch required"); } @@ -134,19 +144,37 @@ export function createCronTool(): AnyAgentTool { ); } case "remove": { - const id = readStringParam(params, "jobId", { required: true }); + const id = + readStringParam(params, "jobId") ?? readStringParam(params, "id"); + if (!id) { + throw new Error( + "jobId required (id accepted for backward compatibility)", + ); + } return jsonResult( await callGatewayTool("cron.remove", gatewayOpts, { id }), ); } case "run": { - const id = readStringParam(params, "jobId", { required: true }); + const id = + readStringParam(params, "jobId") ?? readStringParam(params, "id"); + if (!id) { + throw new Error( + "jobId required (id accepted for backward compatibility)", + ); + } return jsonResult( await callGatewayTool("cron.run", gatewayOpts, { id }), ); } case "runs": { - const id = readStringParam(params, "jobId", { required: true }); + const id = + readStringParam(params, "jobId") ?? readStringParam(params, "id"); + if (!id) { + throw new Error( + "jobId required (id accepted for backward compatibility)", + ); + } return jsonResult( await callGatewayTool("cron.runs", gatewayOpts, { id }), ); From 16d8017528a125e4ba1982374b988b0ac0e3d4d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 19:54:20 +0000 Subject: [PATCH 24/37] fix: restore hidden gateway-daemon alias --- CHANGELOG.md | 1 + src/cli/gateway-cli.ts | 532 ++++++++++++++++++++++------------------- 2 files changed, 284 insertions(+), 249 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08751d4a3..44f85b532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset. - Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker). - Daemon runtime: remove Bun from selection options. +- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs. ## 2026.1.8 diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 28f99cbcb..d402c1ffd 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -37,6 +37,25 @@ type GatewayRpcOpts = { expectFinal?: boolean; }; +type GatewayRunOpts = { + port?: unknown; + bind?: unknown; + token?: unknown; + auth?: unknown; + password?: unknown; + tailscale?: unknown; + tailscaleResetOnExit?: boolean; + allowUnconfigured?: boolean; + force?: boolean; + verbose?: boolean; + wsLog?: unknown; + compact?: boolean; +}; + +type GatewayRunParams = { + legacyTokenEnv?: boolean; +}; + const gatewayLog = createSubsystemLogger("gateway"); type GatewayRunSignalAction = "stop" | "restart"; @@ -246,10 +265,255 @@ const callGatewayCli = async ( }), ); -export function registerGatewayCli(program: Command) { - const gateway = program - .command("gateway") - .description("Run the WebSocket Gateway") +async function runGatewayCommand( + opts: GatewayRunOpts, + params: GatewayRunParams = {}, +) { + if (params.legacyTokenEnv) { + const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN; + if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) { + process.env.CLAWDBOT_GATEWAY_TOKEN = legacyToken; + } + } + + setVerbose(Boolean(opts.verbose)); + const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as + | string + | undefined; + const wsLogStyle: GatewayWsLogStyle = + wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto"; + if ( + wsLogRaw !== undefined && + wsLogRaw !== "auto" && + wsLogRaw !== "compact" && + wsLogRaw !== "full" + ) { + defaultRuntime.error('Invalid --ws-log (use "auto", "full", "compact")'); + defaultRuntime.exit(1); + } + setGatewayWsLogStyle(wsLogStyle); + + const cfg = loadConfig(); + const portOverride = parsePort(opts.port); + if (opts.port !== undefined && portOverride === null) { + defaultRuntime.error("Invalid port"); + defaultRuntime.exit(1); + } + const port = portOverride ?? resolveGatewayPort(cfg); + if (!Number.isFinite(port) || port <= 0) { + defaultRuntime.error("Invalid port"); + defaultRuntime.exit(1); + } + if (opts.force) { + try { + const { killed, waitedMs, escalatedToSigkill } = + await forceFreePortAndWait(port, { + timeoutMs: 2000, + intervalMs: 100, + sigtermTimeoutMs: 700, + }); + if (killed.length === 0) { + gatewayLog.info(`force: no listeners on port ${port}`); + } else { + for (const proc of killed) { + gatewayLog.info( + `force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`, + ); + } + if (escalatedToSigkill) { + gatewayLog.info( + `force: escalated to SIGKILL while freeing port ${port}`, + ); + } + if (waitedMs > 0) { + gatewayLog.info(`force: waited ${waitedMs}ms for port ${port} to free`); + } + } + } catch (err) { + defaultRuntime.error(`Force: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + } + if (opts.token) { + process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token); + } + const authModeRaw = opts.auth ? String(opts.auth) : undefined; + const authMode: GatewayAuthMode | null = + authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null; + if (authModeRaw && !authMode) { + defaultRuntime.error('Invalid --auth (use "token" or "password")'); + defaultRuntime.exit(1); + return; + } + const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined; + const tailscaleMode = + tailscaleRaw === "off" || + tailscaleRaw === "serve" || + tailscaleRaw === "funnel" + ? tailscaleRaw + : null; + if (tailscaleRaw && !tailscaleMode) { + defaultRuntime.error('Invalid --tailscale (use "off", "serve", or "funnel")'); + defaultRuntime.exit(1); + return; + } + const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT); + const mode = cfg.gateway?.mode; + if (!opts.allowUnconfigured && mode !== "local") { + if (!configExists) { + defaultRuntime.error( + "Missing config. Run `clawdbot setup` or set gateway.mode=local (or pass --allow-unconfigured).", + ); + } else { + defaultRuntime.error( + `Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`, + ); + } + defaultRuntime.exit(1); + return; + } + const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback"); + const bind = + bindRaw === "loopback" || + bindRaw === "tailnet" || + bindRaw === "lan" || + bindRaw === "auto" + ? bindRaw + : null; + if (!bind) { + defaultRuntime.error( + 'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")', + ); + defaultRuntime.exit(1); + return; + } + + const snapshot = await readConfigFileSnapshot().catch(() => null); + const miskeys = extractGatewayMiskeys(snapshot?.parsed); + const authConfig = { + ...cfg.gateway?.auth, + ...(authMode ? { mode: authMode } : {}), + ...(opts.password ? { password: String(opts.password) } : {}), + ...(opts.token ? { token: String(opts.token) } : {}), + }; + const resolvedAuth = resolveGatewayAuth({ + authConfig, + env: process.env, + tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off", + }); + const resolvedAuthMode = resolvedAuth.mode; + const tokenValue = resolvedAuth.token; + const passwordValue = resolvedAuth.password; + const authHints: string[] = []; + if (miskeys.hasGatewayToken) { + authHints.push( + 'Found "gateway.token" in config. Use "gateway.auth.token" instead.', + ); + } + if (miskeys.hasRemoteToken) { + authHints.push( + '"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.', + ); + } + if (resolvedAuthMode === "token" && !tokenValue) { + defaultRuntime.error( + [ + "Gateway auth is set to token, but no token is configured.", + "Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN), or pass --token.", + ...authHints, + ] + .filter(Boolean) + .join("\n"), + ); + defaultRuntime.exit(1); + return; + } + if (resolvedAuthMode === "password" && !passwordValue) { + defaultRuntime.error( + [ + "Gateway auth is set to password, but no password is configured.", + "Set gateway.auth.password (or CLAWDBOT_GATEWAY_PASSWORD), or pass --password.", + ...authHints, + ] + .filter(Boolean) + .join("\n"), + ); + defaultRuntime.exit(1); + return; + } + if (bind !== "loopback" && resolvedAuthMode === "none") { + defaultRuntime.error( + [ + `Refusing to bind gateway to ${bind} without auth.`, + "Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.", + ...authHints, + ] + .filter(Boolean) + .join("\n"), + ); + defaultRuntime.exit(1); + return; + } + + try { + await runGatewayLoop({ + runtime: defaultRuntime, + start: async () => + await startGatewayServer(port, { + bind, + auth: + authMode || opts.password || opts.token || authModeRaw + ? { + mode: authMode ?? undefined, + token: opts.token ? String(opts.token) : undefined, + password: opts.password ? String(opts.password) : undefined, + } + : undefined, + tailscale: + tailscaleMode || opts.tailscaleResetOnExit + ? { + mode: tailscaleMode ?? undefined, + resetOnExit: Boolean(opts.tailscaleResetOnExit), + } + : undefined, + }), + }); + } catch (err) { + if ( + err instanceof GatewayLockError || + (err && + typeof err === "object" && + (err as { name?: string }).name === "GatewayLockError") + ) { + const errMessage = describeUnknownError(err); + defaultRuntime.error( + `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot daemon stop`, + ); + try { + const diagnostics = await inspectPortUsage(port); + if (diagnostics.status === "busy") { + for (const line of formatPortDiagnostics(diagnostics)) { + defaultRuntime.error(line); + } + } + } catch { + // ignore diagnostics failures + } + await maybeExplainGatewayServiceStop(); + defaultRuntime.exit(1); + return; + } + defaultRuntime.error(`Gateway failed to start: ${String(err)}`); + defaultRuntime.exit(1); + } +} + +function addGatewayRunCommand( + cmd: Command, + params: GatewayRunParams = {}, +): Command { + return cmd .option("--port ", "Port for the gateway WebSocket") .option( "--bind ", @@ -288,252 +552,22 @@ export function registerGatewayCli(program: Command) { ) .option("--compact", 'Alias for "--ws-log compact"', false) .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as - | string - | undefined; - const wsLogStyle: GatewayWsLogStyle = - wsLogRaw === "compact" - ? "compact" - : wsLogRaw === "full" - ? "full" - : "auto"; - if ( - wsLogRaw !== undefined && - wsLogRaw !== "auto" && - wsLogRaw !== "compact" && - wsLogRaw !== "full" - ) { - defaultRuntime.error( - 'Invalid --ws-log (use "auto", "full", "compact")', - ); - defaultRuntime.exit(1); - } - setGatewayWsLogStyle(wsLogStyle); - - const cfg = loadConfig(); - const portOverride = parsePort(opts.port); - if (opts.port !== undefined && portOverride === null) { - defaultRuntime.error("Invalid port"); - defaultRuntime.exit(1); - } - const port = portOverride ?? resolveGatewayPort(cfg); - if (!Number.isFinite(port) || port <= 0) { - defaultRuntime.error("Invalid port"); - defaultRuntime.exit(1); - } - if (opts.force) { - try { - const { killed, waitedMs, escalatedToSigkill } = - await forceFreePortAndWait(port, { - timeoutMs: 2000, - intervalMs: 100, - sigtermTimeoutMs: 700, - }); - if (killed.length === 0) { - gatewayLog.info(`force: no listeners on port ${port}`); - } else { - for (const proc of killed) { - gatewayLog.info( - `force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`, - ); - } - if (escalatedToSigkill) { - gatewayLog.info( - `force: escalated to SIGKILL while freeing port ${port}`, - ); - } - if (waitedMs > 0) { - gatewayLog.info( - `force: waited ${waitedMs}ms for port ${port} to free`, - ); - } - } - } catch (err) { - defaultRuntime.error(`Force: ${String(err)}`); - defaultRuntime.exit(1); - return; - } - } - if (opts.token) { - process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token); - } - const authModeRaw = opts.auth ? String(opts.auth) : undefined; - const authMode: GatewayAuthMode | null = - authModeRaw === "token" || authModeRaw === "password" - ? authModeRaw - : null; - if (authModeRaw && !authMode) { - defaultRuntime.error('Invalid --auth (use "token" or "password")'); - defaultRuntime.exit(1); - return; - } - const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined; - const tailscaleMode = - tailscaleRaw === "off" || - tailscaleRaw === "serve" || - tailscaleRaw === "funnel" - ? tailscaleRaw - : null; - if (tailscaleRaw && !tailscaleMode) { - defaultRuntime.error( - 'Invalid --tailscale (use "off", "serve", or "funnel")', - ); - defaultRuntime.exit(1); - return; - } - const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT); - const mode = cfg.gateway?.mode; - if (!opts.allowUnconfigured && mode !== "local") { - if (!configExists) { - defaultRuntime.error( - "Missing config. Run `clawdbot setup` or set gateway.mode=local (or pass --allow-unconfigured).", - ); - } else { - defaultRuntime.error( - `Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`, - ); - } - defaultRuntime.exit(1); - return; - } - const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback"); - const bind = - bindRaw === "loopback" || - bindRaw === "tailnet" || - bindRaw === "lan" || - bindRaw === "auto" - ? bindRaw - : null; - if (!bind) { - defaultRuntime.error( - 'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")', - ); - defaultRuntime.exit(1); - return; - } - - const snapshot = await readConfigFileSnapshot().catch(() => null); - const miskeys = extractGatewayMiskeys(snapshot?.parsed); - const authConfig = { - ...cfg.gateway?.auth, - ...(authMode ? { mode: authMode } : {}), - ...(opts.password ? { password: String(opts.password) } : {}), - ...(opts.token ? { token: String(opts.token) } : {}), - }; - const resolvedAuth = resolveGatewayAuth({ - authConfig, - env: process.env, - tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off", - }); - const resolvedAuthMode = resolvedAuth.mode; - const tokenValue = resolvedAuth.token; - const passwordValue = resolvedAuth.password; - const authHints: string[] = []; - if (miskeys.hasGatewayToken) { - authHints.push( - 'Found "gateway.token" in config. Use "gateway.auth.token" instead.', - ); - } - if (miskeys.hasRemoteToken) { - authHints.push( - '"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.', - ); - } - if (resolvedAuthMode === "token" && !tokenValue) { - defaultRuntime.error( - [ - "Gateway auth is set to token, but no token is configured.", - "Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN), or pass --token.", - ...authHints, - ] - .filter(Boolean) - .join("\n"), - ); - defaultRuntime.exit(1); - return; - } - if (resolvedAuthMode === "password" && !passwordValue) { - defaultRuntime.error( - [ - "Gateway auth is set to password, but no password is configured.", - "Set gateway.auth.password (or CLAWDBOT_GATEWAY_PASSWORD), or pass --password.", - ...authHints, - ] - .filter(Boolean) - .join("\n"), - ); - defaultRuntime.exit(1); - return; - } - if (bind !== "loopback" && resolvedAuthMode === "none") { - defaultRuntime.error( - [ - `Refusing to bind gateway to ${bind} without auth.`, - "Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.", - ...authHints, - ] - .filter(Boolean) - .join("\n"), - ); - defaultRuntime.exit(1); - return; - } - - try { - await runGatewayLoop({ - runtime: defaultRuntime, - start: async () => - await startGatewayServer(port, { - bind, - auth: - authMode || opts.password || opts.token || authModeRaw - ? { - mode: authMode ?? undefined, - token: opts.token ? String(opts.token) : undefined, - password: opts.password - ? String(opts.password) - : undefined, - } - : undefined, - tailscale: - tailscaleMode || opts.tailscaleResetOnExit - ? { - mode: tailscaleMode ?? undefined, - resetOnExit: Boolean(opts.tailscaleResetOnExit), - } - : undefined, - }), - }); - } catch (err) { - if ( - err instanceof GatewayLockError || - (err && - typeof err === "object" && - (err as { name?: string }).name === "GatewayLockError") - ) { - const errMessage = describeUnknownError(err); - defaultRuntime.error( - `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot daemon stop`, - ); - try { - const diagnostics = await inspectPortUsage(port); - if (diagnostics.status === "busy") { - for (const line of formatPortDiagnostics(diagnostics)) { - defaultRuntime.error(line); - } - } - } catch { - // ignore diagnostics failures - } - await maybeExplainGatewayServiceStop(); - defaultRuntime.exit(1); - return; - } - defaultRuntime.error(`Gateway failed to start: ${String(err)}`); - defaultRuntime.exit(1); - } + await runGatewayCommand(opts, params); }); +} + +export function registerGatewayCli(program: Command) { + const gateway = addGatewayRunCommand( + program.command("gateway").description("Run the WebSocket Gateway"), + ); + + // Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias. + addGatewayRunCommand( + program + .command("gateway-daemon", { hidden: true }) + .description("Run the WebSocket Gateway as a long-lived daemon"), + { legacyTokenEnv: true }, + ); gatewayCallOpts( gateway From 8f7ba3d349a52692c99db8d367b3259977a7980b Mon Sep 17 00:00:00 2001 From: Marc Terns Date: Wed, 7 Jan 2026 21:21:32 -0600 Subject: [PATCH 25/37] UI: improve skill install feedback --- ui/src/ui/app-render.ts | 3 +++ ui/src/ui/app.ts | 2 ++ ui/src/ui/controllers/skills.ts | 45 ++++++++++++++++++++++++++++++--- ui/src/ui/views/skills.ts | 22 ++++++++++++++-- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 97f766477..473d41b09 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -60,6 +60,7 @@ import { saveSkillApiKey, updateSkillEdit, updateSkillEnabled, + type SkillMessage, } from "./controllers/skills"; import { loadNodes } from "./controllers/nodes"; import { loadChatHistory } from "./controllers/chat"; @@ -166,6 +167,7 @@ export type AppViewState = { skillsError: string | null; skillsFilter: string; skillEdits: Record; + skillMessages: Record; skillsBusyKey: string | null; debugLoading: boolean; debugStatus: StatusSummary | null; @@ -391,6 +393,7 @@ export function renderApp(state: AppViewState) { error: state.skillsError, filter: state.skillsFilter, edits: state.skillEdits, + messages: state.skillMessages, busyKey: state.skillsBusyKey, onFilterChange: (next) => (state.skillsFilter = next), onRefresh: () => loadSkills(state), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 83f1fdc89..f839f92f6 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -78,6 +78,7 @@ import { } from "./controllers/cron"; import { loadSkills, + type SkillMessage, } from "./controllers/skills"; import { loadDebug } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; @@ -356,6 +357,7 @@ export class ClawdbotApp extends LitElement { @state() skillsFilter = ""; @state() skillEdits: Record = {}; @state() skillsBusyKey: string | null = null; + @state() skillMessages: Record = {}; @state() debugLoading = false; @state() debugStatus: StatusSummary | null = null; diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts index fe3329956..688c54fb4 100644 --- a/ui/src/ui/controllers/skills.ts +++ b/ui/src/ui/controllers/skills.ts @@ -9,8 +9,24 @@ export type SkillsState = { skillsError: string | null; skillsBusyKey: string | null; skillEdits: Record; + skillMessages: SkillMessageMap; }; +export type SkillMessage = { + kind: "success" | "error"; + message: string; +}; + +export type SkillMessageMap = Record; + +function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) { + if (!key.trim()) return; + const next = { ...state.skillMessages }; + if (message) next[key] = message; + else delete next[key]; + state.skillMessages = next; +} + export async function loadSkills(state: SkillsState) { if (!state.client || !state.connected) return; if (state.skillsLoading) return; @@ -47,8 +63,16 @@ export async function updateSkillEnabled( try { await state.client.request("skills.update", { skillKey, enabled }); await loadSkills(state); + setSkillMessage(state, skillKey, { + kind: "success", + message: enabled ? "Skill enabled" : "Skill disabled", + }); } catch (err) { state.skillsError = String(err); + setSkillMessage(state, skillKey, { + kind: "error", + message: String(err), + }); } finally { state.skillsBusyKey = null; } @@ -62,8 +86,16 @@ export async function saveSkillApiKey(state: SkillsState, skillKey: string) { const apiKey = state.skillEdits[skillKey] ?? ""; await state.client.request("skills.update", { skillKey, apiKey }); await loadSkills(state); + setSkillMessage(state, skillKey, { + kind: "success", + message: "API key saved", + }); } catch (err) { state.skillsError = String(err); + setSkillMessage(state, skillKey, { + kind: "error", + message: String(err), + }); } finally { state.skillsBusyKey = null; } @@ -78,16 +110,23 @@ export async function installSkill( state.skillsBusyKey = name; state.skillsError = null; try { - await state.client.request("skills.install", { + const result = (await state.client.request("skills.install", { name, installId, timeoutMs: 120000, - }); + })) as { ok?: boolean; message?: string }; await loadSkills(state); + setSkillMessage(state, name, { + kind: "success", + message: result?.message ?? "Installed", + }); } catch (err) { state.skillsError = String(err); + setSkillMessage(state, name, { + kind: "error", + message: String(err), + }); } finally { state.skillsBusyKey = null; } } - diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 50861fee1..f10aae6dc 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { clampText } from "../format"; import type { SkillStatusEntry, SkillStatusReport } from "../types"; +import type { SkillMessageMap } from "../controllers/skills"; export type SkillsProps = { loading: boolean; @@ -10,6 +11,7 @@ export type SkillsProps = { filter: string; edits: Record; busyKey: string | null; + messages: SkillMessageMap; onFilterChange: (next: string) => void; onRefresh: () => void; onToggle: (skillKey: string, enabled: boolean) => void; @@ -73,6 +75,10 @@ export function renderSkills(props: SkillsProps) { function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name; const apiKey = props.edits[skill.skillKey] ?? ""; + const message = + props.messages[skill.skillKey] ?? props.messages[skill.name] ?? null; + const canInstall = + skill.install.length > 0 && skill.missing.bins.length > 0; const missing = [ ...skill.missing.bins.map((b) => `bin:${b}`), ...skill.missing.env.map((e) => `env:${e}`), @@ -120,16 +126,28 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { > ${skill.disabled ? "Enable" : "Disable"} - ${skill.install.length > 0 + ${canInstall ? html`` : nothing}
+ ${message + ? html`
+ ${message.message} +
` + : nothing} ${skill.primaryEnv ? html`
From f55f822703aa5a2f82db6740f8f78731b2b4f683 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:04:10 +0000 Subject: [PATCH 26/37] style: format gateway-cli log lines --- src/cli/gateway-cli.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index d402c1ffd..03ac40fda 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -326,7 +326,9 @@ async function runGatewayCommand( ); } if (waitedMs > 0) { - gatewayLog.info(`force: waited ${waitedMs}ms for port ${port} to free`); + gatewayLog.info( + `force: waited ${waitedMs}ms for port ${port} to free`, + ); } } } catch (err) { @@ -354,7 +356,9 @@ async function runGatewayCommand( ? tailscaleRaw : null; if (tailscaleRaw && !tailscaleMode) { - defaultRuntime.error('Invalid --tailscale (use "off", "serve", or "funnel")'); + defaultRuntime.error( + 'Invalid --tailscale (use "off", "serve", or "funnel")', + ); defaultRuntime.exit(1); return; } From ae23f96d5db0067e066a5d973910896ccfe47a0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:04:13 +0000 Subject: [PATCH 27/37] docs: add changelog for skill install feedback (PR #445, thanks @pkrmf) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f85b532..57a32842d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker). - Daemon runtime: remove Bun from selection options. - CLI: restore hidden `gateway-daemon` alias for legacy launchd configs. +- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf ## 2026.1.8 From 35f7e3455dbe27d9224d920ffb62047b9555d337 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 21:03:11 +0100 Subject: [PATCH 28/37] refactor(outbound): centralize telegram account defaults --- CHANGELOG.md | 1 + src/commands/agent.test.ts | 4 +- src/commands/send.test.ts | 6 +-- src/infra/heartbeat-runner.test.ts | 71 +++++++++++++++++++++++++++++- src/infra/heartbeat-runner.ts | 8 ---- src/infra/outbound/deliver.test.ts | 28 +++++++++++- src/infra/outbound/deliver.ts | 29 ++++++------ src/telegram/accounts.test.ts | 69 +++++++++++++++++++++++++++++ src/telegram/accounts.ts | 41 ++++++++++++----- 9 files changed, 214 insertions(+), 43 deletions(-) create mode 100644 src/telegram/accounts.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a32842d..e39ad2dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index e7d647c16..7aac629d1 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -266,7 +266,7 @@ describe("agentCommand", () => { }); }); - it("passes telegram account id when delivering", async () => { + it("passes through telegram accountId when delivering", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, undefined, undefined, { botToken: "t-1" }); @@ -297,7 +297,7 @@ describe("agentCommand", () => { expect(deps.sendMessageTelegram).toHaveBeenCalledWith( "123", "ok", - expect.objectContaining({ accountId: "default", verbose: false }), + expect.objectContaining({ accountId: undefined, verbose: false }), ); } finally { if (prevTelegramToken === undefined) { diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts index 51c83d489..0cf52f259 100644 --- a/src/commands/send.test.ts +++ b/src/commands/send.test.ts @@ -137,7 +137,7 @@ describe("sendCommand", () => { expect(deps.sendMessageTelegram).toHaveBeenCalledWith( "123", "hi", - expect.objectContaining({ accountId: "default", verbose: false }), + expect.objectContaining({ accountId: undefined, verbose: false }), ); expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); @@ -158,7 +158,7 @@ describe("sendCommand", () => { expect(deps.sendMessageTelegram).toHaveBeenCalledWith( "123", "hi", - expect.objectContaining({ accountId: "default", verbose: false }), + expect.objectContaining({ accountId: undefined, verbose: false }), ); }); @@ -212,7 +212,7 @@ describe("sendCommand", () => { expect(deps.sendMessageSlack).toHaveBeenCalledWith( "channel:C123", "hi", - expect.objectContaining({ accountId: "default" }), + expect.objectContaining({ accountId: undefined }), ); expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 54ad17b62..2cdc595a9 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -368,7 +368,7 @@ describe("runHeartbeatOnce", () => { } }); - it("passes telegram token from config to sendTelegram", async () => { + it("passes through accountId for telegram heartbeats", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); @@ -418,7 +418,74 @@ describe("runHeartbeatOnce", () => { expect(sendTelegram).toHaveBeenCalledWith( "123456", "Hello from heartbeat", - expect.objectContaining({ accountId: "default", verbose: false }), + expect.objectContaining({ accountId: undefined, verbose: false }), + ); + } finally { + replySpy.mockRestore(); + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not pre-resolve telegram accountId (allows config-only account tokens)", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = ""; + try { + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: Date.now(), + lastProvider: "telegram", + lastTo: "123456", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdbotConfig = { + agent: { + heartbeat: { every: "5m", target: "telegram", to: "123456" }, + }, + telegram: { + accounts: { + work: { botToken: "test-bot-token-123" }, + }, + }, + session: { store: storePath }, + }; + + replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); + const sendTelegram = vi.fn().mockResolvedValue({ + messageId: "m1", + chatId: "123456", + }); + + await runHeartbeatOnce({ + cfg, + deps: { + sendTelegram, + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "123456", + "Hello from heartbeat", + expect.objectContaining({ accountId: undefined, verbose: false }), ); } finally { replySpy.mockRestore(); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 25b5aab8e..b015a0896 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -22,7 +22,6 @@ import { createSubsystemLogger } from "../logging.js"; import { getQueueSize } from "../process/command-queue.js"; import { webAuthExists } from "../providers/web/index.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { resolveDefaultTelegramAccountId } from "../telegram/accounts.js"; import { getActiveWebListener } from "../web/active-listener.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { @@ -316,17 +315,10 @@ export async function runHeartbeatOnce(opts: { } } - // Resolve accountId for providers that support multiple accounts - const accountId = - delivery.provider === "telegram" - ? resolveDefaultTelegramAccountId(cfg) - : undefined; - await deliverOutboundPayloads({ cfg, provider: delivery.provider, to: delivery.to, - accountId, payloads: [ { text: normalized.text, diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 0cf53236a..3dc29b2f1 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -7,7 +7,7 @@ import { } from "./deliver.js"; describe("deliverOutboundPayloads", () => { - it("chunks telegram markdown and passes account id", async () => { + it("chunks telegram markdown and passes through accountId", async () => { const sendTelegram = vi .fn() .mockResolvedValue({ messageId: "m1", chatId: "c1" }); @@ -28,7 +28,7 @@ describe("deliverOutboundPayloads", () => { expect(sendTelegram).toHaveBeenCalledTimes(2); for (const call of sendTelegram.mock.calls) { expect(call[2]).toEqual( - expect.objectContaining({ accountId: "default", verbose: false }), + expect.objectContaining({ accountId: undefined, verbose: false }), ); } expect(results).toHaveLength(2); @@ -42,6 +42,30 @@ describe("deliverOutboundPayloads", () => { } }); + it("passes explicit accountId to sendTelegram", async () => { + const sendTelegram = vi + .fn() + .mockResolvedValue({ messageId: "m1", chatId: "c1" }); + const cfg: ClawdbotConfig = { + telegram: { botToken: "tok-1", textChunkLimit: 2 }, + }; + + await deliverOutboundPayloads({ + cfg, + provider: "telegram", + to: "123", + accountId: "default", + payloads: [{ text: "hi" }], + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledWith( + "123", + "hi", + expect.objectContaining({ accountId: "default", verbose: false }), + ); + }); + it("uses signal media maxBytes from config", async () => { const sendSignal = vi .fn() diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 5644de226..7b915715d 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -86,7 +86,8 @@ function createProviderHandler(params: { deps: Required; }): ProviderHandler { const { cfg, to, deps } = params; - const accountId = normalizeAccountId(params.accountId); + const rawAccountId = params.accountId; + const accountId = normalizeAccountId(rawAccountId); const signalMaxBytes = params.provider === "signal" ? resolveMediaMaxBytes(cfg, "signal", accountId) @@ -103,7 +104,7 @@ function createProviderHandler(params: { provider: "whatsapp", ...(await deps.sendWhatsApp(to, text, { verbose: false, - accountId, + accountId: rawAccountId, })), }), sendMedia: async (caption, mediaUrl) => ({ @@ -111,7 +112,7 @@ function createProviderHandler(params: { ...(await deps.sendWhatsApp(to, caption, { verbose: false, mediaUrl, - accountId, + accountId: rawAccountId, })), }), }, @@ -121,7 +122,7 @@ function createProviderHandler(params: { provider: "telegram", ...(await deps.sendTelegram(to, text, { verbose: false, - accountId, + accountId: rawAccountId, })), }), sendMedia: async (caption, mediaUrl) => ({ @@ -129,7 +130,7 @@ function createProviderHandler(params: { ...(await deps.sendTelegram(to, caption, { verbose: false, mediaUrl, - accountId, + accountId: rawAccountId, })), }), }, @@ -139,7 +140,7 @@ function createProviderHandler(params: { provider: "discord", ...(await deps.sendDiscord(to, text, { verbose: false, - accountId, + accountId: rawAccountId, })), }), sendMedia: async (caption, mediaUrl) => ({ @@ -147,7 +148,7 @@ function createProviderHandler(params: { ...(await deps.sendDiscord(to, caption, { verbose: false, mediaUrl, - accountId, + accountId: rawAccountId, })), }), }, @@ -156,14 +157,14 @@ function createProviderHandler(params: { sendText: async (text) => ({ provider: "slack", ...(await deps.sendSlack(to, text, { - accountId, + accountId: rawAccountId, })), }), sendMedia: async (caption, mediaUrl) => ({ provider: "slack", ...(await deps.sendSlack(to, caption, { mediaUrl, - accountId, + accountId: rawAccountId, })), }), }, @@ -173,7 +174,7 @@ function createProviderHandler(params: { provider: "signal", ...(await deps.sendSignal(to, text, { maxBytes: signalMaxBytes, - accountId, + accountId: rawAccountId, })), }), sendMedia: async (caption, mediaUrl) => ({ @@ -181,7 +182,7 @@ function createProviderHandler(params: { ...(await deps.sendSignal(to, caption, { mediaUrl, maxBytes: signalMaxBytes, - accountId, + accountId: rawAccountId, })), }), }, @@ -191,7 +192,7 @@ function createProviderHandler(params: { provider: "imessage", ...(await deps.sendIMessage(to, text, { maxBytes: imessageMaxBytes, - accountId, + accountId: rawAccountId, })), }), sendMedia: async (caption, mediaUrl) => ({ @@ -199,7 +200,7 @@ function createProviderHandler(params: { ...(await deps.sendIMessage(to, caption, { mediaUrl, maxBytes: imessageMaxBytes, - accountId, + accountId: rawAccountId, })), }), }, @@ -220,7 +221,7 @@ export async function deliverOutboundPayloads(params: { onPayload?: (payload: NormalizedOutboundPayload) => void; }): Promise { const { cfg, provider, to, payloads } = params; - const accountId = normalizeAccountId(params.accountId); + const accountId = params.accountId; const deps = { sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp, sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram, diff --git a/src/telegram/accounts.test.ts b/src/telegram/accounts.test.ts new file mode 100644 index 000000000..d7fb60d6f --- /dev/null +++ b/src/telegram/accounts.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveTelegramAccount } from "./accounts.js"; + +describe("resolveTelegramAccount", () => { + it("falls back to the first configured account when accountId is omitted", () => { + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = ""; + try { + const cfg: ClawdbotConfig = { + telegram: { accounts: { work: { botToken: "tok-work" } } }, + }; + + const account = resolveTelegramAccount({ cfg }); + expect(account.accountId).toBe("work"); + expect(account.token).toBe("tok-work"); + expect(account.tokenSource).toBe("config"); + } finally { + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + } + }); + + it("prefers TELEGRAM_BOT_TOKEN when accountId is omitted", () => { + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = "tok-env"; + try { + const cfg: ClawdbotConfig = { + telegram: { accounts: { work: { botToken: "tok-work" } } }, + }; + + const account = resolveTelegramAccount({ cfg }); + expect(account.accountId).toBe("default"); + expect(account.token).toBe("tok-env"); + expect(account.tokenSource).toBe("env"); + } finally { + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + } + }); + + it("does not fall back when accountId is explicitly provided", () => { + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = ""; + try { + const cfg: ClawdbotConfig = { + telegram: { accounts: { work: { botToken: "tok-work" } } }, + }; + + const account = resolveTelegramAccount({ cfg, accountId: "default" }); + expect(account.accountId).toBe("default"); + expect(account.tokenSource).toBe("none"); + expect(account.token).toBe(""); + } finally { + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + } + }); +}); diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index f76113880..e646a161a 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -56,20 +56,37 @@ export function resolveTelegramAccount(params: { cfg: ClawdbotConfig; accountId?: string | null; }): ResolvedTelegramAccount { - const accountId = normalizeAccountId(params.accountId); + const hasExplicitAccountId = Boolean(params.accountId?.trim()); const baseEnabled = params.cfg.telegram?.enabled !== false; - const merged = mergeTelegramAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const enabled = baseEnabled && accountEnabled; - const tokenResolution = resolveTelegramToken(params.cfg, { accountId }); - return { - accountId, - enabled, - name: merged.name?.trim() || undefined, - token: tokenResolution.token, - tokenSource: tokenResolution.source, - config: merged, + + const resolve = (accountId: string) => { + const merged = mergeTelegramAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const tokenResolution = resolveTelegramToken(params.cfg, { accountId }); + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: tokenResolution.token, + tokenSource: tokenResolution.source, + config: merged, + } satisfies ResolvedTelegramAccount; }; + + const normalized = normalizeAccountId(params.accountId); + const primary = resolve(normalized); + if (hasExplicitAccountId) return primary; + if (primary.tokenSource !== "none") return primary; + + // If accountId is omitted, prefer a configured account token over failing on + // the implicit "default" account. This keeps env-based setups working (env + // still wins) while making config-only tokens work for things like heartbeats. + const fallbackId = resolveDefaultTelegramAccountId(params.cfg); + if (fallbackId === primary.accountId) return primary; + const fallback = resolve(fallbackId); + if (fallback.tokenSource === "none") return primary; + return fallback; } export function listEnabledTelegramAccounts( From 06a05f28e49c76e63964f6f275b3db66e15023a5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 21:08:34 +0100 Subject: [PATCH 29/37] docs(changelog): follow-up #516 (thanks @YuriNachos) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e39ad2dc4..3cf3c15bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. +- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 From afcb6536eeca4c2ddf4bb56e5e7a0b1daa61af3f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:13:21 +0000 Subject: [PATCH 30/37] refactor: normalize skills UI status keys --- ui/src/ui/app-render.ts | 5 +++-- ui/src/ui/controllers/skills.ts | 38 +++++++++++++++++++++++---------- ui/src/ui/views/skills.ts | 10 ++++----- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 473d41b09..417f267b3 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -396,11 +396,12 @@ export function renderApp(state: AppViewState) { messages: state.skillMessages, busyKey: state.skillsBusyKey, onFilterChange: (next) => (state.skillsFilter = next), - onRefresh: () => loadSkills(state), + onRefresh: () => loadSkills(state, { clearMessages: true }), onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), onEdit: (key, value) => updateSkillEdit(state, key, value), onSaveKey: (key) => saveSkillApiKey(state, key), - onInstall: (name, installId) => installSkill(state, name, installId), + onInstall: (skillKey, name, installId) => + installSkill(state, skillKey, name, installId), }) : nothing} diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts index 688c54fb4..6e26a98ae 100644 --- a/ui/src/ui/controllers/skills.ts +++ b/ui/src/ui/controllers/skills.ts @@ -19,6 +19,10 @@ export type SkillMessage = { export type SkillMessageMap = Record; +type LoadSkillsOptions = { + clearMessages?: boolean; +}; + function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) { if (!key.trim()) return; const next = { ...state.skillMessages }; @@ -27,7 +31,15 @@ function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage state.skillMessages = next; } -export async function loadSkills(state: SkillsState) { +function getErrorMessage(err: unknown) { + if (err instanceof Error) return err.message; + return String(err); +} + +export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions) { + if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) { + state.skillMessages = {}; + } if (!state.client || !state.connected) return; if (state.skillsLoading) return; state.skillsLoading = true; @@ -38,7 +50,7 @@ export async function loadSkills(state: SkillsState) { | undefined; if (res) state.skillsReport = res; } catch (err) { - state.skillsError = String(err); + state.skillsError = getErrorMessage(err); } finally { state.skillsLoading = false; } @@ -68,10 +80,11 @@ export async function updateSkillEnabled( message: enabled ? "Skill enabled" : "Skill disabled", }); } catch (err) { - state.skillsError = String(err); + const message = getErrorMessage(err); + state.skillsError = message; setSkillMessage(state, skillKey, { kind: "error", - message: String(err), + message, }); } finally { state.skillsBusyKey = null; @@ -91,10 +104,11 @@ export async function saveSkillApiKey(state: SkillsState, skillKey: string) { message: "API key saved", }); } catch (err) { - state.skillsError = String(err); + const message = getErrorMessage(err); + state.skillsError = message; setSkillMessage(state, skillKey, { kind: "error", - message: String(err), + message, }); } finally { state.skillsBusyKey = null; @@ -103,11 +117,12 @@ export async function saveSkillApiKey(state: SkillsState, skillKey: string) { export async function installSkill( state: SkillsState, + skillKey: string, name: string, installId: string, ) { if (!state.client || !state.connected) return; - state.skillsBusyKey = name; + state.skillsBusyKey = skillKey; state.skillsError = null; try { const result = (await state.client.request("skills.install", { @@ -116,15 +131,16 @@ export async function installSkill( timeoutMs: 120000, })) as { ok?: boolean; message?: string }; await loadSkills(state); - setSkillMessage(state, name, { + setSkillMessage(state, skillKey, { kind: "success", message: result?.message ?? "Installed", }); } catch (err) { - state.skillsError = String(err); - setSkillMessage(state, name, { + const message = getErrorMessage(err); + state.skillsError = message; + setSkillMessage(state, skillKey, { kind: "error", - message: String(err), + message, }); } finally { state.skillsBusyKey = null; diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index f10aae6dc..cfc024cb1 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -17,7 +17,7 @@ export type SkillsProps = { onToggle: (skillKey: string, enabled: boolean) => void; onEdit: (skillKey: string, value: string) => void; onSaveKey: (skillKey: string) => void; - onInstall: (name: string, installId: string) => void; + onInstall: (skillKey: string, name: string, installId: string) => void; }; export function renderSkills(props: SkillsProps) { @@ -73,10 +73,9 @@ export function renderSkills(props: SkillsProps) { } function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { - const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name; + const busy = props.busyKey === skill.skillKey; const apiKey = props.edits[skill.skillKey] ?? ""; - const message = - props.messages[skill.skillKey] ?? props.messages[skill.name] ?? null; + const message = props.messages[skill.skillKey] ?? null; const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0; const missing = [ @@ -130,7 +129,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { ? html`` From ee875dc1484eea41f74f6bb4ab27d439d51f046d Mon Sep 17 00:00:00 2001 From: Azade Date: Thu, 8 Jan 2026 11:20:05 +0000 Subject: [PATCH 31/37] fix(daemon): align systemd unit with documentation Align generated systemd service file with docs: https://docs.clawd.bot/gateway#supervision-systemd-user-unit Adds: - After=network-online.target - Wants=network-online.target - RestartSec=5 --- src/daemon/systemd.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index c0a8b495b..2b659ec77 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -141,10 +141,13 @@ function buildSystemdUnit({ return [ "[Unit]", "Description=Clawdbot Gateway", + "After=network-online.target", + "Wants=network-online.target", "", "[Service]", `ExecStart=${execStart}`, "Restart=always", + "RestartSec=5", workingDirLine, ...envLines, "", From c9ca2f4bf9ada14d6dd994ec3982d92394eaff15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 21:16:06 +0100 Subject: [PATCH 32/37] docs: note systemd unit alignment (#479) - thanks @azade-c --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf3c15bc..1c87f3113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 From adbbb84932ca675b185ab7872201900544dbbd91 Mon Sep 17 00:00:00 2001 From: Gregor's Bot Date: Thu, 8 Jan 2026 11:50:44 +0100 Subject: [PATCH 33/37] fix(cron): support Telegram topic/thread ID in delivery target When delivering cron job output to Telegram, the 'to' field now supports specifying a topic (forum thread) ID in addition to the chat ID. Supported formats: - chatId (plain chat ID or @username) - chatId:topicId (chat ID with numeric topic ID) - chatId:topic:topicId (alternative format with explicit marker) This enables cron jobs to deliver messages to specific forum topics rather than always going to the main/general topic. Adds parseTelegramTarget helper function with unit tests. (cherry picked from commit 24a6595e81d6d1824b2657d714b212c587c3ee8e) From 86f4f76a835efe983d3cfd419b7c03f487f029b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 21:20:59 +0100 Subject: [PATCH 34/37] fix(cron): parse telegram targets with prefixes (thanks @mitschabaude-bot) --- CHANGELOG.md | 1 + src/cron/isolated-agent.test.ts | 16 ++++++++++++++++ src/cron/isolated-agent.ts | 11 ++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c87f3113..f4bb1a5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos +- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index c9677c7f7..d7f29716c 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -716,4 +716,20 @@ describe("parseTelegramTarget", () => { topicId: undefined, }); }); + + it("strips internal telegram prefix", () => { + expect(parseTelegramTarget("telegram:123")).toEqual({ + chatId: "123", + topicId: undefined, + }); + }); + + it("strips internal telegram + group prefixes before parsing topic", () => { + expect(parseTelegramTarget("telegram:group:-1001234567890:topic:456")).toEqual( + { + chatId: "-1001234567890", + topicId: 456, + }, + ); + }); }); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 0cc056c5a..fa093719f 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -61,7 +61,16 @@ export function parseTelegramTarget(to: string): { chatId: string; topicId: number | undefined; } { - const trimmed = to.trim(); + let trimmed = to.trim(); + + // Cron "lastTo" values can include internal prefixes like `telegram:...` or + // `telegram:group:...` (see normalizeChatId in telegram/send.ts). + // Strip these before parsing `:topic:` / `:` suffixes. + while (true) { + const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); + if (next === trimmed) break; + trimmed = next; + } // Try format: chatId:topic:topicId const topicMatch = /^(.+?):topic:(\d+)$/.exec(trimmed); From ebbb61249b75d706516d116d55f13700bfde0eab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 21:21:50 +0100 Subject: [PATCH 35/37] style(cron): format telegram target tests --- src/cron/isolated-agent.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index d7f29716c..dc37e762e 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -725,11 +725,11 @@ describe("parseTelegramTarget", () => { }); it("strips internal telegram + group prefixes before parsing topic", () => { - expect(parseTelegramTarget("telegram:group:-1001234567890:topic:456")).toEqual( - { - chatId: "-1001234567890", - topicId: 456, - }, - ); + expect( + parseTelegramTarget("telegram:group:-1001234567890:topic:456"), + ).toEqual({ + chatId: "-1001234567890", + topicId: 456, + }); }); }); From 4f5256da7c27881d6a5965286feb5842f408f395 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 20:26:05 +0000 Subject: [PATCH 36/37] docs(changelog): note PR #483 (thanks @AbhisekBasu1) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4bb1a5c4..01b2c09af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 +- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy From b8b0994a440e98dc7285efb845c6fef95fedb927 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 21:28:40 +0100 Subject: [PATCH 37/37] feat(doctor): audit supervisor config + docs --- CHANGELOG.md | 1 + docs/gateway/doctor.md | 13 +- docs/gateway/index.md | 8 ++ docs/platforms/linux.md | 6 +- src/cli/daemon-cli.ts | 18 +++ src/commands/doctor-gateway-services.ts | 90 +++++++++++++ src/commands/doctor.ts | 7 + src/daemon/service-audit.ts | 165 ++++++++++++++++++++++++ src/daemon/systemd.ts | 6 + 9 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 src/daemon/service-audit.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b2c09af..61e63120e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Doctor/Daemon: audit supervisor configs, recommend doctor from daemon status, and document user vs system services. (#?) — thanks @steipete - Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos - Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 0b10f8d56..2bfc221ab 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -51,6 +51,7 @@ cat ~/.clawdbot/clawdbot.json - Sandbox image repair when sandboxing is enabled. - Legacy service migration and extra gateway detection. - Gateway runtime checks (service installed but not running; cached launchd label). +- Supervisor config audit (launchd/systemd/schtasks) with optional repair. - Gateway port collision diagnostics (default `18789`). - Security warnings for open DM policies. - systemd linger check on Linux. @@ -143,17 +144,23 @@ workspace. Doctor runs a health check and offers to restart the gateway when it looks unhealthy. -### 11) Gateway runtime + port diagnostics +### 11) Supervisor config audit + repair +Doctor checks the installed supervisor config (launchd/systemd/schtasks) for +missing or outdated defaults (e.g., systemd network-online dependencies and +restart delay). When it finds a mismatch, it recommends an update and can +rewrite the service file/task to the current defaults. + +### 12) Gateway runtime + port diagnostics Doctor inspects the daemon runtime (PID, last exit status) and warns when the service is installed but not actually running. It also checks for port collisions on the gateway port (default `18789`) and reports likely causes (gateway already running, SSH tunnel). -### 12) Config write + wizard metadata +### 13) Config write + wizard metadata Doctor persists any config changes and stamps wizard metadata to record the doctor run. -### 13) Workspace tips (backup + memory system) +### 14) Workspace tips (backup + memory system) Doctor suggests a workspace memory system when missing and prints a backup tip if the workspace is not already under git. diff --git a/docs/gateway/index.md b/docs/gateway/index.md index f30ce3db6..9179b1c13 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -189,6 +189,14 @@ Bundled mac app: - `launchctl` only works if the LaunchAgent is installed; otherwise use `clawdbot daemon install` first. ## Supervision (systemd user unit) +Clawdbot installs a **systemd user service** by default on Linux/WSL2. We +recommend user services for single-user machines (simpler env, per-user config). +Use a **system service** for multi-user or always-on servers (no lingering +required, shared supervision). + +`clawdbot daemon install` writes the user unit. `clawdbot doctor` audits the +unit and can update it to match the current recommended defaults. + Create `~/.config/systemd/user/clawdbot-gateway.service`: ``` [Unit] diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index 819462199..1c4913983 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -54,7 +54,11 @@ clawdbot doctor ``` ## System control (systemd user unit) -Full unit example lives in the [Gateway runbook](/gateway). Minimal setup: +Clawdbot installs a systemd **user** service by default. Use a **system** +service for shared or always-on servers. The full unit example and guidance +live in the [Gateway runbook](/gateway). + +Minimal setup: Create `~/.config/systemd/user/clawdbot-gateway.service`: diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index d24ed3b08..f135fc868 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -33,6 +33,8 @@ import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { findLegacyGatewayServices } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import type { ServiceConfigAudit } from "../daemon/service-audit.js"; +import { auditGatewayServiceConfig } from "../daemon/service-audit.js"; import { callGateway } from "../gateway/call.js"; import { resolveGatewayBindHost } from "../gateway/net.js"; import { @@ -89,6 +91,7 @@ type DaemonStatus = { cachedLabel?: boolean; missingUnit?: boolean; }; + configAudit?: ServiceConfigAudit; }; config?: { cli: ConfigSummary; @@ -343,6 +346,10 @@ async function gatherDaemonStatus(opts: { service.readCommand(process.env).catch(() => null), service.readRuntime(process.env).catch(() => undefined), ]); + const configAudit = await auditGatewayServiceConfig({ + env: process.env, + command, + }); const serviceEnv = command?.environment ?? undefined; const mergedDaemonEnv = { @@ -484,6 +491,7 @@ async function gatherDaemonStatus(opts: { notLoadedText: service.notLoadedText, command, runtime, + configAudit, }, config: { cli: cliConfigSummary, @@ -538,6 +546,16 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { if (daemonEnvLines.length > 0) { defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`); } + if (service.configAudit?.issues.length) { + defaultRuntime.error( + "Service config looks out of date or non-standard.", + ); + for (const issue of service.configAudit.issues) { + const detail = issue.detail ? ` (${issue.detail})` : ""; + defaultRuntime.error(`Service config issue: ${issue.message}${detail}`); + } + defaultRuntime.error('Recommendation: run "clawdbot doctor".'); + } if (status.config) { const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`; defaultRuntime.log(`Config (cli): ${cliCfg}`); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 113fd9b17..015fadb94 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -15,6 +15,7 @@ import { } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { auditGatewayServiceConfig } from "../daemon/service-audit.js"; import type { RuntimeEnv } from "../runtime.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, @@ -23,6 +24,18 @@ import { } from "./daemon-runtime.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; +function detectGatewayRuntime( + programArguments: string[] | undefined, +): GatewayDaemonRuntime { + const first = programArguments?.[0]; + if (first) { + const base = path.basename(first).toLowerCase(); + if (base === "bun" || base === "bun.exe") return "bun"; + if (base === "node" || base === "node.exe") return "node"; + } + return DEFAULT_GATEWAY_DAEMON_RUNTIME; +} + export async function maybeMigrateLegacyGatewayService( cfg: ClawdbotConfig, mode: "local" | "remote", @@ -112,6 +125,83 @@ export async function maybeMigrateLegacyGatewayService( }); } +export async function maybeRepairGatewayServiceConfig( + cfg: ClawdbotConfig, + mode: "local" | "remote", + runtime: RuntimeEnv, + prompter: DoctorPrompter, +) { + if (resolveIsNixMode(process.env)) { + note("Nix mode detected; skip service updates.", "Gateway"); + return; + } + + if (mode === "remote") { + note("Gateway mode is remote; skipped local service audit.", "Gateway"); + return; + } + + const service = resolveGatewayService(); + const command = await service.readCommand(process.env).catch(() => null); + if (!command) return; + + const audit = await auditGatewayServiceConfig({ + env: process.env, + command, + }); + if (audit.issues.length === 0) return; + + note( + audit.issues + .map((issue) => + issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`, + ) + .join("\n"), + "Gateway service config", + ); + + const repair = await prompter.confirmSkipInNonInteractive({ + message: "Update gateway service config to the recommended defaults now?", + initialValue: true, + }); + if (!repair) return; + + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const port = resolveGatewayPort(cfg, process.env); + const runtimeChoice = detectGatewayRuntime(command.programArguments); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ + port, + dev: devMode, + runtime: runtimeChoice, + }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE, + CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR, + CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH, + CLAWDBOT_GATEWAY_PORT: String(port), + CLAWDBOT_GATEWAY_TOKEN: + cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, + CLAWDBOT_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + + try { + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + } catch (err) { + runtime.error(`Gateway service update failed: ${String(err)}`); + } +} + export async function maybeScanExtraGatewayServices(options: DoctorOptions) { const extraServices = await findExtraGatewayServices(process.env, { deep: options.deep, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 7c04e0a3d..d999d41e4 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -30,6 +30,7 @@ import { } from "./doctor-format.js"; import { maybeMigrateLegacyGatewayService, + maybeRepairGatewayServiceConfig, maybeScanExtraGatewayServices, } from "./doctor-gateway-services.js"; import { @@ -157,6 +158,12 @@ export async function doctorCommand( prompter, ); await maybeScanExtraGatewayServices(options); + await maybeRepairGatewayServiceConfig( + cfg, + resolveMode(cfg), + runtime, + prompter, + ); await noteSecurityWarnings(cfg); diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts new file mode 100644 index 000000000..c8ae0c8b8 --- /dev/null +++ b/src/daemon/service-audit.ts @@ -0,0 +1,165 @@ +import fs from "node:fs/promises"; +import { resolveLaunchAgentPlistPath } from "./launchd.js"; +import { resolveSystemdUserUnitPath } from "./systemd.js"; + +export type GatewayServiceCommand = { + programArguments: string[]; + workingDirectory?: string; + environment?: Record; + sourcePath?: string; +} | null; + +export type ServiceConfigIssue = { + code: string; + message: string; + detail?: string; +}; + +export type ServiceConfigAudit = { + ok: boolean; + issues: ServiceConfigIssue[]; +}; + +function hasGatewaySubcommand(programArguments?: string[]): boolean { + return Boolean(programArguments?.some((arg) => arg === "gateway")); +} + +function parseSystemdUnit(content: string): { + after: Set; + wants: Set; + restartSec?: string; +} { + const after = new Set(); + const wants = new Set(); + let restartSec: string | undefined; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + if (line.startsWith("#") || line.startsWith(";")) continue; + if (line.startsWith("[")) continue; + const idx = line.indexOf("="); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (!value) continue; + if (key === "After") { + for (const entry of value.split(/\s+/)) { + if (entry) after.add(entry); + } + } else if (key === "Wants") { + for (const entry of value.split(/\s+/)) { + if (entry) wants.add(entry); + } + } else if (key === "RestartSec") { + restartSec = value; + } + } + + return { after, wants, restartSec }; +} + +function isRestartSecPreferred(value: string | undefined): boolean { + if (!value) return false; + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) return false; + return Math.abs(parsed - 5) < 0.01; +} + +async function auditSystemdUnit( + env: Record, + issues: ServiceConfigIssue[], +) { + const unitPath = resolveSystemdUserUnitPath(env); + let content = ""; + try { + content = await fs.readFile(unitPath, "utf8"); + } catch { + return; + } + + const parsed = parseSystemdUnit(content); + if (!parsed.after.has("network-online.target")) { + issues.push({ + code: "systemd-after-network-online", + message: "Missing systemd After=network-online.target", + detail: unitPath, + }); + } + if (!parsed.wants.has("network-online.target")) { + issues.push({ + code: "systemd-wants-network-online", + message: "Missing systemd Wants=network-online.target", + detail: unitPath, + }); + } + if (!isRestartSecPreferred(parsed.restartSec)) { + issues.push({ + code: "systemd-restart-sec", + message: "RestartSec does not match the recommended 5s", + detail: unitPath, + }); + } +} + +async function auditLaunchdPlist( + env: Record, + issues: ServiceConfigIssue[], +) { + const plistPath = resolveLaunchAgentPlistPath(env); + let content = ""; + try { + content = await fs.readFile(plistPath, "utf8"); + } catch { + return; + } + + const hasRunAtLoad = /RunAtLoad<\/key>\s*/i.test(content); + const hasKeepAlive = /KeepAlive<\/key>\s*/i.test(content); + if (!hasRunAtLoad) { + issues.push({ + code: "launchd-run-at-load", + message: "LaunchAgent is missing RunAtLoad=true", + detail: plistPath, + }); + } + if (!hasKeepAlive) { + issues.push({ + code: "launchd-keep-alive", + message: "LaunchAgent is missing KeepAlive=true", + detail: plistPath, + }); + } +} + +function auditGatewayCommand( + programArguments: string[] | undefined, + issues: ServiceConfigIssue[], +) { + if (!programArguments || programArguments.length === 0) return; + if (!hasGatewaySubcommand(programArguments)) { + issues.push({ + code: "gateway-command-missing", + message: "Service command does not include the gateway subcommand", + }); + } +} + +export async function auditGatewayServiceConfig(params: { + env: Record; + command: GatewayServiceCommand; + platform?: NodeJS.Platform; +}): Promise { + const issues: ServiceConfigIssue[] = []; + const platform = params.platform ?? process.platform; + + auditGatewayCommand(params.command?.programArguments, issues); + + if (platform === "linux") { + await auditSystemdUnit(params.env, issues); + } else if (platform === "darwin") { + await auditLaunchdPlist(params.env, issues); + } + + return { ok: issues.length === 0, issues }; +} diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 2b659ec77..f5fdc4829 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -33,6 +33,12 @@ function resolveSystemdUnitPath( return resolveSystemdUnitPathForName(env, GATEWAY_SYSTEMD_SERVICE_NAME); } +export function resolveSystemdUserUnitPath( + env: Record, +): string { + return resolveSystemdUnitPath(env); +} + function resolveLoginctlUser( env: Record, ): string | null {