Compare commits

...

5 Commits

Author SHA1 Message Date
Peter Steinberger
16cd9a4189 build: add Sendable to protocol swift gen 2026-01-04 06:46:09 +00:00
Peter Steinberger
c62fd2379a docs: update changelog 2026-01-04 06:42:40 +00:00
Peter Steinberger
bf4847c9ca chore: fix lint formatting 2026-01-04 06:42:36 +00:00
Peter Steinberger
ad1932dca3 fix: stabilize macos build 2026-01-04 06:42:32 +00:00
jeffersonwarrior
bab5bd61a5 docs: add remote gateway SSH tunnel setup guide
- Add SSH config setup for remote gateway access
- Document step-by-step setup process
- Include auto-start LaunchAgent configuration
- Add troubleshooting section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-03 23:04:55 -06:00
46 changed files with 621 additions and 398 deletions

View File

@ -23,6 +23,7 @@
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support. - Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
### Fixes ### Fixes
- Docs: add remote gateway SSH tunnel setup guide (#171) — thanks @jeffersonwarrior.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete. - CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow. - CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway. - WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.

View File

@ -254,41 +254,41 @@ final class AppState {
let configRoot = ClawdisConfigFile.loadDict() let configRoot = ClawdisConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any] let configGateway = configRoot["gateway"] as? [String: Any]
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configMode: ConnectionMode? = { let configMode: ConnectionMode? = switch configModeRaw {
switch configModeRaw { case "local":
case "local": .local
return .local case "remote":
case "remote": .remote
return .remote default:
default: nil
return nil }
}
}()
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl? let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
if let configMode { let resolvedConnectionMode: ConnectionMode = if let configMode {
self.connectionMode = configMode configMode
} else if configHasRemoteUrl { } else if configHasRemoteUrl {
self.connectionMode = .remote .remote
} else if let storedMode { } else if let storedMode {
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local ConnectionMode(rawValue: storedMode) ?? .local
} else { } else {
self.connectionMode = onboardingSeen ? .local : .unconfigured onboardingSeen ? .local : .unconfigured
} }
self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if self.connectionMode == .remote, let resolvedRemoteTarget = if resolvedConnectionMode == .remote,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl) let host = AppState.remoteHost(from: configRemoteUrl)
{ {
self.remoteTarget = "\(NSUserName())@\(host)" "\(NSUserName())@\(host)"
} else { } else {
self.remoteTarget = storedRemoteTarget storedRemoteTarget
} }
self.remoteTarget = resolvedRemoteTarget
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.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@ -322,10 +322,6 @@ final class AppState {
} }
} }
deinit {
self.configWatcher?.stop()
}
private static func remoteHost(from urlString: String?) -> String? { private static func remoteHost(from urlString: String?) -> String? {
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines), guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty, !raw.isEmpty,
@ -361,18 +357,16 @@ final class AppState {
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let desiredMode: ConnectionMode? = { let desiredMode: ConnectionMode? = switch modeRaw {
switch modeRaw { case "local":
case "local": .local
return .local case "remote":
case "remote": .remote
return .remote case "unconfigured":
case "unconfigured": .unconfigured
return .unconfigured default:
default: nil
return nil }
}
}()
if let desiredMode { if let desiredMode {
if desiredMode != self.connectionMode { if desiredMode != self.connectionMode {
@ -407,14 +401,13 @@ final class AppState {
var gateway = root["gateway"] as? [String: Any] ?? [:] var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false var changed = false
let desiredMode: String? let desiredMode: String? = switch self.connectionMode {
switch self.connectionMode {
case .local: case .local:
desiredMode = "local" "local"
case .remote: case .remote:
desiredMode = "remote" "remote"
case .unconfigured: case .unconfigured:
desiredMode = nil nil
} }
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -244,7 +244,7 @@ actor CameraCaptureService {
deviceId: String?) -> AVCaptureDevice? deviceId: String?) -> AVCaptureDevice?
{ {
if let deviceId, !deviceId.isEmpty { if let deviceId, !deviceId.isEmpty {
if let match = Self.availableCameras().first(where: { $0.uniqueID == deviceId }) { if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
return match return match
} }
} }
@ -331,7 +331,7 @@ actor CameraCaptureService {
private func sleepDelayMs(_ delayMs: Int) async { private func sleepDelayMs(_ delayMs: Int) async {
guard delayMs > 0 else { return } guard delayMs > 0 else { return }
let ns = UInt64(min(delayMs, 10_000)) * 1_000_000 let ns = UInt64(min(delayMs, 10000)) * 1_000_000
try? await Task.sleep(nanoseconds: ns) try? await Task.sleep(nanoseconds: ns)
} }

View File

@ -100,7 +100,8 @@ enum ClawdisConfigFile {
static func gatewayPassword() -> String? { static func gatewayPassword() -> String? {
let root = self.loadDict() let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any], guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any] else { let remote = gateway["remote"] as? [String: Any]
else {
return nil return nil
} }
return remote["password"] as? String return remote["password"] as? String
@ -121,5 +122,4 @@ enum ClawdisConfigFile {
} }
return nil return nil
} }
} }

View File

@ -157,7 +157,7 @@ enum CommandResolver {
} }
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
for dir in (searchPaths ?? self.preferredPaths()) { for dir in searchPaths ?? self.preferredPaths() {
let candidate = (dir as NSString).appendingPathComponent(name) let candidate = (dir as NSString).appendingPathComponent(name)
if FileManager.default.isExecutableFile(atPath: candidate) { if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate return candidate

View File

@ -90,8 +90,8 @@ extension ConfigFileWatcher {
private func handleEvents( private func handleEvents(
numEvents: Int, numEvents: Int,
eventPaths: UnsafeMutableRawPointer?, eventPaths: UnsafeMutableRawPointer?,
eventFlags: UnsafePointer<FSEventStreamEventFlags>? eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
) { {
guard numEvents > 0 else { return } guard numEvents > 0 else { return }
guard eventFlags != nil else { return } guard eventFlags != nil else { return }
guard self.matchesTarget(eventPaths: eventPaths) else { return } guard self.matchesTarget(eventPaths: eventPaths) else { return }

View File

@ -78,7 +78,7 @@ enum ConfigStore {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else { guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [ throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config." NSLocalizedDescriptionKey: "Failed to encode config.",
]) ])
} }
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)] let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
@ -88,7 +88,7 @@ enum ConfigStore {
timeoutMs: 10000) timeoutMs: 10000)
} }
#if DEBUG #if DEBUG
static func _testSetOverrides(_ overrides: Overrides) async { static func _testSetOverrides(_ overrides: Overrides) async {
await self.overrideStore.setOverride(overrides) await self.overrideStore.setOverride(overrides)
} }
@ -96,5 +96,5 @@ enum ConfigStore {
static func _testClearOverrides() async { static func _testClearOverrides() async {
await self.overrideStore.setOverride(.init()) await self.overrideStore.setOverride(.init())
} }
#endif #endif
} }

View File

@ -492,7 +492,7 @@ struct ConnectionsSettings: View {
GroupBox("Guilds") { GroupBox("Guilds") {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
ForEach($store.discordGuilds) { $guild in ForEach(self.$store.discordGuilds) { $guild in
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack { HStack {
TextField("guild id or slug", text: $guild.key) TextField("guild id or slug", text: $guild.key)

View File

@ -172,8 +172,8 @@ struct DiscordGuildForm: Identifiable {
requireMention: Bool = false, requireMention: Bool = false,
reactionNotifications: String = "own", reactionNotifications: String = "own",
users: String = "", users: String = "",
channels: [DiscordGuildChannelForm] = [] channels: [DiscordGuildChannelForm] = [])
) { {
self.key = key self.key = key
self.slug = slug self.slug = slug
self.requireMention = requireMention self.requireMention = requireMention
@ -473,12 +473,16 @@ final class ConnectionsStore {
} else { } else {
self.discordMediaMaxMb = "" self.discordMediaMaxMb = ""
} }
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) { if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue
.map(Double.init)
{
self.discordHistoryLimit = String(Int(history)) self.discordHistoryLimit = String(Int(history))
} else { } else {
self.discordHistoryLimit = "" self.discordHistoryLimit = ""
} }
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) { if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue
.map(Double.init)
{
self.discordTextChunkLimit = String(Int(limit)) self.discordTextChunkLimit = String(Int(limit))
} else { } else {
self.discordTextChunkLimit = "" self.discordTextChunkLimit = ""
@ -506,9 +510,10 @@ final class ConnectionsStore {
return nil return nil
} }
.joined(separator: ", ") ?? "" .joined(separator: ", ") ?? ""
let channels: [DiscordGuildChannelForm] let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?
if let channelMap = entry["channels"]?.dictionaryValue { .dictionaryValue
channels = channelMap.map { channelKey, channelValue in {
channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:] let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention = let channelRequireMention =
@ -519,7 +524,7 @@ final class ConnectionsStore {
requireMention: channelRequireMention) requireMention: channelRequireMention)
} }
} else { } else {
channels = [] []
} }
return DiscordGuildForm( return DiscordGuildForm(
key: key, key: key,

View File

@ -12,11 +12,13 @@ struct ContextUsageBar: View {
if match == .darkAqua { return base } if match == .darkAqua { return base }
return base.blended(withFraction: 0.24, of: .black) ?? base return base.blended(withFraction: 0.24, of: .black) ?? base
} }
private static let trackFill: NSColor = .init(name: nil) { appearance in private static let trackFill: NSColor = .init(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua]) let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) } if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
return NSColor.black.withAlphaComponent(0.12) return NSColor.black.withAlphaComponent(0.12)
} }
private static let trackStroke: NSColor = .init(name: nil) { appearance in private static let trackStroke: NSColor = .init(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua]) let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) } if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }

