From 975aa5bf82e981f9ddbf80520e17c02b67bb7f9d Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 9 Jan 2026 14:19:43 +0200 Subject: [PATCH 1/2] fix(macos): add node bridge ping loop --- .../NodeMode/MacNodeBridgeSession.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift index d9b7c5777..b6ddf6451 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift @@ -23,6 +23,9 @@ actor MacNodeBridgeSession { private var buffer = Data() private var pendingRPC: [String: CheckedContinuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] + private var pingTask: Task? + private var lastPongAt: Date? + private var lastPingId: String? private(set) var state: State = .idle @@ -77,6 +80,7 @@ actor MacNodeBridgeSession { if base.type == "hello-ok" { let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) self.state = .connected(serverName: ok.serverName) + self.startPingLoop() await onConnected?(ok.serverName) } else if base.type == "error" { let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) @@ -113,6 +117,10 @@ actor MacNodeBridgeSession { let ping = try self.decoder.decode(BridgePing.self, from: nextData) try await self.send(BridgePong(type: "pong", id: ping.id)) + case "pong": + let pong = try self.decoder.decode(BridgePong.self, from: nextData) + self.notePong(pong) + case "invoke": let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData) let res = await onInvoke(req) @@ -182,6 +190,11 @@ actor MacNodeBridgeSession { } func disconnect() async { + self.pingTask?.cancel() + self.pingTask = nil + self.lastPongAt = nil + self.lastPingId = nil + self.connection?.cancel() self.connection = nil self.queue = nil @@ -280,6 +293,52 @@ actor MacNodeBridgeSession { } } + private func startPingLoop() { + self.pingTask?.cancel() + self.lastPongAt = Date() + self.pingTask = Task { [weak self] in + guard let self else { return } + await self.runPingLoop() + } + } + + private func runPingLoop() async { + let intervalSeconds = 15.0 + let timeoutSeconds = 45.0 + + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: UInt64(intervalSeconds * 1_000_000_000)) + } catch { + return + } + + guard self.connection != nil else { return } + + if let last = self.lastPongAt, + Date().timeIntervalSince(last) > timeoutSeconds + { + await self.disconnect() + return + } + + let id = UUID().uuidString + self.lastPingId = id + do { + try await self.send(BridgePing(type: "ping", id: id)) + } catch { + await self.disconnect() + return + } + } + } + + private func notePong(_ pong: BridgePong) { + if pong.id == self.lastPingId || self.lastPingId == nil { + self.lastPongAt = Date() + } + } + private static func makeStateStream( for connection: NWConnection) -> AsyncStream { From cb86d0d6d4c3c3f2ecabc13a331cd6405e5ecba2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:01:20 +0100 Subject: [PATCH 2/2] fix: land mac node bridge ping loop (#572) (thanks @ngutman) --- CHANGELOG.md | 1 + .../NodeMode/MacNodeBridgeSession.swift | 12 ++++--- .../MacNodeBridgeSessionTests.swift | 19 ++++++++++ src/cli/gateway-cli.ts | 25 ++++++++----- src/commands/doctor.test.ts | 2 +- src/commands/doctor.ts | 11 +++--- src/commands/onboard-auth.ts | 36 ++++++++----------- src/commands/onboard-providers.ts | 6 ++-- src/config/types.ts | 1 + src/msteams/monitor.ts | 5 +-- src/telegram/send.ts | 8 ++--- src/telegram/webhook-set.ts | 10 ++---- 12 files changed, 80 insertions(+), 56 deletions(-) create mode 100644 apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeSessionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c775398c5..4dc7f0491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Commands: accept /models as an alias for /model. diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift index b6ddf6451..7c8f5ec7e 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift @@ -252,12 +252,17 @@ actor MacNodeBridgeSession { } private func send(_ obj: some Encodable) async throws { + guard let connection = self.connection else { + throw NSError(domain: "Bridge", code: 15, userInfo: [ + NSLocalizedDescriptionKey: "not connected", + ]) + } let data = try self.encoder.encode(obj) var line = Data() line.append(data) line.append(0x0A) try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation) in - self.connection?.send(content: line, completion: .contentProcessed { err in + connection.send(content: line, completion: .contentProcessed { err in if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) } }) } @@ -334,9 +339,8 @@ actor MacNodeBridgeSession { } private func notePong(_ pong: BridgePong) { - if pong.id == self.lastPingId || self.lastPingId == nil { - self.lastPongAt = Date() - } + _ = pong + self.lastPongAt = Date() } private static func makeStateStream( diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeSessionTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeSessionTests.swift new file mode 100644 index 000000000..f7521a66a --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeSessionTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import Clawdbot + +@Suite +struct MacNodeBridgeSessionTests { + @Test func sendEventThrowsWhenNotConnected() async { + let session = MacNodeBridgeSession() + + do { + try await session.sendEvent(event: "test", payloadJSON: "{}") + Issue.record("Expected sendEvent to throw when disconnected") + } catch { + let ns = error as NSError + #expect(ns.domain == "Bridge") + #expect(ns.code == 15) + } + } +} + diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 86776d28d..8bde6b319 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -179,14 +179,23 @@ async function ensureDevGatewayConfig(opts: { reset?: boolean }) { mode: "local", bind: "loopback", }, - agent: { - workspace, - skipBootstrap: true, - }, - identity: { - name: DEV_IDENTITY_NAME, - theme: DEV_IDENTITY_THEME, - emoji: DEV_IDENTITY_EMOJI, + agents: { + defaults: { + workspace, + skipBootstrap: true, + }, + list: [ + { + id: "dev", + default: true, + workspace, + identity: { + name: DEV_IDENTITY_NAME, + theme: DEV_IDENTITY_THEME, + emoji: DEV_IDENTITY_EMOJI, + }, + }, + ], }, }); await ensureDevWorkspace(workspace); diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 468548e55..25753bb91 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -278,7 +278,7 @@ describe("doctor", () => { changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], }); - await doctorCommand(runtime); + await doctorCommand(runtime, { nonInteractive: true }); expect(writeConfigFile).toHaveBeenCalledTimes(1); const written = writeConfigFile.mock.calls[0]?.[0] as Record< diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 9c27ff030..f26bc61bb 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -114,10 +114,13 @@ export async function doctorCommand( .join("\n"), "Legacy config keys detected", ); - const migrate = await prompter.confirm({ - message: "Migrate legacy config entries now?", - initialValue: true, - }); + const migrate = + options.nonInteractive === true + ? true + : await prompter.confirm({ + message: "Migrate legacy config entries now?", + initialValue: true, + }); if (migrate) { // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. const { config: migrated, changes } = migrateLegacyConfig( diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 14325ff20..37c4ce95c 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -10,12 +10,6 @@ const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; const DEFAULT_MINIMAX_MAX_TOKENS = 8192; export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; -const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; -export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1"; -const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; -const DEFAULT_MINIMAX_MAX_TOKENS = 8192; -export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; - export async function writeOAuthCredentials( provider: OAuthProvider, creds: OAuthCredentials, @@ -176,7 +170,7 @@ export function applyMinimaxHostedProviderConfig( cfg: ClawdbotConfig, params?: { baseUrl?: string }, ): ClawdbotConfig { - const models = { ...cfg.agent?.models }; + const models = { ...cfg.agents?.defaults?.models }; models[MINIMAX_HOSTED_MODEL_REF] = { ...models[MINIMAX_HOSTED_MODEL_REF], alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax", @@ -212,9 +206,12 @@ export function applyMinimaxHostedProviderConfig( return { ...cfg, - agent: { - ...cfg.agent, - models, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, }, models: { mode: cfg.models?.mode ?? "merge", @@ -254,17 +251,14 @@ export function applyMinimaxHostedConfig( const next = applyMinimaxHostedProviderConfig(cfg, params); return { ...next, - agent: { - ...next.agent, - model: { - ...(next.agent?.model && - "fallbacks" in (next.agent.model as Record) - ? { - fallbacks: (next.agent.model as { fallbacks?: string[] }) - .fallbacks, - } - : undefined), - primary: MINIMAX_HOSTED_MODEL_REF, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(next.agents?.defaults?.model ?? {}), + primary: MINIMAX_HOSTED_MODEL_REF, + }, }, }, }; diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 23dc60e22..dd08e30cf 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -546,7 +546,8 @@ async function promptWhatsAppAllowFrom( "WhatsApp number", ); const entry = await prompter.text({ - message: "Your personal WhatsApp number (the phone you will message from)", + message: + "Your personal WhatsApp number (the phone you will message from)", placeholder: "+15555550123", initialValue: existingAllowFrom[0], validate: (value) => { @@ -613,7 +614,8 @@ async function promptWhatsAppAllowFrom( "WhatsApp number", ); const entry = await prompter.text({ - message: "Your personal WhatsApp number (the phone you will message from)", + message: + "Your personal WhatsApp number (the phone you will message from)", placeholder: "+15555550123", initialValue: existingAllowFrom[0], validate: (value) => { diff --git a/src/config/types.ts b/src/config/types.ts index e09d1af69..0b3ab55da 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1211,6 +1211,7 @@ export type AgentDefaultsConfig = { | "slack" | "signal" | "imessage" + | "msteams" | "none"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts index a137cd190..c9bf23bf2 100644 --- a/src/msteams/monitor.ts +++ b/src/msteams/monitor.ts @@ -56,8 +56,9 @@ export async function monitorMSTeamsProvider( const textLimit = resolveTextChunkLimit(cfg, "msteams"); const MB = 1024 * 1024; const mediaMaxBytes = - typeof cfg.agent?.mediaMaxMb === "number" && cfg.agent.mediaMaxMb > 0 - ? Math.floor(cfg.agent.mediaMaxMb * MB) + typeof cfg.agents?.defaults?.mediaMaxMb === "number" && + cfg.agents.defaults.mediaMaxMb > 0 + ? Math.floor(cfg.agents.defaults.mediaMaxMb * MB) : 8 * MB; const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs(); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 2464d5b5e..5309a5f89 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -122,9 +122,7 @@ export async function sendMessageTelegram( 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 api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const mediaUrl = opts.mediaUrl?.trim(); // Build optional params for forum topics and reply threading. @@ -296,9 +294,7 @@ export async function reactMessageTelegram( 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 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 69609bcd6..eced660e6 100644 --- a/src/telegram/webhook-set.ts +++ b/src/telegram/webhook-set.ts @@ -11,10 +11,7 @@ export async function setTelegramWebhook(opts: { const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; - const bot = new Bot( - opts.token, - client ? { client } : 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, @@ -26,9 +23,6 @@ export async function deleteTelegramWebhook(opts: { token: string }) { const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; - const bot = new Bot( - opts.token, - client ? { client } : undefined, - ); + const bot = new Bot(opts.token, client ? { client } : undefined); await bot.api.deleteWebhook(); }