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.
### Fixes
- Docs: add remote gateway SSH tunnel setup guide (#171) — thanks @jeffersonwarrior.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.

View File

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

View File

@ -244,7 +244,7 @@ actor CameraCaptureService {
deviceId: String?) -> AVCaptureDevice?
{
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
}
}
@ -331,7 +331,7 @@ actor CameraCaptureService {
private func sleepDelayMs(_ delayMs: Int) async {
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)
}

View File

@ -100,7 +100,8 @@ enum ClawdisConfigFile {
static func gatewayPassword() -> String? {
let root = self.loadDict()
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 remote["password"] as? String
@ -121,5 +122,4 @@ enum ClawdisConfigFile {
}
return nil
}
}

View File

@ -157,7 +157,7 @@ enum CommandResolver {
}
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)
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate

View File

@ -90,8 +90,8 @@ extension ConfigFileWatcher {
private func handleEvents(
numEvents: Int,
eventPaths: UnsafeMutableRawPointer?,
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
) {
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
{
guard numEvents > 0 else { return }
guard eventFlags != nil 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])
guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config."
NSLocalizedDescriptionKey: "Failed to encode config.",
])
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
@ -88,7 +88,7 @@ enum ConfigStore {
timeoutMs: 10000)
}
#if DEBUG
#if DEBUG
static func _testSetOverrides(_ overrides: Overrides) async {
await self.overrideStore.setOverride(overrides)
}
@ -96,5 +96,5 @@ enum ConfigStore {
static func _testClearOverrides() async {
await self.overrideStore.setOverride(.init())
}
#endif
#endif
}

View File

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

View File

