From 6fdc62c008e62b37d93a27d7da87d2c124357b19 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 16:07:37 +0100 Subject: [PATCH] macOS: fold AgentRPC into GatewayConnection --- apps/macos/Sources/Clawdis/AgentRPC.swift | 74 ------------------- apps/macos/Sources/Clawdis/AppState.swift | 2 +- .../Sources/Clawdis/Bridge/BridgeServer.swift | 12 ++- apps/macos/Sources/Clawdis/CanvasWindow.swift | 4 +- .../Clawdis/ControlRequestHandler.swift | 8 +- apps/macos/Sources/Clawdis/DebugActions.swift | 2 +- .../Sources/Clawdis/GatewayConnection.swift | 72 +++++++++++++++++- apps/macos/Sources/Clawdis/MenuBar.swift | 1 - .../Sources/Clawdis/VoiceWakeForwarder.swift | 6 +- 9 files changed, 87 insertions(+), 94 deletions(-) delete mode 100644 apps/macos/Sources/Clawdis/AgentRPC.swift diff --git a/apps/macos/Sources/Clawdis/AgentRPC.swift b/apps/macos/Sources/Clawdis/AgentRPC.swift deleted file mode 100644 index 3cee19cca..000000000 --- a/apps/macos/Sources/Clawdis/AgentRPC.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import OSLog - -struct ControlRequestParams: @unchecked Sendable { - /// Heterogeneous JSON-ish params (Bool/String/Int/Double/[...]/[String:...]). - /// `@unchecked Sendable` is intentional: values are treated as immutable payloads. - let raw: [String: Any] -} - -actor AgentRPC { - static let shared = AgentRPC() - - private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc") - - func shutdown() async { - // no-op; socket managed by GatewayConnection - } - - func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { - do { - _ = try await self.controlRequest( - method: "set-heartbeats", - params: ControlRequestParams(raw: ["enabled": enabled])) - return true - } catch { - self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") - return false - } - } - - func status() async -> (ok: Bool, error: String?) { - do { - let data = try await controlRequest(method: "status") - if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - (obj["ok"] as? Bool) ?? true - { - return (true, nil) - } - return (false, "status error") - } catch { - return (false, error.localizedDescription) - } - } - - func send( - text: String, - thinking: String?, - sessionKey: String, - deliver: Bool, - to: String?, - channel: String? = nil) async -> (ok: Bool, text: String?, error: String?) - { - do { - let params: [String: Any] = [ - "message": text, - "sessionKey": sessionKey, - "thinking": thinking ?? "default", - "deliver": deliver, - "to": to ?? "", - "channel": channel ?? "", - "idempotencyKey": UUID().uuidString, - ] - _ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params)) - return (true, nil, nil) - } catch { - return (false, nil, error.localizedDescription) - } - } - - func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data { - let rawParams = params?.raw.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) } - return try await GatewayConnection.shared.request(method: method, params: rawParams) - } -} diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 2528406fc..07ebda9ee 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -131,7 +131,7 @@ final class AppState { didSet { self.ifNotPreview { UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) - Task { _ = await AgentRPC.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) } + Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) } } } } diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift index 9295d94e1..1b1e25517 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift @@ -167,8 +167,8 @@ actor BridgeServer { let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "node-\(nodeId)" - _ = await AgentRPC.shared.send( - text: text, + _ = await GatewayConnection.shared.sendAgent( + message: text, thinking: "low", sessionKey: sessionKey, deliver: false, @@ -193,8 +193,8 @@ actor BridgeServer { let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - _ = await AgentRPC.shared.send( - text: message, + _ = await GatewayConnection.shared.sendAgent( + message: message, thinking: thinking, sessionKey: sessionKey, deliver: link.deliver, @@ -357,9 +357,7 @@ actor BridgeServer { ] if let ip { params["ip"] = ip } if let version { params["version"] = version } - _ = try await AgentRPC.shared.controlRequest( - method: "system-event", - params: ControlRequestParams(raw: params)) + _ = try await GatewayConnection.shared.controlRequest(method: "system-event", params: params) } catch { // Best-effort only. } diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift index d433903ef..2e19f28bc 100644 --- a/apps/macos/Sources/Clawdis/CanvasWindow.swift +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -550,8 +550,8 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan : "A2UI action: \(name)\n\n```json\n\(json)\n```" Task { - let result = await AgentRPC.shared.send( - text: text, + let result = await GatewayConnection.shared.sendAgent( + message: text, thinking: nil, sessionKey: self.sessionKey, deliver: false, diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 55666fe26..fb075617b 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -140,7 +140,7 @@ enum ControlRequestHandler { } private static func handleRPCStatus() async -> Response { - let result = await AgentRPC.shared.status() + let result = await GatewayConnection.shared.status() return Response(ok: result.ok, message: result.error) } @@ -169,15 +169,15 @@ enum ControlRequestHandler { let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") } let sessionKey = session ?? "main" - let rpcResult = await AgentRPC.shared.send( - text: trimmed, + let rpcResult = await GatewayConnection.shared.sendAgent( + message: trimmed, thinking: thinking, sessionKey: sessionKey, deliver: deliver, to: to, channel: nil) return rpcResult.ok - ? Response(ok: true, message: rpcResult.text ?? "sent") + ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error ?? "failed to send") } diff --git a/apps/macos/Sources/Clawdis/DebugActions.swift b/apps/macos/Sources/Clawdis/DebugActions.swift index 508f1f5b8..b0dcfa453 100644 --- a/apps/macos/Sources/Clawdis/DebugActions.swift +++ b/apps/macos/Sources/Clawdis/DebugActions.swift @@ -115,7 +115,7 @@ enum DebugActions { static func sendTestHeartbeat() async -> Result { do { - _ = await AgentRPC.shared.setHeartbeatsEnabled(true) + _ = await GatewayConnection.shared.setHeartbeatsEnabled(true) await ControlChannel.shared.configure() let data = try await ControlChannel.shared.request(method: "last-heartbeat") if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) { diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index 56aec32d1..4c15708a0 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -1,10 +1,11 @@ import ClawdisProtocol import Foundation +import OSLog /// Single, shared Gateway websocket connection for the whole app. /// /// This owns exactly one `GatewayChannelActor` and reuses it across all callers -/// (ControlChannel, AgentRPC, SwiftUI WebChat, etc.). +/// (ControlChannel, debug actions, SwiftUI WebChat, etc.). actor GatewayConnection { static let shared = GatewayConnection() @@ -112,3 +113,72 @@ actor GatewayConnection { try await GatewayEndpointStore.shared.requireConfig() } } + +private let gatewayControlLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.control") + +extension GatewayConnection { + private static func wrapParams(_ raw: [String: Any]?) -> [String: AnyCodable]? { + guard let raw else { return nil } + return raw.reduce(into: [String: AnyCodable]()) { acc, pair in + acc[pair.key] = AnyCodable(pair.value) + } + } + + func controlRequest( + method: String, + params: [String: Any]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + try await self.request(method: method, params: Self.wrapParams(params), timeoutMs: timeoutMs) + } + + func status() async -> (ok: Bool, error: String?) { + do { + let data = try await self.controlRequest(method: "status") + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (obj["ok"] as? Bool) ?? true + { + return (true, nil) + } + return (false, "status error") + } catch { + return (false, error.localizedDescription) + } + } + + func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { + do { + _ = try await self.controlRequest(method: "set-heartbeats", params: ["enabled": enabled]) + return true + } catch { + gatewayControlLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") + return false + } + } + + func sendAgent( + message: String, + thinking: String?, + sessionKey: String, + deliver: Bool, + to: String?, + channel: String? = nil, + idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) + { + do { + let params: [String: Any] = [ + "message": message, + "sessionKey": sessionKey, + "thinking": thinking ?? "default", + "deliver": deliver, + "to": to ?? "", + "channel": channel ?? "", + "idempotencyKey": idempotencyKey, + ] + _ = try await self.controlRequest(method: "agent", params: params) + return (true, nil) + } catch { + return (false, error.localizedDescription) + } + } +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 106d651e3..2b36a9cb9 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -206,7 +206,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { WebChatManager.shared.close() WebChatManager.shared.resetTunnels() Task { await RemoteTunnelManager.shared.stopAll() } - Task { await AgentRPC.shared.shutdown() } Task { await GatewayConnection.shared.shutdown() } Task { await self.socketServer.stop() } Task { await PeekabooBridgeHostCoordinator.shared.stop() } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift index cc2b368e1..58170383d 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -48,8 +48,8 @@ enum VoiceWakeForwarder { let payload = Self.prefixedTranscript(transcript) let channel = options.channel.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let deliver = options.deliver && channel != "webchat" - let result = await AgentRPC.shared.send( - text: payload, + let result = await GatewayConnection.shared.sendAgent( + message: payload, thinking: options.thinking, sessionKey: options.sessionKey, deliver: deliver, @@ -67,7 +67,7 @@ enum VoiceWakeForwarder { } static func checkConnection() async -> Result { - let status = await AgentRPC.shared.status() + let status = await GatewayConnection.shared.status() if status.ok { return .success(()) } return .failure(.rpcFailed(status.error ?? "agent rpc unreachable")) }