From 2a45455c803893eefc953e71b958a71ed6fdf436 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Dec 2025 18:19:37 +0100 Subject: [PATCH] feat: add remote clawd toggle --- apps/macos/Sources/Clawdis/AppState.swift | 35 ++++++- apps/macos/Sources/Clawdis/Constants.swift | 6 +- .../Sources/Clawdis/GeneralSettings.swift | 97 +++++++++++++++++++ apps/macos/Sources/Clawdis/Onboarding.swift | 96 +++++++++++------- apps/macos/Sources/Clawdis/Utilities.swift | 78 ++++++++++++++- .../Sources/Clawdis/VoiceWakeForwarder.swift | 6 +- .../macos/Sources/Clawdis/WebChatWindow.swift | 11 ++- docs/mac/remote.md | 29 ++++++ 8 files changed, 314 insertions(+), 44 deletions(-) create mode 100644 docs/mac/remote.md diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 6eff4ab4c..4c08113a4 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -5,6 +5,11 @@ import SwiftUI @MainActor final class AppState: ObservableObject { + enum ConnectionMode: String { + case local + case remote + } + @Published var isPaused: Bool { didSet { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } @@ -105,6 +110,22 @@ final class AppState: ObservableObject { } } + @Published var connectionMode: ConnectionMode { + didSet { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } + } + + @Published var remoteTarget: String { + didSet { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } + } + + @Published var remoteIdentity: String { + didSet { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } + } + + @Published var remoteProjectRoot: String { + didSet { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } + } + private var earBoostTask: Task? init() { @@ -133,8 +154,14 @@ final class AppState: ObservableObject { self.voiceWakeForwardTarget = UserDefaults.standard .string(forKey: voiceWakeForwardTargetKey) ?? legacyTarget self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? "" - self.voiceWakeForwardCommand = UserDefaults.standard + + var storedForwardCommand = UserDefaults.standard .string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand + if !storedForwardCommand.contains("--deliver") || !storedForwardCommand.contains("--session") { + storedForwardCommand = defaultVoiceWakeForwardCommand + UserDefaults.standard.set(storedForwardCommand, forKey: voiceWakeForwardCommandKey) + } + self.voiceWakeForwardCommand = storedForwardCommand if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { self.heartbeatsEnabled = storedHeartbeats } else { @@ -142,6 +169,12 @@ final class AppState: ObservableObject { UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey) } + let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) + self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local + self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" + self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" + self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" + if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() { self.swabbleEnabled = false } diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index d420e55e5..c1920b79d 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -3,7 +3,7 @@ import Foundation let serviceName = "com.steipete.clawdis.xpc" let launchdLabel = "com.steipete.clawdis" let onboardingVersionKey = "clawdis.onboardingVersion" -let currentOnboardingVersion = 2 +let currentOnboardingVersion = 3 let pauseDefaultsKey = "clawdis.pauseEnabled" let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled" let swabbleEnabledKey = "clawdis.swabbleEnabled" @@ -20,6 +20,10 @@ let voiceWakeForwardUserKey = "clawdis.voiceWakeForwardUser" let voiceWakeForwardPortKey = "clawdis.voiceWakeForwardPort" let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity" let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand" +let connectionModeKey = "clawdis.connectionMode" +let remoteTargetKey = "clawdis.remoteTarget" +let remoteIdentityKey = "clawdis.remoteIdentity" +let remoteProjectRootKey = "clawdis.remoteProjectRoot" 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 07a579626..6bb321eea 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -8,9 +8,12 @@ struct GeneralSettings: View { @State private var cliStatus: String? @State private var cliInstalled = false @State private var cliInstallLocation: String? + @State private var remoteStatus: RemoteStatus = .idle var body: some View { VStack(alignment: .leading, spacing: 18) { + self.connectionSection + if !self.state.onboardingSeen { Text("Complete onboarding to finish setup") .font(.callout.weight(.semibold)) @@ -87,6 +90,80 @@ struct GeneralSettings: View { set: { self.state.isPaused = !$0 }) } + private var connectionSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Clawdis runs") + .font(.callout.weight(.semibold)) + + Picker("Mode", 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) + + if self.state.connectionMode == .remote { + VStack(alignment: .leading, spacing: 10) { + LabeledContent("SSH target") { + TextField("user@host[:22]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + } + + LabeledContent("Identity file") { + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + } + + LabeledContent("Project root") { + TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: 320) + } + + HStack(spacing: 10) { + Button { + Task { await self.testRemote() } + } label: { + if self.remoteStatus == .checking { + ProgressView().controlSize(.small) + } else { + Text("Test remote") + } + } + .disabled(self.remoteStatus == .checking || self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + switch self.remoteStatus { + case .idle: + EmptyView() + case .checking: + Text("Checking…").font(.caption).foregroundStyle(.secondary) + case .ok: + Label("Ready", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + case let .failed(message): + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + Text("Tip: use Tailscale for stable remote access; we recommend enabling it when you pick a remote Clawdis.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(12) + .background(Color.gray.opacity(0.08)) + .cornerRadius(10) + .transition(.opacity) + } + } + } + private var cliInstaller: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { @@ -217,7 +294,27 @@ struct GeneralSettings: View { } } +private enum RemoteStatus: Equatable { + case idle + case checking + case ok + case failed(String) +} + extension GeneralSettings { + @MainActor + fileprivate func testRemote() async { + self.remoteStatus = .checking + let command = CommandResolver.clawdisCommand(subcommand: "status", extraArgs: ["--json"]) + 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) + } + } + private func revealLogs() { let path = URL(fileURLWithPath: "/tmp/clawdis/clawdis.log") if FileManager.default.fileExists(atPath: path.path) { diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index f9851d567..b4d002dae 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -64,10 +64,10 @@ struct OnboardingView: View { GeometryReader { _ in HStack(spacing: 0) { self.welcomePage().frame(width: self.pageWidth) - self.focusPage().frame(width: self.pageWidth) + self.connectionPage().frame(width: self.pageWidth) self.permissionsPage().frame(width: self.pageWidth) self.cliPage().frame(width: self.pageWidth) - self.launchPage().frame(width: self.pageWidth) + self.whatsappPage().frame(width: self.pageWidth) self.readyPage().frame(width: self.pageWidth) } .offset(x: CGFloat(-self.currentPage) * self.pageWidth) @@ -113,25 +113,48 @@ struct OnboardingView: View { } } - private func focusPage() -> some View { + private func connectionPage() -> some View { self.onboardingPage { - Text("What Clawdis handles") + Text("Where Clawdis runs") .font(.largeTitle.weight(.semibold)) + Text("Pick local or remote. Remote uses SSH; we recommend Tailscale for reliable reachability.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + self.onboardingCard { - self.featureRow( - title: "Owns the TCC prompts", - subtitle: "Requests Notifications, Accessibility, and Screen Recording " - + "so your agents stay unblocked.", - systemImage: "lock.shield") - self.featureRow( - title: "Native notifications", - subtitle: "Shows desktop toasts for agent events with your preferred sound.", - systemImage: "bell.and.waveform") - self.featureRow( - title: "Privileged helpers", - subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI " - + "with the right permissions.", - systemImage: "terminal") + Picker("Mode", 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) + + if self.state.connectionMode == .remote { + VStack(alignment: .leading, spacing: 10) { + LabeledContent("SSH target") { + TextField("user@host[:22]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } + LabeledContent("Identity file") { + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } + LabeledContent("Project root") { + TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: 320) + } + Text("Tip: keep a Tailscale IP here so the agent stays reachable off-LAN.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } } } } @@ -212,11 +235,11 @@ struct OnboardingView: View { } } - private func launchPage() -> some View { + private func whatsappPage() -> some View { self.onboardingPage { - Text("Keep it running") + Text("Link WhatsApp") .font(.largeTitle.weight(.semibold)) - Text("Let Clawdis launch with macOS so permissions and notifications are ready when automations start.") + Text("Run `clawdis login` where the relay runs (local if local mode, remote if remote). Scan the QR to pair your account.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -224,21 +247,18 @@ struct OnboardingView: View { .fixedSize(horizontal: false, vertical: true) self.onboardingCard { - HStack { - Spacer() - Toggle("Launch at login", isOn: self.$state.launchAtLogin) - .toggleStyle(.switch) - .onChange(of: self.state.launchAtLogin) { _, newValue in - AppStateStore.updateLaunchAtLogin(enabled: newValue) - } - Spacer() - } - Text( - "You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" " - + "button if you need to revisit.") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) + self.featureRow( + title: "Open a terminal", + subtitle: "Use the same host selected above. If remote, SSH in first.", + systemImage: "terminal") + self.featureRow( + title: "Run `clawdis login --verbose`", + subtitle: "Scan the QR code with WhatsApp on your phone. We only use your personal session; no cloud relay involved.", + systemImage: "qrcode.viewfinder") + self.featureRow( + title: "Re-link after timeouts", + subtitle: "If Baileys auth expires, re-run login on that host. Settings → General shows remote/local mode so you know where to run it.", + systemImage: "clock.arrow.circlepath") } } } @@ -257,6 +277,10 @@ struct OnboardingView: View { title: "Test a notification", subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.", systemImage: "bell.badge") + Toggle("Launch at login", isOn: self.$state.launchAtLogin) + .onChange(of: self.state.launchAtLogin) { _, newValue in + AppStateStore.updateLaunchAtLogin(enabled: newValue) + } } Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.") .font(.footnote) diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 9ec384cfe..02197aaf7 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -178,7 +178,26 @@ enum CommandResolver { private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath" private static let helperName = "clawdis" + private static func bundledRelayRoot() -> URL? { + guard let resource = Bundle.main.resourceURL else { return nil } + let relay = resource.appendingPathComponent("Relay") + return FileManager.default.fileExists(atPath: relay.path) ? relay : nil + } + + private static func bundledRelayCommand(subcommand: String, extraArgs: [String]) -> [String]? { + guard let relay = self.bundledRelayRoot() else { return nil } + let bunPath = relay.appendingPathComponent("bun").path + let entry = relay.appendingPathComponent("dist/index.js").path + guard FileManager.default.isExecutableFile(atPath: bunPath), + FileManager.default.isReadableFile(atPath: entry) + else { return nil } + return [bunPath, entry, subcommand] + extraArgs + } + static func projectRoot() -> URL { + if let bundled = self.bundledRelayRoot() { + return bundled + } if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), let url = self.expandPath(stored) { @@ -203,7 +222,7 @@ enum CommandResolver { static func preferredPaths() -> [String] { let current = ProcessInfo.processInfo.environment["PATH"]? .split(separator: ":").map(String.init) ?? [] - let extras = [ + var extras = [ self.projectRoot().appendingPathComponent("node_modules/.bin").path, FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path, "/opt/homebrew/bin", @@ -211,6 +230,9 @@ enum CommandResolver { "/usr/bin", "/bin", ] + if let relay = self.bundledRelayRoot() { + extras.insert(relay.appendingPathComponent("node_modules/.bin").path, at: 0) + } var seen = Set() return (extras + current).filter { seen.insert($0).inserted } } @@ -242,6 +264,13 @@ enum CommandResolver { } static func clawdisCommand(subcommand: String, extraArgs: [String] = []) -> [String] { + let settings = self.connectionSettings() + if settings.mode == .remote, let ssh = self.sshCommand(subcommand: subcommand, extraArgs: extraArgs, settings: settings) { + return ssh + } + if let bundled = self.bundledRelayCommand(subcommand: subcommand, extraArgs: extraArgs) { + return bundled + } if let clawdisPath = self.clawdisExecutable() { return [clawdisPath, subcommand] + extraArgs } @@ -257,6 +286,53 @@ enum CommandResolver { return ["clawdis", subcommand] + extraArgs } + 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 } + + var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"] + if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } + if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["-i", settings.identity]) + } + let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host + args.append(userHost) + + let quotedArgs = (["clawdis", subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") + 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]) + return ["/usr/bin/ssh"] + args + } + + private struct RemoteSettings { + let mode: AppState.ConnectionMode + let target: String + let identity: String + let projectRoot: String + } + + private 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) ?? "" + let identity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" + let projectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" + return RemoteSettings(mode: mode, target: self.sanitizedTarget(target), identity: identity, projectRoot: projectRoot) + } + + private static func sanitizedTarget(_ raw: String) -> String { + VoiceWakeForwarder.sanitizedTarget(raw) + } + + private static func shellQuote(_ text: String) -> String { + if text.isEmpty { return "''" } + let escaped = text.replacingOccurrences(of: "'", with: "'\\''") + return "'\(escaped)'" + } + private static func expandPath(_ path: String) -> URL? { var expanded = path if expanded.hasPrefix("~") { diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift index 9ec28ef57..09376ec28 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -138,7 +138,9 @@ enum VoiceWakeForwarder { } args.append(userHost) - let escaped = Self.shellEscape(transcript) // single-quoted literal, safe for sh/zsh + // Avoid stdin and globbing entirely: marshal the transcript as a single-quoted literal. + // `shellEscape` keeps it POSIX-safe for /bin/sh even when the text has quotes/parentheses. + let escaped = Self.shellEscape(transcript) let templated: String = config.commandTemplate.contains("${text}") ? config.commandTemplate.replacingOccurrences(of: "${text}", with: "$CLAW_TEXT") : Self.renderedCommand(template: config.commandTemplate, transcript: transcript) @@ -320,7 +322,7 @@ enum VoiceWakeForwarder { return (user: user?.trimmingCharacters(in: .whitespacesAndNewlines), host: host, port: port) } - private static func sanitizedTarget(_ raw: String) -> String { + static func sanitizedTarget(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("ssh ") { return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 68aa64bce..44d8e0a60 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -215,10 +215,15 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, let data: Data do { data = try await Task.detached(priority: .utility) { () -> Data in + let command = CommandResolver.clawdisCommand( + subcommand: "agent", + extraArgs: ["--to", sessionKey, "--message", text, "--json"]) let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["pnpm", "clawdis", "agent", "--to", sessionKey, "--message", text, "--json"] - process.currentDirectoryURL = URL(fileURLWithPath: "/Users/steipete/Projects/clawdis") + process.executableURL = URL(fileURLWithPath: command.first ?? "/usr/bin/env") + process.arguments = Array(command.dropFirst()) + if command.first != "/usr/bin/ssh" { + process.currentDirectoryURL = URL(fileURLWithPath: CommandResolver.projectRootPath()) + } let pipe = Pipe() process.standardOutput = pipe diff --git a/docs/mac/remote.md b/docs/mac/remote.md new file mode 100644 index 000000000..b854d3868 --- /dev/null +++ b/docs/mac/remote.md @@ -0,0 +1,29 @@ +# Remote Clawd mode (Dec 2025) + +## 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. + +## 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. + +## 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). + +## 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). + +## 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.