feat: add heartbeat toggle with live RPC control

This commit is contained in:
Peter Steinberger
2025-12-07 15:32:48 +01:00
parent 2dbef6105d
commit b30db08110
7 changed files with 57 additions and 1 deletions

View File

@@ -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 // MARK: - Process lifecycle
func start() async throws { func start() async throws {

View File

@@ -98,6 +98,12 @@ final class AppState: ObservableObject {
@Published var isWorking: Bool = false @Published var isWorking: Bool = false
@Published var earBoostActive: 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<Void, Never>? private var earBoostTask: Task<Void, Never>?
@@ -129,6 +135,12 @@ final class AppState: ObservableObject {
self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? "" self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? ""
self.voiceWakeForwardCommand = UserDefaults.standard self.voiceWakeForwardCommand = UserDefaults.standard
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand .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() { if self.swabbleEnabled && !PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = false self.swabbleEnabled = false

View File

@@ -22,6 +22,7 @@ let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity"
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand" let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload" let modelCatalogReloadKey = "clawdis.modelCatalogReload"
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"] let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
let defaultVoiceWakeForwardCommand = "clawdis-mac agent --message \"${text}\" --thinking low --session main --deliver" let defaultVoiceWakeForwardCommand = "clawdis-mac agent --message \"${text}\" --thinking low --session main --deliver"

View File

@@ -62,6 +62,7 @@ private struct MenuContent: View {
Toggle(isOn: self.activeBinding) { Text("Clawdis Active") } Toggle(isOn: self.activeBinding) { Text("Clawdis Active") }
self.relayStatusRow self.relayStatusRow
self.healthStatusRow self.healthStatusRow
Toggle(isOn: self.heartbeatsBinding) { Text("Send heartbeats") }
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") } Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
.disabled(!voiceWakeSupported) .disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5) .opacity(voiceWakeSupported ? 1 : 0.5)
@@ -131,6 +132,10 @@ private struct MenuContent: View {
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
} }
private var heartbeatsBinding: Binding<Bool> {
Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 })
}
private var voiceWakeBinding: Binding<Bool> { private var voiceWakeBinding: Binding<Bool> {
Binding( Binding(
get: { self.state.swabbleEnabled }, get: { self.state.swabbleEnabled },
@@ -451,7 +456,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
if let state { if let state {
RelayProcessManager.shared.setActive(!state.isPaused) 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) } Task { await HealthStore.shared.refresh(onDemand: true) }
self.startListener() self.startListener()
self.scheduleFirstRunOnboardingIfNeeded() self.scheduleFirstRunOnboardingIfNeeded()

View File

@@ -14,6 +14,7 @@ import {
monitorWebProvider, monitorWebProvider,
resolveHeartbeatRecipients, resolveHeartbeatRecipients,
runWebHeartbeatOnce, runWebHeartbeatOnce,
setHeartbeatsEnabled,
type WebMonitorTuning, type WebMonitorTuning,
} from "../provider-web.js"; } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -240,6 +241,11 @@ Examples:
respond({ type: "result", ok: true }); respond({ type: "result", ok: true });
return; return;
} }
if (cmd.type === "set-heartbeats") {
setHeartbeatsEnabled(Boolean(cmd.enabled));
respond({ type: "result", ok: true });
return;
}
if (cmd.type !== "send" || !cmd.text) { if (cmd.type !== "send" || !cmd.text) {
respond({ type: "error", error: "unsupported command" }); respond({ type: "error", error: "unsupported command" });
return; return;

View File

@@ -7,6 +7,7 @@ export {
monitorWebProvider, monitorWebProvider,
resolveHeartbeatRecipients, resolveHeartbeatRecipients,
runWebHeartbeatOnce, runWebHeartbeatOnce,
setHeartbeatsEnabled,
type WebMonitorTuning, type WebMonitorTuning,
} from "./web/auto-reply.js"; } from "./web/auto-reply.js";
export { export {

View File

@@ -33,6 +33,11 @@ import { getWebAuthAgeMs } from "./session.js";
const WEB_TEXT_LIMIT = 4000; const WEB_TEXT_LIMIT = 4000;
const DEFAULT_GROUP_HISTORY_LIMIT = 50; 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. * Send a message via IPC if relay is running, otherwise fall back to direct.
* This avoids Signal session corruption from multiple Baileys connections. * This avoids Signal session corruption from multiple Baileys connections.
@@ -1026,6 +1031,7 @@ export async function monitorWebProvider(
if (keepAlive) { if (keepAlive) {
heartbeat = setInterval(() => { heartbeat = setInterval(() => {
if (!heartbeatsEnabled) return;
const authAgeMs = getWebAuthAgeMs(); const authAgeMs = getWebAuthAgeMs();
const minutesSinceLastMessage = lastMessageAt const minutesSinceLastMessage = lastMessageAt
? Math.floor((Date.now() - lastMessageAt) / 60000) ? Math.floor((Date.now() - lastMessageAt) / 60000)
@@ -1081,6 +1087,7 @@ export async function monitorWebProvider(
} }
const runReplyHeartbeat = async () => { const runReplyHeartbeat = async () => {
if (!heartbeatsEnabled) return;
const queued = getQueueSize(); const queued = getQueueSize();
if (queued > 0) { if (queued > 0) {
heartbeatLogger.info( heartbeatLogger.info(
@@ -1282,6 +1289,7 @@ export async function monitorWebProvider(
if (replyHeartbeatMinutes && !replyHeartbeatTimer) { if (replyHeartbeatMinutes && !replyHeartbeatTimer) {
const intervalMs = replyHeartbeatMinutes * 60_000; const intervalMs = replyHeartbeatMinutes * 60_000;
replyHeartbeatTimer = setInterval(() => { replyHeartbeatTimer = setInterval(() => {
if (!heartbeatsEnabled) return;
void runReplyHeartbeat(); void runReplyHeartbeat();
}, intervalMs); }, intervalMs);
if (tuning.replyHeartbeatNow) { if (tuning.replyHeartbeatNow) {