feat: add remote clawd toggle
This commit is contained in:
@@ -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<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String>()
|
||||
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("~") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
29
docs/mac/remote.md
Normal file
29
docs/mac/remote.md
Normal file
@@ -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 '<cd project && clawdis …>'` 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.
|
||||
Reference in New Issue
Block a user