diff --git a/CHANGELOG.md b/CHANGELOG.md index e887663b0..3cf3c15bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,27 @@ ## Unreleased +- 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 +- 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 - 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). - 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. - 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. +- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf ## 2026.1.8 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/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") + } +} 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 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"); }); 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", diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 0cc248d1c..6e65acb83 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -16,12 +16,20 @@ 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" }], + [ + "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 95f7a68ba..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()), - id: 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()), - id: 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()), - id: 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()), - id: 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, "id", { 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, "id", { 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, "id", { 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, "id", { 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 }), ); diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index f4462cfc9..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", () => { @@ -184,4 +190,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; } 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/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, 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/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: {}, 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[]; }; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 28f99cbcb..03ac40fda 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,259 @@ 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 +556,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 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/discord/monitor.gateway.test.ts b/src/discord/monitor.gateway.test.ts new file mode 100644 index 000000000..26685da72 --- /dev/null +++ b/src/discord/monitor.gateway.test.ts @@ -0,0 +1,61 @@ +import { EventEmitter } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { waitForDiscordGatewayStop } from "./monitor.gateway.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); + }); + + 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 27baa3c66..754d5f302 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -61,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"; @@ -402,18 +406,19 @@ 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 = getDiscordGatewayEmitter(gateway); + await waitForDiscordGatewayStop({ + gateway: gateway + ? { + emitter: gatewayEmitter, + disconnect: () => gateway.disconnect(), + } + : undefined, + abortSignal: opts.abortSignal, + onGatewayError: (err) => { + runtime.error?.(danger(`discord gateway error: ${String(err)}`)); + }, }); } 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/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( diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 97f766477..417f267b3 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,13 +393,15 @@ 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), + 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/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..6e26a98ae 100644 --- a/ui/src/ui/controllers/skills.ts +++ b/ui/src/ui/controllers/skills.ts @@ -9,9 +9,37 @@ export type SkillsState = { skillsError: string | null; skillsBusyKey: string | null; skillEdits: Record; + skillMessages: SkillMessageMap; }; -export async function loadSkills(state: SkillsState) { +export type SkillMessage = { + kind: "success" | "error"; + message: string; +}; + +export type SkillMessageMap = Record; + +type LoadSkillsOptions = { + clearMessages?: boolean; +}; + +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; +} + +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; @@ -22,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; } @@ -47,8 +75,17 @@ 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); + const message = getErrorMessage(err); + state.skillsError = message; + setSkillMessage(state, skillKey, { + kind: "error", + message, + }); } finally { state.skillsBusyKey = null; } @@ -62,8 +99,17 @@ 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); + const message = getErrorMessage(err); + state.skillsError = message; + setSkillMessage(state, skillKey, { + kind: "error", + message, + }); } finally { state.skillsBusyKey = null; } @@ -71,23 +117,32 @@ 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 { - 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, skillKey, { + kind: "success", + message: result?.message ?? "Installed", + }); } catch (err) { - state.skillsError = String(err); + const message = getErrorMessage(err); + state.skillsError = message; + setSkillMessage(state, skillKey, { + kind: "error", + message, + }); } finally { state.skillsBusyKey = null; } } - diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 50861fee1..cfc024cb1 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,12 +11,13 @@ export type SkillsProps = { filter: string; edits: Record; busyKey: string | null; + messages: SkillMessageMap; onFilterChange: (next: string) => void; onRefresh: () => void; 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) { @@ -71,8 +73,11 @@ 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] ?? 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 +125,29 @@ 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`