Compare commits

..
Author SHA1 Message Date
Peter Steinberger 990a778d00 docs: update changelog for #166 2026-01-04 06:09:33 +01:00
Peter Steinberger 56274eb76e style: fix lint formatting 2026-01-04 06:09:04 +01:00
Peter Steinberger d9987a65b2 fix(macos): bridge wizard option values 2026-01-04 06:04:32 +01:00
Peter Steinberger b59c9c38c2 chore: update Peekaboo submodule 2026-01-04 06:04:30 +01:00
Tu Nombre RealandClaude Opus 4.5 d32b77b330 feat(macos): add Swift 6 strict concurrency compatibility
Prepares the macOS app for Swift 6 strict concurrency mode by:

1. Adding Sendable conformance to WizardNextResult, WizardStartResult,
   and WizardStatusResult in GatewayModels.swift

2. Adding AnyCodable bridging helpers in OnboardingWizard.swift to
   handle type conflicts between ClawdisProtocol and local module

3. Making CLLocationManagerDelegate methods nonisolated in:
   - MacNodeLocationService.swift
   - PermissionManager.swift (LocationPermissionRequester)

   Using Task { @MainActor in } pattern to safely access MainActor
   state from nonisolated protocol requirements.

These changes are forward-compatible and don't affect behavior on
current Swift versions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:57:08 +01:00
51 changed files with 520 additions and 960 deletions

No files matched your search

