From 92457f7fab039b17253c9ec5f6480e8d9cc4e482 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Dec 2025 12:50:37 +0100 Subject: [PATCH] Remote web chat tunnel and onboarding polish --- apps/macos/Sources/Clawdis/AppState.swift | 20 ++++ .../Sources/Clawdis/ConfigSettings.swift | 23 +++++ apps/macos/Sources/Clawdis/Constants.swift | 2 + .../Sources/Clawdis/GeneralSettings.swift | 48 ++++++---- apps/macos/Sources/Clawdis/HealthStore.swift | 7 +- apps/macos/Sources/Clawdis/MenuBar.swift | 92 ++++++++++++++++++- apps/macos/Sources/Clawdis/Onboarding.swift | 29 +++--- apps/macos/Sources/Clawdis/Utilities.swift | 21 +++-- .../Sources/Clawdis/VoiceWakeRuntime.swift | 10 +- .../macos/Sources/Clawdis/WebChatWindow.swift | 86 +++++++++-------- docs/mac/remote.md | 70 +++++++++----- docs/webchat.md | 77 ++++++---------- src/cli/program.test.ts | 15 +++ 13 files changed, 338 insertions(+), 162 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 39346fd9d..7e049065f 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -110,6 +110,14 @@ final class AppState: ObservableObject { didSet { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } } + @Published var webChatEnabled: Bool { + didSet { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } + } + + @Published var webChatPort: Int { + didSet { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } + } + @Published var remoteTarget: String { didSet { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } } @@ -170,6 +178,9 @@ final class AppState: ObservableObject { self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" + self.webChatEnabled = UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true + let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey) + self.webChatPort = storedPort > 0 ? storedPort : 18788 if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() { self.swabbleEnabled = false @@ -232,6 +243,15 @@ enum AppStateStore { static func updateLaunchAtLogin(enabled: Bool) { LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath) } + + static var webChatEnabled: Bool { + UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true + } + + static var webChatPort: Int { + let stored = UserDefaults.standard.integer(forKey: webChatPortKey) + return stored > 0 ? stored : 18788 + } } extension AppState { diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 55f3b5ba8..724a96f1a 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -14,6 +14,8 @@ struct ConfigSettings: View { @State private var allowAutosave = false @State private var heartbeatMinutes: Int? @State private var heartbeatBody: String = "HEARTBEAT" + @AppStorage(webChatEnabledKey) private var webChatEnabled: Bool = true + @AppStorage(webChatPortKey) private var webChatPort: Int = 18788 var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -92,6 +94,27 @@ struct ConfigSettings: View { } } + Divider().padding(.vertical, 4) + + LabeledContent("Web chat") { + VStack(alignment: .leading, spacing: 6) { + Toggle("Enable embedded web chat (loopback only)", isOn: self.$webChatEnabled) + .toggleStyle(.switch) + .frame(width: 320, alignment: .leading) + HStack(spacing: 8) { + Text("Port") + TextField("18788", value: self.$webChatPort, formatter: NumberFormatter()) + .textFieldStyle(.roundedBorder) + .frame(width: 100) + .disabled(!self.webChatEnabled) + } + Text("Mac app connects to the relay’s loopback web chat on this port. Remote mode uses SSH -L to forward it.") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: 480, alignment: .leading) + } + } + Spacer() } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index c1920b79d..d88268549 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -24,6 +24,8 @@ let connectionModeKey = "clawdis.connectionMode" let remoteTargetKey = "clawdis.remoteTarget" let remoteIdentityKey = "clawdis.remoteIdentity" let remoteProjectRootKey = "clawdis.remoteProjectRoot" +let webChatEnabledKey = "clawdis.webChatEnabled" +let webChatPortKey = "clawdis.webChatPort" let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogReloadKey = "clawdis.modelCatalogReload" let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled" diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 0beb20c10..1ae28596b 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -71,19 +71,23 @@ struct GeneralSettings: View { } private var connectionSection: some View { - VStack(alignment: .leading, spacing: 8) { - Picker("Clawdis runs", selection: self.$state.connectionMode) { + VStack(alignment: .leading, spacing: 10) { + Text("Clawdis runs") + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + + Picker("", selection: self.$state.connectionMode) { Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Remote over SSH").tag(AppState.ConnectionMode.remote) } .pickerStyle(.segmented) - .frame(width: 360) + .frame(width: 380, alignment: .leading) + + self.healthRow if self.state.connectionMode == .remote { self.remoteCard } - - self.healthRow } } @@ -92,10 +96,10 @@ struct GeneralSettings: View { HStack(alignment: .center, spacing: 10) { Text("SSH") .font(.callout.weight(.semibold)) - .frame(width: 44, alignment: .leading) + .frame(width: 48, alignment: .leading) TextField("user@host[:22]", text: self.$state.remoteTarget) .textFieldStyle(.roundedBorder) - .frame(width: 260) + .frame(width: 280) } DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { @@ -103,12 +107,12 @@ struct GeneralSettings: View { LabeledContent("Identity file") { TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) .textFieldStyle(.roundedBorder) - .frame(width: 260) + .frame(width: 280) } LabeledContent("Project root") { TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) .textFieldStyle(.roundedBorder) - .frame(width: 260) + .frame(width: 280) } } .padding(.top, 4) @@ -148,14 +152,11 @@ struct GeneralSettings: View { } } - Text("Tip: use Tailscale so your remote Clawdis stays reachable.") + Text("Tip: enable Tailscale for stable remote access.") .font(.footnote) .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(1) } - .padding(12) - .background(Color.gray.opacity(0.08)) - .cornerRadius(10) .transition(.opacity) } @@ -304,6 +305,8 @@ extension GeneralSettings { .frame(width: 10, height: 10) Text(self.healthStore.summaryLine) .font(.callout) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -314,10 +317,21 @@ extension GeneralSettings { let response = await ShellRunner.run(command: command, cwd: nil, env: nil, timeout: 10) if response.ok { self.remoteStatus = .ok - } else { - let msg = response.message ?? "test failed" - self.remoteStatus = .failed(msg) + return } + + let msg: String + if let payload = response.payload, + let text = String(data: payload, encoding: .utf8), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + msg = text.trimmingCharacters(in: .whitespacesAndNewlines) + } else if let message = response.message, !message.isEmpty { + msg = message + } else { + msg = "Remote status failed (is clawdis on PATH on the remote host?)" + } + self.remoteStatus = .failed(msg) } private func revealLogs() { diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index b1dba486a..8dd7db1f1 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -163,12 +163,11 @@ final class HealthStore: ObservableObject { return "Not linked — run clawdis login" } if let connect = snap.web.connect, !connect.ok { - if let err = connect.error, err.contains("timeout") { - let elapsed = connect.elapsedMs.map { " after \(Int($0))ms" } ?? "" - return "Web connect timed out\(elapsed)" + let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration" + if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil { + return "Health check timed out (\(elapsed))" } let code = connect.status.map { "status \($0)" } ?? "status unknown" - let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration" let reason = connect.error ?? "connect failed" return "\(reason) (\(code), \(elapsed))" } diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 9e3623a6f..7b5ec29b3 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -1,3 +1,4 @@ +import AVFoundation import AppKit import Darwin import Foundation @@ -56,6 +57,8 @@ private struct MenuContent: View { @ObservedObject private var relayManager = RelayProcessManager.shared @ObservedObject private var healthStore = HealthStore.shared @Environment(\.openSettings) private var openSettings + @State private var availableMics: [AudioInputDevice] = [] + @State private var loadingMics = false var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -68,7 +71,12 @@ private struct MenuContent: View { Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") } .disabled(!voiceWakeSupported) .opacity(voiceWakeSupported ? 1 : 0.5) - Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) } + if self.showVoiceWakeMicPicker { + self.voiceWakeMicMenu + } + if AppStateStore.webChatEnabled { + Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) } + } Divider() Button("Settings…") { self.open(tab: .general) } .keyboardShortcut(",", modifiers: [.command]) @@ -79,6 +87,11 @@ private struct MenuContent: View { Divider() Button("Quit") { NSApplication.shared.terminate(nil) } } + .task(id: self.state.swabbleEnabled) { + if self.state.swabbleEnabled { + await self.loadMicrophones(force: true) + } + } } private func open(tab: SettingsTab) { @@ -166,6 +179,77 @@ private struct MenuContent: View { }) } + private var showVoiceWakeMicPicker: Bool { + voiceWakeSupported && self.state.swabbleEnabled + } + + private var voiceWakeMicMenu: some View { + Menu { + Picker("Microphone", selection: self.$state.voiceWakeMicID) { + Text(self.defaultMicLabel).tag("") + ForEach(self.availableMics) { mic in + Text(mic.name).tag(mic.uid) + } + } + .labelsHidden() + + if self.loadingMics { + Divider() + Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath") + .labelStyle(.titleOnly) + .foregroundStyle(.secondary) + .disabled(true) + } + } label: { + HStack { + Text("Microphone") + Spacer() + Text(self.selectedMicLabel) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .task { await self.loadMicrophones() } + } + + private var selectedMicLabel: String { + if self.state.voiceWakeMicID.isEmpty { return self.defaultMicLabel } + if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) { + return match.name + } + return "Unavailable" + } + + private var defaultMicLabel: String { + if let host = Host.current().localizedName, !host.isEmpty { + return "Auto-detect (\(host))" + } + return "System default" + } + + @MainActor + private func loadMicrophones(force: Bool = false) async { + guard self.showVoiceWakeMicPicker else { + self.availableMics = [] + self.loadingMics = false + return + } + if !force, !self.availableMics.isEmpty { return } + self.loadingMics = true + let discovery = AVCaptureDevice.DiscoverySession( + deviceTypes: [.external, .microphone], + mediaType: .audio, + position: .unspecified) + self.availableMics = discovery.devices + .sorted { lhs, rhs in + lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending + } + .map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } + self.loadingMics = false + } + private func primarySessionKey() -> String { // Prefer canonical main session; fall back to most recent. let storePath = SessionLoader.defaultStorePath @@ -183,6 +267,12 @@ private struct MenuContent: View { } return "+1003" } + + private struct AudioInputDevice: Identifiable, Equatable { + let uid: String + let name: String + var id: String { self.uid } + } } private struct CritterStatusLabel: View { diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 769681370..9d293a137 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -49,8 +49,8 @@ struct OnboardingView: View { @ObservedObject private var state = AppStateStore.shared @ObservedObject private var permissionMonitor = PermissionMonitor.shared - private let pageWidth: CGFloat = 640 - private let contentHeight: CGFloat = 340 + private let pageWidth: CGFloat = 680 + private let contentHeight: CGFloat = 520 private let permissionsPageIndex = 2 private var pageCount: Int { 6 } private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } @@ -59,9 +59,9 @@ struct OnboardingView: View { var body: some View { VStack(spacing: 0) { GlowingClawdisIcon(size: 156) - .padding(.top, 20) - .padding(.bottom, 8) - .frame(height: 200) + .padding(.top, 10) + .padding(.bottom, 2) + .frame(height: 176) GeometryReader { _ in HStack(spacing: 0) { @@ -79,11 +79,11 @@ struct OnboardingView: View { .frame(height: self.contentHeight, alignment: .top) .clipped() } - .frame(height: 260) + .frame(height: self.contentHeight) self.navigationBar } - .frame(width: self.pageWidth, height: 560) + .frame(width: self.pageWidth, height: 720) .background(Color(NSColor.windowBackgroundColor)) .onAppear { self.currentPage = 0 @@ -129,20 +129,20 @@ struct OnboardingView: View { .frame(maxWidth: 520) .fixedSize(horizontal: false, vertical: true) - self.onboardingCard(spacing: 10, padding: 12) { - Picker("Mode", selection: self.$state.connectionMode) { + self.onboardingCard(spacing: 12, padding: 14) { + Picker("Clawdis runs", selection: self.$state.connectionMode) { Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Remote over SSH").tag(AppState.ConnectionMode.remote) } .pickerStyle(.segmented) - .frame(width: 320) + .frame(width: 360) if self.state.connectionMode == .remote { VStack(alignment: .leading, spacing: 8) { LabeledContent("SSH target") { TextField("user@host[:22]", text: self.$state.remoteTarget) .textFieldStyle(.roundedBorder) - .frame(width: 260) + .frame(width: 300) } DisclosureGroup("Advanced") { @@ -150,20 +150,21 @@ struct OnboardingView: View { LabeledContent("Identity file") { TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) .textFieldStyle(.roundedBorder) - .frame(width: 260) + .frame(width: 300) } LabeledContent("Project root") { TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) .textFieldStyle(.roundedBorder) - .frame(width: 260) + .frame(width: 300) } } .padding(.top, 4) } - Text("Tip: keep a Tailscale IP here so the agent stays reachable off-LAN.") + Text("Tip: enable Tailscale so your remote Clawdis stays reachable.") .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(1) } .transition(.opacity.combined(with: .move(edge: .top))) } diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 9cea6de30..d389c28fb 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -309,8 +309,7 @@ enum CommandResolver { private static func sshCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { guard !settings.target.isEmpty else { return nil } - let parsed = VoiceWakeForwarder.parse(target: settings.target) - guard let parsed else { return nil } + guard let parsed = VoiceWakeForwarder.parse(target: settings.target) else { return nil } var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"] if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } @@ -320,11 +319,21 @@ enum CommandResolver { let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host args.append(userHost) - let quotedArgs = (["clawdis", subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") + // Prefer the Node CLI ("clawdis") on the remote host; fall back to pnpm or the mac helper if present. + let exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm:$PATH" let cdPrefix = settings.projectRoot.isEmpty ? "" : "cd \(self.shellQuote(settings.projectRoot)) && " - let scriptBody = "\(cdPrefix)\(quotedArgs)" - let wrapped = VoiceWakeForwarder.commandWithCliPath(scriptBody, target: settings.target) - args.append(contentsOf: ["/bin/sh", "-c", wrapped]) + let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") + let scriptBody = """ + PATH=\(exportedPath); + CLI=""; + if command -v clawdis >/dev/null 2>&1; then CLI="clawdis"; + elif command -v pnpm >/dev/null 2>&1; then CLI="pnpm --silent clawdis"; + elif command -v clawdis-mac >/dev/null 2>&1; then CLI="clawdis-mac"; + fi; + if [ -z "$CLI" ]; then echo "clawdis missing on remote host"; exit 127; fi; + \(cdPrefix)$CLI \(quotedArgs) + """ + args.append(contentsOf: ["/bin/sh", "-c", scriptBody]) return ["/usr/bin/ssh"] + args } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index 354735324..3320b4b68 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -182,6 +182,7 @@ actor VoiceWakeRuntime { private func monitorCapture(config: RuntimeConfig) async { let start = self.captureStartedAt ?? Date() let hardStop = start.addingTimeInterval(self.captureHardStop) + var silentStrikes = 0 while self.isCapturing { let now = Date() @@ -191,8 +192,13 @@ actor VoiceWakeRuntime { } if let last = self.lastHeard, now.timeIntervalSince(last) >= self.silenceWindow { - await self.finalizeCapture(config: config) - return + silentStrikes += 1 + if silentStrikes >= 2 { + await self.finalizeCapture(config: config) + return + } + } else { + silentStrikes = 0 } try? await Task.sleep(nanoseconds: 200_000_000) diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index c8d1d5859..b9fc9ea23 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -110,6 +110,9 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, private func bootstrap() async { do { let cliInfo = try await self.fetchWebChatCliInfo() + guard AppStateStore.webChatEnabled else { + throw NSError(domain: "WebChat", code: 5, userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"]) + } let endpoint = try await self.prepareEndpoint(remotePort: cliInfo.port) self.baseEndpoint = endpoint let infoURL = endpoint.appendingPathComponent("webchat/info") @@ -138,8 +141,11 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, } private func fetchWebChatCliInfo() async throws -> WebChatCliInfo { + var args = ["--json"] + let port = AppStateStore.webChatPort + if port > 0 { args += ["--port", String(port)] } let response = await ShellRunner.run( - command: CommandResolver.clawdisCommand(subcommand: "webchat", extraArgs: ["--json"]), + command: CommandResolver.clawdisCommand(subcommand: "webchat", extraArgs: args), cwd: CommandResolver.projectRootPath(), env: nil, timeout: 10) @@ -209,50 +215,42 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) { await MainActor.run { AppStateStore.shared.setWorking(true) } defer { Task { await MainActor.run { AppStateStore.shared.setWorking(false) } } } - if let base = self.baseEndpoint { - do { - var req = URLRequest(url: base.appendingPathComponent("webchat/rpc")) - req.httpMethod = "POST" - var headers: [String: String] = ["Content-Type": "application/json"] - if let apiToken, !apiToken.isEmpty { headers["Authorization"] = "Bearer \(apiToken)" } - req.allHTTPHeaderFields = headers - let body: [String: Any] = [ - "text": text, - "session": sessionKey, - "thinking": "default", - "deliver": false, - "to": sessionKey, - ] - req.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, _) = try await URLSession.shared.data(for: req) - if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let ok = obj["ok"] as? Bool, - ok == true - { - if let payloads = obj["payloads"] as? [[String: Any]], - let first = payloads.first, - let txt = first["text"] as? String - { - return (txt, nil) - } - return (nil, nil) - } - let errObj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) - let err = (errObj?["error"] as? String) ?? "rpc failed" - return (nil, err) - } catch { - return (nil, error.localizedDescription) - } + guard let base = self.baseEndpoint else { + return (nil, "web chat endpoint missing") + } + do { + var req = URLRequest(url: base.appendingPathComponent("webchat/rpc")) + req.httpMethod = "POST" + var headers: [String: String] = ["Content-Type": "application/json"] + if let apiToken, !apiToken.isEmpty { headers["Authorization"] = "Bearer \(apiToken)" } + req.allHTTPHeaderFields = headers + let body: [String: Any] = [ + "text": text, + "session": sessionKey, + "thinking": "default", + "deliver": false, + "to": sessionKey, + ] + req.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, _) = try await URLSession.shared.data(for: req) + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let ok = obj["ok"] as? Bool, + ok == true + { + if let payloads = obj["payloads"] as? [[String: Any]], + let first = payloads.first, + let txt = first["text"] as? String + { + return (txt, nil) + } + return (nil, nil) + } + let errObj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) + let err = (errObj?["error"] as? String) ?? "rpc failed" + return (nil, err) + } catch { + return (nil, error.localizedDescription) } - - // Fallback to AgentRPC when no base endpoint is known (should not happen after bootstrap). - let result = await AgentRPC.shared.send( - text: text, - thinking: "default", - session: sessionKey, - deliver: false, - to: sessionKey) - return (result.text, result.error) } } diff --git a/docs/mac/remote.md b/docs/mac/remote.md index b854d3868..3fb9be4d9 100644 --- a/docs/mac/remote.md +++ b/docs/mac/remote.md @@ -1,29 +1,51 @@ -# Remote Clawd mode (Dec 2025) +# Remote Clawdis (macOS ⇄ remote host) -## What it is -- Run the Clawdis relay on another machine (Linux/macOS) reachable over SSH while the macOS app keeps TCC, notifications, and UI. -- You can toggle Local vs Remote in **Settings → General → Clawdis runs**; remote adds fields for SSH target, identity file, and project root. -- We recommend running a Tailscale node on both sides so the target is reachable even off-LAN. +Updated: 2025-12-08 -## Requirements -- SSH access with public-key auth (`BatchMode=yes`); set `user@host[:port]` and an identity file. -- The remote host must have a working `clawdis` install in the project root you specify. -- `clawdis-mac` is still used for permissioned actions; the CLI path is auto-discovered on the remote via `command -v` + common prefixes. +This flow lets the macOS app act as a full remote control for a Clawdis relay running on another host (e.g. a Mac Studio). All features—health checks, permissions bootstrapping via the helper CLI, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from *Settings → General*. -## How it works -- The app builds commands through the new runner: - - `clawdis status/health/agent/relay` are wrapped in `ssh … /bin/sh -c ''` with CLI path lookup. - - `clawdis rpc` is tunneled over a long-lived SSH process so web chat and the app’s Agent tab stay responsive. -- Local TCC flows remain unchanged; if the remote agent needs local permissions, it should SSH back here and invoke `clawdis-mac …` (same CLI surface). +## Modes +- **Local (this Mac)**: Everything runs on the laptop. No SSH involved. +- **Remote over SSH**: Clawdis commands are executed on the remote host. The mac app opens an SSH connection with `-o BatchMode` plus your chosen identity/key. -## Setup steps -1) Open **Settings → General → Clawdis runs** and pick **Remote over SSH**. -2) Fill **SSH target**, **Identity file**, and **Project root** (where `clawdis` lives on the remote). -3) Click **Test remote**; it runs `clawdis status --json` remotely and caches the resolved CLI path. -4) Run onboarding’s WhatsApp login step on the machine where the relay will run (remote if remote mode is enabled). +## Prereqs on the remote host +1) Install Node + pnpm and build/install the Clawdis CLI (`pnpm install && pnpm build && pnpm link --global`). +2) Ensure `clawdis` is on PATH for non-interactive shells. If you prefer, symlink `clawdis-mac` too so TCC-capable actions can run remotely when needed. +3) Open SSH with key auth. We recommend **Tailscale** IPs for stable reachability off-LAN. -## Notes -- Connection strings accept `user@host:port`; leading `ssh ` is stripped if pasted from a shell snippet. -- Project root defaults to the path you enter; if blank, no `cd` is issued before the relay command. -- The remote log path remains `/tmp/clawdis/clawdis.log`; view it via SSH if you need details. -- If you switch back to Local, existing remote state is left untouched; re-run Test remote when switching again. +## macOS app setup +1) Open *Settings → General*. +2) Under **Clawdis runs**, pick **Remote over SSH** and set: + - **SSH target**: `user@host` (optional `:port`). + - **Identity file** (advanced): path to your key. + - **Project root** (advanced): remote checkout path used for commands. +3) Hit **Test remote**. Success indicates the remote `clawdis status --json` runs correctly. Failures usually mean PATH/CLI issues; exit 127 means the CLI isn’t found remotely. +4) Health checks and Web Chat will now run through this SSH tunnel automatically. + +## Web Chat over SSH +- The relay hosts a loopback-only HTTP server (`clawdis webchat --port `; default 18788). +- The mac app forwards `127.0.0.1:` over SSH (`ssh -L :127.0.0.1:`), loads `/webchat/info`, and serves the Web Chat UI in-app. +- Keep the feature enabled in *Settings → Config → Web chat*. Disable it to hide the menu entry entirely. + +## Permissions +- The remote host needs the same TCC approvals as local (Automation, Accessibility, Screen Recording, Microphone, Speech Recognition, Notifications). Run onboarding on that machine to grant them once. +- When remote commands need local TCC (e.g., screenshots on the remote Mac), ensure `clawdis-mac` is installed there so the helper can request/hold those permissions. + +## WhatsApp login flow (remote) +- Run `clawdis login --verbose` **on the remote host**. Scan the QR with WhatsApp on your phone. +- Re-run login on that host if auth expires. Health check will surface link problems. + +## Troubleshooting +- **exit 127 / not found**: `clawdis` isn’t on PATH for non-login shells. Add it to `/etc/paths`, your shell rc, or symlink into `/usr/local/bin`/`/opt/homebrew/bin`. +- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`clawdis status --json`). +- **Web Chat stuck**: confirm the remote webchat server is running (`clawdis webchat --json`) and the port matches *Settings → Config*. +- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed. + +## Notification sounds +Pick sounds per notification from scripts with the helper CLI, e.g.: + +```bash +clawdis-mac notify --title "Ping" --body "Remote relay ready" --sound Glass +``` + +There is no global “default sound” toggle in the app anymore; callers choose a sound (or none) per request. diff --git a/docs/webchat.md b/docs/webchat.md index 80f6f39b2..e614d0608 100644 --- a/docs/webchat.md +++ b/docs/webchat.md @@ -1,56 +1,33 @@ -# Web Chat architecture (local + remote) +# Web Chat (loopback + SSH tunnel) -Date: 2025-12-08 · Status: draft plan +Updated: 2025-12-08 -## Goal -- Serve the Clawdis Web Chat UI from the Node relay (loopback-only HTTP), while the macOS app keeps the same UX by embedding it in a WKWebView. -- Keep remote mode working: when Clawdis runs on a remote host via SSH, the mac app should still show the web chat backed by that remote relay (via an SSH tunnel). +## What shipped +- A lightweight HTTP server now lives inside the Node relay (`clawdis webchat --port 18788`). +- It binds to **127.0.0.1** only and serves: + - `GET /webchat/info?session=` → `{port, token, sessionId, initialMessages, basePath}` plus history from the relay’s session store. + - `GET /webchat/*` → static Web Chat assets. + - `POST /webchat/rpc` → runs `clawdis agent --json` and returns `{ ok, payloads?, error? }`. +- The macOS app embeds this UI in a WKWebView. In **remote mode** it first opens an SSH tunnel (`ssh -L :127.0.0.1:`) to the remote host, then loads `/webchat/info` through that tunnel. +- Initial messages are preloaded from the relay’s session store, so remote sessions appear immediately. +- Sending now goes over the HTTP `/webchat/rpc` endpoint (no more AgentRPC fallback). +- Feature flag + port live in *Settings → Config → Web chat*. When disabled, the “Open Chat” menu entry is hidden. -## Proposed architecture -1) **Server location** - - A tiny HTTP server lives in the Node relay process. - - Bind to 127.0.0.1 on a chosen port (fixed or random with discovery endpoint). - - Serve static assets for `/webchat/` and a JSON RPC endpoint for sending messages. +## Security +- Loopback only; remote access requires SSH port-forwarding. +- Optional bearer token support is wired; tokens are returned by `/webchat/info` and accepted by `/webchat/rpc`. -2) **Endpoints** - - `GET /webchat/*`: serves bundled web assets (current WebChat build, moved from mac bundle into the Node package, e.g., `src/webchat/dist`). - - `GET /webchat/info`: returns `{ baseUrl, token? }` for the mac app to embed (token optional; see security below). - - `POST /webchat/rpc`: accepts `{ text, session, thinking?, deliver?, to? }` and replies with `{ ok, payloads?, error? }`. Internally calls the same agent pipeline that `clawdis rpc` uses today (in-process, no subprocess). - - (Optional) `GET /webchat/history?session=`: returns pre-serialized message history so the mac app doesn’t scrape JSONL. Can be folded into `/webchat/info` as an `initialMessages` field. +## Failure handling +- Bootstrap errors show in-app (“Web chat failed to connect …”) instead of hanging. +- The mac app logs tunnel and endpoint details to the `com.steipete.clawdis/WebChat` subsystem. -3) **Sessions & history** - - Use the relay’s own session store (default `~/.clawdis/sessions/sessions.json` on the relay host). No SSH file reads from the mac app anymore. - - When the page loads, it receives `initialMessages` from the server (either embedded in `info` or via a history endpoint). - - Remote mode automatically shows the remote session because the remote relay owns that store. +## Dev notes +- Static assets stay in `apps/macos/Sources/Clawdis/Resources/WebChat`; the server reads them directly. +- Server code: `src/webchat/server.ts`. +- CLI entrypoint: `clawdis webchat --json [--port N]`. +- Mac glue: `WebChatWindow.swift` (bootstrap + tunnel) and `WebChatTunnel` (SSH -L). -4) **Mac app embedding** - - On WebChatWindow init, the mac app calls `/webchat/info`: - - Local mode: directly over loopback (127.0.0.1:port chosen by relay). - - Remote mode: establish/reuse an SSH tunnel forwarding the relay’s webchat port to a local ephemeral port, then call `/webchat/info` through the tunnel and load the returned `baseUrl`. - - WKWebView loads `baseUrl` (e.g., `http://127.0.0.1:/webchat/`). - - Web page sends messages to `/webchat/rpc` (same origin as the static assets), so no extra mac plumbing. - -5) **Security** - - Bind to loopback only. For extra hardening, issue a random short-lived token in `/webchat/info` and require it as a header/query on `/webchat/rpc` and history. - - Remote mode relies on SSH port forwarding; no WAN exposure. - -6) **Failure handling** - - If `/webchat/info` fails, show an in-app error (“Web chat server unreachable”). - - Log the chosen port/URL and tunnel target in mac logs for debugging. - - History fetch failures fall back to an empty transcript but keep sending enabled. - -7) **Migration steps** - - Move WebChat bundle into the Node project (e.g., `src/webchat/dist`) and serve statically. - - Add the loopback HTTP server and `/webchat` routes to the relay startup. - - Expose `/webchat/info` (port + token + optional initialMessages). - - Mac app: replace local asset load with the fetched `baseUrl`; use SSH tunnel in remote mode. - - Remove mac-side JSONL scraping and `AgentRPC` usage for web chat; keep other agent uses intact. - - Tests: webchat loads + sends in local and remote modes; tunnel discovery works; history returns non-empty when sessions exist. - -8) **Current behavior (for reference, to be replaced)** - - Mac app reads remote session files over SSH (`clawdis sessions --json`, then `cat` the `.jsonl`) and injects history; sends via `clawdis rpc` subprocess. This document tracks the plan to move both pieces into the relay server instead. - -## Open questions -- Fixed port vs random per run? (Random + info endpoint is safer.) -- Token enforcement default on/off? (Recommended on when remote tunneling isn’t used.) -- Should `/webchat/rpc` also expose typing/streaming? (Nice-to-have; not required for parity.) +## TODO / nice-to-haves +- Enforce token by default once mobile/remote auth flows are in place. +- Stream responses instead of one-shot payloads. +- Expose a readiness endpoint for health checks. diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index 27210c354..ffd2b3115 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -7,6 +7,7 @@ const monitorWebProvider = vi.fn(); const logWebSelfId = vi.fn(); const waitForever = vi.fn(); const monitorTelegramProvider = vi.fn(); +const startWebChatServer = vi.fn(async () => ({ port: 18788, token: null })); const runtime = { log: vi.fn(), @@ -26,6 +27,10 @@ vi.mock("../provider-web.js", () => ({ vi.mock("../telegram/monitor.js", () => ({ monitorTelegramProvider, })); +vi.mock("../webchat/server.js", () => ({ + startWebChatServer, + getWebChatServer: () => null, +})); vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({ waitForever }), logWebSelfId, @@ -92,4 +97,14 @@ describe("cli program", () => { await program.parseAsync(["status"], { from: "user" }); expect(statusCommand).toHaveBeenCalled(); }); + + it("starts webchat server and prints json", async () => { + const program = buildProgram(); + runtime.log.mockClear(); + await program.parseAsync(["webchat", "--json"], { from: "user" }); + expect(startWebChatServer).toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith( + JSON.stringify({ port: 18788, token: null, basePath: "/webchat/", host: "127.0.0.1" }), + ); + }); });