From 871e88828396b310bd89b06370d8ac19ada66bdc 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 908fb94f0b7c343b9e29b886be72425bc9a0f20e 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 1f7dbb6a48a47305866b26244017b79346a61782 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 5fe39307cd852d022e5380bea72ef7c11492fb72 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 7450aed663a60c34d2e03e9d81a76b319ab26b7d 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 cc94db458c254a0dbe753c2031715f2863263464 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 d05de07fdda67c0ac59e720871a6f60c71f10839 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 b7c900739e28dc85f87ae9d671c4121ce831766c 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 c54f2a122a335f490436b5f590d5559944aecbd0 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 7866203c5cf554990b5304845d9b3cbb18f17f9e 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 43e0462405ba4cd4d8fe5f1f2cea54f9448eb199 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 bf67b29a0e72de620195d50708a5a94724402ab9 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 393c414e905185f94224779e44a9d87e5acecd52 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 ba19173b96a2e001190fa02bfb30c3a57f5d5a86 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 29e9a574b04f727d3a3d7809410e9d48b9d0fd2c 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 f1bc1781416a4a7c69425edd1516588fe871728a 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 265ef6d0c65d6357616444f7239395220a0b1294 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 4082b90aa4841453eaecc940eff2b8e977a915ce 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 4a918ccf4735f0df7d8abca6859c1fa16b532a3d 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 2f9306b98ad39db614197e72e9d173083cd375fb 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 4dac298ae2e2acaca47ef140687b7d5722a627ac 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 9d42972b8ab0f2a63bd4472b738581fa983b147f 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 871c9e52865bcd8d0b328723f25ac7ccca1d723e 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 04e0e104111252fcc06d58b5a120f69ae84f6402 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 965615a46c11125d2b2929fb09e9d271c2fb3189 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 7905a27416f86932644189037e1048878c444110 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 dc4c174f4e47f011ae38b3fc76dfae55cf0ff0f5 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 31fb5867e8836ee30d5f40d965a89c5790a36401 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 5a2242ee928606bdeb655ea8bad49fc2479942fb 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 0c74ef25d6078ecba55f7326cd3bc16f9d675269 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 3a8d8bc6a48e2d726352e12774ba75440779b2ce 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 9632ebed7b81b7ea258b1aa0542c8b0ad8d016fd 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 5fea53d89c099103cb5504af79ade13194f72a8b 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 58a9e3523379db10983da6dd71e7d9e6a62da2c8 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 d0c4ce674979813add7cbaf760389ef51c7c1df6 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 01641b34ea62b586cd2380bfe106c86b0d227e6f 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 {