+3 -4
View File
@@ -9,7 +9,6 @@
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
### Features
- Highlight: agent-to-agent ping-pong (reply-back loop) with `REPLY_SKIP` plus target announce step with `ANNOUNCE_SKIP` (max turns configurable, 0–5).
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
@@ -21,9 +20,10 @@
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
### Fixes
- Docs: add remote gateway SSH tunnel setup guide (#171) — thanks @jeffersonwarrior.
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
- 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.
@@ -73,8 +73,7 @@
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
- macOS: clarify menu bar uses sessionKey from agent events.
- Sessions: document agent-to-agent reply loop (`REPLY_SKIP`) and announce step (`ANNOUNCE_SKIP`).
- Skills: clarify wacli third-party messaging scope and JID format examples.
- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`.
## 2.0.0-beta5 — 2026-01-03
Submodule Peekaboo updated: 9db365b73c...b69e4e8dc0.
+41 -34
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":
.local
case "remote":
.remote
default:
nil
}
let configMode: ConnectionMode? = {
switch configModeRaw {
case "local":
return .local
case "remote":
return .remote
default:
return 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)
let resolvedConnectionMode: ConnectionMode = if let configMode {
configMode
if let configMode {
self.connectionMode = configMode
} else if configHasRemoteUrl {
.remote
self.connectionMode = .remote
} else if let storedMode {
ConnectionMode(rawValue: storedMode) ?? .local
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
} else {
onboardingSeen ? .local : .unconfigured
self.connectionMode = onboardingSeen ? .local : .unconfigured
}
self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
let resolvedRemoteTarget = if resolvedConnectionMode == .remote,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl)
if self.connectionMode == .remote,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl)
{
"\(NSUserName())@\(host)"
self.remoteTarget = "\(NSUserName())@\(host)"
} else {
storedRemoteTarget
self.remoteTarget = 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,6 +322,10 @@ final class AppState {
}
}
deinit {
self.configWatcher?.stop()
}
private static func remoteHost(from urlString: String?) -> String? {
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty,
@@ -357,16 +361,18 @@ final class AppState {
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let desiredMode: ConnectionMode? = switch modeRaw {
case "local":
.local
case "remote":
.remote
case "unconfigured":
.unconfigured
default:
nil
}
let desiredMode: ConnectionMode? = {
switch modeRaw {
case "local":
return .local
case "remote":
return .remote
case "unconfigured":
return .unconfigured
default:
return nil
}
}()
if let desiredMode {
if desiredMode != self.connectionMode {
@@ -401,13 +407,14 @@ 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:
"local"
desiredMode = "local"
case .remote:
"remote"
desiredMode = "remote"
case .unconfigured:
nil
desiredMode = nil
}
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -244,7 +244,7 @@ actor CameraCaptureService {
deviceId: String?) -> AVCaptureDevice?
{
if let deviceId, !deviceId.isEmpty {
if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
if let match = Self.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, 10000)) * 1_000_000
let ns = UInt64(min(delayMs, 10_000)) * 1_000_000
try? await Task.sleep(nanoseconds: ns)
}
@@ -100,8 +100,7 @@ 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
@@ -122,4 +121,5 @@ enum ClawdisConfigFile {
}
return nil
}
}
@@ -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
@@ -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 }
+3 -3
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
}
@@ -492,7 +492,7 @@ struct ConnectionsSettings: View {
GroupBox("Guilds") {
VStack(alignment: .leading, spacing: 12) {
ForEach(self.$store.discordGuilds) { $guild in
ForEach($store.discordGuilds) { $guild in
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("guild id or slug", text: $guild.key)
@@ -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,16 +473,12 @@ 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 = ""
@@ -510,10 +506,9 @@ final class ConnectionsStore {
return nil
}
.joined(separator: ", ") ?? ""
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?
.dictionaryValue
{
channelMap.map { channelKey, channelValue in
let channels: [DiscordGuildChannelForm]
if let channelMap = entry["channels"]?.dictionaryValue {
channels = channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention =
@@ -524,7 +519,7 @@ final class ConnectionsStore {
requireMention: channelRequireMention)
}
} else {
[]
channels = []
}
return DiscordGuildForm(
key: key,
@@ -12,13 +12,11 @@ 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) }
@@ -55,8 +55,8 @@ final class ControlChannel {
private(set) var state: ConnectionState = .disconnected {
didSet {
CanvasManager.shared.refreshDebugStatus()
guard oldValue != self.state else { return }
switch self.state {
guard oldValue != state else { return }
switch state {
case .connected:
self.logger.info("control channel state -> connected")
case .connecting:
@@ -71,7 +71,6 @@ final class ControlChannel {
}
}
}
private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.clawdis", category: "control")
@@ -138,20 +138,17 @@ 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])
@@ -237,9 +237,8 @@ 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> {
@@ -271,9 +270,7 @@ 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 {
@@ -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,11 +93,7 @@ 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:
@@ -129,22 +125,14 @@ 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"))
}
@@ -225,8 +213,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)
}
}
@@ -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())/\(self.legacyGatewayLaunchdLabel)"])
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
@@ -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 == .authorized {
if mode == .always, status != .authorizedAlways {
if status == .authorizedAlways || status == .authorizedWhenInUse {
if mode == .always && status != .authorizedAlways {
let updated = await LocationPermissionRequester.shared.request(always: true)
return updated == .authorizedAlways || updated == .authorized
return updated == .authorizedAlways || updated == .authorizedWhenInUse
}
return true
}
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
switch updated {
case .authorizedAlways, .authorized:
case .authorizedAlways, .authorizedWhenInUse:
return true
default:
return false
@@ -6,7 +6,6 @@ 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")
@@ -20,7 +19,7 @@ enum LaunchAgentManager {
static func set(enabled: Bool, bundlePath: String) async {
if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
self.writePlist(bundlePath: bundlePath)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
@@ -1,7 +1,7 @@
import Foundation
@_exported import Logging
import os
import Foundation
import OSLog
import os
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: labelSeparator) else {
guard let range = label.range(of: Self.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(_ value: some Any, privacy: OSLogPrivacy) {
mutating func appendInterpolation<T>(_ value: T, 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:
.debug
return .debug
case .info, .notice:
.info
return .info
case .warning:
.default
return .default
case .error:
.error
return .error
case .critical:
.fault
return .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)=\(self.stringify($0.value))" }
.map { "\($0.key)=\(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 { self.stringify($0) }.joined(separator: ",") + "]"
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
"{" + entries.map { "\($0.key)=\(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 { self.stringify($0) }.joined(separator: ",") + "]"
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
}
}
}
@@ -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
@@ -86,6 +86,7 @@ final class HighlightedMenuItemHostView: NSView {
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
self.invalidateIntrinsicContentSize()
}
}
struct MenuHostedHighlightedItem: NSViewRepresentable {
@@ -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,14 +541,12 @@ 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: ", "),
@@ -591,7 +589,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
return trimmed
}
@objc
private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],
@@ -773,7 +770,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
private func sortedNodeEntries() -> [NodeInfo] {
let entries = self.nodesStore.nodes.filter(\.isConnected)
let entries = self.nodesStore.nodes.filter { $0.isConnected }
return entries.sorted { lhs, rhs in
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
@@ -784,6 +781,8 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
}
// MARK: - Views
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
@@ -192,4 +192,6 @@ actor MacNodeBridgePairingClient {
}
}
}
}
@@ -325,4 +325,6 @@ actor MacNodeBridgeSession {
])
})
}
}
@@ -3,7 +3,7 @@ import CoreLocation
import Foundation
@MainActor
final class MacNodeLocationService: NSObject {
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
enum Error: Swift.Error {
case timeout
case unavailable
@@ -47,8 +47,10 @@ final class MacNodeLocationService: NSObject {
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10000)
return try await self.requestLocationWithTimeout(timeoutMs: timeout)
let timeout = max(0, timeoutMs ?? 10_000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
@@ -58,69 +60,57 @@ final class MacNodeLocationService: NSObject {
}
}
private func requestLocationWithTimeout(timeoutMs: Int) async throws -> CLLocation {
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await self.requestLocation()
return try await operation()
}
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))
}
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 result = try await group.next()!
group.cancelAll()
return result
}
}
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
kCLLocationAccuracyKilometer
return kCLLocationAccuracyKilometer
case .balanced:
kCLLocationAccuracyHundredMeters
return kCLLocationAccuracyHundredMeters
case .precise:
kCLLocationAccuracyBest
return kCLLocationAccuracyBest
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: error)
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let errorCopy = error // Capture error for Sendable compliance
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: errorCopy)
}
}
}
@MainActor
extension MacNodeLocationService: @preconcurrency CLLocationManagerDelegate {}
@@ -104,7 +104,7 @@ actor MacNodeRuntime {
}
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
ClawdisCameraSnapParams()
let delayMs = min(10000, max(0, params.delayMs ?? 2000))
let delayMs = min(10_000, 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 != .authorized {
if status != .authorizedAlways && status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -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.count(where: { $0.isRepair == true })
self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count
}
private func reconcileOnce(timeoutMs: Double) async {
+3 -2
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,6 +227,7 @@ struct NodeMenuRowView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
.padding(.leading, 18)
@@ -139,7 +139,6 @@ 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
@@ -86,7 +86,7 @@ extension OnboardingView {
var navigationBar: some View {
let wizardLockIndex = self.wizardPageOrderIndex
return HStack(spacing: 20) {
HStack(spacing: 20) {
ZStack(alignment: .leading) {
Button(action: {}, label: {
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
@@ -112,8 +112,7 @@ 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: {
@@ -444,7 +444,7 @@ extension OnboardingView {
}
func permissionsPage() -> some View {
self.onboardingPage {
return self.onboardingPage {
Text("Grant permissions")
.font(.largeTitle.weight(.semibold))
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
@@ -1,4 +1,3 @@
import ClawdisProtocol
import Observation
import SwiftUI
@@ -45,15 +44,15 @@ private struct OnboardingWizardCardContent: View {
private var state: CardState {
if let error = wizard.errorMessage { return .error(error) }
if self.wizard.isStarting { return .starting }
if wizard.isStarting { return .starting }
if let step = wizard.currentStep { return .step(step) }
if self.wizard.isComplete { return .complete }
if wizard.isComplete { return .complete }
return .waiting
}
var body: some View {
switch self.state {
case let .error(error):
switch state {
case .error(let error):
Text("Wizard error")
.font(.headline)
Text(error)
@@ -61,11 +60,11 @@ private struct OnboardingWizardCardContent: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Retry") {
self.wizard.reset()
wizard.reset()
Task {
await self.wizard.startIfNeeded(
mode: self.mode,
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
await wizard.startIfNeeded(
mode: mode,
workspace: workspacePath.isEmpty ? nil : workspacePath)
}
}
.buttonStyle(.borderedProminent)
@@ -75,12 +74,12 @@ private struct OnboardingWizardCardContent: View {
Text("Starting wizard…")
.foregroundStyle(.secondary)
}
case let .step(step):
case .step(let step):
OnboardingWizardStepView(
step: step,
isSubmitting: self.wizard.isSubmitting)
isSubmitting: wizard.isSubmitting)
{ value in
Task { await self.wizard.submit(step: step, value: value) }
Task { await wizard.submit(step: step, value: value) }
}
.id(step.id)
case .complete:
@@ -101,7 +101,7 @@ extension OnboardingView {
do {
try await ConfigStore.save(root)
return (true, nil)
} catch {
} catch let error {
let errorMessage = "Failed to save config: \(error.localizedDescription)"
return (false, errorMessage)
}
@@ -4,9 +4,26 @@ import Observation
import OSLog
import SwiftUI
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
// MARK: - Swift 6 AnyCodable Bridging Helpers
// Bridge between ClawdisProtocol.AnyCodable and the local module to avoid
// Swift 6 strict concurrency type conflicts.
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable {
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data)
{
return decoded
}
return AnyCodable(value.value)
}
private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? {
value.map(bridgeToLocal)
}
@MainActor
@Observable
@@ -45,7 +62,7 @@ final class OnboardingWizardModel {
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardStart,
params: params)
self.applyStartResult(res)
applyStartResult(res)
} catch {
self.status = "error"
self.errorMessage = error.localizedDescription
@@ -69,7 +86,7 @@ final class OnboardingWizardModel {
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardNext,
params: params)
self.applyNextResult(res)
applyNextResult(res)
} catch {
self.status = "error"
self.errorMessage = error.localizedDescription
@@ -83,7 +100,7 @@ final class OnboardingWizardModel {
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardCancel,
params: ["sessionId": AnyCodable(sessionId)])
self.applyStatusResult(res)
applyStatusResult(res)
} catch {
self.status = "error"
self.errorMessage = error.localizedDescription
@@ -105,8 +122,7 @@ 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
}
}
@@ -145,7 +161,8 @@ struct OnboardingWizardStepView: View {
let initialMulti = Set(
options.filter { option in
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
}.map(\.index))
}.map { $0.index }
)
_textValue = State(initialValue: initialText)
_confirmValue = State(initialValue: initialConfirm)
@@ -166,18 +183,18 @@ struct OnboardingWizardStepView: View {
.fixedSize(horizontal: false, vertical: true)
}
switch wizardStepType(self.step) {
switch wizardStepType(step) {
case "note":
EmptyView()
case "text":
self.textField
textField
case "confirm":
Toggle("", isOn: self.$confirmValue)
Toggle("", isOn: $confirmValue)
.toggleStyle(.switch)
case "select":
self.selectOptions
selectOptions
case "multiselect":
self.multiselectOptions
multiselectOptions
case "progress":
ProgressView()
.controlSize(.small)
@@ -188,25 +205,25 @@ struct OnboardingWizardStepView: View {
.foregroundStyle(.secondary)
}
Button(action: self.submit) {
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
Button(action: submit) {
Text(wizardStepType(step) == "action" ? "Run" : "Continue")
.frame(minWidth: 120)
}
.buttonStyle(.borderedProminent)
.disabled(self.isSubmitting || self.isBlocked)
.disabled(isSubmitting || isBlocked)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var textField: some View {
let isSensitive = self.step.sensitive == true
let isSensitive = step.sensitive == true
if isSensitive {
SecureField(self.step.placeholder ?? "", text: self.$textValue)
SecureField(step.placeholder ?? "", text: $textValue)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 360)
} else {
TextField(self.step.placeholder ?? "", text: self.$textValue)
TextField(step.placeholder ?? "", text: $textValue)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 360)
}
@@ -214,21 +231,33 @@ struct OnboardingWizardStepView: View {
private var selectOptions: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.optionItems) { item in
WizardOptionRow(
item: item,
isSelected: self.selectedIndex == item.index,
onSelect: {
self.selectedIndex = item.index
})
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)
}
}
}
private var multiselectOptions: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.optionItems) { item in
Toggle(isOn: self.bindingForOption(item)) {
ForEach(optionItems) { item in
Toggle(isOn: bindingForOption(item)) {
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
if let hint = item.option.hint, !hint.isEmpty {
@@ -244,88 +273,56 @@ struct OnboardingWizardStepView: View {
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
Binding(get: {
self.selectedIndices.contains(item.index)
selectedIndices.contains(item.index)
}, set: { newValue in
if newValue {
self.selectedIndices.insert(item.index)
selectedIndices.insert(item.index)
} else {
self.selectedIndices.remove(item.index)
selectedIndices.remove(item.index)
}
})
}
private var isBlocked: Bool {
let type = wizardStepType(step)
if type == "select" { return self.optionItems.isEmpty }
if type == "multiselect" { return self.optionItems.isEmpty }
if type == "select" { return optionItems.isEmpty }
if type == "multiselect" { return optionItems.isEmpty }
return false
}
private func submit() {
switch wizardStepType(self.step) {
switch wizardStepType(step) {
case "note", "progress":
self.onSubmit(nil)
onSubmit(nil)
case "text":
self.onSubmit(AnyCodable(self.textValue))
onSubmit(AnyCodable(textValue))
case "confirm":
self.onSubmit(AnyCodable(self.confirmValue))
onSubmit(AnyCodable(confirmValue))
case "select":
guard self.optionItems.indices.contains(self.selectedIndex) else {
self.onSubmit(nil)
guard optionItems.indices.contains(selectedIndex) else {
onSubmit(nil)
return
}
let option = self.optionItems[self.selectedIndex].option
self.onSubmit(self.gatewayValue(option.value, fallback: option.label))
let option = optionItems[selectedIndex].option
onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect":
let values = self.optionItems
.filter { self.selectedIndices.contains($0.index) }
.map { self.gatewayValue($0.option.value, fallback: $0.option.label) }
self.onSubmit(AnyCodable(values))
let values = optionItems
.filter { selectedIndices.contains($0.index) }
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
onSubmit(AnyCodable(values))
case "action":
self.onSubmit(AnyCodable(true))
onSubmit(AnyCodable(true))
default:
self.onSubmit(nil)
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 { 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)
}
var id: Int { index }
}
private struct WizardOption {
@@ -362,15 +359,15 @@ private func wizardStepType(_ step: WizardStep) -> String {
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
switch value?.value {
case let string as String:
string
return string
case let int as Int:
String(int)
return String(int)
case let double as Double:
String(double)
return String(double)
case let bool as Bool:
bool ? "true" : "false"
return bool ? "true" : "false"
default:
""
return ""
}
}
@@ -381,44 +378,44 @@ private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
switch value?.value {
case let bool as Bool:
bool
return bool
case let string as String:
string.lowercased() == "true"
return string.lowercased() == "true"
default:
false
return false
}
}
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
switch value?.value {
case let arr as [ProtocolAnyCodable]:
arr
return arr
case let arr as [Any]:
arr.map { ProtocolAnyCodable($0) }
return arr.map { ProtocolAnyCodable($0) }
default:
[]
return []
}
}
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) {
case let (l as String, r as String):
l == r
return l == r
case let (l as Int, r as Int):
l == r
return l == r
case let (l as Double, r as Double):
l == r
return l == r
case let (l as Bool, r as Bool):
l == r
return l == r
case let (l as String, r as Int):
l == String(r)
return l == String(r)
case let (l as Int, r as String):
String(l) == r
return String(l) == r
case let (l as String, r as Double):
l == String(r)
return l == String(r)
case let (l as Double, r as String):
String(l) == r
return String(l) == r
default:
false
return false
}
}
@@ -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
}
@@ -140,12 +140,12 @@ enum PermissionManager {
private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorized:
case .authorizedAlways, .authorizedWhenInUse:
return true
case .notDetermined:
guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways || updated == .authorized
return updated == .authorizedAlways || updated == .authorizedWhenInUse
case .denied, .restricted:
if interactive {
LocationPermissionHelper.openSettings()
@@ -198,10 +198,9 @@ enum PermissionManager {
case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
case .location:
let status = CLLocationManager.authorizationStatus()
results[cap] = status == .authorizedAlways || status == .authorized
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse
}
}
return results
@@ -269,7 +268,7 @@ enum LocationPermissionHelper {
}
@MainActor
final class LocationPermissionRequester: NSObject {
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
static let shared = LocationPermissionRequester()
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
@@ -290,16 +289,17 @@ final class LocationPermissionRequester: NSObject {
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: manager.authorizationStatus)
// nonisolated for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
}
}
}
@MainActor
extension LocationPermissionRequester: @preconcurrency CLLocationManagerDelegate {}
enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
@@ -174,13 +174,12 @@ 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 {
@@ -199,9 +198,7 @@ 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)")
}
}
@@ -285,7 +285,8 @@ struct TailscaleIntegrationSection: View {
requireCredentialsForServe: self.requireCredentialsForServe,
password: trimmedPassword,
connectionMode: self.connectionMode,
isPaused: self.isPaused)
isPaused: self.isPaused
)
if !success, let errorMessage {
self.statusMessage = errorMessage
@@ -306,8 +307,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] ?? [:]
@@ -348,7 +349,7 @@ struct TailscaleIntegrationSection: View {
do {
try await ConfigStore.save(root)
return (true, nil)
} catch {
} catch let error {
return (false, error.localizedDescription)
}
}
@@ -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)
}
.onHover { self.hoveringWindow = $0 }
.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 }
}
.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 = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
let pulse = 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: self.phase, level: self.level, time: t, accent: self.accent)
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent)
if self.phase == .thinking {
if phase == .thinking {
TalkOrbitArcs(time: t)
}
}
@@ -186,12 +186,11 @@ private struct TalkWaveRings: View {
var body: some View {
ZStack {
ForEach(0..<3, id: \.self) { idx in
let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6
let speed = phase == .speaking ? 1.4 : phase == .listening ? 0.9 : 0.6
let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1)
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
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
Circle()
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
.scaleEffect(scale)
@@ -209,11 +208,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(self.time * 42))
.rotationEffect(.degrees(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(-self.time * 35))
.rotationEffect(.degrees(-time * 35))
}
.scaleEffect(1.08)
}
@@ -213,7 +213,7 @@ final class WorkActivityStore {
meta: String?,
args: [String: AnyCodable]?) -> String
{
let wrappedArgs = self.wrapToolArgs(args)
let wrappedArgs = 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 { self.unwrapJSONValue($0.value) }
let converted: [String: Any] = args.mapValues { unwrapJSONValue($0.value) }
return ClawdisKit.AnyCodable(converted)
}
private static func unwrapJSONValue(_ value: Any) -> Any {
if let dict = value as? [String: AnyCodable] {
return dict.mapValues { self.unwrapJSONValue($0.value) }
return dict.mapValues { unwrapJSONValue($0.value) }
}
if let array = value as? [AnyCodable] {
return array.map { self.unwrapJSONValue($0.value) }
return array.map { unwrapJSONValue($0.value) }
}
if let dict = value as? [String: Any] {
return dict.mapValues { self.unwrapJSONValue($0) }
return dict.mapValues { unwrapJSONValue($0) }
}
if let array = value as? [Any] {
return array.map { self.unwrapJSONValue($0) }
return array.map { unwrapJSONValue($0) }
}
return value
}
@@ -10,7 +10,7 @@ public enum ErrorCode: String, Codable {
case unavailable = "UNAVAILABLE"
}
public struct ConnectParams: Codable, Sendable {
public struct ConnectParams: Codable {
public let minprotocol: Int
public let maxprotocol: Int
public let client: [String: AnyCodable]
@@ -47,7 +47,7 @@ public struct ConnectParams: Codable, Sendable {
}
}
public struct HelloOk: Codable, Sendable {
public struct HelloOk: Codable {
public let type: String
public let _protocol: Int
public let server: [String: AnyCodable]
@@ -84,7 +84,7 @@ public struct HelloOk: Codable, Sendable {
}
}
public struct RequestFrame: Codable, Sendable {
public struct RequestFrame: Codable {
public let type: String
public let id: String
public let method: String
@@ -109,7 +109,7 @@ public struct RequestFrame: Codable, Sendable {
}
}
public struct ResponseFrame: Codable, Sendable {
public struct ResponseFrame: Codable {
public let type: String
public let id: String
public let ok: Bool
@@ -138,7 +138,7 @@ public struct ResponseFrame: Codable, Sendable {
}
}
public struct EventFrame: Codable, Sendable {
public struct EventFrame: Codable {
public let type: String
public let event: String
public let payload: AnyCodable?
@@ -167,7 +167,7 @@ public struct EventFrame: Codable, Sendable {
}
}
public struct PresenceEntry: Codable, Sendable {
public struct PresenceEntry: Codable {
public let host: String?
public let ip: String?
public let version: String?
@@ -228,7 +228,7 @@ public struct PresenceEntry: Codable, Sendable {
}
}
public struct StateVersion: Codable, Sendable {
public struct StateVersion: Codable {
public let presence: Int
public let health: Int
@@ -245,7 +245,7 @@ public struct StateVersion: Codable, Sendable {
}
}
public struct Snapshot: Codable, Sendable {
public struct Snapshot: Codable {
public let presence: [PresenceEntry]
public let health: AnyCodable
public let stateversion: StateVersion
@@ -278,7 +278,7 @@ public struct Snapshot: Codable, Sendable {
}
}
public struct ErrorShape: Codable, Sendable {
public struct ErrorShape: Codable {
public let code: String
public let message: String
public let details: AnyCodable?
@@ -307,7 +307,7 @@ public struct ErrorShape: Codable, Sendable {
}
}
public struct AgentEvent: Codable, Sendable {
public struct AgentEvent: Codable {
public let runid: String
public let seq: Int
public let stream: String
@@ -336,7 +336,7 @@ public struct AgentEvent: Codable, Sendable {
}
}
public struct SendParams: Codable, Sendable {
public struct SendParams: Codable {
public let to: String
public let message: String
public let mediaurl: String?
@@ -369,7 +369,7 @@ public struct SendParams: Codable, Sendable {
}
}
public struct AgentParams: Codable, Sendable {
public struct AgentParams: Codable {
public let message: String
public let to: String?
public let sessionid: String?
@@ -422,7 +422,7 @@ public struct AgentParams: Codable, Sendable {
}
}
public struct AgentWaitParams: Codable, Sendable {
public struct AgentWaitParams: Codable {
public let runid: String
public let afterms: Int?
public let timeoutms: Int?
@@ -443,7 +443,7 @@ public struct AgentWaitParams: Codable, Sendable {
}
}
public struct WakeParams: Codable, Sendable {
public struct WakeParams: Codable {
public let mode: AnyCodable
public let text: String
@@ -460,7 +460,7 @@ public struct WakeParams: Codable, Sendable {
}
}
public struct NodePairRequestParams: Codable, Sendable {
public struct NodePairRequestParams: Codable {
public let nodeid: String
public let displayname: String?
public let platform: String?
@@ -509,10 +509,10 @@ public struct NodePairRequestParams: Codable, Sendable {
}
}
public struct NodePairListParams: Codable, Sendable {
public struct NodePairListParams: Codable {
}
public struct NodePairApproveParams: Codable, Sendable {
public struct NodePairApproveParams: Codable {
public let requestid: String
public init(
@@ -525,7 +525,7 @@ public struct NodePairApproveParams: Codable, Sendable {
}
}
public struct NodePairRejectParams: Codable, Sendable {
public struct NodePairRejectParams: Codable {
public let requestid: String
public init(
@@ -538,7 +538,7 @@ public struct NodePairRejectParams: Codable, Sendable {
}
}
public struct NodePairVerifyParams: Codable, Sendable {
public struct NodePairVerifyParams: Codable {
public let nodeid: String
public let token: String
@@ -555,7 +555,7 @@ public struct NodePairVerifyParams: Codable, Sendable {
}
}
public struct NodeRenameParams: Codable, Sendable {
public struct NodeRenameParams: Codable {
public let nodeid: String
public let displayname: String
@@ -572,10 +572,10 @@ public struct NodeRenameParams: Codable, Sendable {
}
}
public struct NodeListParams: Codable, Sendable {
public struct NodeListParams: Codable {
}
public struct NodeDescribeParams: Codable, Sendable {
public struct NodeDescribeParams: Codable {
public let nodeid: String
public init(
@@ -588,7 +588,7 @@ public struct NodeDescribeParams: Codable, Sendable {
}
}
public struct NodeInvokeParams: Codable, Sendable {
public struct NodeInvokeParams: Codable {
public let nodeid: String
public let command: String
public let params: AnyCodable?
@@ -617,7 +617,7 @@ public struct NodeInvokeParams: Codable, Sendable {
}
}
public struct SessionsListParams: Codable, Sendable {
public struct SessionsListParams: Codable {
public let limit: Int?
public let activeminutes: Int?
public let includeglobal: Bool?
@@ -642,7 +642,7 @@ public struct SessionsListParams: Codable, Sendable {
}
}
public struct SessionsPatchParams: Codable, Sendable {
public struct SessionsPatchParams: Codable {
public let key: String
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
@@ -675,7 +675,7 @@ public struct SessionsPatchParams: Codable, Sendable {
}
}
public struct SessionsResetParams: Codable, Sendable {
public struct SessionsResetParams: Codable {
public let key: String
public init(
@@ -688,7 +688,7 @@ public struct SessionsResetParams: Codable, Sendable {
}
}
public struct SessionsDeleteParams: Codable, Sendable {
public struct SessionsDeleteParams: Codable {
public let key: String
public let deletetranscript: Bool?
@@ -705,7 +705,7 @@ public struct SessionsDeleteParams: Codable, Sendable {
}
}
public struct SessionsCompactParams: Codable, Sendable {
public struct SessionsCompactParams: Codable {
public let key: String
public let maxlines: Int?
@@ -722,10 +722,10 @@ public struct SessionsCompactParams: Codable, Sendable {
}
}
public struct ConfigGetParams: Codable, Sendable {
public struct ConfigGetParams: Codable {
}
public struct ConfigSetParams: Codable, Sendable {
public struct ConfigSetParams: Codable {
public let raw: String
public init(
@@ -738,10 +738,10 @@ public struct ConfigSetParams: Codable, Sendable {
}
}
public struct ConfigSchemaParams: Codable, Sendable {
public struct ConfigSchemaParams: Codable {
}
public struct ConfigSchemaResponse: Codable, Sendable {
public struct ConfigSchemaResponse: Codable {
public let schema: AnyCodable
public let uihints: [String: AnyCodable]
public let version: String
@@ -766,7 +766,7 @@ public struct ConfigSchemaResponse: Codable, Sendable {
}
}
public struct WizardStartParams: Codable, Sendable {
public struct WizardStartParams: Codable {
public let mode: AnyCodable?
public let workspace: String?
@@ -783,7 +783,7 @@ public struct WizardStartParams: Codable, Sendable {
}
}
public struct WizardNextParams: Codable, Sendable {
public struct WizardNextParams: Codable {
public let sessionid: String
public let answer: [String: AnyCodable]?
@@ -800,7 +800,7 @@ public struct WizardNextParams: Codable, Sendable {
}
}
public struct WizardCancelParams: Codable, Sendable {
public struct WizardCancelParams: Codable {
public let sessionid: String
public init(
@@ -813,7 +813,7 @@ public struct WizardCancelParams: Codable, Sendable {
}
}
public struct WizardStatusParams: Codable, Sendable {
public struct WizardStatusParams: Codable {
public let sessionid: String
public init(
@@ -826,7 +826,7 @@ public struct WizardStatusParams: Codable, Sendable {
}
}
public struct WizardStep: Codable, Sendable {
public struct WizardStep: Codable {
public let id: String
public let type: AnyCodable
public let title: String?
@@ -942,7 +942,7 @@ public struct WizardStatusResult: Codable, Sendable {
}
}
public struct TalkModeParams: Codable, Sendable {
public struct TalkModeParams: Codable {
public let enabled: Bool
public let phase: String?
@@ -959,7 +959,7 @@ public struct TalkModeParams: Codable, Sendable {
}
}
public struct ProvidersStatusParams: Codable, Sendable {
public struct ProvidersStatusParams: Codable {
public let probe: Bool?
public let timeoutms: Int?
@@ -976,7 +976,7 @@ public struct ProvidersStatusParams: Codable, Sendable {
}
}
public struct WebLoginStartParams: Codable, Sendable {
public struct WebLoginStartParams: Codable {
public let force: Bool?
public let timeoutms: Int?
public let verbose: Bool?
@@ -997,7 +997,7 @@ public struct WebLoginStartParams: Codable, Sendable {
}
}
public struct WebLoginWaitParams: Codable, Sendable {
public struct WebLoginWaitParams: Codable {
public let timeoutms: Int?
public init(
@@ -1010,7 +1010,7 @@ public struct WebLoginWaitParams: Codable, Sendable {
}
}
public struct ModelChoice: Codable, Sendable {
public struct ModelChoice: Codable {
public let id: String
public let name: String
public let provider: String
@@ -1039,10 +1039,10 @@ public struct ModelChoice: Codable, Sendable {
}
}
public struct ModelsListParams: Codable, Sendable {
public struct ModelsListParams: Codable {
}
public struct ModelsListResult: Codable, Sendable {
public struct ModelsListResult: Codable {
public let models: [ModelChoice]
public init(
@@ -1055,10 +1055,10 @@ public struct ModelsListResult: Codable, Sendable {
}
}
public struct SkillsStatusParams: Codable, Sendable {
public struct SkillsStatusParams: Codable {
}
public struct SkillsInstallParams: Codable, Sendable {
public struct SkillsInstallParams: Codable {
public let name: String
public let installid: String
public let timeoutms: Int?
@@ -1079,7 +1079,7 @@ public struct SkillsInstallParams: Codable, Sendable {
}
}
public struct SkillsUpdateParams: Codable, Sendable {
public struct SkillsUpdateParams: Codable {
public let skillkey: String
public let enabled: Bool?
public let apikey: String?
@@ -1104,7 +1104,7 @@ public struct SkillsUpdateParams: Codable, Sendable {
}
}
public struct CronJob: Codable, Sendable {
public struct CronJob: Codable {
public let id: String
public let name: String
public let description: String?
@@ -1161,7 +1161,7 @@ public struct CronJob: Codable, Sendable {
}
}
public struct CronListParams: Codable, Sendable {
public struct CronListParams: Codable {
public let includedisabled: Bool?
public init(
@@ -1174,10 +1174,10 @@ public struct CronListParams: Codable, Sendable {
}
}
public struct CronStatusParams: Codable, Sendable {
public struct CronStatusParams: Codable {
}
public struct CronAddParams: Codable, Sendable {
public struct CronAddParams: Codable {
public let name: String
public let description: String?
public let enabled: Bool?
@@ -1218,7 +1218,7 @@ public struct CronAddParams: Codable, Sendable {
}
}
public struct CronUpdateParams: Codable, Sendable {
public struct CronUpdateParams: Codable {
public let id: String
public let patch: [String: AnyCodable]
@@ -1235,7 +1235,7 @@ public struct CronUpdateParams: Codable, Sendable {
}
}
public struct CronRemoveParams: Codable, Sendable {
public struct CronRemoveParams: Codable {
public let id: String
public init(
@@ -1248,7 +1248,7 @@ public struct CronRemoveParams: Codable, Sendable {
}
}
public struct CronRunParams: Codable, Sendable {
public struct CronRunParams: Codable {
public let id: String
public let mode: AnyCodable?
@@ -1265,7 +1265,7 @@ public struct CronRunParams: Codable, Sendable {
}
}
public struct CronRunsParams: Codable, Sendable {
public struct CronRunsParams: Codable {
public let id: String
public let limit: Int?
@@ -1282,7 +1282,7 @@ public struct CronRunsParams: Codable, Sendable {
}
}
public struct CronRunLogEntry: Codable, Sendable {
public struct CronRunLogEntry: Codable {
public let ts: Int
public let jobid: String
public let action: String
@@ -1327,7 +1327,7 @@ public struct CronRunLogEntry: Codable, Sendable {
}
}
public struct ChatHistoryParams: Codable, Sendable {
public struct ChatHistoryParams: Codable {
public let sessionkey: String
public let limit: Int?
@@ -1344,7 +1344,7 @@ public struct ChatHistoryParams: Codable, Sendable {
}
}
public struct ChatSendParams: Codable, Sendable {
public struct ChatSendParams: Codable {
public let sessionkey: String
public let message: String
public let thinking: String?
@@ -1381,7 +1381,7 @@ public struct ChatSendParams: Codable, Sendable {
}
}
public struct ChatAbortParams: Codable, Sendable {
public struct ChatAbortParams: Codable {
public let sessionkey: String
public let runid: String
@@ -1398,7 +1398,7 @@ public struct ChatAbortParams: Codable, Sendable {
}
}
public struct ChatEvent: Codable, Sendable {
public struct ChatEvent: Codable {
public let runid: String
public let sessionkey: String
public let seq: Int
@@ -1439,7 +1439,7 @@ public struct ChatEvent: Codable, Sendable {
}
}
public struct TickEvent: Codable, Sendable {
public struct TickEvent: Codable {
public let ts: Int
public init(
@@ -1452,7 +1452,7 @@ public struct TickEvent: Codable, Sendable {
}
}
public struct ShutdownEvent: Codable, Sendable {
public struct ShutdownEvent: Codable {
public let reason: String
public let restartexpectedms: Int?
-5
View File
@@ -621,10 +621,6 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
resetTriggers: ["/new", "/reset"],
store: "~/.clawdis/sessions/sessions.json",
// mainKey is ignored; primary key is fixed to "main"
agentToAgent: {
// Max ping-pong reply turns between requester/target (0–5).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
{ action: "deny", match: { surface: "discord", chatType: "group" } }
@@ -636,7 +632,6 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
```
Fields:
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
-148
View File
@@ -1,148 +0,0 @@
# 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.
+4 -7
View File
@@ -76,14 +76,11 @@ Behavior:
- If the run fails: `{ runId, status: "error", error }`.
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
- Agent-to-agent message context is injected for the primary run.
- After the primary run completes, Clawdis runs a **reply-back loop**:
- Round 2+ alternates between requester and target agents.
- Reply exactly `REPLY_SKIP` to stop the ping‑pong.
- Max turns is `session.agentToAgent.maxPingPongTurns` (0–5, default 5).
- Once the loop ends, Clawdis runs the **agent‑to‑agent announce step** (target agent only):
- Reply exactly `ANNOUNCE_SKIP` to stay silent.
- After the primary run completes, Clawdis starts an **agent-to-agent post step**:
- The agent can reply with the announcement to post to the target session.
- To stay silent, reply exactly `ANNOUNCE_SKIP`.
- Any other reply is sent to the target channel.
- Announce step includes the original request + round‑1 reply + latest ping‑pong reply.
- The post step includes the original request and round‑1 reply in context.
## Provider Field
- For groups, `provider` is the `surface` recorded on the session entry.
+1 -2
View File
@@ -119,8 +119,7 @@ Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
- `sessions_send` always runs a follow‑up **agent‑to‑agent post step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
### `discord`
Send Discord reactions, stickers, or polls.
+1 -1
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, Sendable {`);
lines.push(`public struct ${name}: Codable {`);
if (Object.keys(props).length === 0) {
lines.push("}\n");
return lines.join("\n");
+18 -149
View File
@@ -7,11 +7,7 @@ vi.mock("../gateway/call.js", () => ({
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: {
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
session: { mainKey: "main", scope: "per-sender" },
}),
resolveGatewayPort: () => 18789,
}));
@@ -131,28 +127,18 @@ describe("sessions tools", () => {
let agentCallCount = 0;
let _historyCallCount = 0;
let sendCallCount = 0;
let lastWaitedRunId: string | undefined;
const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
let waitRunId: string | undefined;
let nextHistoryIsWaitReply = false;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as
| { message?: string; sessionKey?: string }
| undefined;
const message = params?.message ?? "";
let reply = "REPLY_SKIP";
if (message === "ping" || message === "wait") {
reply = "done";
} else if (message === "Agent-to-agent announce step.") {
reply = "ANNOUNCE_SKIP";
} else if (params?.sessionKey === requesterKey) {
reply = "pong";
const params = request.params as { message?: string } | undefined;
if (params?.message === "wait") {
waitRunId = runId;
}
replyByRunId.set(runId, reply);
return {
runId,
status: "accepted",
@@ -161,13 +147,15 @@ describe("sessions tools", () => {
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
lastWaitedRunId = params?.runId;
if (params?.runId && params.runId === waitRunId) {
nextHistoryIsWaitReply = true;
}
return { runId: params?.runId ?? "run-1", status: "ok" };
}
if (request.method === "chat.history") {
_historyCallCount += 1;
const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP";
nextHistoryIsWaitReply = false;
return {
messages: [
{
@@ -190,10 +178,9 @@ describe("sessions tools", () => {
return {};
});
const tool = createClawdisTools({
agentSessionKey: requesterKey,
agentSurface: "discord",
}).find((candidate) => candidate.name === "sessions_send");
const tool = createClawdisTools().find(
(candidate) => candidate.name === "sessions_send",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
@@ -204,7 +191,6 @@ describe("sessions tools", () => {
});
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const waitPromise = tool.execute("call6", {
sessionKey: "main",
@@ -218,14 +204,13 @@ describe("sessions tools", () => {
});
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait");
const historyOnlyCalls = calls.filter(
(call) => call.method === "chat.history",
);
expect(agentCalls).toHaveLength(8);
expect(agentCalls).toHaveLength(4);
for (const call of agentCalls) {
expect(call.params).toMatchObject({ lane: "nested" });
}
@@ -246,21 +231,11 @@ describe("sessions tools", () => {
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
)?.extraSystemPrompt?.includes("Agent-to-agent post step"),
),
).toBe(true);
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
),
).toBe(true);
expect(waitCalls).toHaveLength(8);
expect(historyOnlyCalls).toHaveLength(8);
expect(waitCalls).toHaveLength(3);
expect(historyOnlyCalls).toHaveLength(3);
expect(
waitCalls.some(
(call) =>
@@ -269,110 +244,4 @@ describe("sessions tools", () => {
).toBe(true);
expect(sendCallCount).toBe(0);
});
it("sessions_send runs ping-pong then announces", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let lastWaitedRunId: string | undefined;
const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
const targetKey = "discord:group:target";
let sendParams: { to?: string; provider?: string; message?: string } = {};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as
| {
message?: string;
sessionKey?: string;
extraSystemPrompt?: string;
}
| undefined;
let reply = "initial";
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
}
if (
params?.extraSystemPrompt?.includes("Agent-to-agent announce step")
) {
reply = "announce now";
}
replyByRunId.set(runId, reply);
return {
runId,
status: "accepted",
acceptedAt: 2000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
lastWaitedRunId = params?.runId;
return { runId: params?.runId ?? "run-1", status: "ok" };
}
if (request.method === "chat.history") {
const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return {
messages: [
{
role: "assistant",
content: [{ type: "text", text }],
timestamp: 20,
},
],
};
}
if (request.method === "send") {
const params = request.params as
| { to?: string; provider?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
provider: params?.provider,
message: params?.message,
};
return { messageId: "m-announce" };
}
return {};
});
const tool = createClawdisTools({
agentSessionKey: requesterKey,
agentSurface: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
const waited = await tool.execute("call7", {
sessionKey: targetKey,
message: "ping",
timeoutSeconds: 1,
});
expect(waited.details).toMatchObject({
status: "ok",
reply: "initial",
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const replySteps = calls.filter(
(call) =>
call.method === "agent" &&
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
);
expect(replySteps).toHaveLength(2);
expect(sendParams).toMatchObject({
to: "channel:target",
provider: "discord",
message: "announce now",
});
});
});
+83 -218
View File
@@ -2713,9 +2713,6 @@ function createSessionsHistoryTool(): AnyAgentTool {
}
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
const DEFAULT_PING_PONG_TURNS = 5;
const MAX_PING_PONG_TURNS = 5;
type AnnounceTarget = {
channel: string;
@@ -2750,76 +2747,40 @@ function buildAgentToAgentMessageContext(params: {
const lines = [
"Agent-to-agent message context:",
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
? `Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
? `Requester surface: ${params.requesterSurface}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
`Target session: ${params.targetSessionKey}.`,
].filter(Boolean);
return lines.join("\n");
}
function buildAgentToAgentReplyContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
targetSessionKey: string;
targetChannel?: string;
currentRole: "requester" | "target";
turn: number;
maxTurns: number;
}) {
const currentLabel =
params.currentRole === "requester"
? "Agent 1 (requester)"
: "Agent 2 (target)";
const lines = [
"Agent-to-agent reply step:",
`Current agent: ${currentLabel}.`,
`Turn ${params.turn} of ${params.maxTurns}.`,
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
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");
}
function buildAgentToAgentAnnounceContext(params: {
function buildAgentToAgentPostContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
targetSessionKey: string;
targetChannel?: string;
originalMessage: string;
roundOneReply?: string;
latestReply?: string;
}) {
const lines = [
"Agent-to-agent announce step:",
"Agent-to-agent post step:",
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
? `Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
? `Requester surface: ${params.requesterSurface}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
`Target session: ${params.targetSessionKey}.`,
params.targetChannel
? `Agent 2 (target) surface: ${params.targetChannel}.`
? `Target surface: ${params.targetChannel}.`
: undefined,
`Original request: ${params.originalMessage}`,
params.roundOneReply
? `Round 1 reply: ${params.roundOneReply}`
: "Round 1 reply: (not available).",
params.latestReply
? `Latest reply: ${params.latestReply}`
: "Latest reply: (not available).",
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
"Any other reply will be posted to the target channel.",
"After this reply, the agent-to-agent conversation is over.",
@@ -2831,18 +2792,6 @@ function isAnnounceSkip(text?: string) {
return (text ?? "").trim() === ANNOUNCE_SKIP_TOKEN;
}
function isReplySkip(text?: string) {
return (text ?? "").trim() === REPLY_SKIP_TOKEN;
}
function resolvePingPongTurns(cfg?: ClawdisConfig) {
const raw = cfg?.session?.agentToAgent?.maxPingPongTurns;
const fallback = DEFAULT_PING_PONG_TURNS;
if (typeof raw !== "number" || !Number.isFinite(raw)) return fallback;
const rounded = Math.floor(raw);
return Math.max(0, Math.min(MAX_PING_PONG_TURNS, rounded));
}
function createSessionsSendTool(opts?: {
agentSessionKey?: string;
agentSurface?: string;
@@ -2892,9 +2841,6 @@ function createSessionsSendTool(opts?: {
lane: "nested",
extraSystemPrompt: agentMessageContext,
};
const requesterSessionKey = opts?.agentSessionKey;
const requesterSurface = opts?.agentSurface;
const maxPingPongTurns = resolvePingPongTurns(cfg);
const resolveAnnounceTarget =
async (): Promise<AnnounceTarget | null> => {
@@ -2926,160 +2872,83 @@ function createSessionsSendTool(opts?: {
return null;
};
const readLatestAssistantReply = async (
sessionKeyToRead: string,
): Promise<string | undefined> => {
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: sessionKeyToRead, limit: 50 },
})) as { messages?: unknown[] };
const filtered = stripToolMessages(
Array.isArray(history?.messages) ? history.messages : [],
);
const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
return last ? extractAssistantText(last) : undefined;
};
const runAgentStep = async (params: {
sessionKey: string;
message: string;
extraSystemPrompt: string;
timeoutMs: number;
}): Promise<string | undefined> => {
const stepIdem = crypto.randomUUID();
const response = (await callGateway({
method: "agent",
params: {
message: params.message,
sessionKey: params.sessionKey,
idempotencyKey: stepIdem,
deliver: false,
lane: "nested",
extraSystemPrompt: params.extraSystemPrompt,
},
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const stepRunId =
typeof response?.runId === "string" && response.runId
? response.runId
: stepIdem;
const stepAcceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
const stepWaitMs = Math.min(params.timeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: stepRunId,
afterMs: stepAcceptedAt,
timeoutMs: stepWaitMs,
},
timeoutMs: stepWaitMs + 2000,
})) as { status?: string };
if (wait?.status !== "ok") return undefined;
return readLatestAssistantReply(params.sessionKey);
};
const runAgentToAgentFlow = async (
roundOneReply?: string,
runInfo?: { runId: string; acceptedAt?: number },
) => {
const runAgentToAgentPost = async (roundOneReply?: string) => {
const announceTarget = await resolveAnnounceTarget();
try {
let primaryReply = roundOneReply;
let latestReply = roundOneReply;
if (!primaryReply && runInfo?.runId) {
const waitMs = Math.min(announceTimeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: runInfo.runId,
afterMs: runInfo.acceptedAt,
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
})) as { status?: string };
if (wait?.status === "ok") {
primaryReply = await readLatestAssistantReply(resolvedKey);
latestReply = primaryReply;
}
}
if (!latestReply) return;
const announceTarget = await resolveAnnounceTarget();
const targetChannel = announceTarget?.channel ?? "unknown";
if (
maxPingPongTurns > 0 &&
requesterSessionKey &&
requesterSessionKey !== resolvedKey
) {
let currentSessionKey = requesterSessionKey;
let nextSessionKey = resolvedKey;
let incomingMessage = latestReply;
for (let turn = 1; turn <= maxPingPongTurns; turn += 1) {
const currentRole =
currentSessionKey === requesterSessionKey
? "requester"
: "target";
const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey,
requesterSurface,
targetSessionKey: displayKey,
targetChannel,
currentRole,
turn,
maxTurns: maxPingPongTurns,
});
const replyText = await runAgentStep({
sessionKey: currentSessionKey,
message: incomingMessage,
extraSystemPrompt: replyPrompt,
timeoutMs: announceTimeoutMs,
});
if (!replyText || isReplySkip(replyText)) {
break;
}
latestReply = replyText;
incomingMessage = replyText;
const swap = currentSessionKey;
currentSessionKey = nextSessionKey;
nextSessionKey = swap;
}
}
const announcePrompt = buildAgentToAgentAnnounceContext({
requesterSessionKey,
requesterSurface,
const postPrompt = buildAgentToAgentPostContext({
requesterSessionKey: opts?.agentSessionKey,
requesterSurface: opts?.agentSurface,
targetSessionKey: displayKey,
targetChannel,
targetChannel: announceTarget?.channel ?? "unknown",
originalMessage: message,
roundOneReply: primaryReply,
latestReply,
roundOneReply,
});
const announceReply = await runAgentStep({
sessionKey: resolvedKey,
message: "Agent-to-agent announce step.",
extraSystemPrompt: announcePrompt,
timeoutMs: announceTimeoutMs,
});
if (
announceTarget &&
announceReply &&
announceReply.trim() &&
!isAnnounceSkip(announceReply)
) {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: announceReply.trim(),
provider: announceTarget.channel,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
const postIdem = crypto.randomUUID();
const postResponse = (await callGateway({
method: "agent",
params: {
message: "Agent-to-agent post step.",
sessionKey: resolvedKey,
idempotencyKey: postIdem,
deliver: false,
lane: "nested",
extraSystemPrompt: postPrompt,
},
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const postRunId =
typeof postResponse?.runId === "string" && postResponse.runId
? postResponse.runId
: postIdem;
const postAcceptedAt =
typeof postResponse?.acceptedAt === "number"
? postResponse.acceptedAt
: undefined;
const postWaitMs = Math.min(announceTimeoutMs, 60_000);
const postWait = (await callGateway({
method: "agent.wait",
params: {
runId: postRunId,
afterMs: postAcceptedAt,
timeoutMs: postWaitMs,
},
timeoutMs: postWaitMs + 2000,
})) as { status?: string };
if (postWait?.status === "ok") {
const postHistory = (await callGateway({
method: "chat.history",
params: { sessionKey: resolvedKey, limit: 50 },
})) as { messages?: unknown[] };
const postFiltered = stripToolMessages(
Array.isArray(postHistory?.messages) ? postHistory.messages : [],
);
const postLast =
postFiltered.length > 0
? postFiltered[postFiltered.length - 1]
: undefined;
const postReply = postLast
? extractAssistantText(postLast)
: undefined;
if (
announceTarget &&
postReply &&
postReply.trim() &&
!isAnnounceSkip(postReply)
) {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: postReply.trim(),
provider: announceTarget.channel,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
}
}
} catch {
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
// Best-effort announce; ignore failures to avoid breaking the caller response.
}
};
@@ -3089,15 +2958,11 @@ function createSessionsSendTool(opts?: {
method: "agent",
params: sendParams,
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const acceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
})) as { runId?: string };
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
void runAgentToAgentFlow(undefined, { runId, acceptedAt });
void runAgentToAgentPost();
return jsonResult({
runId,
status: "accepted",
@@ -3203,7 +3068,7 @@ function createSessionsSendTool(opts?: {
const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const reply = last ? extractAssistantText(last) : undefined;
void runAgentToAgentFlow(reply ?? undefined);
void runAgentToAgentPost(reply ?? undefined);
return jsonResult({
runId,
+31 -1
View File
@@ -3,7 +3,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdisConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { resolveNodeManagerOptions } from "./onboard-helpers.js";
import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js";
function summarizeInstallFailure(message: string): string | undefined {
const cleaned = message
@@ -59,6 +59,13 @@ export async function setupSkills(
);
const blocked = report.skills.filter((s) => s.blockedByAllowlist);
const needsBrewPrompt =
process.platform !== "win32" &&
report.skills.some((skill) =>
skill.install.some((option) => option.kind === "brew"),
) &&
!(await detectBinary("brew"));
await prompter.note(
[
`Eligible: ${eligible.length}`,
@@ -74,6 +81,29 @@ export async function setupSkills(
});
if (!shouldConfigure) return cfg;
if (needsBrewPrompt) {
await prompter.note(
[
"Many skill dependencies are shipped via Homebrew.",
"Without brew, you'll need to build from source or download releases manually.",
].join("\n"),
"Homebrew recommended",
);
const showBrewInstall = await prompter.confirm({
message: "Show Homebrew install command?",
initialValue: true,
});
if (showBrewInstall) {
await prompter.note(
[
"Run:",
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
].join("\n"),
"Homebrew install",
);
}
}
const nodeManager = (await prompter.select({
message: "Preferred node manager for skill installs",
options: resolveNodeManagerOptions(),
-9
View File
@@ -44,10 +44,6 @@ export type SessionConfig = {
typingIntervalSeconds?: number;
mainKey?: string;
sendPolicy?: SessionSendPolicyConfig;
agentToAgent?: {
/** Max ping-pong turns between requester/target (0–5). Default: 5. */
maxPingPongTurns?: number;
};
};
export type LoggingConfig = {
@@ -898,11 +894,6 @@ const SessionSchema = z
.optional(),
})
.optional(),
agentToAgent: z
.object({
maxPingPongTurns: z.number().int().min(0).max(5).optional(),
})
.optional(),
})
.optional();
-3
View File
@@ -88,7 +88,6 @@ const FIELD_LABELS: Record<string, string> = {
"agent.model": "Default Model",
"ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"talk.apiKey": "Talk API Key",
"telegram.botToken": "Telegram Bot Token",
"discord.token": "Discord Bot Token",
@@ -107,8 +106,6 @@ const FIELD_HELP: Record<string, string> = {
'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs":
"Debounce window (ms) before applying config changes.",
"session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (0–5).",
};
const FIELD_PLACEHOLDERS: Record<string, string> = {