View File

@ -55,8 +55,8 @@ final class ControlChannel {
private(set) var state: ConnectionState = .disconnected { private(set) var state: ConnectionState = .disconnected {
didSet { didSet {
CanvasManager.shared.refreshDebugStatus() CanvasManager.shared.refreshDebugStatus()
guard oldValue != state else { return } guard oldValue != self.state else { return }
switch state { switch self.state {
case .connected: case .connected:
self.logger.info("control channel state -> connected") self.logger.info("control channel state -> connected")
case .connecting: case .connecting:
@ -71,6 +71,7 @@ final class ControlChannel {
} }
} }
} }
private(set) var lastPingMs: Double? private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.clawdis", category: "control") private let logger = Logger(subsystem: "com.clawdis", category: "control")

View File

@ -138,17 +138,20 @@ enum DeviceModelCatalog {
if bundle.url( if bundle.url(
forResource: "ios-device-identifiers", forResource: "ios-device-identifiers",
withExtension: "json", withExtension: "json",
subdirectory: self.resourceSubdirectory) != nil { subdirectory: self.resourceSubdirectory) != nil
{
return bundle return bundle
} }
if bundle.url( if bundle.url(
forResource: "mac-device-identifiers", forResource: "mac-device-identifiers",
withExtension: "json", withExtension: "json",
subdirectory: self.resourceSubdirectory) != nil { subdirectory: self.resourceSubdirectory) != nil
{
return bundle return bundle
} }
return nil return nil
} }
private enum NameValue: Decodable { private enum NameValue: Decodable {
case string(String) case string(String)
case stringArray([String]) case stringArray([String])

View File

@ -237,8 +237,9 @@ actor GatewayConnection {
guard let snapshot = self.lastSnapshot else { return (nil, nil) } guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines) let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
return (configPath?.isEmpty == false ? configPath : nil, return (
stateDir?.isEmpty == false ? stateDir : nil) configPath?.isEmpty == false ? configPath : nil,
stateDir?.isEmpty == false ? stateDir : nil)
} }
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> { func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
@ -270,7 +271,9 @@ actor GatewayConnection {
} }
private func configure(url: URL, token: String?, password: String?) async { private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password { if self.client != nil, self.configuredURL == url, self.configuredToken == token,
self.configuredPassword == password
{
return return
} }
if let client { if let client {

View File

@ -40,8 +40,8 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword( private static func resolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String] env: [String: String]) -> String?
) -> String? { {
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? "" let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
@ -93,7 +93,11 @@ actor GatewayEndpointStore {
let password = deps.password() let password = deps.password()
switch initialMode { switch initialMode {
case .local: case .local:
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password) self.state = .ready(
mode: .local,
url: URL(string: "ws://127.0.0.1:\(port)")!,
token: token,
password: password)
case .remote: case .remote:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel") self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
case .unconfigured: case .unconfigured:
@ -125,14 +129,22 @@ actor GatewayEndpointStore {
switch mode { switch mode {
case .local: case .local:
let port = self.deps.localPort() let port = self.deps.localPort()
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)) self.setState(.ready(
mode: .local,
url: URL(string: "ws://127.0.0.1:\(port)")!,
token: token,
password: password))
case .remote: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")) self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
return return
} }
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password)) self.setState(.ready(
mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token,
password: password))
case .unconfigured: case .unconfigured:
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
} }
@ -213,8 +225,8 @@ extension GatewayEndpointStore {
static func _testResolveGatewayPassword( static func _testResolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String] env: [String: String]) -> String?
) -> String? { {
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env) self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
} }
} }

View File

@ -24,7 +24,7 @@ enum GatewayLaunchAgentManager {
} }
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] { private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
#if DEBUG #if DEBUG
let projectRoot = CommandResolver.projectRoot() let projectRoot = CommandResolver.projectRoot()
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) { if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
return [localBin, "gateway", "--port", "\(port)", "--bind", bind] return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
@ -38,7 +38,7 @@ enum GatewayLaunchAgentManager {
subcommand: "gateway", subcommand: "gateway",
extraArgs: ["--port", "\(port)", "--bind", bind]) extraArgs: ["--port", "\(port)", "--bind", bind])
} }
#endif #endif
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
} }
@ -51,7 +51,7 @@ enum GatewayLaunchAgentManager {
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled { if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL) try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else { guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {

View File

@ -151,16 +151,16 @@ struct GeneralSettings: View {
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool { private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
guard mode != .off else { return true } guard mode != .off else { return true }
let status = CLLocationManager.authorizationStatus() let status = CLLocationManager.authorizationStatus()
if status == .authorizedAlways || status == .authorizedWhenInUse { if status == .authorizedAlways || status == .authorized {
if mode == .always && status != .authorizedAlways { if mode == .always, status != .authorizedAlways {
let updated = await LocationPermissionRequester.shared.request(always: true) let updated = await LocationPermissionRequester.shared.request(always: true)
return updated == .authorizedAlways || updated == .authorizedWhenInUse return updated == .authorizedAlways || updated == .authorized
} }
return true return true
} }
let updated = await LocationPermissionRequester.shared.request(always: mode == .always) let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
switch updated { switch updated {
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorized:
return true return true
default: default:
return false return false

View File

@ -6,6 +6,7 @@ enum LaunchAgentManager {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist") .appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist")
} }
private static var legacyPlistURL: URL { private static var legacyPlistURL: URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
@ -19,7 +20,7 @@ enum LaunchAgentManager {
static func set(enabled: Bool, bundlePath: String) async { static func set(enabled: Bool, bundlePath: String) async {
if enabled { if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL) try? FileManager.default.removeItem(at: self.legacyPlistURL)
self.writePlist(bundlePath: bundlePath) self.writePlist(bundlePath: bundlePath)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])

View File

@ -1,7 +1,7 @@
@_exported import Logging
import Foundation import Foundation
import OSLog @_exported import Logging
import os import os
import OSLog
typealias Logger = Logging.Logger typealias Logger = Logging.Logger
@ -65,15 +65,15 @@ enum ClawdisLogging {
}() }()
static func bootstrapIfNeeded() { static func bootstrapIfNeeded() {
_ = Self.didBootstrap _ = self.didBootstrap
} }
static func makeLabel(subsystem: String, category: String) -> String { static func makeLabel(subsystem: String, category: String) -> String {
"\(subsystem)\(Self.labelSeparator)\(category)" "\(subsystem)\(self.labelSeparator)\(category)"
} }
static func parseLabel(_ label: String) -> (String, String) { static func parseLabel(_ label: String) -> (String, String) {
guard let range = label.range(of: Self.labelSeparator) else { guard let range = label.range(of: labelSeparator) else {
return ("com.clawdis", label) return ("com.clawdis", label)
} }
let subsystem = String(label[..<range.lowerBound]) let subsystem = String(label[..<range.lowerBound])
@ -91,7 +91,7 @@ extension Logging.Logger {
} }
extension Logger.Message.StringInterpolation { extension Logger.Message.StringInterpolation {
mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy) { mutating func appendInterpolation(_ value: some Any, privacy: OSLogPrivacy) {
self.appendInterpolation(String(describing: value)) self.appendInterpolation(String(describing: value))
} }
} }
@ -132,15 +132,15 @@ struct ClawdisOSLogHandler: LogHandler {
private static func osLogType(for level: Logger.Level) -> OSLogType { private static func osLogType(for level: Logger.Level) -> OSLogType {
switch level { switch level {
case .trace, .debug: case .trace, .debug:
return .debug .debug
case .info, .notice: case .info, .notice:
return .info .info
case .warning: case .warning:
return .default .default
case .error: case .error:
return .error .error
case .critical: case .critical:
return .fault .fault
} }
} }
@ -156,7 +156,7 @@ struct ClawdisOSLogHandler: LogHandler {
guard !metadata.isEmpty else { return message.description } guard !metadata.isEmpty else { return message.description }
let meta = metadata let meta = metadata
.sorted(by: { $0.key < $1.key }) .sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\(stringify($0.value))" } .map { "\($0.key)=\(self.stringify($0.value))" }
.joined(separator: " ") .joined(separator: " ")
return "\(message.description) [\(meta)]" return "\(message.description) [\(meta)]"
} }
@ -168,9 +168,9 @@ struct ClawdisOSLogHandler: LogHandler {
case let .stringConvertible(value): case let .stringConvertible(value):
String(describing: value) String(describing: value)
case let .array(values): case let .array(values):
"[" + values.map { stringify($0) }.joined(separator: ",") + "]" "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
case let .dictionary(entries): case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}" "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
} }
} }
} }
@ -224,9 +224,9 @@ struct ClawdisFileLogHandler: LogHandler {
case let .stringConvertible(value): case let .stringConvertible(value):
String(describing: value) String(describing: value)
case let .array(values): case let .array(values):
"[" + values.map { stringify($0) }.joined(separator: ",") + "]" "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
case let .dictionary(entries): case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}" "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
} }
} }
} }

View File

