Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
990a778d00 | ||
|
|
56274eb76e | ||
|
|
d9987a65b2 | ||
|
|
b59c9c38c2 | ||
|
|
d32b77b330 |
No files matched your search
+3
-4
@@ -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
|
||||
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: 9db365b73c...b69e4e8dc0.
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in new issue
Block a user