diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index c62998207..9cea6de30 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -328,14 +328,14 @@ enum CommandResolver { return ["/usr/bin/ssh"] + args } - private struct RemoteSettings { + struct RemoteSettings { let mode: AppState.ConnectionMode let target: String let identity: String let projectRoot: String } - private static func connectionSettings() -> RemoteSettings { + static func connectionSettings() -> RemoteSettings { let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) ?? "local" let mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local let target = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" @@ -348,6 +348,10 @@ enum CommandResolver { projectRoot: projectRoot) } + static func connectionModeIsRemote() -> Bool { + self.connectionSettings().mode == .remote + } + private static func sanitizedTarget(_ raw: String) -> String { VoiceWakeForwarder.sanitizedTarget(raw) } diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 81a825318..076223353 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -1,20 +1,29 @@ import AppKit import Foundation +import Network import OSLog import WebKit +import ClawdisIPC + private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat") +private struct WebChatCliInfo: Decodable { + let port: Int + let token: String? + let host: String? + let basePath: String? +} + final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, WKNavigationDelegate { private let webView: WKWebView private let sessionKey: String - private let initialMessagesJSON: String - private let logger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat") + private var initialMessagesJSON: String = "[]" + private var tunnel: WebChatTunnel? init(sessionKey: String) { webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)") self.sessionKey = sessionKey - self.initialMessagesJSON = WebChatWindowController.loadInitialMessagesJSON(for: sessionKey) let config = WKWebViewConfiguration() let contentController = WKUserContentController() @@ -22,7 +31,6 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, config.preferences.isElementFullscreenEnabled = true config.preferences.setValue(true, forKey: "developerExtrasEnabled") - // Inject callback receiver stub let callbackScript = """ window.__clawdisCallbacks = new Map(); window.__clawdisReceive = function(resp) { @@ -47,21 +55,6 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, window.webkit?.messageHandlers?.clawdis?.postMessage({ id: 'log', log: String(msg) }); } catch (_) {} }; - const __origConsoleLog = console.log; - console.log = function(...args) { - try { window.__clawdisLog(args.join(' ')); } catch (_) {} - __origConsoleLog.apply(console, args); - }; - window.addEventListener('error', (e) => { - try { - window.__clawdisLog(`page error: ${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`); - } catch (_) {} - }); - window.addEventListener('unhandledrejection', (e) => { - try { - window.__clawdisLog(`unhandled rejection: ${e.reason}`); - } catch (_) {} - }); """ let userScript = WKUserScript(source: callbackScript, injectionTime: .atDocumentStart, forMainFrameOnly: true) contentController.addUserScript(userScript) @@ -77,25 +70,25 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, super.init(window: window) self.webView.navigationDelegate = self contentController.add(self, name: "clawdis") - self.loadPage() + + self.loadPlaceholder() + Task { await self.bootstrap() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - private func loadPage() { - webChatLogger.debug("loadPage begin") - guard let webChatURL = Bundle.main.url(forResource: "WebChat", withExtension: nil), - let htmlURL = URL(string: "index.html") - else { - NSLog("WebChat resources missing") - webChatLogger.error("WebChat resources missing in bundle") - return - } + private func loadPlaceholder() { + let html = """ +
Connecting to web chat… + """ + self.webView.loadHTMLString(html, baseURL: nil) + } + private func loadPage(baseURL: URL) { let bootstrapScript = """ window.__clawdisBootstrap = { - sessionKey: "\(self.sessionKey)", + sessionKey: \(self.sessionKey), initialMessages: \(self.initialMessagesJSON) }; """ @@ -105,89 +98,76 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, forMainFrameOnly: true) self.webView.configuration.userContentController.addUserScript(userScript) - WebChatServer.shared.start(root: webChatURL) - guard let baseURL = self.waitForWebChatServer() else { - webChatLogger.error("WebChatServer not ready; cannot load web chat") - return - } - let url = baseURL.appendingPathComponent(htmlURL.relativePath) + let url = baseURL.appendingPathComponent("index.html") self.webView.load(URLRequest(url: url)) - webChatLogger.debug("loadPage queued HTML into WKWebView url=\(url.absoluteString, privacy: .public)") + webChatLogger.debug("loadPage url=\(url.absoluteString, privacy: .public)") } - private func waitForWebChatServer(timeout: TimeInterval = 2.0) -> URL? { - let deadline = Date().addingTimeInterval(timeout) - var base: URL? - while Date() < deadline { - if let url = WebChatServer.shared.baseURL() { - base = url - break + // MARK: - Bootstrap + + private func bootstrap() async { + do { + let cliInfo = try await self.fetchWebChatCliInfo() + let endpoint = try await self.prepareEndpoint(remotePort: cliInfo.port) + let infoURL = endpoint.appendingPathComponent("webchat/info") + .appending(queryItems: [URLQueryItem(name: "session", value: self.sessionKey)]) + + let (data, _) = try await URLSession.shared.data(from: infoURL) + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let msgs = obj["initialMessages"] + { + if let json = try? JSONSerialization.data(withJSONObject: msgs, options: []) { + self.initialMessagesJSON = String(data: json, encoding: .utf8) ?? "[]" + } } - RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + await MainActor.run { + self.loadPage(baseURL: endpoint.appendingPathComponent("webchat/")) + } + } catch { + webChatLogger.error("webchat bootstrap failed: \(error.localizedDescription, privacy: .public)") } - if base == nil { - webChatLogger.error("WebChatServer failed to become ready within \(timeout, privacy: .public)s") + } + + private func fetchWebChatCliInfo() async throws -> WebChatCliInfo { + let response = await ShellRunner.run( + command: CommandResolver.clawdisCommand(subcommand: "webchat", extraArgs: ["--json"]), + cwd: CommandResolver.projectRootPath(), + env: nil, + timeout: 10) + guard response.ok, let data = response.payload else { + throw NSError(domain: "WebChat", code: 1, userInfo: [NSLocalizedDescriptionKey: response.message ?? "webchat cli failed"]) + } + return try JSONDecoder().decode(WebChatCliInfo.self, from: data) + } + + private func prepareEndpoint(remotePort: Int) async throws -> URL { + if CommandResolver.connectionModeIsRemote() { + let tunnel = try await WebChatTunnel.create(remotePort: remotePort) + self.tunnel = tunnel + guard let port = tunnel.localPort else { + throw NSError(domain: "WebChat", code: 2, userInfo: [NSLocalizedDescriptionKey: "tunnel missing port"]) + } + return URL(string: "http://127.0.0.1:\(port)/")! + } else { + return URL(string: "http://127.0.0.1:\(remotePort)/")! } - return base } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { webChatLogger.debug("didFinish navigation url=\(webView.url?.absoluteString ?? "nil", privacy: .public)") - webView.evaluateJavaScript("document.body.innerText") { result, error in - if let error { - webChatLogger.error("eval error: \(error.localizedDescription, privacy: .public)") - } else if let text = result as? String { - webChatLogger.debug("body text snapshot: \(String(text.prefix(200)), privacy: .public)") - } - } - webView.evaluateJavaScript("document.readyState") { result, _ in - if let state = result as? String { - webChatLogger.debug("readyState=\(state, privacy: .public)") - } - } - webView.evaluateJavaScript("window.location.href") { result, _ in - if let href = result as? String { - webChatLogger.debug("js location=\(href, privacy: .public)") - } - } - } - - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - webChatLogger.debug("didStartProvisional url=\(webView.url?.absoluteString ?? "nil", privacy: .public)") - } - - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - webChatLogger.debug("didCommit url=\(webView.url?.absoluteString ?? "nil", privacy: .public)") - } - - func webView( - _ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: any Error) - { - webChatLogger.error("didFailProvisional error=\(error.localizedDescription, privacy: .public)") - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { - webChatLogger.error("didFail error=\(error.localizedDescription, privacy: .public)") - } - - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - webChatLogger.error("webContentProcessDidTerminate") } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == "clawdis" else { return } + if let body = message.body as? [String: Any], body["id"] as? String == "log" { + if let log = body["log"] as? String { webChatLogger.debug("JS: \(log, privacy: .public)") } + return + } + guard let body = message.body as? [String: Any], let id = body["id"] as? String else { return } - if id == "log" { - if let log = body["log"] as? String { - webChatLogger.debug("JS: \(log, privacy: .public)") - } - return - } - guard let type = body["type"] as? String, type == "chat", let payload = body["payload"] as? [String: Any], @@ -221,250 +201,81 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, to: sessionKey) return (result.text, result.error) } - - private static func loadInitialMessagesJSON(for sessionKey: String) -> String { - // Prefer remote session log when running in remote mode; fall back to local files. - var content: String? - - if self.connectionModeIsRemote() { - if let remote = self.fetchRemoteSessionLog(sessionKey: sessionKey) { - content = remote - } - } else if let sessionId = self.sessionId(for: sessionKey) { - let primary = self.expand("~/.clawdis/sessions/\(sessionId).jsonl") - let legacy = self.expand("~/.tau/agent/sessions/clawdis/\(sessionId).jsonl") - if FileManager.default.fileExists(atPath: primary), - let text = try? String(contentsOfFile: primary, encoding: .utf8) - { - content = text - } else if FileManager.default.fileExists(atPath: legacy), - let text = try? String(contentsOfFile: legacy, encoding: .utf8) - { - content = text - } - } - - guard let content else { return "[]" } - - var messages: [[String: Any]] = [] - for line in content.split(whereSeparator: { $0.isNewline }) { - guard let data = String(line).data(using: .utf8), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { continue } - let message = (obj["message"] as? [String: Any]) ?? obj - guard let role = message["role"] as? String, - ["user", "assistant", "system"].contains(role) - else { continue } - - var contentPayload = message["content"] as? [[String: Any]] - if contentPayload == nil, let text = message["text"] as? String { - contentPayload = [["type": "text", "text": text]] - } - guard let finalContent = contentPayload else { continue } - messages.append(["role": role, "content": finalContent]) - } - - guard let data = try? JSONSerialization.data(withJSONObject: messages, options: []) else { - return "[]" - } - return String(data: data, encoding: .utf8) ?? "[]" - } - - private static func sessionId(for key: String) -> String? { - let storePath = self.expand("~/.clawdis/sessions/sessions.json") - guard let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)) else { return nil } - guard let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - guard let entry = decoded[key] as? [String: Any] else { return nil } - return entry["sessionId"] as? String - } - - // MARK: - Remote session helpers - - private static func connectionModeIsRemote() -> Bool { - let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) ?? "local" - return modeRaw == AppState.ConnectionMode.remote.rawValue - } - - private static func remoteSettings() -> (target: String, identity: String)? { - guard self.connectionModeIsRemote() else { return nil } - let rawTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" - let target = VoiceWakeForwarder.sanitizedTarget(rawTarget) - let identity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" - return (target: target, identity: identity) - } - - private struct SessionsSummary: Decodable { - struct Entry: Decodable { - let key: String - let sessionId: String? - } - - let path: String - let sessions: [Entry] - } - - private static func remoteSessionsSummary() -> SessionsSummary? { - guard let jsonData = self.runClawdisCommand(subcommand: "sessions", args: ["--json"]) else { - webChatLogger.error("remote sessions summary command failed") - return nil - } - if let decoded = try? JSONDecoder().decode(SessionsSummary.self, from: jsonData) { - return decoded - } - if let text = String(data: jsonData, encoding: .utf8) { - webChatLogger.error("failed to decode sessions summary json=\(text, privacy: .public)") - } - return nil - } - - private static func runClawdisCommand(subcommand: String, args: [String]) -> Data? { - var env = ProcessInfo.processInfo.environment - env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") - - let command = CommandResolver.clawdisCommand(subcommand: subcommand, extraArgs: args) - let process = Process() - process.executableURL = URL(fileURLWithPath: command.first ?? "/usr/bin/env") - process.arguments = Array(command.dropFirst()) - process.currentDirectoryURL = URL(fileURLWithPath: CommandResolver.projectRootPath()) - process.environment = env - - let out = Pipe() - let err = Pipe() - process.standardOutput = out - process.standardError = err - - do { try process.run() } catch { - webChatLogger.error("clawdis \(subcommand) failed to launch: \(error.localizedDescription, privacy: .public)") - return nil - } - process.waitUntilExit() - let data = out.fileHandleForReading.readDataToEndOfFile() - if !data.isEmpty { return data } - let msg = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - webChatLogger.error("clawdis \(subcommand) produced no output: \(msg, privacy: .public)") - return nil - } - - private static func remoteSessionId(for key: String) -> String? { - if let summary = self.remoteSessionsSummary(), - let entry = summary.sessions.first(where: { $0.key == key }), - let sessionId = entry.sessionId - { - return sessionId - } - return self.remoteSessionsSummary()?.sessions.first?.sessionId - } - - private static func readRemoteFile(_ path: String) -> Data? { - guard let settings = self.remoteSettings(), - let parsed = VoiceWakeForwarder.parse(target: settings.target) - else { return nil } - - var sshArgs: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"] - if parsed.port > 0 { sshArgs.append(contentsOf: ["-p", String(parsed.port)]) } - let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) - if !identity.isEmpty { sshArgs.append(contentsOf: ["-i", identity]) } - let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host - sshArgs.append(userHost) - - // Avoid single-quoting to preserve $HOME expansion; escape double quotes instead. - let escapedPath = path.replacingOccurrences(of: "\"", with: "\\\"") - let script = "cat \"\(escapedPath)\"" - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = sshArgs + ["/bin/sh", "-c", script] - let out = Pipe() - let err = Pipe() - process.standardOutput = out - process.standardError = err - - do { try process.run() } catch { return nil } - process.waitUntilExit() - guard process.terminationStatus == 0 else { - let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - webChatLogger.error("ssh cat failed status=\(process.terminationStatus, privacy: .public) stderr=\(stderr, privacy: .public)") - return nil - } - return out.fileHandleForReading.readDataToEndOfFile() - } - - private static func fetchRemoteSessionLog(sessionKey: String) -> String? { - guard let summary = self.remoteSessionsSummary() else { - webChatLogger.error("remote sessions summary missing") - return nil - } - - let sessionId = summary.sessions.first(where: { $0.key == sessionKey })?.sessionId - ?? summary.sessions.first?.sessionId - guard let sessionId else { - webChatLogger.error("remote session id missing for key=\(sessionKey, privacy: .public)") - return nil - } - - // Prefer the path reported by the CLI; replace trailing sessions.json with the log file. - let basePath = summary.path - let logPath: String = if basePath.hasSuffix("sessions.json") { - String(basePath.dropLast("sessions.json".count)) + "\(sessionId).jsonl" - } else { - basePath + "/\(sessionId).jsonl" - } - - if let data = self.readRemoteFile(logPath), let text = String(data: data, encoding: .utf8) { - return text - } - - // Legacy path fallback if the CLI reports a different store. - let legacy = "$HOME/.tau/agent/sessions/clawdis/\(sessionId).jsonl" - if let data = self.readRemoteFile(legacy), let text = String(data: data, encoding: .utf8) { - return text - } - - webChatLogger.error("remote session log not found at \(logPath, privacy: .public)") - return nil - } - - private static func expand(_ path: String) -> String { - (path as NSString).expandingTildeInPath - } } +// MARK: - Manager + @MainActor final class WebChatManager { static let shared = WebChatManager() - private var window: WebChatWindowController? - private var webView: WKWebView? { self.window?.value(forKey: "webView") as? WKWebView } + private var controller: WebChatWindowController? func show(sessionKey: String) { - if self.window == nil { - self.window = WebChatWindowController(sessionKey: sessionKey) + if self.controller == nil { + self.controller = WebChatWindowController(sessionKey: sessionKey) } - self.window?.showWindow(nil) - self.window?.window?.makeKeyAndOrderFront(nil) + self.controller?.showWindow(nil) + self.controller?.window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } +} - /// Send a message into the active web chat session. Returns true if enqueued. - func sendMessage(_ text: String, thinking: String = "default", sessionKey: String = "main") -> Bool { - self.show(sessionKey: sessionKey) - guard let webView else { return false } - guard let script = try? JSONSerialization.data(withJSONObject: [ - "text": text, - "thinking": thinking, - ]) else { return false } +// MARK: - Port forwarding tunnel - let payload = String(data: script, encoding: .utf8) ?? "" - let js = "window.__clawdisEnqueueOutgoing(\(payload))" +final class WebChatTunnel { + let process: Process + let localPort: UInt16? - var success = false - webView.evaluateJavaScript(js) { result, error in - if error == nil { success = true } - if let err = error { - webChatLogger.error("enqueue JS error: \(err.localizedDescription, privacy: .public)") - } else if let res = result { - webChatLogger.debug("enqueue JS result: \(String(describing: res), privacy: .public)") - } + private init(process: Process, localPort: UInt16?) { + self.process = process + self.localPort = localPort + } + + deinit { + self.process.terminate() + } + + static func create(remotePort: Int) async throws -> WebChatTunnel { + let settings = CommandResolver.connectionSettings() + guard settings.mode == .remote, let parsed = VoiceWakeForwarder.parse(target: settings.target) else { + throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"]) } - return success + + let localPort = try Self.findFreePort() + var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-N", "-L", "\(localPort):127.0.0.1:\(remotePort)"] + if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } + let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) + if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) } + let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host + args.append(userHost) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = args + let pipe = Pipe() + process.standardError = pipe + try process.run() + + return WebChatTunnel(process: process, localPort: localPort) + } + + private static func findFreePort() throws -> UInt16 { + let listener = try NWListener(using: .tcp, on: .any) + listener.start(queue: .main) + while listener.port == nil { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + } + let port = listener.port?.rawValue + listener.cancel() + guard let port else { throw NSError(domain: "WebChat", code: 4, userInfo: [NSLocalizedDescriptionKey: "no port"])} + return port + } +} + +extension URL { + func appending(queryItems: [URLQueryItem]) -> URL { + guard var comps = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self } + comps.queryItems = (comps.queryItems ?? []) + queryItems + return comps.url ?? self } } diff --git a/src/cli/program.ts b/src/cli/program.ts index 4cf869e11..c4f5d235f 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -17,6 +17,7 @@ import { setHeartbeatsEnabled, type WebMonitorTuning, } from "../provider-web.js"; +import { startWebChatServer, getWebChatServer } from "../webchat/server.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { VERSION } from "../version.js"; import { @@ -818,5 +819,26 @@ Shows token usage per session when the agent reports it; set inbound.reply.agent } }); + program + .command("webchat") + .description("Start or query the loopback-only web chat server") + .option("--port