diff --git a/apps/macos/Sources/Clawdis/AgentRPC.swift b/apps/macos/Sources/Clawdis/AgentRPC.swift index 080c7e66a..fd8a2cd7e 100644 --- a/apps/macos/Sources/Clawdis/AgentRPC.swift +++ b/apps/macos/Sources/Clawdis/AgentRPC.swift @@ -85,6 +85,26 @@ actor AgentRPC { } } + func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { + guard process?.isRunning == true else { return false } + do { + let payload: [String: Any] = ["type": "set-heartbeats", "enabled": enabled] + let data = try JSONSerialization.data(withJSONObject: payload) + guard let stdinHandle else { throw RpcError(message: "stdin missing") } + stdinHandle.write(data) + stdinHandle.write(Data([0x0A])) + + let line = try await nextLine() + let parsed = try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] + if let ok = parsed?["ok"] as? Bool, ok { return true } + return false + } catch { + logger.error("rpc set-heartbeats failed: \(error.localizedDescription, privacy: .public)") + await stop() + return false + } + } + // MARK: - Process lifecycle func start() async throws { diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 5f3f2bab0..31ffc0608 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -98,6 +98,12 @@ final class AppState: ObservableObject { @Published var isWorking: Bool = false @Published var earBoostActive: Bool = false + @Published var heartbeatsEnabled: Bool { + didSet { + UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) + Task { _ = await AgentRPC.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) } + } + } private var earBoostTask: Task? @@ -129,6 +135,12 @@ final class AppState: ObservableObject { self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? "" self.voiceWakeForwardCommand = UserDefaults.standard .string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand + if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { + self.heartbeatsEnabled = storedHeartbeats + } else { + self.heartbeatsEnabled = true + UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey) + } if self.swabbleEnabled && !PermissionManager.voiceWakePermissionsGranted() { self.swabbleEnabled = false diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index c8c260594..d420e55e5 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -22,6 +22,7 @@ let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity" let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand" let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogReloadKey = "clawdis.modelCatalogReload" +let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled" let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"] let defaultVoiceWakeForwardCommand = "clawdis-mac agent --message \"${text}\" --thinking low --session main --deliver" diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index fd40c9ddc..03d60aba6 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -62,6 +62,7 @@ private struct MenuContent: View { Toggle(isOn: self.activeBinding) { Text("Clawdis Active") } self.relayStatusRow self.healthStatusRow + Toggle(isOn: self.heartbeatsBinding) { Text("Send heartbeats") } Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") } .disabled(!voiceWakeSupported) .opacity(voiceWakeSupported ? 1 : 0.5) @@ -131,6 +132,10 @@ private struct MenuContent: View { Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) } + private var heartbeatsBinding: Binding { + Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 }) + } + private var voiceWakeBinding: Binding { Binding( get: { self.state.swabbleEnabled }, @@ -451,7 +456,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate if let state { RelayProcessManager.shared.setActive(!state.isPaused) } - Task { try? await AgentRPC.shared.start() } + Task { + try? await AgentRPC.shared.start() + _ = await AgentRPC.shared.setHeartbeatsEnabled(AppStateStore.shared.heartbeatsEnabled) + } Task { await HealthStore.shared.refresh(onDemand: true) } self.startListener() self.scheduleFirstRunOnboardingIfNeeded() diff --git a/src/cli/program.ts b/src/cli/program.ts index 4bfeab3a9..4bd8f31c4 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -14,6 +14,7 @@ import { monitorWebProvider, resolveHeartbeatRecipients, runWebHeartbeatOnce, + setHeartbeatsEnabled, type WebMonitorTuning, } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; @@ -240,6 +241,11 @@ Examples: respond({ type: "result", ok: true }); return; } + if (cmd.type === "set-heartbeats") { + setHeartbeatsEnabled(Boolean(cmd.enabled)); + respond({ type: "result", ok: true }); + return; + } if (cmd.type !== "send" || !cmd.text) { respond({ type: "error", error: "unsupported command" }); return; diff --git a/src/provider-web.ts b/src/provider-web.ts index 6b2a3922a..bfd37ea90 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -7,6 +7,7 @@ export { monitorWebProvider, resolveHeartbeatRecipients, runWebHeartbeatOnce, + setHeartbeatsEnabled, type WebMonitorTuning, } from "./web/auto-reply.js"; export { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 738190360..0a9cde6cd 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -33,6 +33,11 @@ import { getWebAuthAgeMs } from "./session.js"; const WEB_TEXT_LIMIT = 4000; const DEFAULT_GROUP_HISTORY_LIMIT = 50; +let heartbeatsEnabled = true; +export function setHeartbeatsEnabled(enabled: boolean) { + heartbeatsEnabled = enabled; +} + /** * Send a message via IPC if relay is running, otherwise fall back to direct. * This avoids Signal session corruption from multiple Baileys connections. @@ -1026,6 +1031,7 @@ export async function monitorWebProvider( if (keepAlive) { heartbeat = setInterval(() => { + if (!heartbeatsEnabled) return; const authAgeMs = getWebAuthAgeMs(); const minutesSinceLastMessage = lastMessageAt ? Math.floor((Date.now() - lastMessageAt) / 60000) @@ -1081,6 +1087,7 @@ export async function monitorWebProvider( } const runReplyHeartbeat = async () => { + if (!heartbeatsEnabled) return; const queued = getQueueSize(); if (queued > 0) { heartbeatLogger.info( @@ -1282,6 +1289,7 @@ export async function monitorWebProvider( if (replyHeartbeatMinutes && !replyHeartbeatTimer) { const intervalMs = replyHeartbeatMinutes * 60_000; replyHeartbeatTimer = setInterval(() => { + if (!heartbeatsEnabled) return; void runReplyHeartbeat(); }, intervalMs); if (tuning.replyHeartbeatNow) {