feat: add heartbeat toggle with live RPC control
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user