@ -172,7 +172,7 @@ struct MenuContent: View {
} }
@MainActor @MainActor
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) { private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
var root = await ConfigStore.load() var root = await ConfigStore.load()
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
browser["enabled"] = enabled browser["enabled"] = enabled

View File

@ -86,7 +86,6 @@ final class HighlightedMenuItemHostView: NSView {
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
self.invalidateIntrinsicContentSize() self.invalidateIntrinsicContentSize()
} }
} }
struct MenuHostedHighlightedItem: NSViewRepresentable { struct MenuHostedHighlightedItem: NSViewRepresentable {

View File

@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
compact.representedObject = row.key compact.representedObject = row.key
menu.addItem(compact) menu.addItem(compact)
if row.key != "main" && row.key != "global" { if row.key != "main", row.key != "global" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self del.target = self
del.representedObject = row.key del.representedObject = row.key
@ -541,12 +541,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No")) menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
!caps.isEmpty { !caps.isEmpty
{
menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", "))) menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", ")))
} }
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
!commands.isEmpty { !commands.isEmpty
{
menu.addItem(self.makeNodeMultilineItem( menu.addItem(self.makeNodeMultilineItem(
label: "Commands", label: "Commands",
value: commands.joined(separator: ", "), value: commands.joined(separator: ", "),
@ -589,6 +591,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
} }
return trimmed return trimmed
} }
@objc @objc
private func patchThinking(_ sender: NSMenuItem) { private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any], guard let dict = sender.representedObject as? [String: Any],
@ -770,7 +773,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
} }
private func sortedNodeEntries() -> [NodeInfo] { private func sortedNodeEntries() -> [NodeInfo] {
let entries = self.nodesStore.nodes.filter { $0.isConnected } let entries = self.nodesStore.nodes.filter(\.isConnected)
return entries.sorted { lhs, rhs in return entries.sorted { lhs, rhs in
if lhs.isConnected != rhs.isConnected { return lhs.isConnected } if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
if lhs.isPaired != rhs.isPaired { return lhs.isPaired } if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
@ -781,8 +784,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
} }
} }
// MARK: - Views // MARK: - Views
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView { private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {

View File

@ -192,6 +192,4 @@ actor MacNodeBridgePairingClient {
} }
} }
} }
} }

View File

@ -325,6 +325,4 @@ actor MacNodeBridgeSession {
]) ])
}) })
} }
} }

View File

@ -3,7 +3,7 @@ import CoreLocation
import Foundation import Foundation
@MainActor @MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate { final class MacNodeLocationService: NSObject {
enum Error: Swift.Error { enum Error: Swift.Error {
case timeout case timeout
case unavailable case unavailable
@ -47,10 +47,8 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
} }
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000) let timeout = max(0, timeoutMs ?? 10000)
return try await self.withTimeout(timeoutMs: timeout) { return try await self.requestLocationWithTimeout(timeoutMs: timeout)
try await self.requestLocation()
}
} }
private func requestLocation() async throws -> CLLocation { private func requestLocation() async throws -> CLLocation {
@ -60,34 +58,50 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
} }
} }
private func withTimeout<T>( private func requestLocationWithTimeout(timeoutMs: Int) async throws -> CLLocation {
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 { if timeoutMs == 0 {
return try await operation() return try await self.requestLocation()
} }
return try await withThrowingTaskGroup(of: T.self) { group in let timeoutNs = UInt64(timeoutMs) * 1_000_000
group.addTask { try await operation() } return try await withCheckedThrowingContinuation { continuation in
group.addTask { let lock = NSLock()
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000) var didResume = false
throw Error.timeout
func resume(_ result: Result<CLLocation, Swift.Error>) {
lock.lock()
defer { lock.unlock() }
guard !didResume else { return }
didResume = true
continuation.resume(with: result)
}
let timeoutTask = Task {
try await Task.sleep(nanoseconds: timeoutNs)
resume(.failure(Error.timeout))
}
Task { @MainActor in
do {
let location = try await self.requestLocation()
timeoutTask.cancel()
resume(.success(location))
} catch {
timeoutTask.cancel()
resume(.failure(error))
}
} }
let result = try await group.next()!
group.cancelAll()
return result
} }
} }
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy { private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
switch accuracy { switch accuracy {
case .coarse: case .coarse:
return kCLLocationAccuracyKilometer kCLLocationAccuracyKilometer
case .balanced: case .balanced:
return kCLLocationAccuracyHundredMeters kCLLocationAccuracyHundredMeters
case .precise: case .precise:
return kCLLocationAccuracyBest kCLLocationAccuracyBest
} }
} }
@ -107,3 +121,6 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
cont.resume(throwing: error) cont.resume(throwing: error)
} }
} }
@MainActor
extension MacNodeLocationService: @preconcurrency CLLocationManagerDelegate {}

View File

