diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 07ebda9ee..ec676d604 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -332,14 +332,7 @@ final class AppState { self.voiceWakeGlobalSyncTask?.cancel() self.voiceWakeGlobalSyncTask = Task { [sanitized] in try? await Task.sleep(nanoseconds: 650_000_000) - do { - _ = try await GatewayConnection.shared.request( - method: "voicewake.set", - params: ["triggers": AnyCodable(sanitized)], - timeoutMs: 10000) - } catch { - // Best-effort only. - } + await GatewayConnection.shared.voiceWakeSetTriggers(sanitized) } } diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift index 1b1e25517..a874c7bb4 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift @@ -167,13 +167,13 @@ actor BridgeServer { let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "node-\(nodeId)" - _ = await GatewayConnection.shared.sendAgent( + _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( message: text, - thinking: "low", sessionKey: sessionKey, + thinking: "low", deliver: false, to: nil, - channel: "last") + channel: .last)) case "agent.request": guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { @@ -191,15 +191,15 @@ actor BridgeServer { ?? "node-\(nodeId)" let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + let channel = GatewayAgentChannel(raw: link.channel) - _ = await GatewayConnection.shared.sendAgent( + _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( message: message, - thinking: thinking, sessionKey: sessionKey, + thinking: thinking, deliver: link.deliver, to: to, - channel: channel ?? "last") + channel: channel)) default: break @@ -347,17 +347,17 @@ actor BridgeServer { "reason \(reason)", ].compactMap(\.self).joined(separator: " · ") - var params: [String: Any] = [ - "text": summary, - "instanceId": nodeId, - "host": host, - "mode": "node", - "reason": reason, - "tags": tags, + var params: [String: AnyCodable] = [ + "text": AnyCodable(summary), + "instanceId": AnyCodable(nodeId), + "host": AnyCodable(host), + "mode": AnyCodable("node"), + "reason": AnyCodable(reason), + "tags": AnyCodable(tags), ] - if let ip { params["ip"] = ip } - if let version { params["version"] = version } - _ = try await GatewayConnection.shared.controlRequest(method: "system-event", params: params) + if let ip { params["ip"] = AnyCodable(ip) } + if let version { params["version"] = AnyCodable(version) } + await GatewayConnection.shared.sendSystemEvent(params) } catch { // Best-effort only. } diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift index 2e19f28bc..ebeec96a2 100644 --- a/apps/macos/Sources/Clawdis/CanvasWindow.swift +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -550,13 +550,17 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan : "A2UI action: \(name)\n\n```json\n\(json)\n```" Task { - let result = await GatewayConnection.shared.sendAgent( + if AppStateStore.shared.connectionMode == .local { + GatewayProcessManager.shared.setActive(true) + } + + let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( message: text, - thinking: nil, sessionKey: self.sessionKey, + thinking: "low", deliver: false, to: nil, - channel: "webchat") + channel: .last)) if !result.ok { canvasWindowLogger.error( "A2UI action send failed name=\(name, privacy: .public) error=\(result.error ?? "unknown", privacy: .public)") @@ -678,11 +682,11 @@ private final class HoverChromeContainerView: NSView { v.state = .active v.appearance = NSAppearance(named: .vibrantDark) v.wantsLayer = true - v.layer?.cornerRadius = 10 + v.layer?.cornerRadius = 11 v.layer?.masksToBounds = true v.layer?.borderWidth = 1 - v.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor - v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.22).cgColor + v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor + v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor v.layer?.shadowOpacity = 0.35 v.layer?.shadowRadius = 8 @@ -691,7 +695,7 @@ private final class HoverChromeContainerView: NSView { }() private let closeButton: NSButton = { - let cfg = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold) + let cfg = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold) let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")? .withSymbolConfiguration(cfg) ?? NSImage(size: NSSize(width: 18, height: 18)) @@ -699,7 +703,7 @@ private final class HoverChromeContainerView: NSView { btn.isBordered = false btn.bezelStyle = .regularSquare btn.imageScaling = .scaleProportionallyDown - btn.contentTintColor = NSColor.labelColor + btn.contentTintColor = NSColor.white.withAlphaComponent(0.92) btn.toolTip = "Close" return btn }() @@ -740,13 +744,13 @@ private final class HoverChromeContainerView: NSView { self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor), self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor), - self.closeBackground.widthAnchor.constraint(equalToConstant: 20), - self.closeBackground.heightAnchor.constraint(equalToConstant: 20), + self.closeBackground.widthAnchor.constraint(equalToConstant: 22), + self.closeBackground.heightAnchor.constraint(equalToConstant: 22), - self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), - self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), - self.closeButton.widthAnchor.constraint(equalToConstant: 20), - self.closeButton.heightAnchor.constraint(equalToConstant: 20), + self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -9), + self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 9), + self.closeButton.widthAnchor.constraint(equalToConstant: 18), + self.closeButton.heightAnchor.constraint(equalToConstant: 18), self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor), diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index fb075617b..e7aba708d 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -169,16 +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 GatewayConnection.shared.sendAgent( + let invocation = GatewayAgentInvocation( message: trimmed, - thinking: thinking, sessionKey: sessionKey, + thinking: thinking, deliver: deliver, to: to, - channel: nil) - return rpcResult.ok - ? Response(ok: true, message: "sent") - : Response(ok: false, message: rpcResult.error ?? "failed to send") + channel: .last) + let rpcResult = await GatewayConnection.shared.sendAgent(invocation) + return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error) } private static func canvasEnabled() -> Bool { diff --git a/apps/macos/Sources/Clawdis/CronJobsStore.swift b/apps/macos/Sources/Clawdis/CronJobsStore.swift index b2d4a624d..158d05cce 100644 --- a/apps/macos/Sources/Clawdis/CronJobsStore.swift +++ b/apps/macos/Sources/Clawdis/CronJobsStore.swift @@ -67,16 +67,12 @@ final class CronJobsStore { defer { self.isLoadingJobs = false } do { - if let status = try? await self.fetchCronStatus() { + if let status = try? await GatewayConnection.shared.cronStatus() { self.schedulerEnabled = status.enabled self.schedulerStorePath = status.storePath self.schedulerNextWakeAtMs = status.nextWakeAtMs } - let data = try await self.request( - method: "cron.list", - params: ["includeDisabled": true]) - let res = try JSONDecoder().decode(CronListResponse.self, from: data) - self.jobs = res.jobs + self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true) if self.jobs.isEmpty { self.statusMessage = "No cron jobs yet." } @@ -92,11 +88,7 @@ final class CronJobsStore { defer { self.isLoadingRuns = false } do { - let data = try await self.request( - method: "cron.runs", - params: ["id": jobId, "limit": limit]) - let res = try JSONDecoder().decode(CronRunsResponse.self, from: data) - self.runEntries = res.entries + self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit) } catch { self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)") self.lastError = error.localizedDescription @@ -105,10 +97,7 @@ final class CronJobsStore { func runJob(id: String, force: Bool = true) async { do { - _ = try await self.request( - method: "cron.run", - params: ["id": id, "mode": force ? "force" : "due"], - timeoutMs: 20000) + try await GatewayConnection.shared.cronRun(jobId: id, force: force) } catch { self.lastError = error.localizedDescription } @@ -116,7 +105,7 @@ final class CronJobsStore { func removeJob(id: String) async { do { - _ = try await self.request(method: "cron.remove", params: ["id": id]) + try await GatewayConnection.shared.cronRemove(jobId: id) await self.refreshJobs() if self.selectedJobId == id { self.selectedJobId = nil @@ -129,9 +118,7 @@ final class CronJobsStore { func setJobEnabled(id: String, enabled: Bool) async { do { - _ = try await self.request( - method: "cron.update", - params: ["id": id, "patch": ["enabled": enabled]]) + try await GatewayConnection.shared.cronUpdate(jobId: id, patch: ["enabled": enabled]) await self.refreshJobs() } catch { self.lastError = error.localizedDescription @@ -143,9 +130,9 @@ final class CronJobsStore { payload: [String: Any]) async throws { if let id { - _ = try await self.request(method: "cron.update", params: ["id": id, "patch": payload]) + try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload) } else { - _ = try await self.request(method: "cron.add", params: payload) + try await GatewayConnection.shared.cronAdd(payload: payload) } await self.refreshJobs() } @@ -206,26 +193,5 @@ final class CronJobsStore { } } - // MARK: - RPC - - private func request( - method: String, - params: [String: Any]?, - timeoutMs: Double? = nil) async throws -> Data - { - let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) } - return try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs) - } - - private func fetchCronStatus() async throws -> CronStatusResponse { - let data = try await self.request(method: "cron.status", params: nil) - return try JSONDecoder().decode(CronStatusResponse.self, from: data) - } -} - -private struct CronStatusResponse: Decodable { - let enabled: Bool - let storePath: String - let jobs: Int - let nextWakeAtMs: Int? + // MARK: - (no additional RPC helpers) } diff --git a/apps/macos/Sources/Clawdis/CronSettings.swift b/apps/macos/Sources/Clawdis/CronSettings.swift index c5d1bb016..dd913131b 100644 --- a/apps/macos/Sources/Clawdis/CronSettings.swift +++ b/apps/macos/Sources/Clawdis/CronSettings.swift @@ -530,7 +530,7 @@ struct CronJobEditor: View { @State private var systemEventText: String = "" @State private var agentMessage: String = "" @State private var deliver: Bool = false - @State private var channel: String = "last" + @State private var channel: GatewayAgentChannel = .last @State private var to: String = "" @State private var thinking: String = "" @State private var timeoutSeconds: String = "" @@ -801,9 +801,9 @@ struct CronJobEditor: View { GridRow { self.gridLabel("Channel") Picker("", selection: self.$channel) { - Text("last").tag("last") - Text("whatsapp").tag("whatsapp") - Text("telegram").tag("telegram") + Text("last").tag(GatewayAgentChannel.last) + Text("whatsapp").tag(GatewayAgentChannel.whatsapp) + Text("telegram").tag(GatewayAgentChannel.telegram) } .labelsHidden() .pickerStyle(.segmented) @@ -861,7 +861,7 @@ struct CronJobEditor: View { self.thinking = thinking ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.deliver = deliver ?? false - self.channel = channel ?? "last" + self.channel = GatewayAgentChannel(raw: channel) self.to = to ?? "" self.bestEffortDeliver = bestEffortDeliver ?? false } @@ -980,7 +980,7 @@ struct CronJobEditor: View { if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } payload["deliver"] = self.deliver if self.deliver { - payload["channel"] = self.channel + payload["channel"] = self.channel.rawValue let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) if !to.isEmpty { payload["to"] = to } payload["bestEffortDeliver"] = self.bestEffortDeliver diff --git a/apps/macos/Sources/Clawdis/DeepLinks.swift b/apps/macos/Sources/Clawdis/DeepLinks.swift index 9bbc934ec..aab6dc949 100644 --- a/apps/macos/Sources/Clawdis/DeepLinks.swift +++ b/apps/macos/Sources/Clawdis/DeepLinks.swift @@ -54,18 +54,24 @@ final class DeepLinkHandler { } do { - var params: [String: AnyCodable] = [ - "message": AnyCodable(messagePreview), - "idempotencyKey": AnyCodable(UUID().uuidString), - ] - if let sessionKey = link.sessionKey, !sessionKey.isEmpty { params["sessionKey"] = AnyCodable(sessionKey) } - if let thinking = link.thinking, !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) } - if let to = link.to, !to.isEmpty { params["to"] = AnyCodable(to) } - if let channel = link.channel, !channel.isEmpty { params["channel"] = AnyCodable(channel) } - if let timeout = link.timeoutSeconds { params["timeout"] = AnyCodable(timeout) } - params["deliver"] = AnyCodable(link.deliver) + let channel = GatewayAgentChannel(raw: link.channel) + let invocation = GatewayAgentInvocation( + message: messagePreview, + sessionKey: link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main", + thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, + deliver: channel.shouldDeliver(link.deliver), + to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, + channel: channel, + timeoutSeconds: link.timeoutSeconds, + idempotencyKey: UUID().uuidString) - _ = try await GatewayConnection.shared.request(method: "agent", params: params) + let res = await GatewayConnection.shared.sendAgent(invocation) + if !res.ok { + throw NSError( + domain: "DeepLink", + code: 1, + userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"]) + } } catch { self.presentAlert(title: "Agent request failed", message: error.localizedDescription) } diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index c2e155a53..4de00bd3e 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -423,8 +423,12 @@ actor GatewayChannelActor { throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"]) } if res.ok == false { - let msg = (res.error?["message"]?.value as? String) ?? "gateway error" - throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg]) + let code = res.error?["code"]?.value as? String + let msg = res.error?["message"]?.value as? String + let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in + acc[pair.key] = AnyCodable(pair.value.value) + } + throw GatewayResponseError(method: method, code: code, message: msg, details: details) } if let payload = res.payload { // Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions. diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index dabec1d8e..1871d35a5 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -1,7 +1,37 @@ +import ClawdisChatUI import ClawdisProtocol import Foundation import OSLog +private let gatewayConnectionLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.connection") + +enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { + case last + case whatsapp + case telegram + case webchat + + init(raw: String?) { + let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self = GatewayAgentChannel(rawValue: normalized) ?? .last + } + + var isDeliverable: Bool { self == .whatsapp || self == .telegram } + + func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable } +} + +struct GatewayAgentInvocation: Sendable { + var message: String + var sessionKey: String = "main" + var thinking: String? + var deliver: Bool = false + var to: String? + var channel: GatewayAgentChannel = .last + var timeoutSeconds: Int? + var idempotencyKey: String = UUID().uuidString +} + /// Single, shared Gateway websocket connection for the whole app. /// /// This owns exactly one `GatewayChannelActor` and reuses it across all callers @@ -11,8 +41,31 @@ actor GatewayConnection { typealias Config = (url: URL, token: String?) + enum Method: String, Sendable { + case agent = "agent" + case status = "status" + case setHeartbeats = "set-heartbeats" + case systemEvent = "system-event" + case health = "health" + case chatHistory = "chat.history" + case chatSend = "chat.send" + case chatAbort = "chat.abort" + case voicewakeGet = "voicewake.get" + case voicewakeSet = "voicewake.set" + case nodePairApprove = "node.pair.approve" + case nodePairReject = "node.pair.reject" + case cronList = "cron.list" + case cronRuns = "cron.runs" + case cronRun = "cron.run" + case cronRemove = "cron.remove" + case cronUpdate = "cron.update" + case cronAdd = "cron.add" + case cronStatus = "cron.status" + } + private let configProvider: @Sendable () async throws -> Config private let sessionBox: WebSocketSessionBox? + private let decoder = JSONDecoder() private var client: GatewayChannelActor? private var configuredURL: URL? @@ -29,6 +82,8 @@ actor GatewayConnection { self.sessionBox = sessionBox } + // MARK: - Low-level request + func request( method: String, params: [String: AnyCodable]?, @@ -42,6 +97,43 @@ actor GatewayConnection { return try await client.request(method: method, params: params, timeoutMs: timeoutMs) } + func requestRaw( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs) + } + + func requestRaw( + method: String, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + try await self.request(method: method, params: params, timeoutMs: timeoutMs) + } + + func requestDecoded( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> T + { + let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) + do { + return try self.decoder.decode(T.self, from: data) + } catch { + throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription) + } + } + + func requestVoid( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws + { + _ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) + } + /// Ensure the underlying socket is configured (and replaced if config changed). func refresh() async throws { let cfg = try await self.configProvider() @@ -114,33 +206,13 @@ actor GatewayConnection { } } -private let gatewayControlLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.control") +// MARK: - Typed gateway API 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") + _ = try await self.requestRaw(method: .status) + return (true, nil) } catch { return (false, error.localizedDescription) } @@ -148,39 +220,211 @@ extension GatewayConnection { func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { do { - _ = try await self.controlRequest(method: "set-heartbeats", params: ["enabled": enabled]) + try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)]) return true } catch { - gatewayControlLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") + gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") return false } } + func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { + let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return (false, "message empty") } + + var params: [String: AnyCodable] = [ + "message": AnyCodable(trimmed), + "sessionKey": AnyCodable(invocation.sessionKey), + "thinking": AnyCodable(invocation.thinking ?? "default"), + "deliver": AnyCodable(invocation.deliver), + "to": AnyCodable(invocation.to ?? ""), + "channel": AnyCodable(invocation.channel.rawValue), + "idempotencyKey": AnyCodable(invocation.idempotencyKey), + ] + if let timeout = invocation.timeoutSeconds { + params["timeout"] = AnyCodable(timeout) + } + + do { + try await self.requestVoid(method: .agent, params: params) + return (true, nil) + } catch { + return (false, error.localizedDescription) + } + } + func sendAgent( message: String, thinking: String?, sessionKey: String, deliver: Bool, to: String?, - channel: String? = nil, + channel: GatewayAgentChannel = .last, + timeoutSeconds: Int? = nil, idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) { - let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return (false, "message empty") } + await self.sendAgent(GatewayAgentInvocation( + message: message, + sessionKey: sessionKey, + thinking: thinking, + deliver: deliver, + to: to, + channel: channel, + timeoutSeconds: timeoutSeconds, + idempotencyKey: idempotencyKey)) + } + + func sendSystemEvent(_ params: [String: AnyCodable]) async { do { - let params: [String: Any] = [ - "message": trimmed, - "sessionKey": sessionKey, - "thinking": thinking ?? "default", - "deliver": deliver, - "to": to ?? "", - "channel": channel ?? "", - "idempotencyKey": idempotencyKey, - ] - _ = try await self.controlRequest(method: "agent", params: params) - return (true, nil) + try await self.requestVoid(method: .systemEvent, params: params) } catch { - return (false, error.localizedDescription) + // Best-effort only. } } + + // MARK: - Health + + func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot { + let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs) + if let snap = decodeHealthSnapshot(from: data) { return snap } + throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot") + } + + func healthOK(timeoutMs: Int = 8000) async throws -> Bool { + let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs)) + return (try? self.decoder.decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true + } + + // MARK: - Chat + + func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { + try await self.requestDecoded( + method: .chatHistory, + params: ["sessionKey": AnyCodable(sessionKey)]) + } + + func chatSend( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [ClawdisChatAttachmentPayload], + timeoutMs: Int = 30000) async throws -> ClawdisChatSendResponse + { + var params: [String: AnyCodable] = [ + "sessionKey": AnyCodable(sessionKey), + "message": AnyCodable(message), + "thinking": AnyCodable(thinking), + "idempotencyKey": AnyCodable(idempotencyKey), + "timeoutMs": AnyCodable(timeoutMs), + ] + + if !attachments.isEmpty { + let encoded = attachments.map { att in + [ + "type": att.type, + "mimeType": att.mimeType, + "fileName": att.fileName, + "content": att.content, + ] + } + params["attachments"] = AnyCodable(encoded) + } + + return try await self.requestDecoded(method: .chatSend, params: params) + } + + func chatAbort(sessionKey: String, runId: String) async throws -> Bool { + struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? } + let res: AbortResponse = try await self.requestDecoded( + method: .chatAbort, + params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)]) + return res.aborted ?? false + } + + // MARK: - VoiceWake + + func voiceWakeGetTriggers() async throws -> [String] { + struct VoiceWakePayload: Decodable { let triggers: [String] } + let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet) + return payload.triggers + } + + func voiceWakeSetTriggers(_ triggers: [String]) async { + do { + try await self.requestVoid( + method: .voicewakeSet, + params: ["triggers": AnyCodable(triggers)], + timeoutMs: 10000) + } catch { + // Best-effort only. + } + } + + // MARK: - Node pairing + + func nodePairApprove(requestId: String) async throws { + try await self.requestVoid( + method: .nodePairApprove, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + func nodePairReject(requestId: String) async throws { + try await self.requestVoid( + method: .nodePairReject, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + // MARK: - Cron + + struct CronSchedulerStatus: Decodable, Sendable { + let enabled: Bool + let storePath: String + let jobs: Int + let nextWakeAtMs: Int? + } + + func cronStatus() async throws -> CronSchedulerStatus { + try await self.requestDecoded(method: .cronStatus) + } + + func cronList(includeDisabled: Bool = true) async throws -> [CronJob] { + let res: CronListResponse = try await self.requestDecoded( + method: .cronList, + params: ["includeDisabled": AnyCodable(includeDisabled)]) + return res.jobs + } + + func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] { + let res: CronRunsResponse = try await self.requestDecoded( + method: .cronRuns, + params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)]) + return res.entries + } + + func cronRun(jobId: String, force: Bool = true) async throws { + try await self.requestVoid( + method: .cronRun, + params: [ + "id": AnyCodable(jobId), + "mode": AnyCodable(force ? "force" : "due"), + ], + timeoutMs: 20000) + } + + func cronRemove(jobId: String) async throws { + try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)]) + } + + func cronUpdate(jobId: String, patch: [String: Any]) async throws { + try await self.requestVoid( + method: .cronUpdate, + params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)]) + } + + func cronAdd(payload: [String: Any]) async throws { + try await self.requestVoid(method: .cronAdd, params: payload.mapValues { AnyCodable($0) }) + } } diff --git a/apps/macos/Sources/Clawdis/GatewayErrors.swift b/apps/macos/Sources/Clawdis/GatewayErrors.swift new file mode 100644 index 000000000..50ba05b01 --- /dev/null +++ b/apps/macos/Sources/Clawdis/GatewayErrors.swift @@ -0,0 +1,34 @@ +import ClawdisProtocol +import Foundation + +/// Structured error surfaced when the gateway responds with `{ ok: false }`. +struct GatewayResponseError: LocalizedError, @unchecked Sendable { + let method: String + let code: String + let message: String + let details: [String: AnyCodable] + + init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) { + self.method = method + self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? code!.trimmingCharacters(in: .whitespacesAndNewlines) + : "GATEWAY_ERROR" + self.message = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? message!.trimmingCharacters(in: .whitespacesAndNewlines) + : "gateway error" + self.details = details ?? [:] + } + + var errorDescription: String? { + if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" } + return "\(self.method): [\(self.code)] \(self.message)" + } +} + +struct GatewayDecodingError: LocalizedError, Sendable { + let method: String + let message: String + + var errorDescription: String? { "\(self.method): \(self.message)" } +} + diff --git a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift index 1eadcd252..ce072c11e 100644 --- a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift @@ -152,9 +152,8 @@ final class GatewayProcessManager { private func attachExistingGatewayIfAvailable() async -> Bool { let port = GatewayEnvironment.gatewayPort() do { - let data = try await GatewayConnection.shared.request(method: "health", params: nil) let details: String - if let snap = decodeHealthSnapshot(from: data) { + if let snap = try? await GatewayConnection.shared.healthSnapshot() { let linked = snap.web.linked ? "linked" : "not linked" let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age" let instance = await PortGuardian.shared.describe(port: port) diff --git a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift index 7cbc415fc..5dabd2321 100644 --- a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift @@ -320,10 +320,7 @@ final class NodePairingApprovalPrompter { private func approve(requestId: String) async { do { - _ = try await GatewayConnection.shared.request( - method: "node.pair.approve", - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) + try await GatewayConnection.shared.nodePairApprove(requestId: requestId) self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)") } catch { self.logger.error("approve failed requestId=\(requestId, privacy: .public)") @@ -333,10 +330,7 @@ final class NodePairingApprovalPrompter { private func reject(requestId: String) async { do { - _ = try await GatewayConnection.shared.request( - method: "node.pair.reject", - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) + try await GatewayConnection.shared.nodePairReject(requestId: requestId) self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)") } catch { self.logger.error("reject failed requestId=\(requestId, privacy: .public)") diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift index 58170383d..e43db035b 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -37,7 +37,7 @@ enum VoiceWakeForwarder { var thinking: String = "low" var deliver: Bool = true var to: String? - var channel: String = "last" + var channel: GatewayAgentChannel = .last } @discardableResult @@ -46,15 +46,14 @@ enum VoiceWakeForwarder { options: ForwardOptions = ForwardOptions()) async -> Result { let payload = Self.prefixedTranscript(transcript) - let channel = options.channel.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let deliver = options.deliver && channel != "webchat" - let result = await GatewayConnection.shared.sendAgent( + let deliver = options.channel.shouldDeliver(options.deliver) + let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( message: payload, - thinking: options.thinking, sessionKey: options.sessionKey, + thinking: options.thinking, deliver: deliver, to: options.to, - channel: channel) + channel: options.channel)) if result.ok { self.logger.info("voice wake forward ok") diff --git a/apps/macos/Sources/Clawdis/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/Clawdis/VoiceWakeGlobalSettingsSync.swift index 3eb6fbb29..44dba830e 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeGlobalSettingsSync.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeGlobalSettingsSync.swift @@ -44,9 +44,8 @@ final class VoiceWakeGlobalSettingsSync { private func refreshFromGateway() async { do { - let data = try await GatewayConnection.shared.request(method: "voicewake.get", params: nil, timeoutMs: 8000) - let payload = try JSONDecoder().decode(VoiceWakePayload.self, from: data) - AppStateStore.shared.applyGlobalVoiceWakeTriggers(payload.triggers) + let triggers = try await GatewayConnection.shared.voiceWakeGetTriggers() + AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers) } catch { // Best-effort only. } diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index 3a6c415da..65b00f1ab 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -15,10 +15,7 @@ private enum WebChatSwiftUILayout { struct MacGatewayChatTransport: ClawdisChatTransport, Sendable { func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { - let data = try await GatewayConnection.shared.request( - method: "chat.history", - params: ["sessionKey": AnyCodable(sessionKey)]) - return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: data) + try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) } func sendMessage( @@ -28,36 +25,16 @@ struct MacGatewayChatTransport: ClawdisChatTransport, Sendable { idempotencyKey: String, attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse { - var params: [String: AnyCodable] = [ - "sessionKey": AnyCodable(sessionKey), - "message": AnyCodable(message), - "thinking": AnyCodable(thinking), - "idempotencyKey": AnyCodable(idempotencyKey), - "timeoutMs": AnyCodable(30000), - ] - - if !attachments.isEmpty { - let encoded = attachments.map { att in - [ - "type": att.type, - "mimeType": att.mimeType, - "fileName": att.fileName, - "content": att.content, - ] - } - params["attachments"] = AnyCodable(encoded) - } - - let data = try await GatewayConnection.shared.request(method: "chat.send", params: params) - return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: data) + try await GatewayConnection.shared.chatSend( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments) } func requestHealth(timeoutMs: Int) async throws -> Bool { - let data = try await GatewayConnection.shared.request( - method: "health", - params: nil, - timeoutMs: Double(timeoutMs)) - return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true + try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) } func events() -> AsyncStream { diff --git a/apps/macos/Tests/ClawdisIPCTests/AgentRPCTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayConnectionControlTests.swift similarity index 100% rename from apps/macos/Tests/ClawdisIPCTests/AgentRPCTests.swift rename to apps/macos/Tests/ClawdisIPCTests/GatewayConnectionControlTests.swift diff --git a/docs/refactor/gateway-client.md b/docs/refactor/gateway-client.md new file mode 100644 index 000000000..c0522efdb --- /dev/null +++ b/docs/refactor/gateway-client.md @@ -0,0 +1,24 @@ +# Gateway Client Refactor (Dec 2025) + +Goal: remove stringly-typed gateway calls from the macOS app, centralize routing/channel semantics, and improve error handling. + +## Progress + +- [x] Fold legacy “AgentRPC” into `GatewayConnection` (single layer; no separate client object). +- [x] Typed gateway API: `GatewayConnection.Method` + `requestDecoded/requestVoid` + typed helpers (status/agent/chat/cron/etc). +- [x] Centralize agent routing/channel semantics via `GatewayAgentChannel` + `GatewayAgentInvocation`. +- [x] Improve gateway error model (structured `GatewayResponseError` + decoding errors include method). +- [x] Migrate mac call sites to typed helpers (leave only intentionally dynamic forwarding paths). +- [x] Convert remaining UI raw channel strings to `GatewayAgentChannel` (Cron editor). +- [x] Cleanup naming: rename remaining tests/docs that still reference “RPC/AgentRPC”. + +### Notes + +- Intentionally string-based: + - `BridgeServer` dynamic request forwarding (method is data-driven). + - `ControlChannel` request wrapper (generic escape hatch). + +## Notes / Non-goals + +- No functional behavior changes intended (beyond better errors and removing “magic strings”). +- Keep changes incremental: introduce typed APIs first, then migrate call sites, then remove old helpers. diff --git a/docs/refactor/gateway.md b/docs/refactor/gateway.md index c99899d75..2d55c8cf6 100644 --- a/docs/refactor/gateway.md +++ b/docs/refactor/gateway.md @@ -45,7 +45,7 @@ Goal: enforce the invariant **“one gateway websocket per app process (per effe Key elements: - `GatewayConnection.shared` owns the one websocket and is the *only* supported entry point for app code that needs gateway RPC. -- Consumers (e.g. Control UI, Agent RPC, SwiftUI WebChat) call `GatewayConnection.shared.request(...)` and do not create their own sockets. +- Consumers (e.g. Control UI, agent invocations, SwiftUI WebChat) call `GatewayConnection.shared.request(...)` and do not create their own sockets. - If the effective connection config changes (local ↔ remote tunnel port, token change), `GatewayConnection` replaces the underlying connection. - The transport (`GatewayChannelActor`) is an internal detail and forwards push frames back into `GatewayConnection`. - Server-push frames are delivered via `GatewayConnection.shared.subscribe(...) -> AsyncStream` (in-process event bus). @@ -84,7 +84,7 @@ Minimum invariants: - Config changes (token / endpoint) cancel the old socket and reconnect once. Nice-to-have integration coverage: -- Multiple “consumers” (Control UI + Agent RPC + SwiftUI WebChat) all call through the shared connection and still produce only one websocket. +- Multiple “consumers” (Control UI + agent invocations + SwiftUI WebChat) all call through the shared connection and still produce only one websocket. Additional coverage added (macOS): - Subscribing after connect replays the latest snapshot. diff --git a/docs/refactor/new-arch.md b/docs/refactor/new-arch.md index 6fcb20769..79aaf930f 100644 --- a/docs/refactor/new-arch.md +++ b/docs/refactor/new-arch.md @@ -105,7 +105,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway, - Add lightweight WS client helper for `status/health/send/agent` when Gateway is up. ✅ `gateway` subcommands use the Gateway over WS. - Consider a “local only” flag to avoid accidental remote connects. (optional; not needed with tunnel-first model.) - **WebChat backend**: - - Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via `GatewayClient` in `webchat/server.ts`. + - Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via the WebChat gateway client in `webchat/server.ts`. - Fail fast if handshake fails; no fallback transports. ✅ (webchat returns gateway unavailable) ## Phase 6 — Send/agent path hardening @@ -148,7 +148,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway, - Mac app smoke: presence/health render from snapshot; reconnect on tick loss. (Manual: open Instances tab, verify snapshot after connect, induce seq gap by toggling wifi, ensure UI refreshes.) - WebChat smoke: snapshot seed + event updates; tunnel scenario. ✅ Offline snapshot harness in `src/webchat/server.test.ts` (mock gateway) now passes; live tunnel still recommended for manual. - Idempotency tests: retry send/agent with same key after forced disconnect; expect deduped result. ✅ send + agent dedupe + reconnect retry covered in gateway tests. -- Seq-gap handling: ✅ clients now detect seq gaps (GatewayClient + mac GatewayChannel) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional. +- Seq-gap handling: ✅ clients now detect seq gaps (WebChat gateway client + mac `GatewayConnection/GatewayChannel`) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional. ## Phase 10 — Rollout - Version bump; release notes: breaking change to control plane (WS only).