Remote web chat tunnel and onboarding polish

main
Peter Steinberger 2025-12-08 12:50:37 +01:00
parent 17fa2f4053
commit 92457f7fab
13 changed files with 338 additions and 162 deletions

View File

@ -110,6 +110,14 @@ final class AppState: ObservableObject {
didSet { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } 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 { @Published var remoteTarget: String {
didSet { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } didSet { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
} }
@ -170,6 +178,9 @@ final class AppState: ObservableObject {
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" 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() { if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = false self.swabbleEnabled = false
@ -232,6 +243,15 @@ enum AppStateStore {
static func updateLaunchAtLogin(enabled: Bool) { static func updateLaunchAtLogin(enabled: Bool) {
LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath) 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 { extension AppState {

View File

@ -14,6 +14,8 @@ struct ConfigSettings: View {
@State private var allowAutosave = false @State private var allowAutosave = false
@State private var heartbeatMinutes: Int? @State private var heartbeatMinutes: Int?
@State private var heartbeatBody: String = "HEARTBEAT" @State private var heartbeatBody: String = "HEARTBEAT"
@AppStorage(webChatEnabledKey) private var webChatEnabled: Bool = true
@AppStorage(webChatPortKey) private var webChatPort: Int = 18788
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { 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 relays loopback web chat on this port. Remote mode uses SSH -L to forward it.")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: 480, alignment: .leading)
}
}
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@ -24,6 +24,8 @@ let connectionModeKey = "clawdis.connectionMode"
let remoteTargetKey = "clawdis.remoteTarget" let remoteTargetKey = "clawdis.remoteTarget"
let remoteIdentityKey = "clawdis.remoteIdentity" let remoteIdentityKey = "clawdis.remoteIdentity"
let remoteProjectRootKey = "clawdis.remoteProjectRoot" let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let webChatEnabledKey = "clawdis.webChatEnabled"
let webChatPortKey = "clawdis.webChatPort"
let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload" let modelCatalogReloadKey = "clawdis.modelCatalogReload"
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled" let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"

View File

@ -71,19 +71,23 @@ struct GeneralSettings: View {
} }
private var connectionSection: some View { private var connectionSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 10) {
Picker("Clawdis runs", selection: self.$state.connectionMode) { 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("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote) Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(width: 360) .frame(width: 380, alignment: .leading)
self.healthRow
if self.state.connectionMode == .remote { if self.state.connectionMode == .remote {
self.remoteCard self.remoteCard
} }
self.healthRow
} }
} }
@ -92,10 +96,10 @@ struct GeneralSettings: View {
HStack(alignment: .center, spacing: 10) { HStack(alignment: .center, spacing: 10) {
Text("SSH") Text("SSH")
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.frame(width: 44, alignment: .leading) .frame(width: 48, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget) TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 260) .frame(width: 280)
} }
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
@ -103,12 +107,12 @@ struct GeneralSettings: View {
LabeledContent("Identity file") { LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 260) .frame(width: 280)
} }
LabeledContent("Project root") { LabeledContent("Project root") {
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 260) .frame(width: 280)
} }
} }
.padding(.top, 4) .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) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .lineLimit(1)
} }
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
.transition(.opacity) .transition(.opacity)
} }
@ -304,6 +305,8 @@ extension GeneralSettings {
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
Text(self.healthStore.summaryLine) Text(self.healthStore.summaryLine)
.font(.callout) .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) let response = await ShellRunner.run(command: command, cwd: nil, env: nil, timeout: 10)
if response.ok { if response.ok {
self.remoteStatus = .ok self.remoteStatus = .ok
} else { return
let msg = response.message ?? "test failed"
self.remoteStatus = .failed(msg)
} }
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() { private func revealLogs() {

View File

@ -163,12 +163,11 @@ final class HealthStore: ObservableObject {
return "Not linked — run clawdis login" return "Not linked — run clawdis login"
} }
if let connect = snap.web.connect, !connect.ok { if let connect = snap.web.connect, !connect.ok {
if let err = connect.error, err.contains("timeout") { let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration"
let elapsed = connect.elapsedMs.map { " after \(Int($0))ms" } ?? "" if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil {
return "Web connect timed out\(elapsed)" return "Health check timed out (\(elapsed))"
} }
let code = connect.status.map { "status \($0)" } ?? "status unknown" 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" let reason = connect.error ?? "connect failed"
return "\(reason) (\(code), \(elapsed))" return "\(reason) (\(code), \(elapsed))"
} }

View File

@ -1,3 +1,4 @@
import AVFoundation
import AppKit import AppKit
import Darwin import Darwin
import Foundation import Foundation
@ -56,6 +57,8 @@ private struct MenuContent: View {
@ObservedObject private var relayManager = RelayProcessManager.shared @ObservedObject private var relayManager = RelayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared @ObservedObject private var healthStore = HealthStore.shared
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@ -68,7 +71,12 @@ private struct MenuContent: View {
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") } Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
.disabled(!voiceWakeSupported) .disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5) .opacity(voiceWakeSupported ? 1 : 0.5)
if self.showVoiceWakeMicPicker {
self.voiceWakeMicMenu
}
if AppStateStore.webChatEnabled {
Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) } Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) }
}
Divider() Divider()
Button("Settings…") { self.open(tab: .general) } Button("Settings…") { self.open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command]) .keyboardShortcut(",", modifiers: [.command])
@ -79,6 +87,11 @@ private struct MenuContent: View {
Divider() Divider()
Button("Quit") { NSApplication.shared.terminate(nil) } 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) { 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 { private func primarySessionKey() -> String {
// Prefer canonical main session; fall back to most recent. // Prefer canonical main session; fall back to most recent.
let storePath = SessionLoader.defaultStorePath let storePath = SessionLoader.defaultStorePath
@ -183,6 +267,12 @@ private struct MenuContent: View {
} }
return "+1003" return "+1003"
} }
private struct AudioInputDevice: Identifiable, Equatable {
let uid: String
let name: String
var id: String { self.uid }
}
} }
private struct CritterStatusLabel: View { private struct CritterStatusLabel: View {

View File

@ -49,8 +49,8 @@ struct OnboardingView: View {
@ObservedObject private var state = AppStateStore.shared @ObservedObject private var state = AppStateStore.shared
@ObservedObject private var permissionMonitor = PermissionMonitor.shared @ObservedObject private var permissionMonitor = PermissionMonitor.shared
private let pageWidth: CGFloat = 640 private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 340 private let contentHeight: CGFloat = 520
private let permissionsPageIndex = 2 private let permissionsPageIndex = 2
private var pageCount: Int { 6 } private var pageCount: Int { 6 }
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
@ -59,9 +59,9 @@ struct OnboardingView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
GlowingClawdisIcon(size: 156) GlowingClawdisIcon(size: 156)
.padding(.top, 20) .padding(.top, 10)
.padding(.bottom, 8) .padding(.bottom, 2)
.frame(height: 200) .frame(height: 176)
GeometryReader { _ in GeometryReader { _ in
HStack(spacing: 0) { HStack(spacing: 0) {
@ -79,11 +79,11 @@ struct OnboardingView: View {
.frame(height: self.contentHeight, alignment: .top) .frame(height: self.contentHeight, alignment: .top)
.clipped() .clipped()
} }
.frame(height: 260) .frame(height: self.contentHeight)
self.navigationBar self.navigationBar
} }
.frame(width: self.pageWidth, height: 560) .frame(width: self.pageWidth, height: 720)
.background(Color(NSColor.windowBackgroundColor)) .background(Color(NSColor.windowBackgroundColor))
.onAppear { .onAppear {
self.currentPage = 0 self.currentPage = 0
@ -129,20 +129,20 @@ struct OnboardingView: View {
.frame(maxWidth: 520) .frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10, padding: 12) { self.onboardingCard(spacing: 12, padding: 14) {
Picker("Mode", selection: self.$state.connectionMode) { Picker("Clawdis runs", selection: self.$state.connectionMode) {
Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote) Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(width: 320) .frame(width: 360)
if self.state.connectionMode == .remote { if self.state.connectionMode == .remote {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
LabeledContent("SSH target") { LabeledContent("SSH target") {
TextField("user@host[:22]", text: self.$state.remoteTarget) TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 260) .frame(width: 300)
} }
DisclosureGroup("Advanced") { DisclosureGroup("Advanced") {
@ -150,20 +150,21 @@ struct OnboardingView: View {
LabeledContent("Identity file") { LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 260) .frame(width: 300)
} }
LabeledContent("Project root") { LabeledContent("Project root") {
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 260) .frame(width: 300)
} }
} }
.padding(.top, 4) .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) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1)
} }
.transition(.opacity.combined(with: .move(edge: .top))) .transition(.opacity.combined(with: .move(edge: .top)))
} }

View File

@ -309,8 +309,7 @@ enum CommandResolver {
private static func sshCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { private static func sshCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
guard !settings.target.isEmpty else { return nil } guard !settings.target.isEmpty else { return nil }
let parsed = VoiceWakeForwarder.parse(target: settings.target) guard let parsed = VoiceWakeForwarder.parse(target: settings.target) else { return nil }
guard let parsed else { return nil }
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"] var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } 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 let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost) 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 cdPrefix = settings.projectRoot.isEmpty ? "" : "cd \(self.shellQuote(settings.projectRoot)) && "
let scriptBody = "\(cdPrefix)\(quotedArgs)" let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
let wrapped = VoiceWakeForwarder.commandWithCliPath(scriptBody, target: settings.target) let scriptBody = """
args.append(contentsOf: ["/bin/sh", "-c", wrapped]) 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 return ["/usr/bin/ssh"] + args
} }

View File

@ -182,6 +182,7 @@ actor VoiceWakeRuntime {
private func monitorCapture(config: RuntimeConfig) async { private func monitorCapture(config: RuntimeConfig) async {
let start = self.captureStartedAt ?? Date() let start = self.captureStartedAt ?? Date()
let hardStop = start.addingTimeInterval(self.captureHardStop) let hardStop = start.addingTimeInterval(self.captureHardStop)
var silentStrikes = 0
while self.isCapturing { while self.isCapturing {
let now = Date() let now = Date()
@ -191,9 +192,14 @@ actor VoiceWakeRuntime {
} }
if let last = self.lastHeard, now.timeIntervalSince(last) >= self.silenceWindow { if let last = self.lastHeard, now.timeIntervalSince(last) >= self.silenceWindow {
silentStrikes += 1
if silentStrikes >= 2 {
await self.finalizeCapture(config: config) await self.finalizeCapture(config: config)
return return
} }
} else {
silentStrikes = 0
}
try? await Task.sleep(nanoseconds: 200_000_000) try? await Task.sleep(nanoseconds: 200_000_000)
} }

View File

@ -110,6 +110,9 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
private func bootstrap() async { private func bootstrap() async {
do { do {
let cliInfo = try await self.fetchWebChatCliInfo() 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) let endpoint = try await self.prepareEndpoint(remotePort: cliInfo.port)
self.baseEndpoint = endpoint self.baseEndpoint = endpoint
let infoURL = endpoint.appendingPathComponent("webchat/info") let infoURL = endpoint.appendingPathComponent("webchat/info")
@ -138,8 +141,11 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
} }
private func fetchWebChatCliInfo() async throws -> WebChatCliInfo { 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( let response = await ShellRunner.run(
command: CommandResolver.clawdisCommand(subcommand: "webchat", extraArgs: ["--json"]), command: CommandResolver.clawdisCommand(subcommand: "webchat", extraArgs: args),
cwd: CommandResolver.projectRootPath(), cwd: CommandResolver.projectRootPath(),
env: nil, env: nil,
timeout: 10) timeout: 10)
@ -209,7 +215,9 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) { private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) {
await MainActor.run { AppStateStore.shared.setWorking(true) } await MainActor.run { AppStateStore.shared.setWorking(true) }
defer { Task { await MainActor.run { AppStateStore.shared.setWorking(false) } } } defer { Task { await MainActor.run { AppStateStore.shared.setWorking(false) } } }
if let base = self.baseEndpoint { guard let base = self.baseEndpoint else {
return (nil, "web chat endpoint missing")
}
do { do {
var req = URLRequest(url: base.appendingPathComponent("webchat/rpc")) var req = URLRequest(url: base.appendingPathComponent("webchat/rpc"))
req.httpMethod = "POST" req.httpMethod = "POST"
@ -244,16 +252,6 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
return (nil, error.localizedDescription) 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)
}
} }
// MARK: - Manager // MARK: - Manager

View File

@ -1,29 +1,51 @@
# Remote Clawd mode (Dec 2025) # Remote Clawdis (macOS ⇄ remote host)
## What it is Updated: 2025-12-08
- 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.
## Requirements 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*.
- 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.
## How it works ## Modes
- The app builds commands through the new runner: - **Local (this Mac)**: Everything runs on the laptop. No SSH involved.
- `clawdis status/health/agent/relay` are wrapped in `ssh … /bin/sh -c '<cd project && clawdis …>'` with CLI path lookup. - **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.
- `clawdis rpc` is tunneled over a long-lived SSH process so web chat and the apps 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).
## Setup steps ## Prereqs on the remote host
1) Open **Settings → General → Clawdis runs** and pick **Remote over SSH**. 1) Install Node + pnpm and build/install the Clawdis CLI (`pnpm install && pnpm build && pnpm link --global`).
2) Fill **SSH target**, **Identity file**, and **Project root** (where `clawdis` lives on the remote). 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) Click **Test remote**; it runs `clawdis status --json` remotely and caches the resolved CLI path. 3) Open SSH with key auth. We recommend **Tailscale** IPs for stable reachability off-LAN.
4) Run onboardings WhatsApp login step on the machine where the relay will run (remote if remote mode is enabled).
## Notes ## macOS app setup
- Connection strings accept `user@host:port`; leading `ssh ` is stripped if pasted from a shell snippet. 1) Open *Settings → General*.
- Project root defaults to the path you enter; if blank, no `cd` is issued before the relay command. 2) Under **Clawdis runs**, pick **Remote over SSH** and set:
- The remote log path remains `/tmp/clawdis/clawdis.log`; view it via SSH if you need details. - **SSH target**: `user@host` (optional `:port`).
- If you switch back to Local, existing remote state is left untouched; re-run Test remote when switching again. - **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 isnt 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 <port>`; default 18788).
- The mac app forwards `127.0.0.1:<port>` over SSH (`ssh -L <ephemeral>:127.0.0.1:<port>`), 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` isnt 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.

View File

@ -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 ## What shipped
- 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. - A lightweight HTTP server now lives inside the Node relay (`clawdis webchat --port 18788`).
- 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). - It binds to **127.0.0.1** only and serves:
- `GET /webchat/info?session=<key>``{port, token, sessionId, initialMessages, basePath}` plus history from the relays 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 <local>:127.0.0.1:<port>`) to the remote host, then loads `/webchat/info` through that tunnel.
- Initial messages are preloaded from the relays 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 ## Security
1) **Server location** - Loopback only; remote access requires SSH port-forwarding.
- A tiny HTTP server lives in the Node relay process. - Optional bearer token support is wired; tokens are returned by `/webchat/info` and accepted by `/webchat/rpc`.
- 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.
2) **Endpoints** ## Failure handling
- `GET /webchat/*`: serves bundled web assets (current WebChat build, moved from mac bundle into the Node package, e.g., `src/webchat/dist`). - Bootstrap errors show in-app (“Web chat failed to connect …”) instead of hanging.
- `GET /webchat/info`: returns `{ baseUrl, token? }` for the mac app to embed (token optional; see security below). - The mac app logs tunnel and endpoint details to the `com.steipete.clawdis/WebChat` subsystem.
- `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=<key>`: returns pre-serialized message history so the mac app doesnt scrape JSONL. Can be folded into `/webchat/info` as an `initialMessages` field.
3) **Sessions & history** ## Dev notes
- Use the relays own session store (default `~/.clawdis/sessions/sessions.json` on the relay host). No SSH file reads from the mac app anymore. - Static assets stay in `apps/macos/Sources/Clawdis/Resources/WebChat`; the server reads them directly.
- When the page loads, it receives `initialMessages` from the server (either embedded in `info` or via a history endpoint). - Server code: `src/webchat/server.ts`.
- Remote mode automatically shows the remote session because the remote relay owns that store. - CLI entrypoint: `clawdis webchat --json [--port N]`.
- Mac glue: `WebChatWindow.swift` (bootstrap + tunnel) and `WebChatTunnel` (SSH -L).
4) **Mac app embedding** ## TODO / nice-to-haves
- On WebChatWindow init, the mac app calls `/webchat/info`: - Enforce token by default once mobile/remote auth flows are in place.
- Local mode: directly over loopback (127.0.0.1:port chosen by relay). - Stream responses instead of one-shot payloads.
- Remote mode: establish/reuse an SSH tunnel forwarding the relays webchat port to a local ephemeral port, then call `/webchat/info` through the tunnel and load the returned `baseUrl`. - Expose a readiness endpoint for health checks.
- WKWebView loads `baseUrl` (e.g., `http://127.0.0.1:<forward>/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 isnt used.)
- Should `/webchat/rpc` also expose typing/streaming? (Nice-to-have; not required for parity.)

View File

@ -7,6 +7,7 @@ const monitorWebProvider = vi.fn();
const logWebSelfId = vi.fn(); const logWebSelfId = vi.fn();
const waitForever = vi.fn(); const waitForever = vi.fn();
const monitorTelegramProvider = vi.fn(); const monitorTelegramProvider = vi.fn();
const startWebChatServer = vi.fn(async () => ({ port: 18788, token: null }));
const runtime = { const runtime = {
log: vi.fn(), log: vi.fn(),
@ -26,6 +27,10 @@ vi.mock("../provider-web.js", () => ({
vi.mock("../telegram/monitor.js", () => ({ vi.mock("../telegram/monitor.js", () => ({
monitorTelegramProvider, monitorTelegramProvider,
})); }));
vi.mock("../webchat/server.js", () => ({
startWebChatServer,
getWebChatServer: () => null,
}));
vi.mock("./deps.js", () => ({ vi.mock("./deps.js", () => ({
createDefaultDeps: () => ({ waitForever }), createDefaultDeps: () => ({ waitForever }),
logWebSelfId, logWebSelfId,
@ -92,4 +97,14 @@ describe("cli program", () => {
await program.parseAsync(["status"], { from: "user" }); await program.parseAsync(["status"], { from: "user" });
expect(statusCommand).toHaveBeenCalled(); 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" }),
);
});
}); });