@ -104,7 +104,7 @@ actor MacNodeRuntime {
} }
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ?? let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
ClawdisCameraSnapParams() ClawdisCameraSnapParams()
let delayMs = min(10_000, max(0, params.delayMs ?? 2000)) let delayMs = min(10000, max(0, params.delayMs ?? 2000))
let res = try await self.cameraCapture.snap( let res = try await self.cameraCapture.snap(
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front, facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
maxWidth: params.maxWidth, maxWidth: params.maxWidth,
@ -184,7 +184,7 @@ actor MacNodeRuntime {
let desired = params.desiredAccuracy ?? let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced) (Self.locationPreciseEnabled() ? .precise : .balanced)
let status = await self.locationService.authorizationStatus() let status = await self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse { if status != .authorizedAlways, status != .authorized {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,

View File

@ -611,7 +611,7 @@ final class NodePairingApprovalPrompter {
private func updatePendingCounts() { private func updatePendingCounts() {
// Keep a cheap observable summary for the menu bar status line. // Keep a cheap observable summary for the menu bar status line.
self.pendingCount = self.queue.count self.pendingCount = self.queue.count
self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
} }
private func reconcileOnce(timeoutMs: Double) async { private func reconcileOnce(timeoutMs: Double) async {

View File

@ -110,8 +110,8 @@ struct NodeMenuEntryFormatter {
guard !trimmed.isEmpty else { return trimmed } guard !trimmed.isEmpty else { return trimmed }
if let range = trimmed.range( if let range = trimmed.range(
of: #"\s*\([^)]*\d[^)]*\)$"#, of: #"\s*\([^)]*\d[^)]*\)$"#,
options: .regularExpression options: .regularExpression)
) { {
return String(trimmed[..<range.lowerBound]) return String(trimmed[..<range.lowerBound])
} }
return trimmed return trimmed
@ -227,7 +227,6 @@ struct NodeMenuRowView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(.vertical, 8) .padding(.vertical, 8)
.padding(.leading, 18) .padding(.leading, 18)

View File

@ -139,6 +139,7 @@ struct OnboardingView: View {
var isWizardBlocking: Bool { var isWizardBlocking: Bool {
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
} }
var canAdvance: Bool { !self.isWizardBlocking } var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String { var devLinkCommand: String {
let bundlePath = Bundle.main.bundlePath let bundlePath = Bundle.main.bundlePath

View File

@ -86,7 +86,7 @@ extension OnboardingView {
var navigationBar: some View { var navigationBar: some View {
let wizardLockIndex = self.wizardPageOrderIndex let wizardLockIndex = self.wizardPageOrderIndex
HStack(spacing: 20) { return HStack(spacing: 20) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Button(action: {}, label: { Button(action: {}, label: {
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly) Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
@ -112,7 +112,8 @@ extension OnboardingView {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(0..<self.pageCount, id: \.self) { index in ForEach(0..<self.pageCount, id: \.self) { index in
let isLocked = wizardLockIndex != nil && !self.onboardingWizard.isComplete && index > (wizardLockIndex ?? 0) let isLocked = wizardLockIndex != nil && !self.onboardingWizard
.isComplete && index > (wizardLockIndex ?? 0)
Button { Button {
withAnimation { self.currentPage = index } withAnimation { self.currentPage = index }
} label: { } label: {

View File

@ -444,7 +444,7 @@ extension OnboardingView {
} }
func permissionsPage() -> some View { func permissionsPage() -> some View {
return self.onboardingPage { self.onboardingPage {
Text("Grant permissions") Text("Grant permissions")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.") Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")

View File

@ -1,3 +1,4 @@
import ClawdisProtocol
import Observation import Observation
import SwiftUI import SwiftUI
@ -44,15 +45,15 @@ private struct OnboardingWizardCardContent: View {
private var state: CardState { private var state: CardState {
if let error = wizard.errorMessage { return .error(error) } if let error = wizard.errorMessage { return .error(error) }
if wizard.isStarting { return .starting } if self.wizard.isStarting { return .starting }
if let step = wizard.currentStep { return .step(step) } if let step = wizard.currentStep { return .step(step) }
if wizard.isComplete { return .complete } if self.wizard.isComplete { return .complete }
return .waiting return .waiting
} }
var body: some View { var body: some View {
switch state { switch self.state {
case .error(let error): case let .error(error):
Text("Wizard error") Text("Wizard error")
.font(.headline) .font(.headline)
Text(error) Text(error)
@ -60,11 +61,11 @@ private struct OnboardingWizardCardContent: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Button("Retry") { Button("Retry") {
wizard.reset() self.wizard.reset()
Task { Task {
await wizard.startIfNeeded( await self.wizard.startIfNeeded(
mode: mode, mode: self.mode,
workspace: workspacePath.isEmpty ? nil : workspacePath) workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@ -74,12 +75,12 @@ private struct OnboardingWizardCardContent: View {
Text("Starting wizard…") Text("Starting wizard…")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
case .step(let step): case let .step(step):
OnboardingWizardStepView( OnboardingWizardStepView(
step: step, step: step,
isSubmitting: wizard.isSubmitting) isSubmitting: self.wizard.isSubmitting)
{ value in { value in
Task { await wizard.submit(step: step, value: value) } Task { await self.wizard.submit(step: step, value: value) }
} }
.id(step.id) .id(step.id)
case .complete: case .complete:

View File

@ -101,7 +101,7 @@ extension OnboardingView {
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
return (true, nil) return (true, nil)
} catch let error { } catch {
let errorMessage = "Failed to save config: \(error.localizedDescription)" let errorMessage = "Failed to save config: \(error.localizedDescription)"
return (false, errorMessage) return (false, errorMessage)
} }

View File

@ -4,6 +4,8 @@ import Observation
import OSLog import OSLog
import SwiftUI import SwiftUI
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard") private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
@MainActor @MainActor
@ -43,7 +45,7 @@ final class OnboardingWizardModel {
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded( let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardStart, method: .wizardStart,
params: params) params: params)
applyStartResult(res) self.applyStartResult(res)
} catch { } catch {
self.status = "error" self.status = "error"
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
@ -67,7 +69,7 @@ final class OnboardingWizardModel {
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded( let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardNext, method: .wizardNext,
params: params) params: params)
applyNextResult(res) self.applyNextResult(res)
} catch { } catch {
self.status = "error" self.status = "error"
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
@ -81,7 +83,7 @@ final class OnboardingWizardModel {
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded( let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardCancel, method: .wizardCancel,
params: ["sessionId": AnyCodable(sessionId)]) params: ["sessionId": AnyCodable(sessionId)])
applyStatusResult(res) self.applyStatusResult(res)
} catch { } catch {
self.status = "error" self.status = "error"
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
@ -103,7 +105,8 @@ final class OnboardingWizardModel {
self.currentStep = decodeWizardStep(res.step) self.currentStep = decodeWizardStep(res.step)
if res.done { self.currentStep = nil } if res.done { self.currentStep = nil }
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled" if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|| anyCodableStringValue(res.status) == "error" { || anyCodableStringValue(res.status) == "error"
{
self.sessionId = nil self.sessionId = nil
} }
} }
@ -142,8 +145,7 @@ struct OnboardingWizardStepView: View {
let initialMulti = Set( let initialMulti = Set(
options.filter { option in options.filter { option in
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) } anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
}.map { $0.index } }.map(\.index))
)
_textValue = State(initialValue: initialText) _textValue = State(initialValue: initialText)
_confirmValue = State(initialValue: initialConfirm) _confirmValue = State(initialValue: initialConfirm)
@ -164,18 +166,18 @@ struct OnboardingWizardStepView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
switch wizardStepType(step) { switch wizardStepType(self.step) {
case "note": case "note":
EmptyView() EmptyView()
case "text": case "text":
textField self.textField
case "confirm": case "confirm":
Toggle("", isOn: $confirmValue) Toggle("", isOn: self.$confirmValue)
.toggleStyle(.switch) .toggleStyle(.switch)
case "select": case "select":
selectOptions self.selectOptions
case "multiselect": case "multiselect":
multiselectOptions self.multiselectOptions
case "progress": case "progress":
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
@ -186,25 +188,25 @@ struct OnboardingWizardStepView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Button(action: submit) { Button(action: self.submit) {
Text(wizardStepType(step) == "action" ? "Run" : "Continue") Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
.frame(minWidth: 120) .frame(minWidth: 120)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(isSubmitting || isBlocked) .disabled(self.isSubmitting || self.isBlocked)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@ViewBuilder @ViewBuilder
private var textField: some View { private var textField: some View {
let isSensitive = step.sensitive == true let isSensitive = self.step.sensitive == true
if isSensitive { if isSensitive {
SecureField(step.placeholder ?? "", text: $textValue) SecureField(self.step.placeholder ?? "", text: self.$textValue)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: 360) .frame(maxWidth: 360)
} else { } else {
TextField(step.placeholder ?? "", text: $textValue) TextField(self.step.placeholder ?? "", text: self.$textValue)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: 360) .frame(maxWidth: 360)
} }
@ -212,33 +214,21 @@ struct OnboardingWizardStepView: View {
private var selectOptions: some View { private var selectOptions: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(optionItems) { item in ForEach(self.optionItems) { item in
Button { WizardOptionRow(
selectedIndex = item.index item: item,
} label: { isSelected: self.selectedIndex == item.index,
HStack(alignment: .top, spacing: 8) { onSelect: {
Image(systemName: selectedIndex == item.index ? "largecircle.fill.circle" : "circle") self.selectedIndex = item.index
.foregroundStyle(.accent) })
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
.foregroundStyle(.primary)
if let hint = item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.buttonStyle(.plain)
} }
} }
} }
private var multiselectOptions: some View { private var multiselectOptions: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(optionItems) { item in ForEach(self.optionItems) { item in
Toggle(isOn: bindingForOption(item)) { Toggle(isOn: self.bindingForOption(item)) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(item.option.label) Text(item.option.label)
if let hint = item.option.hint, !hint.isEmpty { if let hint = item.option.hint, !hint.isEmpty {
@ -254,65 +244,97 @@ struct OnboardingWizardStepView: View {
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> { private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
Binding(get: { Binding(get: {
selectedIndices.contains(item.index) self.selectedIndices.contains(item.index)
}, set: { newValue in }, set: { newValue in
if newValue { if newValue {
selectedIndices.insert(item.index) self.selectedIndices.insert(item.index)
} else { } else {
selectedIndices.remove(item.index) self.selectedIndices.remove(item.index)
} }
}) })
} }
private var isBlocked: Bool { private var isBlocked: Bool {
let type = wizardStepType(step) let type = wizardStepType(step)
if type == "select" { return optionItems.isEmpty } if type == "select" { return self.optionItems.isEmpty }
if type == "multiselect" { return optionItems.isEmpty } if type == "multiselect" { return self.optionItems.isEmpty }
return false return false
} }
private func submit() { private func submit() {
switch wizardStepType(step) { switch wizardStepType(self.step) {
case "note", "progress": case "note", "progress":
onSubmit(nil) self.onSubmit(nil)
case "text": case "text":
onSubmit(AnyCodable(textValue)) self.onSubmit(AnyCodable(self.textValue))
case "confirm": case "confirm":
onSubmit(AnyCodable(confirmValue)) self.onSubmit(AnyCodable(self.confirmValue))
case "select": case "select":
guard optionItems.indices.contains(selectedIndex) else { guard self.optionItems.indices.contains(self.selectedIndex) else {
onSubmit(nil) self.onSubmit(nil)
return return
} }
let option = optionItems[selectedIndex].option let option = self.optionItems[self.selectedIndex].option
onSubmit(option.value ?? AnyCodable(option.label)) self.onSubmit(self.gatewayValue(option.value, fallback: option.label))
case "multiselect": case "multiselect":
let values = optionItems let values = self.optionItems
.filter { selectedIndices.contains($0.index) } .filter { self.selectedIndices.contains($0.index) }
.map { $0.option.value ?? AnyCodable($0.option.label) } .map { self.gatewayValue($0.option.value, fallback: $0.option.label) }
onSubmit(AnyCodable(values)) self.onSubmit(AnyCodable(values))
case "action": case "action":
onSubmit(AnyCodable(true)) self.onSubmit(AnyCodable(true))
default: default:
onSubmit(nil) self.onSubmit(nil)
} }
} }
private func gatewayValue(_ value: ProtocolAnyCodable?, fallback: String) -> AnyCodable {
if let value {
return AnyCodable(value.value)
}
return AnyCodable(fallback)
}
} }
private struct WizardOptionItem: Identifiable { private struct WizardOptionItem: Identifiable {
let index: Int let index: Int
let option: WizardOption let option: WizardOption
var id: Int { index } var id: Int { self.index }
}
private struct WizardOptionRow: View {
let item: WizardOptionItem
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: self.onSelect) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: self.isSelected ? "largecircle.fill.circle" : "circle")
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(self.item.option.label)
.foregroundStyle(.primary)
if let hint = self.item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.buttonStyle(.plain)
}
} }
private struct WizardOption { private struct WizardOption {
let value: AnyCodable? let value: ProtocolAnyCodable?
let label: String let label: String
let hint: String? let hint: String?
} }
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? { private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
guard let raw else { return nil } guard let raw else { return nil }
do { do {
let data = try JSONEncoder().encode(raw) let data = try JSONEncoder().encode(raw)
@ -323,7 +345,7 @@ private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
} }
} }
private func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] { private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
guard let raw else { return [] } guard let raw else { return [] }
return raw.map { entry in return raw.map { entry in
let value = entry["value"] let value = entry["value"]
@ -337,66 +359,66 @@ private func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? "" (step.type.value as? String) ?? ""
} }
private func anyCodableString(_ value: AnyCodable?) -> String { private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
switch value?.value { switch value?.value {
case let string as String: case let string as String:
return string string
case let int as Int: case let int as Int:
return String(int) String(int)
case let double as Double: case let double as Double:
return String(double) String(double)
case let bool as Bool: case let bool as Bool:
return bool ? "true" : "false" bool ? "true" : "false"
default: default:
return "" ""
} }
} }
private func anyCodableStringValue(_ value: AnyCodable?) -> String? { private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
value?.value as? String value?.value as? String
} }
private func anyCodableBool(_ value: AnyCodable?) -> Bool { private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
switch value?.value { switch value?.value {
case let bool as Bool: case let bool as Bool:
return bool bool
case let string as String: case let string as String:
return string.lowercased() == "true" string.lowercased() == "true"
default: default:
return false false
} }
} }
private func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] { private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
switch value?.value { switch value?.value {
case let arr as [AnyCodable]: case let arr as [ProtocolAnyCodable]:
return arr arr
case let arr as [Any]: case let arr as [Any]:
return arr.map { AnyCodable($0) } arr.map { ProtocolAnyCodable($0) }
default: default:
return [] []
} }
} }
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool { private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) { switch (lhs?.value, rhs?.value) {
case let (l as String, r as String): case let (l as String, r as String):
return l == r l == r
case let (l as Int, r as Int): case let (l as Int, r as Int):
return l == r l == r
case let (l as Double, r as Double): case let (l as Double, r as Double):
return l == r l == r
case let (l as Bool, r as Bool): case let (l as Bool, r as Bool):
return l == r l == r
case let (l as String, r as Int): case let (l as String, r as Int):
return l == String(r) l == String(r)
case let (l as Int, r as String): case let (l as Int, r as String):
return String(l) == r String(l) == r
case let (l as String, r as Double): case let (l as String, r as Double):
return l == String(r) l == String(r)
case let (l as Double, r as String): case let (l as Double, r as String):
return String(l) == r String(l) == r
default: default:
return false false
} }
} }

View File

@ -1,9 +1,9 @@
import Foundation import Foundation
import Security
import os import os
import PeekabooAutomationKit import PeekabooAutomationKit
import PeekabooBridge import PeekabooBridge
import PeekabooFoundation import PeekabooFoundation
import Security
@MainActor @MainActor
final class PeekabooBridgeHostCoordinator { final class PeekabooBridgeHostCoordinator {
@ -80,7 +80,7 @@ final class PeekabooBridgeHostCoordinator {
staticCode, staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation), SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF) == errSecSuccess, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any] let info = infoCF as? [String: Any]
else { else {
return nil return nil
} }

View File

@ -140,12 +140,12 @@ enum PermissionManager {
private static func ensureLocation(interactive: Bool) async -> Bool { private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager.authorizationStatus() let status = CLLocationManager.authorizationStatus()
switch status { switch status {
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorized:
return true return true
case .notDetermined: case .notDetermined:
guard interactive else { return false } guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false) let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways || updated == .authorizedWhenInUse return updated == .authorizedAlways || updated == .authorized
case .denied, .restricted: case .denied, .restricted:
if interactive { if interactive {
LocationPermissionHelper.openSettings() LocationPermissionHelper.openSettings()
@ -198,9 +198,10 @@ enum PermissionManager {
case .camera: case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
case .location: case .location:
let status = CLLocationManager.authorizationStatus() let status = CLLocationManager.authorizationStatus()
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse results[cap] = status == .authorizedAlways || status == .authorized
} }
} }
return results return results
@ -268,7 +269,7 @@ enum LocationPermissionHelper {
} }
@MainActor @MainActor
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { final class LocationPermissionRequester: NSObject {
static let shared = LocationPermissionRequester() static let shared = LocationPermissionRequester()
private let manager = CLLocationManager() private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>? private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
@ -296,6 +297,9 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
} }
} }
@MainActor
extension LocationPermissionRequester: @preconcurrency CLLocationManagerDelegate {}
enum AppleScriptPermission { enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission") private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")

View File

@ -174,12 +174,13 @@ struct SessionMenuPreviewView: View {
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000) let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
let payload = try await AsyncTimeout.withTimeout( let payload = try await AsyncTimeout.withTimeout(
seconds: Self.previewTimeoutSeconds, seconds: Self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() }) { onTimeout: { PreviewTimeoutError() })
try await GatewayConnection.shared.chatHistory( {
sessionKey: self.sessionKey, try await GatewayConnection.shared.chatHistory(
limit: self.previewLimit, sessionKey: self.sessionKey,
timeoutMs: timeoutMs) limit: self.previewLimit,
} timeoutMs: timeoutMs)
}
let built = Self.previewItems(from: payload, maxItems: self.maxItems) let built = Self.previewItems(from: payload, maxItems: self.maxItems)
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey) await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
await MainActor.run { await MainActor.run {
@ -198,7 +199,9 @@ struct SessionMenuPreviewView: View {
self.status = .error("Preview unavailable") self.status = .error("Preview unavailable")
} }
} }
Self.logger.warning("Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)") Self.logger
.warning(
"Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
} }
} }

View File

@ -285,8 +285,7 @@ struct TailscaleIntegrationSection: View {
requireCredentialsForServe: self.requireCredentialsForServe, requireCredentialsForServe: self.requireCredentialsForServe,
password: trimmedPassword, password: trimmedPassword,
connectionMode: self.connectionMode, connectionMode: self.connectionMode,
isPaused: self.isPaused isPaused: self.isPaused)
)
if !success, let errorMessage { if !success, let errorMessage {
self.statusMessage = errorMessage self.statusMessage = errorMessage
@ -307,8 +306,8 @@ struct TailscaleIntegrationSection: View {
requireCredentialsForServe: Bool, requireCredentialsForServe: Bool,
password: String, password: String,
connectionMode: AppState.ConnectionMode, connectionMode: AppState.ConnectionMode,
isPaused: Bool isPaused: Bool) async -> (Bool, String?)
) async -> (Bool, String?) { {
var root = await ConfigStore.load() var root = await ConfigStore.load()
var gateway = root["gateway"] as? [String: Any] ?? [:] var gateway = root["gateway"] as? [String: Any] ?? [:]
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
@ -349,7 +348,7 @@ struct TailscaleIntegrationSection: View {
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
return (true, nil) return (true, nil)
} catch let error { } catch {
return (false, error.localizedDescription) return (false, error.localizedDescription)
} }
} }

View File

@ -35,14 +35,14 @@ struct TalkOverlayView: View {
.frame(width: 18, height: 18) .frame(width: 18, height: 18)
.background(Color.black.opacity(0.4)) .background(Color.black.opacity(0.4))
.clipShape(Circle()) .clipShape(Circle())
}
.buttonStyle(.plain)
.contentShape(Circle())
.offset(x: -2, y: -2)
.opacity(self.hoveringWindow ? 1 : 0)
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
} }
.buttonStyle(.plain) .onHover { self.hoveringWindow = $0 }
.contentShape(Circle())
.offset(x: -2, y: -2)
.opacity(self.hoveringWindow ? 1 : 0)
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
}
.onHover { self.hoveringWindow = $0 }
} }
.frame( .frame(
width: TalkOverlayController.overlaySize, width: TalkOverlayController.overlaySize,
@ -124,7 +124,7 @@ private final class OrbInteractionNSView: NSView {
} }
override func mouseUp(with event: NSEvent) { override func mouseUp(with event: NSEvent) {
if !self.didDrag && !self.suppressSingleClick { if !self.didDrag, !self.suppressSingleClick {
self.onSingleClick?() self.onSingleClick?()
} }
self.mouseDownEvent = nil self.mouseDownEvent = nil
@ -148,8 +148,8 @@ private struct TalkOrbView: View {
} else { } else {
TimelineView(.animation) { context in TimelineView(.animation) { context in
let t = context.date.timeIntervalSinceReferenceDate let t = context.date.timeIntervalSinceReferenceDate
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1 let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1 let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
ZStack { ZStack {
Circle() Circle()
@ -158,9 +158,9 @@ private struct TalkOrbView: View {
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5) .shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
.scaleEffect(pulse * listenScale) .scaleEffect(pulse * listenScale)
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent) TalkWaveRings(phase: self.phase, level: self.level, time: t, accent: self.accent)
if phase == .thinking { if self.phase == .thinking {
TalkOrbitArcs(time: t) TalkOrbitArcs(time: t)
} }
} }
@ -186,11 +186,12 @@ private struct TalkWaveRings: View {
var body: some View { var body: some View {
ZStack { ZStack {
ForEach(0..<3, id: \.self) { idx in ForEach(0..<3, id: \.self) { idx in
let speed = phase == .speaking ? 1.4 : phase == .listening ? 0.9 : 0.6 let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6
let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1) let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1)
let amplitude = phase == .speaking ? 0.95 : phase == .listening ? 0.5 + level * 0.7 : 0.35 let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0) .level * 0.7 : 0.35
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4 let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0)
let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4
Circle() Circle()
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6) .stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
.scaleEffect(scale) .scaleEffect(scale)
@ -208,11 +209,11 @@ private struct TalkOrbitArcs: View {
Circle() Circle()
.trim(from: 0.08, to: 0.26) .trim(from: 0.08, to: 0.26)
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round)) .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
.rotationEffect(.degrees(time * 42)) .rotationEffect(.degrees(self.time * 42))
Circle() Circle()
.trim(from: 0.62, to: 0.86) .trim(from: 0.62, to: 0.86)
.stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) .stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
.rotationEffect(.degrees(-time * 35)) .rotationEffect(.degrees(-self.time * 35))
} }
.scaleEffect(1.08) .scaleEffect(1.08)
} }

View File

@ -213,7 +213,7 @@ final class WorkActivityStore {
meta: String?, meta: String?,
args: [String: AnyCodable]?) -> String args: [String: AnyCodable]?) -> String
{ {
let wrappedArgs = wrapToolArgs(args) let wrappedArgs = self.wrapToolArgs(args)
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta) let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
if let detail = display.detailLine, !detail.isEmpty { if let detail = display.detailLine, !detail.isEmpty {
return "\(display.label): \(detail)" return "\(display.label): \(detail)"
@ -223,22 +223,22 @@ final class WorkActivityStore {
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? { private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
guard let args else { return nil } guard let args else { return nil }
let converted: [String: Any] = args.mapValues { unwrapJSONValue($0.value) } let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
return ClawdisKit.AnyCodable(converted) return ClawdisKit.AnyCodable(converted)
} }
private static func unwrapJSONValue(_ value: Any) -> Any { private static func unwrapJSONValue(_ value: Any) -> Any {
if let dict = value as? [String: AnyCodable] { if let dict = value as? [String: AnyCodable] {
return dict.mapValues { unwrapJSONValue($0.value) } return dict.mapValues { self.unwrapJSONValue($0.value) }
} }
if let array = value as? [AnyCodable] { if let array = value as? [AnyCodable] {
return array.map { unwrapJSONValue($0.value) } return array.map { self.unwrapJSONValue($0.value) }
} }
if let dict = value as? [String: Any] { if let dict = value as? [String: Any] {
return dict.mapValues { unwrapJSONValue($0) } return dict.mapValues { self.unwrapJSONValue($0) }
} }
if let array = value as? [Any] { if let array = value as? [Any] {
return array.map { unwrapJSONValue($0) } return array.map { self.unwrapJSONValue($0) }
} }
return value return value
} }

View File

@ -10,7 +10,7 @@ public enum ErrorCode: String, Codable {
case unavailable = "UNAVAILABLE" case unavailable = "UNAVAILABLE"
} }
public struct ConnectParams: Codable { public struct ConnectParams: Codable, Sendable {
public let minprotocol: Int public let minprotocol: Int
public let maxprotocol: Int public let maxprotocol: Int
public let client: [String: AnyCodable] public let client: [String: AnyCodable]
@ -47,7 +47,7 @@ public struct ConnectParams: Codable {
} }
} }
public struct HelloOk: Codable { public struct HelloOk: Codable, Sendable {
public let type: String public let type: String
public let _protocol: Int public let _protocol: Int
public let server: [String: AnyCodable] public let server: [String: AnyCodable]
@ -84,7 +84,7 @@ public struct HelloOk: Codable {
} }
} }
public struct RequestFrame: Codable { public struct RequestFrame: Codable, Sendable {
public let type: String public let type: String
public let id: String public let id: String
public let method: String public let method: String
@ -109,7 +109,7 @@ public struct RequestFrame: Codable {
} }
} }
public struct ResponseFrame: Codable { public struct ResponseFrame: Codable, Sendable {
public let type: String public let type: String
public let id: String public let id: String
public let ok: Bool public let ok: Bool
@ -138,7 +138,7 @@ public struct ResponseFrame: Codable {
} }
} }
public struct EventFrame: Codable { public struct EventFrame: Codable, Sendable {
public let type: String public let type: String
public let event: String public let event: String
public let payload: AnyCodable? public let payload: AnyCodable?
@ -167,7 +167,7 @@ public struct EventFrame: Codable {
} }
} }
public struct PresenceEntry: Codable { public struct PresenceEntry: Codable, Sendable {
public let host: String? public let host: String?
public let ip: String? public let ip: String?
public let version: String? public let version: String?
@ -228,7 +228,7 @@ public struct PresenceEntry: Codable {
} }
} }
public struct StateVersion: Codable { public struct StateVersion: Codable, Sendable {
public let presence: Int public let presence: Int
public let health: Int public let health: Int
@ -245,7 +245,7 @@ public struct StateVersion: Codable {
} }
} }
public struct Snapshot: Codable { public struct Snapshot: Codable, Sendable {
public let presence: [PresenceEntry] public let presence: [PresenceEntry]
public let health: AnyCodable public let health: AnyCodable
public let stateversion: StateVersion public let stateversion: StateVersion
@ -278,7 +278,7 @@ public struct Snapshot: Codable {
} }
} }
public struct ErrorShape: Codable { public struct ErrorShape: Codable, Sendable {
public let code: String public let code: String
public let message: String public let message: String
public let details: AnyCodable? public let details: AnyCodable?
@ -307,7 +307,7 @@ public struct ErrorShape: Codable {
} }
} }
public struct AgentEvent: Codable { public struct AgentEvent: Codable, Sendable {
public let runid: String public let runid: String
public let seq: Int public let seq: Int
public let stream: String public let stream: String
@ -336,7 +336,7 @@ public struct AgentEvent: Codable {
} }
} }
public struct SendParams: Codable { public struct SendParams: Codable, Sendable {
public let to: String public let to: String
public let message: String public let message: String
public let mediaurl: String? public let mediaurl: String?
@ -369,7 +369,7 @@ public struct SendParams: Codable {
} }
} }
public struct AgentParams: Codable { public struct AgentParams: Codable, Sendable {
public let message: String public let message: String
public let to: String? public let to: String?
public let sessionid: String? public let sessionid: String?
@ -422,7 +422,7 @@ public struct AgentParams: Codable {
} }
} }
public struct AgentWaitParams: Codable { public struct AgentWaitParams: Codable, Sendable {
public let runid: String public let runid: String
public let afterms: Int? public let afterms: Int?
public let timeoutms: Int? public let timeoutms: Int?
@ -443,7 +443,7 @@ public struct AgentWaitParams: Codable {
} }
} }
public struct WakeParams: Codable { public struct WakeParams: Codable, Sendable {
public let mode: AnyCodable public let mode: AnyCodable
public let text: String public let text: String
@ -460,7 +460,7 @@ public struct WakeParams: Codable {
} }
} }
public struct NodePairRequestParams: Codable { public struct NodePairRequestParams: Codable, Sendable {
public let nodeid: String public let nodeid: String
public let displayname: String? public let displayname: String?
public let platform: String? public let platform: String?
@ -509,10 +509,10 @@ public struct NodePairRequestParams: Codable {
} }
} }
public struct NodePairListParams: Codable { public struct NodePairListParams: Codable, Sendable {
} }
public struct NodePairApproveParams: Codable { public struct NodePairApproveParams: Codable, Sendable {
public let requestid: String public let requestid: String
public init( public init(
@ -525,7 +525,7 @@ public struct NodePairApproveParams: Codable {
} }
} }
public struct NodePairRejectParams: Codable { public struct NodePairRejectParams: Codable, Sendable {
public let requestid: String public let requestid: String
public init( public init(
@ -538,7 +538,7 @@ public struct NodePairRejectParams: Codable {
} }
} }
public struct NodePairVerifyParams: Codable { public struct NodePairVerifyParams: Codable, Sendable {
public let nodeid: String public let nodeid: String
public let token: String public let token: String
@ -555,7 +555,7 @@ public struct NodePairVerifyParams: Codable {
} }
} }
public struct NodeRenameParams: Codable { public struct NodeRenameParams: Codable, Sendable {
public let nodeid: String public let nodeid: String
public let displayname: String public let displayname: String
@ -572,10 +572,10 @@ public struct NodeRenameParams: Codable {
} }
} }
public struct NodeListParams: Codable { public struct NodeListParams: Codable, Sendable {
} }
public struct NodeDescribeParams: Codable { public struct NodeDescribeParams: Codable, Sendable {
public let nodeid: String public let nodeid: String
public init( public init(
@ -588,7 +588,7 @@ public struct NodeDescribeParams: Codable {
} }
} }
public struct NodeInvokeParams: Codable { public struct NodeInvokeParams: Codable, Sendable {
public let nodeid: String public let nodeid: String
public let command: String public let command: String
public let params: AnyCodable? public let params: AnyCodable?
@ -617,7 +617,7 @@ public struct NodeInvokeParams: Codable {
} }
} }
public struct SessionsListParams: Codable { public struct SessionsListParams: Codable, Sendable {
public let limit: Int? public let limit: Int?
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
@ -642,7 +642,7 @@ public struct SessionsListParams: Codable {
} }
} }
public struct SessionsPatchParams: Codable { public struct SessionsPatchParams: Codable, Sendable {
public let key: String public let key: String
public let thinkinglevel: AnyCodable? public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable? public let verboselevel: AnyCodable?
@ -675,7 +675,7 @@ public struct SessionsPatchParams: Codable {
} }
} }
public struct SessionsResetParams: Codable { public struct SessionsResetParams: Codable, Sendable {
public let key: String public let key: String
public init( public init(
@ -688,7 +688,7 @@ public struct SessionsResetParams: Codable {
} }
} }
public struct SessionsDeleteParams: Codable { public struct SessionsDeleteParams: Codable, Sendable {
public let key: String public let key: String
public let deletetranscript: Bool? public let deletetranscript: Bool?
@ -705,7 +705,7 @@ public struct SessionsDeleteParams: Codable {
} }
} }
public struct SessionsCompactParams: Codable { public struct SessionsCompactParams: Codable, Sendable {
public let key: String public let key: String
public let maxlines: Int? public let maxlines: Int?
@ -722,10 +722,10 @@ public struct SessionsCompactParams: Codable {
} }
} }
public struct ConfigGetParams: Codable { public struct ConfigGetParams: Codable, Sendable {
} }
public struct ConfigSetParams: Codable { public struct ConfigSetParams: Codable, Sendable {
public let raw: String public let raw: String
public init( public init(
@ -738,10 +738,10 @@ public struct ConfigSetParams: Codable {
} }
} }
public struct ConfigSchemaParams: Codable { public struct ConfigSchemaParams: Codable, Sendable {
} }
public struct ConfigSchemaResponse: Codable { public struct ConfigSchemaResponse: Codable, Sendable {
public let schema: AnyCodable public let schema: AnyCodable
public let uihints: [String: AnyCodable] public let uihints: [String: AnyCodable]
public let version: String public let version: String
@ -766,7 +766,7 @@ public struct ConfigSchemaResponse: Codable {
} }
} }
public struct WizardStartParams: Codable { public struct WizardStartParams: Codable, Sendable {
public let mode: AnyCodable? public let mode: AnyCodable?
public let workspace: String? public let workspace: String?
@ -783,7 +783,7 @@ public struct WizardStartParams: Codable {
} }
} }
public struct WizardNextParams: Codable { public struct WizardNextParams: Codable, Sendable {
public let sessionid: String public let sessionid: String
public let answer: [String: AnyCodable]? public let answer: [String: AnyCodable]?
@ -800,7 +800,7 @@ public struct WizardNextParams: Codable {
} }
} }
public struct WizardCancelParams: Codable { public struct WizardCancelParams: Codable, Sendable {
public let sessionid: String public let sessionid: String
public init( public init(
@ -813,7 +813,7 @@ public struct WizardCancelParams: Codable {
} }
} }
public struct WizardStatusParams: Codable { public struct WizardStatusParams: Codable, Sendable {
public let sessionid: String public let sessionid: String
public init( public init(
@ -826,7 +826,7 @@ public struct WizardStatusParams: Codable {
} }
} }
public struct WizardStep: Codable { public struct WizardStep: Codable, Sendable {
public let id: String public let id: String
public let type: AnyCodable public let type: AnyCodable
public let title: String? public let title: String?
@ -871,7 +871,7 @@ public struct WizardStep: Codable {
} }
} }
public struct WizardNextResult: Codable { public struct WizardNextResult: Codable, Sendable {
public let done: Bool public let done: Bool
public let step: [String: AnyCodable]? public let step: [String: AnyCodable]?
public let status: AnyCodable? public let status: AnyCodable?
@ -896,7 +896,7 @@ public struct WizardNextResult: Codable {
} }
} }
public struct WizardStartResult: Codable { public struct WizardStartResult: Codable, Sendable {
public let sessionid: String public let sessionid: String
public let done: Bool public let done: Bool
public let step: [String: AnyCodable]? public let step: [String: AnyCodable]?
@ -925,7 +925,7 @@ public struct WizardStartResult: Codable {
} }
} }
public struct WizardStatusResult: Codable { public struct WizardStatusResult: Codable, Sendable {
public let status: AnyCodable public let status: AnyCodable
public let error: String? public let error: String?
@ -942,7 +942,7 @@ public struct WizardStatusResult: Codable {
} }
} }
public struct TalkModeParams: Codable { public struct TalkModeParams: Codable, Sendable {
public let enabled: Bool public let enabled: Bool
public let phase: String? public let phase: String?
@ -959,7 +959,7 @@ public struct TalkModeParams: Codable {
} }
} }
public struct ProvidersStatusParams: Codable { public struct ProvidersStatusParams: Codable, Sendable {
public let probe: Bool? public let probe: Bool?
public let timeoutms: Int? public let timeoutms: Int?
@ -976,7 +976,7 @@ public struct ProvidersStatusParams: Codable {
} }
} }
public struct WebLoginStartParams: Codable { public struct WebLoginStartParams: Codable, Sendable {
public let force: Bool? public let force: Bool?
public let timeoutms: Int? public let timeoutms: Int?
public let verbose: Bool? public let verbose: Bool?
@ -997,7 +997,7 @@ public struct WebLoginStartParams: Codable {
} }
} }
public struct WebLoginWaitParams: Codable { public struct WebLoginWaitParams: Codable, Sendable {
public let timeoutms: Int? public let timeoutms: Int?
public init( public init(
@ -1010,7 +1010,7 @@ public struct WebLoginWaitParams: Codable {
} }
} }
public struct ModelChoice: Codable { public struct ModelChoice: Codable, Sendable {
public let id: String public let id: String
public let name: String public let name: String
public let provider: String public let provider: String
@ -1039,10 +1039,10 @@ public struct ModelChoice: Codable {
} }
} }
public struct ModelsListParams: Codable { public struct ModelsListParams: Codable, Sendable {
} }
public struct ModelsListResult: Codable { public struct ModelsListResult: Codable, Sendable {
public let models: [ModelChoice] public let models: [ModelChoice]
public init( public init(
@ -1055,10 +1055,10 @@ public struct ModelsListResult: Codable {
} }
} }
public struct SkillsStatusParams: Codable { public struct SkillsStatusParams: Codable, Sendable {
} }
public struct SkillsInstallParams: Codable { public struct SkillsInstallParams: Codable, Sendable {
public let name: String public let name: String
public let installid: String public let installid: String
public let timeoutms: Int? public let timeoutms: Int?
@ -1079,7 +1079,7 @@ public struct SkillsInstallParams: Codable {
} }
} }
public struct SkillsUpdateParams: Codable { public struct SkillsUpdateParams: Codable, Sendable {
public let skillkey: String public let skillkey: String
public let enabled: Bool? public let enabled: Bool?
public let apikey: String? public let apikey: String?
@ -1104,7 +1104,7 @@ public struct SkillsUpdateParams: Codable {
} }
} }
public struct CronJob: Codable { public struct CronJob: Codable, Sendable {
public let id: String public let id: String
public let name: String public let name: String
public let description: String? public let description: String?
@ -1161,7 +1161,7 @@ public struct CronJob: Codable {
} }
} }
public struct CronListParams: Codable { public struct CronListParams: Codable, Sendable {
public let includedisabled: Bool? public let includedisabled: Bool?
public init( public init(
@ -1174,10 +1174,10 @@ public struct CronListParams: Codable {
} }
} }
public struct CronStatusParams: Codable { public struct CronStatusParams: Codable, Sendable {
} }
public struct CronAddParams: Codable { public struct CronAddParams: Codable, Sendable {
public let name: String public let name: String
public let description: String? public let description: String?
public let enabled: Bool? public let enabled: Bool?
@ -1218,7 +1218,7 @@ public struct CronAddParams: Codable {
} }
} }
public struct CronUpdateParams: Codable { public struct CronUpdateParams: Codable, Sendable {
public let id: String public let id: String
public let patch: [String: AnyCodable] public let patch: [String: AnyCodable]
@ -1235,7 +1235,7 @@ public struct CronUpdateParams: Codable {
} }
} }
public struct CronRemoveParams: Codable { public struct CronRemoveParams: Codable, Sendable {
public let id: String public let id: String
public init( public init(
@ -1248,7 +1248,7 @@ public struct CronRemoveParams: Codable {
} }
} }
public struct CronRunParams: Codable { public struct CronRunParams: Codable, Sendable {
public let id: String public let id: String
public let mode: AnyCodable? public let mode: AnyCodable?
@ -1265,7 +1265,7 @@ public struct CronRunParams: Codable {
} }
} }
public struct CronRunsParams: Codable { public struct CronRunsParams: Codable, Sendable {
public let id: String public let id: String
public let limit: Int? public let limit: Int?
@ -1282,7 +1282,7 @@ public struct CronRunsParams: Codable {
} }
} }
public struct CronRunLogEntry: Codable { public struct CronRunLogEntry: Codable, Sendable {
public let ts: Int public let ts: Int
public let jobid: String public let jobid: String
public let action: String public let action: String
@ -1327,7 +1327,7 @@ public struct CronRunLogEntry: Codable {
} }
} }
public struct ChatHistoryParams: Codable { public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String public let sessionkey: String
public let limit: Int? public let limit: Int?
@ -1344,7 +1344,7 @@ public struct ChatHistoryParams: Codable {
} }
} }
public struct ChatSendParams: Codable { public struct ChatSendParams: Codable, Sendable {
public let sessionkey: String public let sessionkey: String
public let message: String public let message: String
public let thinking: String? public let thinking: String?
@ -1381,7 +1381,7 @@ public struct ChatSendParams: Codable {
} }
} }
public struct ChatAbortParams: Codable { public struct ChatAbortParams: Codable, Sendable {
public let sessionkey: String public let sessionkey: String
public let runid: String public let runid: String
@ -1398,7 +1398,7 @@ public struct ChatAbortParams: Codable {
} }
} }
public struct ChatEvent: Codable { public struct ChatEvent: Codable, Sendable {
public let runid: String public let runid: String
public let sessionkey: String public let sessionkey: String
public let seq: Int public let seq: Int
@ -1439,7 +1439,7 @@ public struct ChatEvent: Codable {
} }
} }
public struct TickEvent: Codable { public struct TickEvent: Codable, Sendable {
public let ts: Int public let ts: Int
public init( public init(
@ -1452,7 +1452,7 @@ public struct TickEvent: Codable {
} }
} }
public struct ShutdownEvent: Codable { public struct ShutdownEvent: Codable, Sendable {
public let reason: String public let reason: String
public let restartexpectedms: Int? public let restartexpectedms: Int?

View File

@ -0,0 +1,148 @@
# Running Clawdis.app with a Remote Gateway
Clawdis.app uses SSH tunneling to connect to a remote gateway. This guide shows you how to set it up.
## Overview
```
┌─────────────────────────────────────────────────────────────┐
│ MacBook │
│ │
│ Clawdis.app ──► ws://127.0.0.1:18789 (local port) │
│ │ │
│ ▼ │
│ SSH Tunnel ────────────────────────────────────────────────│
│ │ │
└─────────────────────┼──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Remote Machine │
│ │
│ Gateway WebSocket ──► ws://127.0.0.1:18789 ──► │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Quick Setup
### Step 1: Add SSH Config
Edit `~/.ssh/config` and add:
```ssh
Host remote-gateway
HostName <REMOTE_IP> # e.g., 172.27.187.184
User <REMOTE_USER> # e.g., jefferson
LocalForward 18789 127.0.0.1:18789
IdentityFile ~/.ssh/id_rsa
```
Replace `<REMOTE_IP>` and `<REMOTE_USER>` with your values.
### Step 2: Copy SSH Key
Copy your public key to the remote machine (enter password once):
```bash
ssh-copy-id -i ~/.ssh/id_rsa <REMOTE_USER>@<REMOTE_IP>
```
### Step 3: Set Gateway Token
```bash
launchctl setenv CLAWDIS_GATEWAY_TOKEN "<your-token>"
```
### Step 4: Start SSH Tunnel
```bash
ssh -N remote-gateway &
```
### Step 5: Restart Clawdis.app
```bash
killall Clawdis
open /path/to/Clawdis.app
```
The app will now connect to the remote gateway through the SSH tunnel.
---
## Auto-Start Tunnel on Login
To have the SSH tunnel start automatically when you log in, create a Launch Agent.
### Create the PLIST file
Save this as `~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.clawdis.ssh-tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-N</string>
<string>remote-gateway</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
### Load the Launch Agent
```bash
launchctl load ~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist
```
The tunnel will now:
- Start automatically when you log in
- Restart if it crashes
- Keep running in the background
---
## Troubleshooting
**Check if tunnel is running:**
```bash
ps aux | grep "ssh -N remote-gateway" | grep -v grep
lsof -i :18789
```
**Restart the tunnel:**
```bash
launchctl restart com.clawdis.ssh-tunnel
```
**Stop the tunnel:**
```bash
launchctl unload ~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist
```
---
## How It Works
| Component | What It Does |
|-----------|--------------|
| `LocalForward 18789 127.0.0.1:18789` | Forwards local port 18789 to remote port 18789 |
| `ssh -N` | SSH without executing remote commands (just port forwarding) |
| `KeepAlive` | Automatically restarts tunnel if it crashes |
| `RunAtLoad` | Starts tunnel when the agent loads |
Clawdis.app connects to `ws://127.0.0.1:18789` on your MacBook. The SSH tunnel forwards that connection to port 18789 on the remote machine where the Gateway is running.

View File

@ -106,7 +106,7 @@ function emitStruct(name: string, schema: JsonSchema): string {
const props = schema.properties ?? {}; const props = schema.properties ?? {};
const required = new Set(schema.required ?? []); const required = new Set(schema.required ?? []);
const lines: string[] = []; const lines: string[] = [];
lines.push(`public struct ${name}: Codable {`); lines.push(`public struct ${name}: Codable, Sendable {`);
if (Object.keys(props).length === 0) { if (Object.keys(props).length === 0) {
lines.push("}\n"); lines.push("}\n");
return lines.join("\n"); return lines.join("\n");

View File

@ -129,7 +129,7 @@ describe("sessions tools", () => {
callGatewayMock.mockReset(); callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = []; const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0; let agentCallCount = 0;
let historyCallCount = 0; let _historyCallCount = 0;
let sendCallCount = 0; let sendCallCount = 0;
let lastWaitedRunId: string | undefined; let lastWaitedRunId: string | undefined;
const replyByRunId = new Map<string, string>(); const replyByRunId = new Map<string, string>();
@ -165,7 +165,7 @@ describe("sessions tools", () => {
return { runId: params?.runId ?? "run-1", status: "ok" }; return { runId: params?.runId ?? "run-1", status: "ok" };
} }
if (request.method === "chat.history") { if (request.method === "chat.history") {
historyCallCount += 1; _historyCallCount += 1;
const text = const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return { return {
@ -193,9 +193,7 @@ describe("sessions tools", () => {
const tool = createClawdisTools({ const tool = createClawdisTools({
agentSessionKey: requesterKey, agentSessionKey: requesterKey,
agentSurface: "discord", agentSurface: "discord",
}).find( }).find((candidate) => candidate.name === "sessions_send");
(candidate) => candidate.name === "sessions_send",
);
expect(tool).toBeDefined(); expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool"); if (!tool) throw new Error("missing sessions_send tool");
@ -236,8 +234,9 @@ describe("sessions tools", () => {
(call) => (call) =>
typeof (call.params as { extraSystemPrompt?: string }) typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" && ?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string }) (
?.extraSystemPrompt?.includes("Agent-to-agent message context"), call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent message context"),
), ),
).toBe(true); ).toBe(true);
expect( expect(
@ -245,8 +244,9 @@ describe("sessions tools", () => {
(call) => (call) =>
typeof (call.params as { extraSystemPrompt?: string }) typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" && ?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string }) (
?.extraSystemPrompt?.includes("Agent-to-agent reply step"), call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
), ),
).toBe(true); ).toBe(true);
expect( expect(
@ -254,8 +254,9 @@ describe("sessions tools", () => {
(call) => (call) =>
typeof (call.params as { extraSystemPrompt?: string }) typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" && ?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string }) (
?.extraSystemPrompt?.includes("Agent-to-agent announce step"), call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
), ),
).toBe(true); ).toBe(true);
expect(waitCalls).toHaveLength(8); expect(waitCalls).toHaveLength(8);
@ -285,7 +286,11 @@ describe("sessions tools", () => {
agentCallCount += 1; agentCallCount += 1;
const runId = `run-${agentCallCount}`; const runId = `run-${agentCallCount}`;
const params = request.params as const params = request.params as
| { message?: string; sessionKey?: string; extraSystemPrompt?: string } | {
message?: string;
sessionKey?: string;
extraSystemPrompt?: string;
}
| undefined; | undefined;
let reply = "initial"; let reply = "initial";
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) { if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
@ -359,8 +364,9 @@ describe("sessions tools", () => {
call.method === "agent" && call.method === "agent" &&
typeof (call.params as { extraSystemPrompt?: string }) typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" && ?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string }) (
?.extraSystemPrompt?.includes("Agent-to-agent reply step"), call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
); );
expect(replySteps).toHaveLength(2); expect(replySteps).toHaveLength(2);
expect(sendParams).toMatchObject({ expect(sendParams).toMatchObject({

View File

@ -2784,7 +2784,9 @@ function buildAgentToAgentReplyContext(params: {
? `Agent 1 (requester) surface: ${params.requesterSurface}.` ? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined, : undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`, `Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel ? `Agent 2 (target) surface: ${params.targetChannel}.` : undefined, params.targetChannel
? `Agent 2 (target) surface: ${params.targetChannel}.`
: undefined,
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`, `If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
].filter(Boolean); ].filter(Boolean);
return lines.join("\n"); return lines.join("\n");
@ -2808,7 +2810,9 @@ function buildAgentToAgentAnnounceContext(params: {
? `Agent 1 (requester) surface: ${params.requesterSurface}.` ? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined, : undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`, `Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel ? `Agent 2 (target) surface: ${params.targetChannel}.` : undefined, params.targetChannel
? `Agent 2 (target) surface: ${params.targetChannel}.`
: undefined,
`Original request: ${params.originalMessage}`, `Original request: ${params.originalMessage}`,
params.roundOneReply params.roundOneReply
? `Round 1 reply: ${params.roundOneReply}` ? `Round 1 reply: ${params.roundOneReply}`
@ -2892,34 +2896,35 @@ function createSessionsSendTool(opts?: {
const requesterSurface = opts?.agentSurface; const requesterSurface = opts?.agentSurface;
const maxPingPongTurns = resolvePingPongTurns(cfg); const maxPingPongTurns = resolvePingPongTurns(cfg);
const resolveAnnounceTarget = async (): Promise<AnnounceTarget | null> => { const resolveAnnounceTarget =
const parsed = resolveAnnounceTargetFromKey(resolvedKey); async (): Promise<AnnounceTarget | null> => {
if (parsed) return parsed; const parsed = resolveAnnounceTargetFromKey(resolvedKey);
try { if (parsed) return parsed;
const list = (await callGateway({ try {
method: "sessions.list", const list = (await callGateway({
params: { method: "sessions.list",
includeGlobal: true, params: {
includeUnknown: true, includeGlobal: true,
limit: 200, includeUnknown: true,
}, limit: 200,
})) as { sessions?: Array<Record<string, unknown>> }; },
const sessions = Array.isArray(list?.sessions) ? list.sessions : []; })) as { sessions?: Array<Record<string, unknown>> };
const match = const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
sessions.find((entry) => entry?.key === resolvedKey) ?? const match =
sessions.find((entry) => entry?.key === displayKey); sessions.find((entry) => entry?.key === resolvedKey) ??
const channel = sessions.find((entry) => entry?.key === displayKey);
typeof match?.lastChannel === "string" const channel =
? match.lastChannel typeof match?.lastChannel === "string"
: undefined; ? match.lastChannel
const to = : undefined;
typeof match?.lastTo === "string" ? match.lastTo : undefined; const to =
if (channel && to) return { channel, to }; typeof match?.lastTo === "string" ? match.lastTo : undefined;
} catch { if (channel && to) return { channel, to };
// ignore; fall through to null } catch {
} // ignore; fall through to null
return null; }
}; return null;
};
const readLatestAssistantReply = async ( const readLatestAssistantReply = async (
sessionKeyToRead: string, sessionKeyToRead: string,

View File

@ -57,7 +57,10 @@ import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
import { shouldLogVerbose } from "../globals.js"; import { shouldLogVerbose } from "../globals.js";
import { sendMessageIMessage } from "../imessage/index.js"; import { sendMessageIMessage } from "../imessage/index.js";
import { type IMessageProbe, probeIMessage } from "../imessage/probe.js"; import { type IMessageProbe, probeIMessage } from "../imessage/probe.js";
import { onAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; import {
onAgentEvent,
registerAgentRunContext,
} from "../infra/agent-events.js";
import type { startNodeBridgeServer } from "../infra/bridge/server.js"; import type { startNodeBridgeServer } from "../infra/bridge/server.js";
import { getLastHeartbeatEvent } from "../infra/heartbeat-events.js"; import { getLastHeartbeatEvent } from "../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../infra/heartbeat-runner.js"; import { setHeartbeatsEnabled } from "../infra/heartbeat-runner.js";

View File

@ -214,9 +214,7 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
}); });
test( test("enables cron scheduler by default and runs due jobs automatically", async () => {
"enables cron scheduler by default and runs due jobs automatically",
async () => {
const dir = await fs.mkdtemp( const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdis-gw-cron-default-on-"), path.join(os.tmpdir(), "clawdis-gw-cron-default-on-"),
); );
@ -307,7 +305,5 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
} }
}, }, 15_000);
15_000,
);
}); });