@ -172,8 +172,8 @@ struct DiscordGuildForm: Identifiable {
requireMention: Bool = false,
reactionNotifications: String = "own",
users: String = "",
channels: [DiscordGuildChannelForm] = []
) {
channels: [DiscordGuildChannelForm] = [])
{
self.key = key
self.slug = slug
self.requireMention = requireMention
@ -473,12 +473,16 @@ final class ConnectionsStore {
} else {
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))
} else {
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))
} else {
self.discordTextChunkLimit = ""
@ -506,9 +510,10 @@ final class ConnectionsStore {
return nil
}
.joined(separator: ", ") ?? ""
let channels: [DiscordGuildChannelForm]
if let channelMap = entry["channels"]?.dictionaryValue {
channels = channelMap.map { channelKey, channelValue in
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?
.dictionaryValue
{
channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention =
@ -519,7 +524,7 @@ final class ConnectionsStore {
requireMention: channelRequireMention)
}
} else {
channels = []
[]
}
return DiscordGuildForm(
key: key,

View File

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

View File

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

View File

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

View File

@ -237,8 +237,9 @@ actor GatewayConnection {
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
return (configPath?.isEmpty == false ? configPath : nil,
stateDir?.isEmpty == false ? stateDir : nil)
return (
configPath?.isEmpty == false ? configPath : nil,
stateDir?.isEmpty == false ? stateDir : nil)
}
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
@ -270,7 +271,9 @@ actor GatewayConnection {
}
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
}
if let client {

View File

@ -40,8 +40,8 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String]
) -> String? {
env: [String: String]) -> String?
{
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
@ -93,7 +93,11 @@ actor GatewayEndpointStore {
let password = deps.password()
switch initialMode {
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:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
case .unconfigured:
@ -125,14 +129,22 @@ actor GatewayEndpointStore {
switch mode {
case .local:
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:
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
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:
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
}
@ -213,8 +225,8 @@ extension GatewayEndpointStore {
static func _testResolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String]
) -> String? {
env: [String: String]) -> String?
{
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] {
#if DEBUG
#if DEBUG
let projectRoot = CommandResolver.projectRoot()
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
@ -38,7 +38,7 @@ enum GatewayLaunchAgentManager {
subcommand: "gateway",
extraArgs: ["--port", "\(port)", "--bind", bind])
}
#endif
#endif
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
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? {
if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"])
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {

View File

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

View File

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

View File

@ -1,7 +1,7 @@
@_exported import Logging
import Foundation
import OSLog
@_exported import Logging
import os
import OSLog
typealias Logger = Logging.Logger
@ -65,15 +65,15 @@ enum ClawdisLogging {
}()
static func bootstrapIfNeeded() {
_ = Self.didBootstrap
_ = self.didBootstrap
}
static func makeLabel(subsystem: String, category: String) -> String {
"\(subsystem)\(Self.labelSeparator)\(category)"
"\(subsystem)\(self.labelSeparator)\(category)"
}
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)
}
let subsystem = String(label[..<range.lowerBound])
@ -91,7 +91,7 @@ extension Logging.Logger {
}
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))
}
}
@ -132,15 +132,15 @@ struct ClawdisOSLogHandler: LogHandler {
private static func osLogType(for level: Logger.Level) -> OSLogType {
switch level {
case .trace, .debug:
return .debug
.debug
case .info, .notice:
return .info
.info
case .warning:
return .default
.default
case .error:
return .error
.error
case .critical:
return .fault
.fault
}
}
@ -156,7 +156,7 @@ struct ClawdisOSLogHandler: LogHandler {
guard !metadata.isEmpty else { return message.description }
let meta = metadata
.sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\(stringify($0.value))" }
.map { "\($0.key)=\(self.stringify($0.value))" }
.joined(separator: " ")
return "\(message.description) [\(meta)]"
}
@ -168,9 +168,9 @@ struct ClawdisOSLogHandler: LogHandler {
case let .stringConvertible(value):
String(describing: value)
case let .array(values):
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
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):
String(describing: value)
case let .array(values):
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
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
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) {
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
var root = await ConfigStore.load()
var browser = root["browser"] as? [String: Any] ?? [:]
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.invalidateIntrinsicContentSize()
}
}
struct MenuHostedHighlightedItem: NSViewRepresentable {

View File

@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
compact.representedObject = row.key
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: "")
del.target = self
del.representedObject = row.key
@ -541,12 +541,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
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: ", ")))
}
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
!commands.isEmpty {
!commands.isEmpty
{
menu.addItem(self.makeNodeMultilineItem(
label: "Commands",
value: commands.joined(separator: ", "),
@ -589,6 +591,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
return trimmed
}
@objc
private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],
@ -770,7 +773,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
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
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
@ -781,8 +784,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
}
// MARK: - Views
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
@MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
final class MacNodeLocationService: NSObject {
enum Error: Swift.Error {
case timeout
case unavailable
@ -47,10 +47,8 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
let timeout = max(0, timeoutMs ?? 10000)
return try await self.requestLocationWithTimeout(timeoutMs: timeout)
}
private func requestLocation() async throws -> CLLocation {
@ -60,34 +58,50 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
}
}
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
private func requestLocationWithTimeout(timeoutMs: Int) async throws -> CLLocation {
if timeoutMs == 0 {
return try await operation()
return try await self.requestLocation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
let timeoutNs = UInt64(timeoutMs) * 1_000_000
return try await withCheckedThrowingContinuation { continuation in
let lock = NSLock()
var didResume = false
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 {
switch accuracy {
case .coarse:
return kCLLocationAccuracyKilometer
kCLLocationAccuracyKilometer
case .balanced:
return kCLLocationAccuracyHundredMeters
kCLLocationAccuracyHundredMeters
case .precise:
return kCLLocationAccuracyBest
kCLLocationAccuracyBest
}
}
@ -107,3 +121,6 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
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)) ??
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(
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
maxWidth: params.maxWidth,
@ -184,7 +184,7 @@ actor MacNodeRuntime {
let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced)
let status = await self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse {
if status != .authorizedAlways, status != .authorized {
return BridgeInvokeResponse(
id: req.id,
ok: false,

View File

@ -611,7 +611,7 @@ final class NodePairingApprovalPrompter {
private func updatePendingCounts() {
// Keep a cheap observable summary for the menu bar status line.
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 {

View File

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

View File

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

View File

@ -86,7 +86,7 @@ extension OnboardingView {
var navigationBar: some View {
let wizardLockIndex = self.wizardPageOrderIndex
HStack(spacing: 20) {
return HStack(spacing: 20) {
ZStack(alignment: .leading) {
Button(action: {}, label: {
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
@ -112,7 +112,8 @@ extension OnboardingView {
HStack(spacing: 8) {
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 {
withAnimation { self.currentPage = index }
} label: {

View File

@ -444,7 +444,7 @@ extension OnboardingView {
}
func permissionsPage() -> some View {
return self.onboardingPage {
self.onboardingPage {
Text("Grant permissions")
.font(.largeTitle.weight(.semibold))
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 SwiftUI
@ -44,15 +45,15 @@ private struct OnboardingWizardCardContent: View {
private var state: CardState {
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 wizard.isComplete { return .complete }
if self.wizard.isComplete { return .complete }
return .waiting
}
var body: some View {
switch state {
case .error(let error):
switch self.state {
case let .error(error):
Text("Wizard error")
.font(.headline)
Text(error)
@ -60,11 +61,11 @@ private struct OnboardingWizardCardContent: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Retry") {
wizard.reset()
self.wizard.reset()
Task {
await wizard.startIfNeeded(
mode: mode,
workspace: workspacePath.isEmpty ? nil : workspacePath)
await self.wizard.startIfNeeded(
mode: self.mode,
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
}
}
.buttonStyle(.borderedProminent)
@ -74,12 +75,12 @@ private struct OnboardingWizardCardContent: View {
Text("Starting wizard…")
.foregroundStyle(.secondary)
}
case .step(let step):
case let .step(step):
OnboardingWizardStepView(
step: step,
isSubmitting: wizard.isSubmitting)
isSubmitting: self.wizard.isSubmitting)
{ value in
Task { await wizard.submit(step: step, value: value) }
Task { await self.wizard.submit(step: step, value: value) }
}
.id(step.id)
case .complete:

View File

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

View File

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

View File

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

View File

@ -140,12 +140,12 @@ enum PermissionManager {
private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorizedWhenInUse:
case .authorizedAlways, .authorized:
return true
case .notDetermined:
guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways || updated == .authorizedWhenInUse
return updated == .authorizedAlways || updated == .authorized
case .denied, .restricted:
if interactive {
LocationPermissionHelper.openSettings()
@ -198,9 +198,10 @@ enum PermissionManager {
case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
case .location:
let status = CLLocationManager.authorizationStatus()
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse
results[cap] = status == .authorizedAlways || status == .authorized
}
}
return results
@ -268,7 +269,7 @@ enum LocationPermissionHelper {
}
@MainActor
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
final class LocationPermissionRequester: NSObject {
static let shared = LocationPermissionRequester()
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
@ -296,6 +297,9 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
}
}
@MainActor
extension LocationPermissionRequester: @preconcurrency CLLocationManagerDelegate {}
enum 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 payload = try await AsyncTimeout.withTimeout(
seconds: Self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() }) {
try await GatewayConnection.shared.chatHistory(
sessionKey: self.sessionKey,
limit: self.previewLimit,
timeoutMs: timeoutMs)
}
onTimeout: { PreviewTimeoutError() })
{
try await GatewayConnection.shared.chatHistory(
sessionKey: self.sessionKey,
limit: self.previewLimit,
timeoutMs: timeoutMs)
}
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
await MainActor.run {
@ -198,7 +199,9 @@ struct SessionMenuPreviewView: View {
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,
password: trimmedPassword,
connectionMode: self.connectionMode,
isPaused: self.isPaused
)
isPaused: self.isPaused)
if !success, let errorMessage {
self.statusMessage = errorMessage
@ -307,8 +306,8 @@ struct TailscaleIntegrationSection: View {
requireCredentialsForServe: Bool,
password: String,
connectionMode: AppState.ConnectionMode,
isPaused: Bool
) async -> (Bool, String?) {
isPaused: Bool) async -> (Bool, String?)
{
var root = await ConfigStore.load()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
@ -349,7 +348,7 @@ struct TailscaleIntegrationSection: View {
do {
try await ConfigStore.save(root)
return (true, nil)
} catch let error {
} catch {
return (false, error.localizedDescription)
}
}

View File

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

View File

@ -213,7 +213,7 @@ final class WorkActivityStore {
meta: 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)
if let detail = display.detailLine, !detail.isEmpty {
return "\(display.label): \(detail)"
@ -223,22 +223,22 @@ final class WorkActivityStore {
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
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)
}
private static func unwrapJSONValue(_ value: Any) -> Any {
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] {
return array.map { unwrapJSONValue($0.value) }
return array.map { self.unwrapJSONValue($0.value) }
}
if let dict = value as? [String: Any] {
return dict.mapValues { unwrapJSONValue($0) }
return dict.mapValues { self.unwrapJSONValue($0) }
}
if let array = value as? [Any] {
return array.map { unwrapJSONValue($0) }
return array.map { self.unwrapJSONValue($0) }
}
return value
}

View File

@ -10,7 +10,7 @@ public enum ErrorCode: String, Codable {
case unavailable = "UNAVAILABLE"
}
public struct ConnectParams: Codable {
public struct ConnectParams: Codable, Sendable {
public let minprotocol: Int
public let maxprotocol: Int
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 _protocol: Int
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 id: 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 id: String
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 event: String
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 ip: 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 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 health: AnyCodable
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 message: String
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 seq: Int
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 message: 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 to: 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 afterms: 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 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 displayname: 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 init(
@ -525,7 +525,7 @@ public struct NodePairApproveParams: Codable {
}
}
public struct NodePairRejectParams: Codable {
public struct NodePairRejectParams: Codable, Sendable {
public let requestid: String
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 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 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 init(
@ -588,7 +588,7 @@ public struct NodeDescribeParams: Codable {
}
}
public struct NodeInvokeParams: Codable {
public struct NodeInvokeParams: Codable, Sendable {
public let nodeid: String
public let command: String
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 activeminutes: Int?
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 thinkinglevel: 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 init(
@ -688,7 +688,7 @@ public struct SessionsResetParams: Codable {
}
}
public struct SessionsDeleteParams: Codable {
public struct SessionsDeleteParams: Codable, Sendable {
public let key: String
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 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 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 uihints: [String: AnyCodable]
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 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 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 init(
@ -813,7 +813,7 @@ public struct WizardCancelParams: Codable {
}
}
public struct WizardStatusParams: Codable {
public struct WizardStatusParams: Codable, Sendable {
public let sessionid: String
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 type: AnyCodable
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 step: [String: 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 done: Bool
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 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 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 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 timeoutms: Int?
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 init(
@ -1010,7 +1010,7 @@ public struct WebLoginWaitParams: Codable {
}
}
public struct ModelChoice: Codable {
public struct ModelChoice: Codable, Sendable {
public let id: String
public let name: 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 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 installid: String
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 enabled: Bool?
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 name: 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 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 description: String?
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 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 init(
@ -1248,7 +1248,7 @@ public struct CronRemoveParams: Codable {
}
}
public struct CronRunParams: Codable {
public struct CronRunParams: Codable, Sendable {
public let id: String
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 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 jobid: 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 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 message: 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 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 sessionkey: String
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 init(
@ -1452,7 +1452,7 @@ public struct TickEvent: Codable {
}
}
public struct ShutdownEvent: Codable {
public struct ShutdownEvent: Codable, Sendable {
public let reason: String
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 required = new Set(schema.required ?? []);
const lines: string[] = [];
lines.push(`public struct ${name}: Codable {`);
lines.push(`public struct ${name}: Codable, Sendable {`);
if (Object.keys(props).length === 0) {
lines.push("}\n");
return lines.join("\n");

View File

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

View File

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

View File

@ -57,7 +57,10 @@ import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
import { shouldLogVerbose } from "../globals.js";
import { sendMessageIMessage } from "../imessage/index.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 { getLastHeartbeatEvent } from "../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../infra/heartbeat-runner.js";

View File

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