Compare commits

..
Author SHA1 Message Date
Peter Steinberger cc235fc312 Docs: require permission to switch branches 2025-12-17 20:43:04 +01:00
Peter Steinberger 249f97d1ed tools: add blucli 2025-12-17 20:39:34 +01:00
Peter Steinberger 3e9310d6cd Agents: fix pi-tools typing 2025-12-17 20:38:52 +01:00
Peter Steinberger 9051c5891e Canvas: click progress + context-rich actions 2025-12-17 20:34:54 +01:00
Peter Steinberger 56d94e6974 Node pairing: avoid blocking main actor 2025-12-17 20:34:53 +01:00
Peter Steinberger e6a96bea47 fix(macos): improve canvas A2UI forwarding 2025-12-17 20:31:21 +01:00
Peter Steinberger cf82e37c36 Menu: reopen canvas without reload 2025-12-17 20:31:21 +01:00
Peter Steinberger 4fb3e0500a Canvas: fix A2UI click actions 2025-12-17 20:31:21 +01:00
Peter Steinberger 9c7d51429e macOS: auto-start gateway for Canvas actions 2025-12-17 20:31:21 +01:00
Peter Steinberger c1985443fd macOS: fix gateway strict-concurrency issues 2025-12-17 20:31:21 +01:00
Peter Steinberger 17a27fd312 macOS: fold agent control into GatewayConnection 2025-12-17 20:31:21 +01:00
Peter Steinberger 557ffdbe35 Discovery: wide-area bridge DNS-SD
# Conflicts:
#	apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift
#	src/cli/dns-cli.ts
2025-12-17 20:31:02 +01:00
Peter Steinberger e9bfe34850 chore(canvas): rebuild CanvasA2UI bundle 2025-12-17 19:15:19 +00:00
Peter Steinberger 1a4540d386 feat(macos): show Anthropic auth mode + OAuth connect 2025-12-17 19:15:19 +00:00
Peter Steinberger a0c4b1e061 test(web): avoid ENOTEMPTY cleanup race 2025-12-17 19:15:19 +00:00
Peter Steinberger e275ba8d2e chore(a2ui): ignore lit dist build output 2025-12-17 19:15:19 +00:00
Peter Steinberger db7eeee07b fix(macos): sync node pairing approvals 2025-12-17 19:15:19 +00:00
Peter Steinberger 84d5f24f5f chore(pi): add TODO for mime workaround 2025-12-17 19:15:19 +00:00
Peter Steinberger 42948b70e3 fix(pi): harden image read mime 2025-12-17 19:15:19 +00:00
Peter Steinberger 28d3bd03b2 chore(peekaboo): bump submodule 2025-12-17 19:15:19 +00:00
290 changed files with 4318 additions and 12665 deletions

No files matched your search

+3
View File
@@ -17,6 +17,9 @@ apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
# fastlane (iOS)
apps/ios/fastlane/README.md
apps/ios/fastlane/report.xml
+1
View File
@@ -41,6 +41,7 @@
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
@@ -1,7 +1,6 @@
package com.steipete.clawdis.node
import android.content.Context
import android.os.Build
import com.steipete.clawdis.node.bridge.BridgeDiscovery
import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.bridge.BridgePairingClient
@@ -183,8 +182,6 @@ class NodeRuntime(context: Context) {
token = null,
platform = "Android",
version = "dev",
deviceFamily = "Android",
modelIdentifier = Build.MODEL,
),
)
} else {
@@ -207,8 +204,6 @@ class NodeRuntime(context: Context) {
token = authToken,
platform = "Android",
version = "dev",
deviceFamily = "Android",
modelIdentifier = Build.MODEL,
),
)
}
@@ -0,0 +1,30 @@
package com.steipete.clawdis.node.bridge
object BonjourEscapes {
fun decode(input: String): String {
if (input.isEmpty()) return input
val out = StringBuilder(input.length)
var i = 0
while (i < input.length) {
if (input[i] == '\\' && i + 3 < input.length) {
val d0 = input[i + 1]
val d1 = input[i + 2]
val d2 = input[i + 3]
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
val value =
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
if (value in 0..0x10FFFF) {
out.appendCodePoint(value)
i += 4
continue
}
}
}
out.append(input[i])
i += 1
}
return out.toString()
}
}
@@ -1,10 +1,12 @@
package com.steipete.clawdis.node.bridge
import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import java.net.InetAddress
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Lookup
import org.xbill.DNS.PTRRecord
import org.xbill.DNS.SRVRecord
@@ -25,6 +28,7 @@ class BridgeDiscovery(
private val scope: CoroutineScope,
) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val serviceType = "_clawdis-bridge._tcp."
private val wideAreaDomain = "clawdis.internal."
@@ -100,8 +104,10 @@ class BridgeDiscovery(
val port = resolved.port
if (port <= 0) return
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
val id = stableId(resolved.serviceName, "local.")
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val id = stableId(serviceName, "local.")
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
publish()
}
@@ -133,13 +139,15 @@ class BridgeDiscovery(
}
private suspend fun refreshUnicast(domain: String) {
val resolver = createUnicastResolver()
val ptrName = "${serviceType}${domain}"
val ptrRecords = lookup(ptrName, Type.PTR).mapNotNull { it as? PTRRecord }
val ptrRecords = lookup(ptrName, Type.PTR, resolver).mapNotNull { it as? PTRRecord }
val next = LinkedHashMap<String, BridgeEndpoint>()
for (ptr in ptrRecords) {
val instanceFqdn = ptr.target.toString()
val srv = lookup(instanceFqdn, Type.SRV).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
val srv =
lookup(instanceFqdn, Type.SRV, resolver).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
val port = srv.port
if (port <= 0) continue
@@ -152,9 +160,9 @@ class BridgeDiscovery(
null
} ?: continue
val txt = lookup(instanceFqdn, Type.TXT).mapNotNull { it as? TXTRecord }
val instanceName = decodeInstanceName(instanceFqdn, domain)
val displayName = txtValue(txt, "displayName") ?: instanceName
val txt = lookup(instanceFqdn, Type.TXT, resolver).mapNotNull { it as? TXTRecord }
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
val id = stableId(instanceName, domain)
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
}
@@ -179,15 +187,41 @@ class BridgeDiscovery(
return raw.removeSuffix(".")
}
private fun lookup(name: String, type: Int): List<org.xbill.DNS.Record> {
private fun lookup(name: String, type: Int, resolver: org.xbill.DNS.Resolver?): List<org.xbill.DNS.Record> {
return try {
val out = Lookup(name, type).run() ?: return emptyList()
val lookup = Lookup(name, type)
if (resolver != null) {
lookup.setResolver(resolver)
lookup.setCache(null)
}
val out = lookup.run() ?: return emptyList()
out.toList()
} catch (_: Throwable) {
emptyList()
}
}
private fun createUnicastResolver(): org.xbill.DNS.Resolver? {
val cm = connectivity ?: return null
val net = cm.activeNetwork ?: return null
val dnsServers = cm.getLinkProperties(net)?.dnsServers ?: return null
val addrs =
dnsServers
.mapNotNull { it.hostAddress }
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
if (addrs.isEmpty()) return null
return try {
ExtendedResolver(addrs.toTypedArray()).apply {
setTimeout(Duration.ofMillis(1500))
}
} catch (_: Throwable) {
null
}
}
private fun txtValue(records: List<TXTRecord>, key: String): String? {
val prefix = "$key="
for (r in records) {
@@ -24,8 +24,6 @@ class BridgePairingClient {
val token: String?,
val platform: String?,
val version: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
)
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
@@ -57,8 +55,6 @@ class BridgePairingClient {
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
},
)
@@ -80,8 +76,6 @@ class BridgePairingClient {
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
},
)
@@ -39,8 +39,6 @@ class BridgeSession(
val token: String?,
val platform: String?,
val version: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
)
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
@@ -193,8 +191,6 @@ class BridgeSession(
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
},
)
@@ -0,0 +1,19 @@
package com.steipete.clawdis.node.bridge
import org.junit.Assert.assertEquals
import org.junit.Test
class BonjourEscapesTest {
@Test
fun decodeNoop() {
assertEquals("", BonjourEscapes.decode(""))
assertEquals("hello", BonjourEscapes.decode("hello"))
}
@Test
fun decodeDecodesDecimalEscapes() {
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
}
}
@@ -46,8 +46,6 @@ class BridgePairingClientTest {
token = "token-123",
platform = "Android",
version = "test",
deviceFamily = "Android",
modelIdentifier = "SM-X000",
),
)
assertTrue(res.ok)
@@ -93,8 +91,6 @@ class BridgePairingClientTest {
token = null,
platform = "Android",
version = "test",
deviceFamily = "Android",
modelIdentifier = "SM-X000",
),
)
assertTrue(res.ok)
@@ -102,3 +98,4 @@ class BridgePairingClientTest {
server.await()
}
}
@@ -1,5 +1,4 @@
import ClawdisKit
import Darwin
import Foundation
import Network
import Observation
@@ -0,0 +1,152 @@
import AppKit
import SwiftUI
@MainActor
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@State private var statusText: String?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode == .remote {
Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 10) {
Circle()
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(self.oauthStatus.shortDescription)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
}
.buttonStyle(.bordered)
}
Text(PiOAuthStore.oauthURL().path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
HStack(spacing: 12) {
Button {
self.startOAuth()
} label: {
if self.busy {
ProgressView().controlSize(.small)
} else {
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.connectionMode == .remote || self.busy)
if self.pkce != nil {
Button("Cancel") {
self.pkce = nil
self.code = ""
self.statusText = nil
}
.buttonStyle(.bordered)
.disabled(self.busy)
}
}
if self.pkce != nil {
VStack(alignment: .leading, spacing: 8) {
Text("Paste `code#state`")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
TextField("code#state", text: self.$code)
.textFieldStyle(.roundedBorder)
.disabled(self.busy)
Button("Connect") {
Task { await self.finishOAuth() }
}
.buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode == .remote || self.code
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
}
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear {
self.refresh()
}
}
private func refresh() {
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
}
private func startOAuth() {
guard self.connectionMode == .local else { return }
guard !self.busy else { return }
self.busy = true
defer { self.busy = false }
do {
let pkce = try AnthropicOAuth.generatePKCE()
self.pkce = pkce
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
NSWorkspace.shared.open(url)
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
} catch {
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
}
}
@MainActor
private func finishOAuth() async {
guard self.connectionMode == .local else { return }
guard !self.busy else { return }
guard let pkce = self.pkce else { return }
self.busy = true
defer { self.busy = false }
let trimmed = self.code.trimmingCharacters(in: .whitespacesAndNewlines)
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
let code = splits.first ?? ""
let state = splits.count > 1 ? splits[1] : ""
do {
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Pi can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}
}
}
@@ -10,6 +10,52 @@ struct AnthropicOAuthCredentials: Codable {
let expires: Int64
}
enum AnthropicAuthMode: Equatable {
case oauthFile
case oauthEnv
case apiKeyEnv
case missing
var shortLabel: String {
switch self {
case .oauthFile: "OAuth (Pi token file)"
case .oauthEnv: "OAuth (env var)"
case .apiKeyEnv: "API key (env var)"
case .missing: "Missing credentials"
}
}
var isConfigured: Bool {
switch self {
case .missing: false
case .oauthFile, .oauthEnv, .apiKeyEnv: true
}
}
}
enum AnthropicAuthResolver {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
{
if oauthStatus.isConnected { return .oauthFile }
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return .oauthEnv
}
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!key.isEmpty
{
return .apiKeyEnv
}
return .missing
}
}
enum AnthropicOAuth {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
@@ -107,6 +153,7 @@ enum AnthropicOAuth {
enum PiOAuthStore {
static let oauthFilename = "oauth.json"
private static let providerKey = "anthropic"
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
enum AnthropicOAuthStatus: Equatable {
case missingFile
@@ -123,18 +170,26 @@ enum PiOAuthStore {
var shortDescription: String {
switch self {
case .missingFile: "oauth.json not found"
case .unreadableFile: "oauth.json not readable"
case .invalidJSON: "oauth.json invalid"
case .missingProviderEntry: "oauth.json has no anthropic entry"
case .missingTokens: "anthropic entry missing tokens"
case .connected: "OAuth credentials found"
case .missingFile: "Pi OAuth token file not found"
case .unreadableFile: "Pi OAuth token file not readable"
case .invalidJSON: "Pi OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
case .missingTokens: "Anthropic entry missing tokens"
case .connected: "Pi OAuth credentials found"
}
}
}
static func oauthDir() -> URL {
FileManager.default.homeDirectoryForCurrentUser
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".pi", isDirectory: true)
.appendingPathComponent("agent", isDirectory: true)
}
+1 -8
View File
@@ -332,14 +332,7 @@ final class AppState {
self.voiceWakeGlobalSyncTask?.cancel()
self.voiceWakeGlobalSyncTask = Task { [sanitized] in
try? await Task.sleep(nanoseconds: 650_000_000)
do {
_ = try await GatewayConnection.shared.request(
method: "voicewake.set",
params: ["triggers": AnyCodable(sanitized)],
timeoutMs: 10000)
} catch {
// Best-effort only.
}
await GatewayConnection.shared.voiceWakeSetTriggers(sanitized)
}
}
@@ -167,13 +167,13 @@ actor BridgeServer {
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
_ = await GatewayConnection.shared.sendAgent(
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
thinking: "low",
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: "last")
channel: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -191,15 +191,15 @@ actor BridgeServer {
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
thinking: thinking,
sessionKey: sessionKey,
thinking: thinking,
deliver: link.deliver,
to: to,
channel: channel ?? "last")
channel: channel))
default:
break
@@ -328,39 +328,35 @@ actor BridgeServer {
}
private func beaconPresence(nodeId: String, reason: String) async {
do {
let paired = await self.store?.find(nodeId: nodeId)
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? nodeId
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let ip = await self.connections[nodeId]?.remoteAddress()
let paired = await self.store?.find(nodeId: nodeId)
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? nodeId
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let ip = await self.connections[nodeId]?.remoteAddress()
var tags: [String] = ["node", "ios"]
if let platform { tags.append(platform) }
var tags: [String] = ["node", "ios"]
if let platform { tags.append(platform) }
let summary = [
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
platform.map { "platform \($0)" },
version.map { "app \($0)" },
"mode node",
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
let summary = [
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
platform.map { "platform \($0)" },
version.map { "app \($0)" },
"mode node",
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
var params: [String: Any] = [
"text": summary,
"instanceId": nodeId,
"host": host,
"mode": "node",
"reason": reason,
"tags": tags,
]
if let ip { params["ip"] = ip }
if let version { params["version"] = version }
_ = try await GatewayConnection.shared.controlRequest(method: "system-event", params: params)
} catch {
// Best-effort only.
}
var params: [String: AnyCodable] = [
"text": AnyCodable(summary),
"instanceId": AnyCodable(nodeId),
"host": AnyCodable(host),
"mode": AnyCodable("node"),
"reason": AnyCodable(reason),
"tags": AnyCodable(tags),
]
if let ip { params["ip"] = AnyCodable(ip) }
if let version { params["version"] = AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params)
}
private func startPresenceTask(nodeId: String) {
@@ -469,10 +465,3 @@ enum BridgePairingApprover {
}
}
}
extension String {
fileprivate var nonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
+12 -6
View File
@@ -1,11 +1,14 @@
import AppKit
import ClawdisIPC
import Foundation
import OSLog
@MainActor
final class CanvasManager {
static let shared = CanvasManager()
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasManager")
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
@@ -24,6 +27,8 @@ final class CanvasManager {
}
func showDetailed(sessionKey: String, target: String? = nil, placement: CanvasPlacement? = nil) throws -> CanvasShowResult {
Self.logger.debug(
"showDetailed start session=\(sessionKey, privacy: .public) target=\(target ?? "", privacy: .public) placement=\(placement != nil)")
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedTarget = target?
@@ -31,6 +36,7 @@ final class CanvasManager {
.nonEmpty
if let controller = self.panelController, self.panelSessionKey == session {
Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)")
controller.onVisibilityChanged = { [weak self] visible in
self?.onPanelVisibilityChanged?(visible)
}
@@ -54,15 +60,19 @@ final class CanvasManager {
url: nil)
}
Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)")
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
Self.logger.debug("showDetailed ensure canvas root dir")
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
Self.logger.debug("showDetailed init CanvasWindowController")
let controller = try CanvasWindowController(
sessionKey: session,
root: Self.canvasRoot,
presentation: .panel(anchorProvider: anchorProvider))
Self.logger.debug("showDetailed CanvasWindowController init done")
controller.onVisibilityChanged = { [weak self] visible in
self?.onPanelVisibilityChanged?(visible)
}
@@ -72,7 +82,9 @@ final class CanvasManager {
// New session: default to "/" so the user sees either the welcome page or `index.html`.
let effectiveTarget = normalizedTarget ?? "/"
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
controller.showCanvas(path: effectiveTarget)
Self.logger.debug("showDetailed showCanvas done")
return self.makeShowResult(
directory: controller.directoryPath,
@@ -215,9 +227,3 @@ final class CanvasManager {
return FileManager.default.fileExists(atPath: index.path)
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}
+184 -38
View File
@@ -50,18 +50,104 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.root = root
self.presentation = presentation
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
self.schemeHandler = CanvasSchemeHandler(root: root)
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
canvasWindowLogger.debug("CanvasWindowController init config ready")
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
//
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
// (includes the app-generated key so it won't prompt).
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let bridgeScript = """
(() => {
try {
if (location.protocol !== '\(CanvasScheme.scheme):') return;
if (globalThis.__clawdisA2UIBridgeInstalled) return;
globalThis.__clawdisA2UIBridgeInstalled = true;
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
globalThis.addEventListener('a2uiaction', (evt) => {
try {
const payload = evt?.detail ?? evt?.payload ?? null;
if (!payload || payload.eventType !== 'a2ui.action') return;
const action = payload.action ?? null;
const name = action?.name ?? '';
if (!name) return;
const context = Array.isArray(action?.context) ? action.context : [];
const userAction = {
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
name,
surfaceId: payload.surfaceId ?? 'main',
sourceComponentId: payload.sourceComponentId ?? '',
dataContextPath: payload.dataContextPath ?? '',
timestamp: new Date().toISOString(),
...(context.length ? { context } : {}),
};
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
// If the bundled A2UI shell is present, let it forward actions so we keep its richer
// context resolution (data model path lookups, surface detection, etc.).
const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host');
if (hasBundledA2UIHost && handler?.postMessage) return;
// Otherwise, forward directly when possible.
if (!hasBundledA2UIHost && handler?.postMessage) {
handler.postMessage({ userAction });
return;
}
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
const message =
'CANVAS_A2UI action=' + userAction.name +
' session=' + sessionKey +
' surface=' + userAction.surfaceId +
' component=' + (userAction.sourceComponentId || '-') +
' host=' + machineName.replace(/\\s+/g, '_') +
' instance=' + instanceId +
ctx +
' default=update_canvas';
const params = new URLSearchParams();
params.set('message', message);
params.set('sessionKey', sessionKey);
params.set('thinking', 'low');
params.set('deliver', 'false');
params.set('channel', 'last');
params.set('key', deepLinkKey);
location.href = 'clawdis://agent?' + params.toString();
} catch {}
}, true);
} catch {}
})();
"""
config.userContentController.addUserScript(
WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed")
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.setValue(false, forKey: "drawsBackground")
@@ -93,6 +179,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.container = HoverChromeContainerView(containing: self.webView)
let window = Self.makeWindow(for: presentation, contentView: self.container)
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
super.init(window: window)
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
@@ -106,6 +193,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
self.watcher.start()
canvasWindowLogger.debug("CanvasWindowController init done")
}
@available(*, unavailable)
@@ -141,10 +229,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
func hideCanvas() {
if case .panel = self.presentation {
self.persistFrameIfPanel()
self.window?.orderOut(nil)
} else {
self.close()
}
self.window?.orderOut(nil)
self.onVisibilityChanged?(false)
}
@@ -258,16 +344,17 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
switch presentation {
case .window:
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawdis Canvas"
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawdis Canvas"
window.isReleasedWhenClosed = false
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
case .panel:
let panel = CanvasPanel(
@@ -409,6 +496,17 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
let scheme = url.scheme?.lowercased()
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
if scheme == "clawdis" {
if self.webView.url?.scheme == CanvasScheme.scheme {
Task { await DeepLinkHandler.shared.handle(url: url) }
} else {
canvasWindowLogger.debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
return
}
// Keep web content inside the panel when reasonable.
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
if scheme == CanvasScheme.scheme
@@ -466,6 +564,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
return String(scalars)
}
private static func jsStringLiteral(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
}
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
}
@@ -504,9 +607,6 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
webView.url?.scheme == CanvasScheme.scheme
else { return }
let path = webView.url?.path ?? ""
guard path == "/" || path.isEmpty || path.hasPrefix("/__clawdis__/a2ui") else { return }
let body: [String: Any] = {
if let dict = message.body as? [String: Any] { return dict }
if let dict = message.body as? [AnyHashable: Any] {
@@ -533,36 +633,82 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
guard !userAction.isEmpty else { return }
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
let json: String = {
if let data = try? JSONSerialization.data(withJSONObject: userAction, options: [.prettyPrinted, .sortedKeys]),
let str = String(data: data, encoding: .utf8)
{
return str
}
return ""
}()
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
let text = json.isEmpty
? "A2UI action: \(name)"
: "A2UI action: \(name)\n\n```json\n\(json)\n```"
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let sourceComponentId = (userAction["sourceComponentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let host = Self.sanitizeTagValue(InstanceIdentity.displayName)
let instanceId = InstanceIdentity.instanceId.lowercased()
let contextJSON = Self.compactJSON(userAction["context"])
let contextSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
Task {
let result = await GatewayConnection.shared.sendAgent(
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
let text =
"CANVAS_A2UI action=\(Self.sanitizeTagValue(name)) session=\(Self.sanitizeTagValue(self.sessionKey)) surface=\(Self.sanitizeTagValue(surfaceId)) component=\(Self.sanitizeTagValue(sourceComponentId)) host=\(host) instance=\(instanceId)\(contextSuffix) default=update_canvas"
Task { [weak webView] in
if AppStateStore.shared.connectionMode == .local {
GatewayProcessManager.shared.setActive(true)
}
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
thinking: nil,
sessionKey: self.sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: "webchat")
channel: .last,
idempotencyKey: actionId))
await MainActor.run {
guard let webView else { return }
let js = Self.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error)
webView.evaluateJavaScript(js) { _, _ in }
}
if !result.ok {
canvasWindowLogger.error(
"A2UI action send failed name=\(name, privacy: .public) error=\(result.error ?? "unknown", privacy: .public)")
}
}
}
private static func sanitizeTagValue(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let normalized = trimmed.replacingOccurrences(of: " ", with: "_")
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
private static func compactJSON(_ obj: Any?) -> String? {
guard let obj else { return nil }
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
private static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String {
let payload: [String: Any] = [
"id": actionId,
"ok": ok,
"error": error ?? "",
]
let json: String = {
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
let str = String(data: data, encoding: .utf8)
{
return str
}
return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}"
}()
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: \(json) }));"
}
}
// MARK: - Hover chrome container
@@ -681,8 +827,8 @@ private final class HoverChromeContainerView: NSView {
v.layer?.cornerRadius = 10
v.layer?.masksToBounds = true
v.layer?.borderWidth = 1
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.22).cgColor
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
v.layer?.shadowOpacity = 0.35
v.layer?.shadowRadius = 8
@@ -691,7 +837,7 @@ private final class HoverChromeContainerView: NSView {
}()
private let closeButton: NSButton = {
let cfg = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
.withSymbolConfiguration(cfg)
?? NSImage(size: NSSize(width: 18, height: 18))
@@ -699,7 +845,7 @@ private final class HoverChromeContainerView: NSView {
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imageScaling = .scaleProportionallyDown
btn.contentTintColor = NSColor.labelColor
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
btn.toolTip = "Close"
return btn
}()
@@ -745,8 +891,8 @@ private final class HoverChromeContainerView: NSView {
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 20),
self.closeButton.heightAnchor.constraint(equalToConstant: 20),
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
+255 -198
View File
@@ -3,6 +3,7 @@ import SwiftUI
@MainActor
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let state = AppStateStore.shared
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
@@ -31,204 +32,7 @@ struct ConfigSettings: View {
@State private var browserAttachOnly: Bool = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Clawdis CLI config")
.font(.title3.weight(.semibold))
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
.font(.callout)
.foregroundStyle(.secondary)
GroupBox("Agent") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
Picker("Model", selection: self.$configModel) {
ForEach(self.models) { choice in
Text("\(choice.name) — \(choice.provider.uppercased())")
.tag(choice.id)
}
Text("Manual entry…").tag("__custom__")
}
.labelsHidden()
.frame(maxWidth: .infinity)
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.configModel) { _, _ in
self.autosaveConfig()
}
if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.customModel) { _, newValue in
self.configModel = newValue
self.autosaveConfig()
}
}
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Schedule")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Stepper(
value: Binding(
get: { self.heartbeatMinutes ?? 10 },
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
in: 0...720)
{
Text("Every \(self.heartbeatMinutes ?? 10) min")
.frame(width: 150, alignment: .leading)
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.heartbeatBody) { _, _ in
self.autosaveConfig()
}
.help("Message body sent on each heartbeat")
}
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Web Chat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$webChatEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Port")
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
.textFieldStyle(.roundedBorder)
.frame(width: 100)
.disabled(!self.webChatEnabled)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"""
Mac app connects to the gateway’s loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("—")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
ScrollView { self.content }
.onChange(of: self.modelCatalogPath) { _, _ in
Task { await self.loadModels() }
}
@@ -245,6 +49,252 @@ struct ConfigSettings: View {
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 14) {
self.header
self.agentSection
self.heartbeatSection
self.webChatSection
self.browserSection
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
@ViewBuilder
private var header: some View {
Text("Clawdis CLI config")
.font(.title3.weight(.semibold))
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
.font(.callout)
.foregroundStyle(.secondary)
}
private var agentSection: some View {
GroupBox("Agent") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPicker
self.customModelField
self.modelMetaLabels
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var modelPicker: some View {
Picker("Model", selection: self.$configModel) {
ForEach(self.models) { choice in
Text("\(choice.name) — \(choice.provider.uppercased())")
.tag(choice.id)
}
Text("Manual entry…").tag("__custom__")
}
.labelsHidden()
.frame(maxWidth: .infinity)
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.configModel) { _, _ in
self.autosaveConfig()
}
}
@ViewBuilder
private var customModelField: some View {
if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.customModel) { _, newValue in
self.configModel = newValue
self.autosaveConfig()
}
}
}
@ViewBuilder
private var modelMetaLabels: some View {
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let authMode = self.selectedAnthropicAuthMode {
HStack(spacing: 8) {
Circle()
.fill(authMode.isConfigured ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text("Anthropic auth: \(authMode.shortLabel)")
}
.font(.footnote)
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
.help(self.anthropicAuthHelpText)
AnthropicAuthControls(connectionMode: self.state.connectionMode)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var anthropicAuthHelpText: String {
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}
private var heartbeatSection: some View {
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Schedule")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Stepper(
value: Binding(
get: { self.heartbeatMinutes ?? 10 },
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
in: 0...720)
{
Text("Every \(self.heartbeatMinutes ?? 10) min")
.frame(width: 150, alignment: .leading)
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.heartbeatBody) { _, _ in
self.autosaveConfig()
}
.help("Message body sent on each heartbeat")
}
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var webChatSection: some View {
GroupBox("Web Chat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$webChatEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Port")
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
.textFieldStyle(.roundedBorder)
.frame(width: 100)
.disabled(!self.webChatEnabled)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"""
Mac app connects to the gateway’s loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var browserSection: some View {
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("—")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
@@ -424,6 +474,13 @@ struct ConfigSettings: View {
return "Context window: \(human) tokens"
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
@@ -113,9 +113,7 @@ enum ControlRequestHandler {
priority: request.priority)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case .overlay:
await MainActor.run {
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
return Response(ok: true)
case .auto:
let ok = await notifier.send(
@@ -124,9 +122,7 @@ enum ControlRequestHandler {
sound: chosenSound,
priority: request.priority)
if ok { return Response(ok: true) }
await MainActor.run {
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
return Response(ok: true, message: "notification not authorized; used overlay")
}
}
@@ -169,16 +165,15 @@ enum ControlRequestHandler {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
let sessionKey = session ?? "main"
let rpcResult = await GatewayConnection.shared.sendAgent(
let invocation = GatewayAgentInvocation(
message: trimmed,
thinking: thinking,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: nil)
return rpcResult.ok
? Response(ok: true, message: "sent")
: Response(ok: false, message: rpcResult.error ?? "failed to send")
channel: .last)
let rpcResult = await GatewayConnection.shared.sendAgent(invocation)
return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error)
}
private static func canvasEnabled() -> Bool {
@@ -195,28 +190,36 @@ enum ControlRequestHandler {
placement: CanvasPlacement?) async -> Response
{
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasControl")
logger.info("canvas show start session=\(session, privacy: .public) path=\(path ?? "", privacy: .public)")
do {
let res = try await MainActor.run {
try CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement)
}
logger.info("canvas show awaiting CanvasManager")
let res = try await CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement)
logger.info("canvas show done dir=\(res.directory, privacy: .public) status=\(String(describing: res.status), privacy: .public)")
let payload = try? JSONEncoder().encode(res)
return Response(ok: true, message: res.directory, payload: payload)
} catch {
logger.error("canvas show failed \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasHide(session: String) async -> Response {
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
await CanvasManager.shared.hide(sessionKey: session)
return Response(ok: true)
}
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasControl")
logger.info("canvas eval start session=\(session, privacy: .public) bytes=\(javaScript.utf8.count)")
do {
logger.info("canvas eval awaiting CanvasManager.eval")
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
logger.info("canvas eval done bytes=\(result.utf8.count)")
return Response(ok: true, payload: Data(result.utf8))
} catch {
logger.error("canvas eval failed \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
@@ -235,16 +238,12 @@ enum ControlRequestHandler {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
// Ensure the Canvas is visible without forcing a navigation/reload.
_ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: session, path: nil)
}
_ = try await CanvasManager.shared.show(sessionKey: session, path: nil)
// Wait for the in-page A2UI bridge. If it doesn't appear, force-load the bundled A2UI shell once.
var ready = await Self.waitForCanvasA2UI(session: session, requireBuiltinPath: false, timeoutMs: 2_000)
if !ready {
_ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: session, path: "/__clawdis__/a2ui/")
}
_ = try await CanvasManager.shared.show(sessionKey: session, path: "/__clawdis__/a2ui/")
ready = await Self.waitForCanvasA2UI(session: session, requireBuiltinPath: true, timeoutMs: 5_000)
}
@@ -20,7 +20,6 @@ struct CritterStatusLabel: View {
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
@State private var earWiggle: CGFloat = 0
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
private var isWorkingNow: Bool {
self.iconState.isWorking || self.isWorking
@@ -32,34 +31,18 @@ struct CritterStatusLabel: View {
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
.onReceive(self.ticker) { now in
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
.task(id: self.tickTaskID) {
guard self.animationsEnabled, !self.earBoostActive else {
self.resetMotion()
await MainActor.run { self.resetMotion() }
return
}
if now >= self.nextBlink {
self.blink()
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
}
if now >= self.nextWiggle {
self.wiggle()
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
}
if now >= self.nextLegWiggle {
self.wiggleLegs()
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
}
if now >= self.nextEarWiggle {
self.wiggleEars()
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
}
if self.isWorkingNow {
self.scurry()
while !Task.isCancelled {
let now = Date()
await MainActor.run { self.tick(now) }
try? await Task.sleep(nanoseconds: 350_000_000)
}
}
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
@@ -96,6 +79,42 @@ struct CritterStatusLabel: View {
.frame(width: 18, height: 18)
}
private var tickTaskID: Int {
// Ensure SwiftUI restarts (and cancels) the task when these change.
(self.animationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
}
private func tick(_ now: Date) {
guard self.animationsEnabled, !self.earBoostActive else {
self.resetMotion()
return
}
if now >= self.nextBlink {
self.blink()
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
}
if now >= self.nextWiggle {
self.wiggle()
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
}
if now >= self.nextLegWiggle {
self.wiggleLegs()
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
}
if now >= self.nextEarWiggle {
self.wiggleEars()
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
}
if self.isWorkingNow {
self.scurry()
}
}
private var iconImage: Image {
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
CritterIconRenderer.Badge(
@@ -128,7 +147,8 @@ struct CritterStatusLabel: View {
private func blink() {
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 160_000_000)
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
}
}
@@ -140,7 +160,8 @@ struct CritterStatusLabel: View {
self.wiggleAngle = targetAngle
self.wiggleOffset = targetOffset
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 360_000_000)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = 0
self.wiggleOffset = 0
@@ -153,7 +174,8 @@ struct CritterStatusLabel: View {
withAnimation(.easeInOut(duration: 0.14)) {
self.legWiggle = target
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 220_000_000)
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
}
}
@@ -164,7 +186,8 @@ struct CritterStatusLabel: View {
self.legWiggle = target
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 180_000_000)
withAnimation(.easeOut(duration: 0.16)) {
self.legWiggle = 0.25
self.wiggleOffset = 0
@@ -177,8 +200,11 @@ struct CritterStatusLabel: View {
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = target
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 320_000_000)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = 0
}
}
}
+12 -44
View File
@@ -67,16 +67,12 @@ final class CronJobsStore {
defer { self.isLoadingJobs = false }
do {
if let status = try? await self.fetchCronStatus() {
if let status = try? await GatewayConnection.shared.cronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.storePath
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
let data = try await self.request(
method: "cron.list",
params: ["includeDisabled": true])
let res = try JSONDecoder().decode(CronListResponse.self, from: data)
self.jobs = res.jobs
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
if self.jobs.isEmpty {
self.statusMessage = "No cron jobs yet."
}
@@ -92,11 +88,7 @@ final class CronJobsStore {
defer { self.isLoadingRuns = false }
do {
let data = try await self.request(
method: "cron.runs",
params: ["id": jobId, "limit": limit])
let res = try JSONDecoder().decode(CronRunsResponse.self, from: data)
self.runEntries = res.entries
self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit)
} catch {
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
@@ -105,10 +97,7 @@ final class CronJobsStore {
func runJob(id: String, force: Bool = true) async {
do {
_ = try await self.request(
method: "cron.run",
params: ["id": id, "mode": force ? "force" : "due"],
timeoutMs: 20000)
try await GatewayConnection.shared.cronRun(jobId: id, force: force)
} catch {
self.lastError = error.localizedDescription
}
@@ -116,7 +105,7 @@ final class CronJobsStore {
func removeJob(id: String) async {
do {
_ = try await self.request(method: "cron.remove", params: ["id": id])
try await GatewayConnection.shared.cronRemove(jobId: id)
await self.refreshJobs()
if self.selectedJobId == id {
self.selectedJobId = nil
@@ -129,9 +118,9 @@ final class CronJobsStore {
func setJobEnabled(id: String, enabled: Bool) async {
do {
_ = try await self.request(
method: "cron.update",
params: ["id": id, "patch": ["enabled": enabled]])
try await GatewayConnection.shared.cronUpdate(
jobId: id,
patch: ["enabled": AnyCodable(enabled)])
await self.refreshJobs()
} catch {
self.lastError = error.localizedDescription
@@ -140,12 +129,12 @@ final class CronJobsStore {
func upsertJob(
id: String?,
payload: [String: Any]) async throws
payload: [String: AnyCodable]) async throws
{
if let id {
_ = try await self.request(method: "cron.update", params: ["id": id, "patch": payload])
try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload)
} else {
_ = try await self.request(method: "cron.add", params: payload)
try await GatewayConnection.shared.cronAdd(payload: payload)
}
await self.refreshJobs()
}
@@ -206,26 +195,5 @@ final class CronJobsStore {
}
}
// MARK: - RPC
private func request(
method: String,
params: [String: Any]?,
timeoutMs: Double? = nil) async throws -> Data
{
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
return try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
}
private func fetchCronStatus() async throws -> CronStatusResponse {
let data = try await self.request(method: "cron.status", params: nil)
return try JSONDecoder().decode(CronStatusResponse.self, from: data)
}
}
private struct CronStatusResponse: Decodable {
let enabled: Bool
let storePath: String
let jobs: Int
let nextWakeAtMs: Int?
// MARK: - (no additional RPC helpers)
}
+10 -10
View File
@@ -454,7 +454,7 @@ struct CronSettings: View {
return "in \(days)d"
}
private func save(payload: [String: Any]) async {
private func save(payload: [String: AnyCodable]) async {
guard !self.isSaving else { return }
self.isSaving = true
self.editorError = nil
@@ -494,7 +494,7 @@ struct CronJobEditor: View {
@Binding var isSaving: Bool
@Binding var error: String?
let onCancel: () -> Void
let onSave: ([String: Any]) -> Void
let onSave: ([String: AnyCodable]) -> Void
private let labelColumnWidth: CGFloat = 160
private static let introText =
@@ -530,7 +530,7 @@ struct CronJobEditor: View {
@State private var systemEventText: String = ""
@State private var agentMessage: String = ""
@State private var deliver: Bool = false
@State private var channel: String = "last"
@State private var channel: GatewayAgentChannel = .last
@State private var to: String = ""
@State private var thinking: String = ""
@State private var timeoutSeconds: String = ""
@@ -801,9 +801,9 @@ struct CronJobEditor: View {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag("last")
Text("whatsapp").tag("whatsapp")
Text("telegram").tag("telegram")
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
}
.labelsHidden()
.pickerStyle(.segmented)
@@ -861,7 +861,7 @@ struct CronJobEditor: View {
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.channel = channel ?? "last"
self.channel = GatewayAgentChannel(raw: channel)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -879,7 +879,7 @@ struct CronJobEditor: View {
}
}
private func buildPayload() throws -> [String: Any] {
private func buildPayload() throws -> [String: AnyCodable] {
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
switch self.scheduleKind {
@@ -969,7 +969,7 @@ struct CronJobEditor: View {
]
}
return root
return root.mapValues { AnyCodable($0) }
}
private func buildAgentTurnPayload() -> [String: Any] {
@@ -980,7 +980,7 @@ struct CronJobEditor: View {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["channel"] = self.channel
payload["channel"] = self.channel.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
+38 -12
View File
@@ -12,6 +12,11 @@ final class DeepLinkHandler {
private var lastPromptAt: Date = .distantPast
// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
// outside callers can't know this randomly generated key.
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
func handle(url: URL) async {
guard let route = DeepLinkParser.parse(url) else {
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
@@ -35,7 +40,7 @@ final class DeepLinkHandler {
return
}
let allowUnattended = link.key == Self.expectedKey()
let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey()
if !allowUnattended {
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
deepLinkLogger.debug("throttling deep link prompt")
@@ -54,18 +59,24 @@ final class DeepLinkHandler {
}
do {
var params: [String: AnyCodable] = [
"message": AnyCodable(messagePreview),
"idempotencyKey": AnyCodable(UUID().uuidString),
]
if let sessionKey = link.sessionKey, !sessionKey.isEmpty { params["sessionKey"] = AnyCodable(sessionKey) }
if let thinking = link.thinking, !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) }
if let to = link.to, !to.isEmpty { params["to"] = AnyCodable(to) }
if let channel = link.channel, !channel.isEmpty { params["channel"] = AnyCodable(channel) }
if let timeout = link.timeoutSeconds { params["timeout"] = AnyCodable(timeout) }
params["deliver"] = AnyCodable(link.deliver)
let channel = GatewayAgentChannel(raw: link.channel)
let invocation = GatewayAgentInvocation(
message: messagePreview,
sessionKey: link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main",
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: channel.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
channel: channel,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)
_ = try await GatewayConnection.shared.request(method: "agent", params: params)
let res = await GatewayConnection.shared.sendAgent(invocation)
if !res.ok {
throw NSError(
domain: "DeepLink",
code: 1,
userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"])
}
} catch {
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
}
@@ -77,6 +88,10 @@ final class DeepLinkHandler {
self.expectedKey()
}
static func currentCanvasKey() -> String {
self.canvasUnattendedKey
}
private static func expectedKey() -> String {
let defaults = UserDefaults.standard
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
@@ -94,6 +109,17 @@ final class DeepLinkHandler {
return key
}
private nonisolated static func generateRandomKey() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let data = Data(bytes)
return data
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// MARK: - UI
private func confirm(title: String, message: String) -> Bool {
@@ -423,8 +423,12 @@ actor GatewayChannelActor {
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
}
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway error"
throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg])
let code = res.error?["code"]?.value as? String
let msg = res.error?["message"]?.value as? String
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
acc[pair.key] = AnyCodable(pair.value.value)
}
throw GatewayResponseError(method: method, code: code, message: msg, details: details)
}
if let payload = res.payload {
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
@@ -1,7 +1,37 @@
import ClawdisChatUI
import ClawdisProtocol
import Foundation
import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last
case whatsapp
case telegram
case webchat
init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentChannel(rawValue: normalized) ?? .last
}
var isDeliverable: Bool { self == .whatsapp || self == .telegram }
func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable }
}
struct GatewayAgentInvocation: Sendable {
var message: String
var sessionKey: String = "main"
var thinking: String?
var deliver: Bool = false
var to: String?
var channel: GatewayAgentChannel = .last
var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString
}
/// Single, shared Gateway websocket connection for the whole app.
///
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
@@ -11,8 +41,31 @@ actor GatewayConnection {
typealias Config = (url: URL, token: String?)
enum Method: String, Sendable {
case agent = "agent"
case status = "status"
case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event"
case health = "health"
case chatHistory = "chat.history"
case chatSend = "chat.send"
case chatAbort = "chat.abort"
case voicewakeGet = "voicewake.get"
case voicewakeSet = "voicewake.set"
case nodePairApprove = "node.pair.approve"
case nodePairReject = "node.pair.reject"
case cronList = "cron.list"
case cronRuns = "cron.runs"
case cronRun = "cron.run"
case cronRemove = "cron.remove"
case cronUpdate = "cron.update"
case cronAdd = "cron.add"
case cronStatus = "cron.status"
}
private let configProvider: @Sendable () async throws -> Config
private let sessionBox: WebSocketSessionBox?
private let decoder = JSONDecoder()
private var client: GatewayChannelActor?
private var configuredURL: URL?
@@ -29,6 +82,8 @@ actor GatewayConnection {
self.sessionBox = sessionBox
}
// MARK: - Low-level request
func request(
method: String,
params: [String: AnyCodable]?,
@@ -39,7 +94,66 @@ actor GatewayConnection {
guard let client else {
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
// Canvas interactions should "just work" even if the local gateway isn't running yet.
let isLocal = await MainActor.run { AppStateStore.shared.connectionMode == .local }
guard isLocal else { throw error }
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
var lastError: Error = error
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
throw lastError
}
}
func requestRaw(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs)
}
func requestRaw(
method: String,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method, params: params, timeoutMs: timeoutMs)
}
func requestDecoded<T: Decodable>(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> T
{
let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
do {
return try self.decoder.decode(T.self, from: data)
} catch {
throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription)
}
}
func requestVoid(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws
{
_ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
}
/// Ensure the underlying socket is configured (and replaced if config changed).
@@ -114,33 +228,13 @@ actor GatewayConnection {
}
}
private let gatewayControlLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.control")
// MARK: - Typed gateway API
extension GatewayConnection {
private static func wrapParams(_ raw: [String: Any]?) -> [String: AnyCodable]? {
guard let raw else { return nil }
return raw.reduce(into: [String: AnyCodable]()) { acc, pair in
acc[pair.key] = AnyCodable(pair.value)
}
}
func controlRequest(
method: String,
params: [String: Any]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method, params: Self.wrapParams(params), timeoutMs: timeoutMs)
}
func status() async -> (ok: Bool, error: String?) {
do {
let data = try await self.controlRequest(method: "status")
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["ok"] as? Bool) ?? true
{
return (true, nil)
}
return (false, "status error")
_ = try await self.requestRaw(method: .status)
return (true, nil)
} catch {
return (false, error.localizedDescription)
}
@@ -148,39 +242,211 @@ extension GatewayConnection {
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
do {
_ = try await self.controlRequest(method: "set-heartbeats", params: ["enabled": enabled])
try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)])
return true
} catch {
gatewayControlLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
return false
}
}
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") }
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(invocation.sessionKey),
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let timeout = invocation.timeoutSeconds {
params["timeout"] = AnyCodable(timeout)
}
do {
try await self.requestVoid(method: .agent, params: params)
return (true, nil)
} catch {
return (false, error.localizedDescription)
}
}
func sendAgent(
message: String,
thinking: String?,
sessionKey: String,
deliver: Bool,
to: String?,
channel: String? = nil,
channel: GatewayAgentChannel = .last,
timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") }
await self.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: channel,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
}
func sendSystemEvent(_ params: [String: AnyCodable]) async {
do {
let params: [String: Any] = [
"message": trimmed,
"sessionKey": sessionKey,
"thinking": thinking ?? "default",
"deliver": deliver,
"to": to ?? "",
"channel": channel ?? "",
"idempotencyKey": idempotencyKey,
]
_ = try await self.controlRequest(method: "agent", params: params)
return (true, nil)
try await self.requestVoid(method: .systemEvent, params: params)
} catch {
return (false, error.localizedDescription)
// Best-effort only.
}
}
// MARK: - Health
func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot {
let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs)
if let snap = decodeHealthSnapshot(from: data) { return snap }
throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot")
}
func healthOK(timeoutMs: Int = 8000) async throws -> Bool {
let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs))
return (try? self.decoder.decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
}
// MARK: - Chat
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
try await self.requestDecoded(
method: .chatHistory,
params: ["sessionKey": AnyCodable(sessionKey)])
}
func chatSend(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> ClawdisChatSendResponse
{
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(sessionKey),
"message": AnyCodable(message),
"thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey),
"timeoutMs": AnyCodable(timeoutMs),
]
if !attachments.isEmpty {
let encoded = attachments.map { att in
[
"type": att.type,
"mimeType": att.mimeType,
"fileName": att.fileName,
"content": att.content,
]
}
params["attachments"] = AnyCodable(encoded)
}
return try await self.requestDecoded(method: .chatSend, params: params)
}
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
let res: AbortResponse = try await self.requestDecoded(
method: .chatAbort,
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
return res.aborted ?? false
}
// MARK: - VoiceWake
func voiceWakeGetTriggers() async throws -> [String] {
struct VoiceWakePayload: Decodable { let triggers: [String] }
let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet)
return payload.triggers
}
func voiceWakeSetTriggers(_ triggers: [String]) async {
do {
try await self.requestVoid(
method: .voicewakeSet,
params: ["triggers": AnyCodable(triggers)],
timeoutMs: 10000)
} catch {
// Best-effort only.
}
}
// MARK: - Node pairing
func nodePairApprove(requestId: String) async throws {
try await self.requestVoid(
method: .nodePairApprove,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
func nodePairReject(requestId: String) async throws {
try await self.requestVoid(
method: .nodePairReject,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
// MARK: - Cron
struct CronSchedulerStatus: Decodable, Sendable {
let enabled: Bool
let storePath: String
let jobs: Int
let nextWakeAtMs: Int?
}
func cronStatus() async throws -> CronSchedulerStatus {
try await self.requestDecoded(method: .cronStatus)
}
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
let res: CronListResponse = try await self.requestDecoded(
method: .cronList,
params: ["includeDisabled": AnyCodable(includeDisabled)])
return res.jobs
}
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
let res: CronRunsResponse = try await self.requestDecoded(
method: .cronRuns,
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
return res.entries
}
func cronRun(jobId: String, force: Bool = true) async throws {
try await self.requestVoid(
method: .cronRun,
params: [
"id": AnyCodable(jobId),
"mode": AnyCodable(force ? "force" : "due"),
],
timeoutMs: 20000)
}
func cronRemove(jobId: String) async throws {
try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)])
}
func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws {
try await self.requestVoid(
method: .cronUpdate,
params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)])
}
func cronAdd(payload: [String: AnyCodable]) async throws {
try await self.requestVoid(method: .cronAdd, params: payload)
}
}
@@ -0,0 +1,34 @@
import ClawdisProtocol
import Foundation
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
struct GatewayResponseError: LocalizedError, @unchecked Sendable {
let method: String
let code: String
let message: String
let details: [String: AnyCodable]
init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) {
self.method = method
self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? code!.trimmingCharacters(in: .whitespacesAndNewlines)
: "GATEWAY_ERROR"
self.message = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? message!.trimmingCharacters(in: .whitespacesAndNewlines)
: "gateway error"
self.details = details ?? [:]
}
var errorDescription: String? {
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
return "\(self.method): [\(self.code)] \(self.message)"
}
}
struct GatewayDecodingError: LocalizedError, Sendable {
let method: String
let message: String
var errorDescription: String? { "\(self.method): \(self.message)" }
}
@@ -152,23 +152,27 @@ final class GatewayProcessManager {
private func attachExistingGatewayIfAvailable() async -> Bool {
let port = GatewayEnvironment.gatewayPort()
do {
let data = try await GatewayConnection.shared.request(method: "health", params: nil)
let data = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
let snap = decodeHealthSnapshot(from: data)
let instance = await PortGuardian.shared.describe(port: port)
let instanceText: String
if let instance {
let path = instance.executablePath ?? "path unknown"
instanceText = "pid \(instance.pid) \(instance.command) @ \(path)"
} else {
instanceText = "pid unknown"
}
let details: String
if let snap = decodeHealthSnapshot(from: data) {
if let snap {
let linked = snap.web.linked ? "linked" : "not linked"
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
let instance = await PortGuardian.shared.describe(port: port)
let instanceText: String
if let instance {
let path = instance.executablePath ?? "path unknown"
instanceText = "pid \(instance.pid) \(instance.command) @ \(path)"
} else {
instanceText = "pid unknown"
}
details = "port \(port), \(linked), auth \(authAge), \(instanceText)"
} else {
details = "port \(port), health probe succeeded"
details = "port \(port), health probe succeeded, \(instanceText)"
}
self.existingGatewayDetails = details
self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n")
@@ -136,10 +136,9 @@ struct InstancesSettings: View {
}
private func deviceDescription(_ inst: InstanceInfo) -> String? {
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let model = inst.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !family.isEmpty, !model.isEmpty { return "\(family) (\(model))" }
if !model.isEmpty { return model }
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return family.isEmpty ? nil : family
}
+2
View File
@@ -182,6 +182,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let state {
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
}
TerminationSignalWatcher.shared.start()
NodePairingApprovalPrompter.shared.start()
VoiceWakeGlobalSettingsSync.shared.start()
Task { PresenceReporter.shared.start() }
@@ -202,6 +203,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
TerminationSignalWatcher.shared.stop()
VoiceWakeGlobalSettingsSync.shared.stop()
WebChatManager.shared.close()
WebChatManager.shared.resetTunnels()
@@ -68,7 +68,8 @@ struct MenuContent: View {
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
_ = try? CanvasManager.shared.show(sessionKey: "main", path: "/")
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
}
}
}
@@ -1,7 +1,9 @@
import AppKit
import ClawdisIPC
import ClawdisProtocol
import Foundation
import OSLog
import UserNotifications
@MainActor
final class NodePairingApprovalPrompter {
@@ -9,8 +11,28 @@ final class NodePairingApprovalPrompter {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
private var task: Task<Void, Never>?
private var reconcileTask: Task<Void, Never>?
private var isStopping = false
private var isPresenting = false
private var queue: [PendingRequest] = []
private var activeAlert: NSAlert?
private var activeRequestId: String?
private var alertHostWindow: NSWindow?
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
private struct PairingList: Codable {
let pending: [PendingRequest]
let paired: [PairedNode]?
}
private struct PairedNode: Codable, Equatable {
let nodeId: String
let approvedAtMs: Double?
let displayName: String?
let platform: String?
let version: String?
let remoteIp: String?
}
private struct PendingRequest: Codable, Equatable, Identifiable {
let requestId: String
@@ -25,11 +47,22 @@ final class NodePairingApprovalPrompter {
var id: String { self.requestId }
}
private enum PairingResolution: String {
case approved
case rejected
}
func start() {
guard self.task == nil else { return }
self.isStopping = false
self.reconcileTask?.cancel()
self.reconcileTask = Task { [weak self] in
await self?.reconcileLoop()
}
self.task = Task { [weak self] in
guard let self else { return }
_ = try? await GatewayConnection.shared.refresh()
await self.loadPendingRequestsFromGateway()
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
for await push in stream {
if Task.isCancelled { return }
@@ -39,10 +72,159 @@ final class NodePairingApprovalPrompter {
}
func stop() {
self.isStopping = true
self.endActiveAlert()
self.task?.cancel()
self.task = nil
self.reconcileTask?.cancel()
self.reconcileTask = nil
self.queue.removeAll(keepingCapacity: false)
self.isPresenting = false
self.activeRequestId = nil
self.alertHostWindow?.orderOut(nil)
self.alertHostWindow?.close()
self.alertHostWindow = nil
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
}
private func loadPendingRequestsFromGateway() async {
// The gateway process may start slightly after the app. Retry a bit so
// pending pairing prompts are still shown on launch.
var delayMs: UInt64 = 200
for attempt in 1...8 {
if Task.isCancelled { return }
do {
let data = try await GatewayConnection.shared.request(
method: "node.pair.list",
params: nil,
timeoutMs: 6000)
guard !data.isEmpty else { return }
let list = try JSONDecoder().decode(PairingList.self, from: data)
let pending = list.pending.sorted { $0.ts < $1.ts }
guard !pending.isEmpty else { return }
await MainActor.run { [weak self] in
guard let self else { return }
self.logger.info(
"loaded \(pending.count, privacy: .public) pending node pairing request(s) on startup")
for req in pending {
self.enqueue(req)
}
}
return
} catch {
if attempt == 8 {
self.logger
.error(
"failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)")
return
}
try? await Task.sleep(nanoseconds: delayMs * 1_000_000)
delayMs = min(delayMs * 2, 2000)
}
}
}
private func reconcileLoop() async {
// Reconcile requests periodically so multiple running apps stay in sync
// (e.g. close dialogs + notify if another machine approves/rejects via app or CLI).
let intervalMs: UInt64 = 800
while !Task.isCancelled {
if self.isStopping { return }
do {
let list = try await self.fetchPairingList(timeoutMs: 2500)
await self.apply(list: list)
} catch {
// best effort: ignore transient connectivity failures
}
try? await Task.sleep(nanoseconds: intervalMs * 1_000_000)
}
}
private func fetchPairingList(timeoutMs: Double) async throws -> PairingList {
let data = try await GatewayConnection.shared.request(
method: "node.pair.list",
params: nil,
timeoutMs: timeoutMs)
return try JSONDecoder().decode(PairingList.self, from: data)
}
private func apply(list: PairingList) async {
if self.isStopping { return }
let pendingById = Dictionary(
uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) })
// Enqueue any missing requests (covers missed pushes while reconnecting).
for req in list.pending.sorted(by: { $0.ts < $1.ts }) {
self.enqueue(req)
}
// Detect resolved requests (approved/rejected elsewhere).
let queued = self.queue
for req in queued {
if pendingById[req.requestId] != nil { continue }
let resolution = self.inferResolution(for: req, list: list)
if self.activeRequestId == req.requestId, self.activeAlert != nil {
self.remoteResolutionsByRequestId[req.requestId] = resolution
self.logger.info(
"pairing request resolved elsewhere; closing dialog requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
self.endActiveAlert()
continue
}
self.logger.info(
"pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
self.queue.removeAll { $0 == req }
Task { @MainActor in
await self.notify(resolution: resolution, request: req, via: "remote")
}
}
if self.queue.isEmpty {
self.isPresenting = false
}
self.presentNextIfNeeded()
}
private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution {
let paired = list.paired ?? []
guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else {
return .rejected
}
if request.isRepair == true, let approvedAtMs = node.approvedAtMs {
return approvedAtMs >= request.ts ? .approved : .rejected
}
return .approved
}
private func endActiveAlert() {
guard let alert = self.activeAlert else { return }
if let parent = alert.window.sheetParent {
parent.endSheet(alert.window, returnCode: .abort)
}
self.activeAlert = nil
self.activeRequestId = nil
}
private func requireAlertHostWindow() -> NSWindow {
if let alertHostWindow {
return alertHostWindow
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 440, height: 1),
styleMask: [.titled],
backing: .buffered,
defer: false)
window.title = "Clawdis"
window.isReleasedWhenClosed = false
window.level = .floating
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.center()
self.alertHostWindow = window
return window
}
private func handle(push: GatewayPush) {
@@ -64,6 +246,7 @@ final class NodePairingApprovalPrompter {
}
private func presentNextIfNeeded() {
guard !self.isStopping else { return }
guard !self.isPresenting else { return }
guard let next = self.queue.first else { return }
self.isPresenting = true
@@ -71,22 +254,33 @@ final class NodePairingApprovalPrompter {
}
private func presentAlert(for req: PendingRequest) {
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow node to connect?"
alert.informativeText = Self.describe(req)
// Fail-safe ordering: if the dialog can't be presented, default to "Later".
alert.addButton(withTitle: "Later")
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
alert.addButton(withTitle: "Later")
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
alert.buttons[1].hasDestructiveAction = true
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
let response = alert.runModal()
Task { [weak self] in
await self?.handleAlertResponse(response, request: req)
self.activeAlert = alert
self.activeRequestId = req.requestId
let hostWindow = self.requireAlertHostWindow()
hostWindow.makeKeyAndOrderFront(nil)
alert.beginSheetModal(for: hostWindow) { [weak self] response in
Task { @MainActor [weak self] in
guard let self else { return }
self.activeRequestId = nil
self.activeAlert = nil
await self.handleAlertResponse(response, request: req)
hostWindow.orderOut(nil)
}
}
}
@@ -101,23 +295,32 @@ final class NodePairingApprovalPrompter {
self.presentNextIfNeeded()
}
// Never approve/reject while shutting down (alerts can get dismissed during app termination).
guard !self.isStopping else { return }
if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) {
await self.notify(resolution: resolved, request: request, via: "remote")
return
}
switch response {
case .alertFirstButtonReturn:
await self.approve(requestId: request.requestId)
case .alertSecondButtonReturn:
await self.reject(requestId: request.requestId)
default:
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
return
case .alertSecondButtonReturn:
await self.approve(requestId: request.requestId)
await self.notify(resolution: .approved, request: request, via: "local")
case .alertThirdButtonReturn:
await self.reject(requestId: request.requestId)
await self.notify(resolution: .rejected, request: request, via: "local")
default:
return
}
}
private func approve(requestId: String) async {
do {
_ = try await GatewayConnection.shared.request(
method: "node.pair.approve",
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
@@ -127,10 +330,7 @@ final class NodePairingApprovalPrompter {
private func reject(requestId: String) async {
do {
_ = try await GatewayConnection.shared.request(
method: "node.pair.reject",
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
@@ -167,4 +367,25 @@ final class NodePairingApprovalPrompter {
if raw.lowercased() == "macos" { return "macOS" }
return raw
}
private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional
else {
return
}
let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected"
let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
let device = name?.isEmpty == false ? name! : request.nodeId
let body = "\(device)\n(via \(via))"
_ = await NotificationManager().send(
title: title,
body: body,
sound: nil,
priority: .active)
}
}
+2 -2
View File
@@ -325,7 +325,7 @@ struct OnboardingView: View {
private func anthropicAuthPage() -> some View {
self.onboardingPage {
Text("Sign in to Claude")
Text("Connect Claude")
.font(.largeTitle.weight(.semibold))
Text("Give your model the token it needs!")
.font(.body)
@@ -422,7 +422,7 @@ struct OnboardingView: View {
.font(.headline)
Text(
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
"(GUI apps don’t automatically inherit your shell env vars).")
"(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -35,6 +35,7 @@ final class PresenceReporter {
let host = InstanceIdentity.displayName
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
let version = Self.appVersionString()
let platform = Self.platformString()
let lastInput = Self.lastInputSeconds()
let text = Self.composePresenceSummary(mode: mode, reason: reason)
var params: [String: AnyHashable] = [
@@ -43,9 +44,10 @@ final class PresenceReporter {
"ip": AnyHashable(ip),
"mode": AnyHashable(mode),
"version": AnyHashable(version),
"platform": AnyHashable(platform),
"deviceFamily": AnyHashable("Mac"),
"reason": AnyHashable(reason),
]
params["deviceFamily"] = AnyHashable("Mac")
if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) }
if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) }
do {
@@ -80,6 +82,11 @@ final class PresenceReporter {
return version
}
private static func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private static func lastInputSeconds() -> Int? {
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
File diff suppressed because it is too large. Load diff
+149 -2
View File
@@ -115,6 +115,8 @@ const clawdisTheme = {
class ClawdisA2UIHost extends LitElement {
static properties = {
surfaces: { state: true },
pendingAction: { state: true },
toast: { state: true },
};
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
@@ -124,11 +126,15 @@ class ClawdisA2UIHost extends LitElement {
});
surfaces = [];
pendingAction = null;
toast = null;
#statusListener = null;
static styles = css`
:host {
display: block;
height: 100%;
position: relative;
box-sizing: border-box;
padding: 12px;
}
@@ -141,6 +147,71 @@ class ClawdisA2UIHost extends LitElement {
overflow: auto;
padding-bottom: 24px;
}
.status {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 12px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
pointer-events: none;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
z-index: 5;
}
.toast {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 12px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
pointer-events: none;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
z-index: 5;
}
.toast.error {
border-color: rgba(255, 109, 109, 0.35);
color: rgba(255, 223, 223, 0.98);
}
.spinner {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: rgba(255, 255, 255, 0.92);
animation: spin 0.75s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`;
connectedCallback() {
@@ -151,9 +222,50 @@ class ClawdisA2UIHost extends LitElement {
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
};
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
this.#statusListener = (evt) => this.#handleActionStatus(evt);
globalThis.addEventListener("clawdis:a2ui-action-status", this.#statusListener);
this.#syncSurfaces();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.#statusListener) {
globalThis.removeEventListener("clawdis:a2ui-action-status", this.#statusListener);
this.#statusListener = null;
}
}
#makeActionId() {
return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
#setToast(text, kind = "ok", timeoutMs = 1400) {
const toast = { text, kind, expiresAt: Date.now() + timeoutMs };
this.toast = toast;
this.requestUpdate();
setTimeout(() => {
if (this.toast === toast) {
this.toast = null;
this.requestUpdate();
}
}, timeoutMs + 30);
}
#handleActionStatus(evt) {
const detail = evt?.detail ?? null;
if (!detail || typeof detail.id !== "string") return;
if (!this.pendingAction || this.pendingAction.id !== detail.id) return;
if (detail.ok) {
this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() };
} else {
const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed";
this.pendingAction = { ...this.pendingAction, phase: "error", error: msg };
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
this.requestUpdate();
}
#handleA2UIAction(evt) {
const payload = evt?.detail ?? evt?.payload ?? null;
if (!payload || payload.eventType !== "a2ui.action") {
@@ -208,7 +320,12 @@ class ClawdisA2UIHost extends LitElement {
}
}
const actionId = this.#makeActionId();
this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() };
this.requestUpdate();
const userAction = {
id: actionId,
name,
surfaceId: surfaceId ?? "main",
sourceComponentId,
@@ -220,7 +337,16 @@ class ClawdisA2UIHost extends LitElement {
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
if (handler?.postMessage) {
handler.postMessage({ userAction });
try {
handler.postMessage({ userAction });
} catch (e) {
const msg = String(e?.message ?? e);
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
this.#setToast(`Failed: ${msg}`, "error", 4500);
}
} else {
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" };
this.#setToast("Failed: missing native bridge", "error", 4500);
}
}
@@ -230,6 +356,10 @@ class ClawdisA2UIHost extends LitElement {
}
this.#processor.processMessages(messages);
this.#syncSurfaces();
if (this.pendingAction?.phase === "sent") {
this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100);
this.pendingAction = null;
}
this.requestUpdate();
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
}
@@ -237,6 +367,7 @@ class ClawdisA2UIHost extends LitElement {
reset() {
this.#processor.clearSurfaces();
this.#syncSurfaces();
this.pendingAction = null;
this.requestUpdate();
return { ok: true };
}
@@ -253,7 +384,23 @@ class ClawdisA2UIHost extends LitElement {
</div>`;
}
return html`<section id="surfaces">
const statusText =
this.pendingAction?.phase === "sent"
? `Working: ${this.pendingAction.name}`
: this.pendingAction?.phase === "sending"
? `Sending: ${this.pendingAction.name}`
: this.pendingAction?.phase === "error"
? `Failed: ${this.pendingAction.name}`
: "";
return html`
${this.pendingAction && this.pendingAction.phase !== "error"
? html`<div class="status"><div class="spinner"></div><div>${statusText}</div></div>`
: ""}
${this.toast
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">${this.toast.text}</div>`
: ""}
<section id="surfaces">
${repeat(
this.surfaces,
([surfaceId]) => surfaceId,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,9 @@
import Foundation
extension String {
var nonEmpty: String? {
let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -0,0 +1,52 @@
import AppKit
import Foundation
import OSLog
@MainActor
final class TerminationSignalWatcher {
static let shared = TerminationSignalWatcher()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "lifecycle")
private var sources: [DispatchSourceSignal] = []
private var terminationRequested = false
func start() {
guard self.sources.isEmpty else { return }
self.install(SIGTERM)
self.install(SIGINT)
}
func stop() {
for s in self.sources {
s.cancel()
}
self.sources.removeAll(keepingCapacity: false)
self.terminationRequested = false
}
private func install(_ sig: Int32) {
// Make sure the default action doesn't kill the process before we can gracefully shut down.
signal(sig, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
source.setEventHandler { [weak self] in
self?.handle(sig)
}
source.resume()
self.sources.append(source)
}
private func handle(_ sig: Int32) {
guard !self.terminationRequested else { return }
self.terminationRequested = true
self.logger.info("received signal \(sig, privacy: .public); terminating")
// Ensure any pairing prompt can't accidentally approve during shutdown.
NodePairingApprovalPrompter.shared.stop()
NSApp.terminate(nil)
// Safety net: don't hang forever if something blocks termination.
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
exit(0)
}
}
}
@@ -146,6 +146,13 @@ struct ToolsSettings: View {
description: "Control Sonos speakers (discover, status, play/pause, volume, grouping) from scripts.",
method: .go(module: "github.com/steipete/sonoscli/cmd/sonos@latest", binary: "sonos"),
kind: .tool),
ToolEntry(
id: "blucli",
name: "🫐 blucli",
url: URL(string: "https://github.com/steipete/blucli")!,
description: "Play, group, and automate BluOS players from scripts.",
method: .go(module: "github.com/steipete/blucli/cmd/blu@latest", binary: "blu"),
kind: .tool),
ToolEntry(
id: "sag",
name: "🗣️ sag",
@@ -1,11 +1,11 @@
import AppKit
import AVFoundation
import Dispatch
import OSLog
import Speech
/// Observes right Option and starts a push-to-talk capture while it is held.
@MainActor
final class VoicePushToTalkHotkey {
final class VoicePushToTalkHotkey: @unchecked Sendable {
static let shared = VoicePushToTalkHotkey()
private var globalMonitor: Any?
@@ -13,29 +13,48 @@ final class VoicePushToTalkHotkey {
private var optionDown = false // right option only
private var active = false
private let beginAction: @Sendable () async -> Void
private let endAction: @Sendable () async -> Void
init(
beginAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.begin() },
endAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.end() }
) {
self.beginAction = beginAction
self.endAction = endAction
}
func setEnabled(_ enabled: Bool) {
if enabled {
self.startMonitoring()
} else {
self.stopMonitoring()
self.withMainThread { [weak self] in
guard let self else { return }
if enabled {
self.startMonitoring()
} else {
self.stopMonitoring()
}
}
}
private func startMonitoring() {
assert(Thread.isMainThread)
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
guard let self else { return }
self.updateModifierState(from: event)
let keyCode = event.keyCode
let flags = event.modifierFlags
self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags)
}
// Also listen locally so we still catch events when the app is active/focused.
self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
self?.updateModifierState(from: event)
let keyCode = event.keyCode
let flags = event.modifierFlags
self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags)
return event
}
}
private func stopMonitoring() {
assert(Thread.isMainThread)
if let globalMonitor {
NSEvent.removeMonitor(globalMonitor)
self.globalMonitor = nil
@@ -48,10 +67,25 @@ final class VoicePushToTalkHotkey {
self.active = false
}
private func updateModifierState(from event: NSEvent) {
private func handleFlagsChanged(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
self.withMainThread { [weak self] in
self?.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags)
}
}
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
assert(Thread.isMainThread)
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
if event.keyCode == 61 {
self.optionDown = event.modifierFlags.contains(.option)
if keyCode == 61 {
self.optionDown = modifierFlags.contains(.option)
}
let chordActive = self.optionDown
@@ -60,17 +94,21 @@ final class VoicePushToTalkHotkey {
Task {
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
.info("ptt hotkey down")
await VoicePushToTalk.shared.begin()
await self.beginAction()
}
} else if !chordActive, self.active {
self.active = false
Task {
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
.info("ptt hotkey up")
await VoicePushToTalk.shared.end()
await self.endAction()
}
}
}
func _testUpdateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
self.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags)
}
}
/// Short-lived speech recognizer that records while the hotkey is held.
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low"
var deliver: Bool = true
var to: String?
var channel: String = "last"
var channel: GatewayAgentChannel = .last
}
@discardableResult
@@ -46,15 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
{
let payload = Self.prefixedTranscript(transcript)
let channel = options.channel.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let deliver = options.deliver && channel != "webchat"
let result = await GatewayConnection.shared.sendAgent(
let deliver = options.channel.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload,
thinking: options.thinking,
sessionKey: options.sessionKey,
thinking: options.thinking,
deliver: deliver,
to: options.to,
channel: channel)
channel: options.channel))
if result.ok {
self.logger.info("voice wake forward ok")
@@ -44,9 +44,8 @@ final class VoiceWakeGlobalSettingsSync {
private func refreshFromGateway() async {
do {
let data = try await GatewayConnection.shared.request(method: "voicewake.get", params: nil, timeoutMs: 8000)
let payload = try JSONDecoder().decode(VoiceWakePayload.self, from: data)
AppStateStore.shared.applyGlobalVoiceWakeTriggers(payload.triggers)
let triggers = try await GatewayConnection.shared.voiceWakeGetTriggers()
AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers)
} catch {
// Best-effort only.
}
@@ -15,10 +15,7 @@ private enum WebChatSwiftUILayout {
struct MacGatewayChatTransport: ClawdisChatTransport, Sendable {
func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
let data = try await GatewayConnection.shared.request(
method: "chat.history",
params: ["sessionKey": AnyCodable(sessionKey)])
return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: data)
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
}
func sendMessage(
@@ -28,36 +25,16 @@ struct MacGatewayChatTransport: ClawdisChatTransport, Sendable {
idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
{
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(sessionKey),
"message": AnyCodable(message),
"thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey),
"timeoutMs": AnyCodable(30000),
]
if !attachments.isEmpty {
let encoded = attachments.map { att in
[
"type": att.type,
"mimeType": att.mimeType,
"fileName": att.fileName,
"content": att.content,
]
}
params["attachments"] = AnyCodable(encoded)
}
let data = try await GatewayConnection.shared.request(method: "chat.send", params: params)
return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: data)
try await GatewayConnection.shared.chatSend(
sessionKey: sessionKey,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments)
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
let data = try await GatewayConnection.shared.request(
method: "health",
params: nil,
timeoutMs: Double(timeoutMs))
return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
}
func events() -> AsyncStream<ClawdisChatTransportEvent> {
@@ -0,0 +1,64 @@
import Foundation
import Testing
@testable import Clawdis
@Suite
struct AnthropicAuthResolverTests {
@Test
func prefersOAuthFileOverEnv() throws {
let key = "PI_CODING_AGENT_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
setenv(key, previous, 1)
} else {
unsetenv(key)
}
}
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
setenv(key, dir.path, 1)
let oauthFile = dir.appendingPathComponent("oauth.json")
let payload = [
"anthropic": [
"type": "oauth",
"refresh": "r1",
"access": "a1",
"expires": 1_234_567_890,
],
]
let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
try data.write(to: oauthFile, options: [.atomic])
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_API_KEY": "sk-ant-ignored",
])
#expect(mode == .oauthFile)
}
@Test
func reportsOAuthEnvWhenPresent() {
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_OAUTH_TOKEN": "token",
], oauthStatus: .missingFile)
#expect(mode == .oauthEnv)
}
@Test
func reportsAPIKeyEnvWhenPresent() {
let mode = AnthropicAuthResolver.resolve(environment: [
"ANTHROPIC_API_KEY": "sk-ant-key",
], oauthStatus: .missingFile)
#expect(mode == .apiKeyEnv)
}
@Test
func reportsMissingWhenNothingConfigured() {
let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
#expect(mode == .missing)
}
}
@@ -22,7 +22,7 @@ struct CanvasWindowSmokeTests {
controller.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680))
controller.showCanvas(path: "/")
_ = await controller.eval(javaScript: "1 + 1")
_ = try await controller.eval(javaScript: "1 + 1")
controller.windowDidMove(Notification(name: NSWindow.didMoveNotification))
controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification))
controller.hideCanvas()
@@ -45,4 +45,3 @@ struct CanvasWindowSmokeTests {
controller.close()
}
}
@@ -12,6 +12,25 @@ struct PiOAuthStoreTests {
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
}
@Test
func usesEnvOverrideForPiAgentDir() throws {
let key = "PI_CODING_AGENT_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
setenv(key, previous, 1)
} else {
unsetenv(key)
}
}
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1)
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
}
@Test
func acceptsPiFormatTokens() throws {
let url = try self.writeOAuthFile([
@@ -0,0 +1,37 @@
import AppKit
import Testing
@testable import Clawdis
@Suite(.serialized) struct VoicePushToTalkHotkeyTests {
actor Counter {
private(set) var began = 0
private(set) var ended = 0
func incBegin() { self.began += 1 }
func incEnd() { self.ended += 1 }
func snapshot() -> (began: Int, ended: Int) { (self.began, self.ended) }
}
@Test func beginEndFiresOncePerHold() async {
let counter = Counter()
let hotkey = VoicePushToTalkHotkey(
beginAction: { await counter.incBegin() },
endAction: { await counter.incEnd() })
await MainActor.run {
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option])
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option])
hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [])
}
for _ in 0..<50 {
let snap = await counter.snapshot()
if snap.began == 1, snap.ended == 1 { break }
try? await Task.sleep(nanoseconds: 10_000_000)
}
let snap = await counter.snapshot()
#expect(snap.began == 1)
#expect(snap.ended == 1)
}
}
@@ -17,6 +17,6 @@ import Testing
#expect(opts.thinking == "low")
#expect(opts.deliver == true)
#expect(opts.to == nil)
#expect(opts.channel == "last")
#expect(opts.channel == .last)
}
}
+1
View File
@@ -66,6 +66,7 @@ git commit -m "Add Clawd workspace"
- **spotify-player** — Terminal Spotify client to search/queue/control playback.
- **sag** — ElevenLabs speech with mac-style say UX; streams to speakers by default.
- **Sonos CLI** — Control Sonos speakers (discover/status/playback/volume/grouping) from scripts.
- **blucli** — Play, group, and automate BluOS players from scripts.
- **OpenHue CLI** — Philips Hue lighting control for scenes and automations.
- **OpenAI Whisper** — Local speech-to-text for quick dictation and voicemail transcripts.
- **Gemini CLI** — Google Gemini models from the terminal for fast Q&A.
+3 -3
View File
@@ -108,15 +108,15 @@ open 'clawdis://agent?message=Hello%20from%20deep%20link'
Query parameters:
- `message` (required): the agent prompt (URL-encoded).
- `sessionKey` (optional): explicit session key to use.
- `thinking` (optional): `off|minimal|low|medium|high` (or omit for default).
- `thinking` (optional): thinking hint (e.g. `low`; omit for default).
- `deliver` (optional): `true|false` (default: false).
- `to` / `channel` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`).
- `timeoutSeconds` (optional): timeout hint forwarded to the Gateway.
- `key` (optional): unattended mode key (see below).
Safety/guardrails:
- Disabled by default; enable in **Clawdis → Settings → Debug** (“Allow URL scheme (agent)”).
- Without `key`, Clawdis prompts with a confirmation dialog before invoking the agent.
- Always enabled.
- Without a `key` query param, the app will prompt for confirmation before invoking the agent.
- With `key=<value>`, Clawdis runs without prompting (intended for personal automations).
- The current key is shown in Debug Settings and stored locally in UserDefaults.
+2 -2
View File
@@ -36,12 +36,12 @@ pnpm clawdis gateway --force
- If a token is configured, clients must include it in `connect.params.auth.token` even over the tunnel.
## Protocol (operator view)
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
- After handshake:
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
- Events: `{type:"event", event, payload, seq?, stateVersion?}`
- Structured presence entries: `{host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
- Structured presence entries: `{host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
- `agent` responses are two-stage: first `res` ack `{runId,status:"accepted"}`, then a final `res` `{runId,status:"ok"|"error",summary}` after the run finishes; streamed output arrives as `event:"agent"`.
## Methods (initial set)
+2 -1
View File
@@ -139,8 +139,9 @@ Implementation note (important):
- In `WKWebView`, intercept `clawdis://…` navigations in `WKNavigationDelegate` and forward them to the app, e.g. by calling `DeepLinkHandler.shared.handle(url:)` and returning `.cancel` for the navigation.
Safety:
- `clawdis://agent` is disabled by default and must be enabled in **Clawdis → Settings → Debug** (“Allow URL scheme (agent)”).
- Deep links (`clawdis://agent?...`) are always enabled.
- Without a `key` query param, the app will prompt for confirmation before invoking the agent.
- With a valid `key`, the run is unattended (no prompt). For Canvas-originated actions, the app injects an internal key automatically.
## Security / guardrails
+2
View File
@@ -20,6 +20,8 @@ Presence entries are structured objects with (some) fields:
- `host`: a human-readable name (often the machine name)
- `ip`: best-effort IP address (may be missing or stale)
- `version`: client version string
- `deviceFamily` (optional): hardware family like `iPad`, `iPhone`, `Mac`
- `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6`
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh`
+48
View File
@@ -0,0 +1,48 @@
# Canvas / A2UI
## Goal
- A2UI rendering works out-of-the-box (no per-user toggles).
- A2UI button clicks always reach the agent automatically.
- Canvas chrome (close button) stays readable on any content.
## Current behavior
- Canvas can show a bundled A2UI shell at `/__clawdis__/a2ui/` when no session `index.html` exists.
- The A2UI shell forwards `a2ui.action` button clicks to native via `WKScriptMessageHandler` (`clawdisCanvasA2UIAction`).
- Native forwards the click to the gateway as an agent invocation.
## Fixes (2025-12-17)
- Close button: render a small vibrancy/material pill behind the “x” and reduce the button size for less visual weight.
- Click reliability:
- Allow A2UI clicks from any local Canvas path (not just `/` or the built-in A2UI shell).
- Inject an A2UI → native bridge at document start that listens for `a2uiaction` and forwards it:
- Prefer `WKScriptMessageHandler` when available.
- Otherwise fall back to an unattended `clawdis://agent?...&key=...` deep link (no prompt).
- Avoid double-sending actions when the bundled A2UI shell is present (let the shell forward clicks so it can resolve richer context).
- Intercept `clawdis://…` navigations inside the Canvas WKWebView and route them through `DeepLinkHandler` (no NSWorkspace bounce).
- `GatewayConnection` auto-starts the local gateway (and retries briefly) when a request fails in `.local` mode, so Canvas actions don’t silently fail if the gateway isn’t running yet.
- Fix a crash that made `clawdis-mac canvas show`/`eval` look “hung”:
- `VoicePushToTalkHotkey`’s NSEvent monitor could call `@MainActor` code off-main, triggering executor checks / EXC_BAD_ACCESS on macOS 26.2.
- Now it hops back to the main actor before mutating state.
- Preserve in-page state when closing Canvas (hide the window instead of closing the `WKWebView`).
- Fix another “Canvas looks hung” source: node pairing approval used `NSAlert.runModal()` on the main actor, which stalls Canvas/IPC while the alert is open.
- Add UX feedback + better agent prompting:
- Show a small “Sending/Working” spinner when a button is clicked.
- Show “Updated/Failed” toasts (failures include the gateway error string).
- Send a compact, unambiguous agent message that includes machine identity + Canvas context (instead of a big JSON markdown block).
- Native acks the click back into the page via `clawdis:a2ui-action-status` so the UI can switch from “Sending…” to “Working…” immediately.
## Suggested message format (token-efficient)
We want the model to immediately understand:
- This is a **Canvas UI event** (not user chat).
- It happened on **this specific Mac**.
- Default behavior is to **update the Canvas UI** (unless the button context says otherwise).
Proposed message line (single-line, parseable):
```
CANVAS_A2UI action=<name> session=<sessionKey> surface=<surfaceId> component=<componentId> host=<machine> instance=<instanceId> ctx=<json?> default=update_canvas
```
## Follow-ups
- Add a small “action sent / failed” debug overlay in the A2UI shell (dev-only) to make failures obvious.
- Decide whether non-local Canvas content should ever be allowed to emit A2UI actions (current stance: no, for safety).
+24
View File
@@ -0,0 +1,24 @@
# Gateway Client Refactor (Dec 2025)
Goal: remove stringly-typed gateway calls from the macOS app, centralize routing/channel semantics, and improve error handling.
## Progress
- [x] Fold legacy “AgentRPC” into `GatewayConnection` (single layer; no separate client object).
- [x] Typed gateway API: `GatewayConnection.Method` + `requestDecoded/requestVoid` + typed helpers (status/agent/chat/cron/etc).
- [x] Centralize agent routing/channel semantics via `GatewayAgentChannel` + `GatewayAgentInvocation`.
- [x] Improve gateway error model (structured `GatewayResponseError` + decoding errors include method).
- [x] Migrate mac call sites to typed helpers (leave only intentionally dynamic forwarding paths).
- [x] Convert remaining UI raw channel strings to `GatewayAgentChannel` (Cron editor).
- [x] Cleanup naming: rename remaining tests/docs that still reference “RPC/AgentRPC”.
### Notes
- Intentionally string-based:
- `BridgeServer` dynamic request forwarding (method is data-driven).
- `ControlChannel` request wrapper (generic escape hatch).
## Notes / Non-goals
- No functional behavior changes intended (beyond better errors and removing “magic strings”).
- Keep changes incremental: introduce typed APIs first, then migrate call sites, then remove old helpers.
+2 -2
View File
@@ -45,7 +45,7 @@ Goal: enforce the invariant **“one gateway websocket per app process (per effe
Key elements:
- `GatewayConnection.shared` owns the one websocket and is the *only* supported entry point for app code that needs gateway RPC.
- Consumers (e.g. Control UI, Agent RPC, SwiftUI WebChat) call `GatewayConnection.shared.request(...)` and do not create their own sockets.
- Consumers (e.g. Control UI, agent invocations, SwiftUI WebChat) call `GatewayConnection.shared.request(...)` and do not create their own sockets.
- If the effective connection config changes (local ↔ remote tunnel port, token change), `GatewayConnection` replaces the underlying connection.
- The transport (`GatewayChannelActor`) is an internal detail and forwards push frames back into `GatewayConnection`.
- Server-push frames are delivered via `GatewayConnection.shared.subscribe(...) -> AsyncStream<GatewayPush>` (in-process event bus).
@@ -84,7 +84,7 @@ Minimum invariants:
- Config changes (token / endpoint) cancel the old socket and reconnect once.
Nice-to-have integration coverage:
- Multiple “consumers” (Control UI + Agent RPC + SwiftUI WebChat) all call through the shared connection and still produce only one websocket.
- Multiple “consumers” (Control UI + agent invocations + SwiftUI WebChat) all call through the shared connection and still produce only one websocket.
Additional coverage added (macOS):
- Subscribing after connect replays the latest snapshot.
+4 -4
View File
@@ -23,7 +23,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
## Phase 1 — Protocol specification
- Frames (WS text JSON, all with explicit `type`):
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
- `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close)
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
- `req {type:"req", id, method, params?}`
@@ -31,7 +31,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
- `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart)
- Payload types:
- `PresenceEntry {host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
- `PresenceEntry {host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
- `HealthSnapshot` (match existing `clawdis health --json` fields)
- `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`)
- `TickEvent {ts}`
@@ -105,7 +105,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
- Add lightweight WS client helper for `status/health/send/agent` when Gateway is up. ✅ `gateway` subcommands use the Gateway over WS.
- Consider a “local only” flag to avoid accidental remote connects. (optional; not needed with tunnel-first model.)
- **WebChat backend**:
- Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via `GatewayClient` in `webchat/server.ts`.
- Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via the WebChat gateway client in `webchat/server.ts`.
- Fail fast if handshake fails; no fallback transports. ✅ (webchat returns gateway unavailable)
## Phase 6 — Send/agent path hardening
@@ -148,7 +148,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
- Mac app smoke: presence/health render from snapshot; reconnect on tick loss. (Manual: open Instances tab, verify snapshot after connect, induce seq gap by toggling wifi, ensure UI refreshes.)
- WebChat smoke: snapshot seed + event updates; tunnel scenario. ✅ Offline snapshot harness in `src/webchat/server.test.ts` (mock gateway) now passes; live tunnel still recommended for manual.
- Idempotency tests: retry send/agent with same key after forced disconnect; expect deduped result. ✅ send + agent dedupe + reconnect retry covered in gateway tests.
- Seq-gap handling: ✅ clients now detect seq gaps (GatewayClient + mac GatewayChannel) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional.
- Seq-gap handling: ✅ clients now detect seq gaps (WebChat gateway client + mac `GatewayConnection/GatewayChannel`) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional.
## Phase 10 — Rollout
- Version bump; release notes: breaking change to control plane (WS only).
+44
View File
@@ -0,0 +1,44 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
export function extractAssistantText(msg: AssistantMessage): string {
const isTextBlock = (
block: unknown,
): block is { type: "text"; text: string } => {
if (!block || typeof block !== "object") return false;
const rec = block as Record<string, unknown>;
return rec.type === "text" && typeof rec.text === "string";
};
const blocks = Array.isArray(msg.content)
? msg.content
.filter(isTextBlock)
.map((c) => c.text.trim())
.filter(Boolean)
: [];
return blocks.join("\n").trim();
}
export function inferToolMetaFromArgs(
toolName: string,
args: unknown,
): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const p = typeof record.path === "string" ? record.path : undefined;
const command =
typeof record.command === "string" ? record.command : undefined;
if (toolName === "read" && p) {
const offset =
typeof record.offset === "number" ? record.offset : undefined;
const limit = typeof record.limit === "number" ? record.limit : undefined;
if (offset !== undefined && limit !== undefined) {
return `${p}:${offset}-${offset + limit}`;
}
return p;
}
if ((toolName === "edit" || toolName === "write") && p) return p;
if (toolName === "bash" && command) return command;
return p ?? command;
}
+8 -45
View File
@@ -19,7 +19,6 @@ import {
} from "@mariozechner/pi-ai";
import {
AgentSession,
codingTools,
messageTransformer,
SessionManager,
SettingsManager,
@@ -34,7 +33,12 @@ import { splitMediaFromOutput } from "../media/parse.js";
import { enqueueCommand } from "../process/command-queue.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
extractAssistantText,
inferToolMetaFromArgs,
} from "./pi-embedded-utils.js";
import { getAnthropicOAuthToken } from "./pi-oauth.js";
import { createClawdisCodingTools } from "./pi-tools.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
@@ -86,49 +90,6 @@ function resolveModel(
return model as Model<Api> | undefined;
}
function extractAssistantText(msg: AssistantMessage): string {
const isTextBlock = (
block: unknown,
): block is { type: "text"; text: string } => {
if (!block || typeof block !== "object") return false;
const rec = block as Record<string, unknown>;
return rec.type === "text" && typeof rec.text === "string";
};
const blocks = Array.isArray(msg.content)
? msg.content
.filter(isTextBlock)
.map((c) => c.text.trim())
.filter(Boolean)
: [];
return blocks.join("\n").trim();
}
function inferToolMetaFromArgs(
toolName: string,
args: unknown,
): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const p = typeof record.path === "string" ? record.path : undefined;
const command =
typeof record.command === "string" ? record.command : undefined;
if (toolName === "read" && p) {
const offset =
typeof record.offset === "number" ? record.offset : undefined;
const limit = typeof record.limit === "number" ? record.limit : undefined;
if (offset !== undefined && limit !== undefined) {
return `${p}:${offset}-${offset + limit}`;
}
return p;
}
if ((toolName === "edit" || toolName === "write") && p) return p;
if (toolName === "bash" && command) return command;
return p ?? command;
}
async function ensureSessionHeader(params: {
sessionFile: string;
sessionId: string;
@@ -239,7 +200,9 @@ export async function runEmbeddedPiAgent(params: {
systemPrompt,
model,
thinkingLevel,
tools: codingTools,
// TODO(steipete): Once pi-mono publishes file-magic MIME detection in `read` image payloads,
// remove `createClawdisCodingTools()` and use upstream `codingTools` again.
tools: createClawdisCodingTools(),
},
messageTransformer,
queueMode: settingsManager.getQueueMode(),
+32
View File
@@ -0,0 +1,32 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createClawdisCodingTools } from "./pi-tools.js";
const PNG_1x1 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
describe("createClawdisCodingTools", () => {
it("sniffs mime from bytes when extension lies", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-pi-"));
const filePath = path.join(tmpDir, "image.jpg"); // actually PNG bytes
await fs.writeFile(filePath, Buffer.from(PNG_1x1, "base64"));
const read = createClawdisCodingTools().find((t) => t.name === "read");
expect(read).toBeTruthy();
if (!read) throw new Error("read tool missing");
const res = await read.execute("toolCallId", { path: filePath });
const image = res.content.find(
(b): b is { type: "image"; mimeType: string } =>
!!b &&
typeof b === "object" &&
(b as Record<string, unknown>).type === "image" &&
typeof (b as Record<string, unknown>).mimeType === "string",
);
expect(image?.mimeType).toBe("image/png");
});
});
+113
View File
@@ -0,0 +1,113 @@
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai";
import { codingTools, readTool } from "@mariozechner/pi-coding-agent";
import { detectMime } from "../media/mime.js";
// TODO(steipete): Remove this wrapper once pi-mono ships file-magic MIME detection
// for `read` image payloads in `@mariozechner/pi-coding-agent` (then switch back to `codingTools` directly).
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
type ImageContentBlock = Extract<ToolContentBlock, { type: "image" }>;
type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
function sniffMimeFromBase64(base64: string): string | undefined {
const trimmed = base64.trim();
if (!trimmed) return undefined;
const take = Math.min(256, trimmed.length);
const sliceLen = take - (take % 4);
if (sliceLen < 8) return undefined;
try {
const head = Buffer.from(trimmed.slice(0, sliceLen), "base64");
return detectMime({ buffer: head });
} catch {
return undefined;
}
}
function rewriteReadImageHeader(text: string, mimeType: string): string {
// pi-coding-agent uses: "Read image file [image/png]"
if (text.startsWith("Read image file [") && text.endsWith("]")) {
return `Read image file [${mimeType}]`;
}
return text;
}
function normalizeReadImageResult(
result: AgentToolResult<unknown>,
filePath: string,
): AgentToolResult<unknown> {
const content = Array.isArray(result.content) ? result.content : [];
const image = content.find(
(b): b is ImageContentBlock =>
!!b &&
typeof b === "object" &&
(b as { type?: unknown }).type === "image" &&
typeof (b as { data?: unknown }).data === "string" &&
typeof (b as { mimeType?: unknown }).mimeType === "string",
);
if (!image) return result;
if (!image.data.trim()) {
throw new Error(`read: image payload is empty (${filePath})`);
}
const sniffed = sniffMimeFromBase64(image.data);
if (!sniffed) return result;
if (!sniffed.startsWith("image/")) {
throw new Error(
`read: file looks like ${sniffed} but was treated as ${image.mimeType} (${filePath})`,
);
}
if (sniffed === image.mimeType) return result;
const nextContent = content.map((block) => {
if (
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "image"
) {
const b = block as ImageContentBlock & { mimeType: string };
return { ...b, mimeType: sniffed } satisfies ImageContentBlock;
}
if (
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
const b = block as TextContentBlock & { text: string };
return { ...b, text: rewriteReadImageHeader(b.text, sniffed) } satisfies TextContentBlock;
}
return block;
});
return { ...result, content: nextContent };
}
type AnyAgentTool = AgentTool<any, any>;
function createClawdisReadTool(base: AnyAgentTool): AnyAgentTool {
return {
...base,
execute: async (toolCallId, params, signal) => {
const result = (await base.execute(toolCallId, params as any, signal)) as AgentToolResult<unknown>;
const record =
params && typeof params === "object"
? (params as Record<string, unknown>)
: undefined;
const filePath =
typeof record?.path === "string" ? String(record.path) : "<unknown>";
return normalizeReadImageResult(result, filePath);
},
};
}
export function createClawdisCodingTools(): AnyAgentTool[] {
return (codingTools as unknown as AnyAgentTool[]).map((tool) =>
tool.name === readTool.name ? createClawdisReadTool(tool) : (tool as AnyAgentTool),
);
}
+3
View File
@@ -85,6 +85,9 @@ describe("gateway SIGTERM", () => {
env: {
...process.env,
CLAWDIS_SKIP_PROVIDERS: "1",
// Avoid port collisions with other test processes that may also start a bridge server.
CLAWDIS_BRIDGE_HOST: "127.0.0.1",
CLAWDIS_BRIDGE_PORT: "0",
},
stdio: ["ignore", "pipe", "pipe"],
},
+2 -2
View File
@@ -65,10 +65,10 @@ export const ConnectParamsSchema = Type.Object(
name: NonEmptyString,
version: NonEmptyString,
platform: NonEmptyString,
mode: NonEmptyString,
instanceId: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString),
mode: NonEmptyString,
instanceId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
+4 -4
View File
@@ -2109,10 +2109,10 @@ describe("gateway server", () => {
name: "fingerprint",
version: "9.9.9",
platform: "test",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
mode: "ui",
instanceId: "abc",
deviceFamily: "Mac",
modelIdentifier: "Mac16,6",
},
});
@@ -2135,8 +2135,8 @@ describe("gateway server", () => {
expect(clientEntry?.host).toBe("fingerprint");
expect(clientEntry?.version).toBe("9.9.9");
expect(clientEntry?.mode).toBe("ui");
expect(clientEntry?.deviceFamily).toBe("Mac");
expect(clientEntry?.modelIdentifier).toBe("Mac16,6");
expect(clientEntry?.deviceFamily).toBe("iPad");
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
ws.close();
await server.close();
+10
View File
@@ -225,6 +225,8 @@ describe("node bridge server", () => {
displayName?: string;
platform?: string;
version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
} | null = null;
@@ -233,6 +235,8 @@ describe("node bridge server", () => {
displayName?: string;
platform?: string;
version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
} | null = null;
@@ -262,6 +266,8 @@ describe("node bridge server", () => {
displayName: "Iris",
platform: "ios",
version: "1.0",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
});
// Approve the pending request from the gateway side.
@@ -296,6 +302,8 @@ describe("node bridge server", () => {
displayName: "Different name",
platform: "ios",
version: "2.0",
deviceFamily: "iPad",
modelIdentifier: "iPad99,1",
});
const line3 = JSON.parse(await readLine2()) as { type: string };
expect(line3.type).toBe("hello-ok");
@@ -310,6 +318,8 @@ describe("node bridge server", () => {
expect(lastAuthed?.displayName).toBe("Iris");
expect(lastAuthed?.platform).toBe("ios");
expect(lastAuthed?.version).toBe("1.0");
expect(lastAuthed?.deviceFamily).toBe("iPad");
expect(lastAuthed?.modelIdentifier).toBe("iPad16,6");
expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true);
socket2.destroy();
+2
View File
@@ -357,6 +357,8 @@ export async function startNodeBridgeServer(
displayName: req.displayName,
platform: req.platform,
version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
remoteIp: remoteAddress,
};
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
-13
View File
@@ -1,4 +1,3 @@
import { spawnSync } from "node:child_process";
import os from "node:os";
export type SystemPresence = {
@@ -50,17 +49,6 @@ function initSelfPresence() {
const ip = resolvePrimaryIPv4() ?? undefined;
const version =
process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "unknown";
const modelIdentifier = (() => {
const p = os.platform();
if (p === "darwin") {
const res = spawnSync("sysctl", ["-n", "hw.model"], {
encoding: "utf-8",
});
const out = typeof res.stdout === "string" ? res.stdout.trim() : "";
return out.length > 0 ? out : undefined;
}
return os.arch();
})();
const platform = (() => {
const p = os.platform();
const rel = os.release();
@@ -82,7 +70,6 @@ function initSelfPresence() {
version,
platform,
deviceFamily,
modelIdentifier,
mode: "gateway",
reason: "self",
text,
+23 -1
View File
@@ -35,6 +35,28 @@ import {
let previousHome: string | undefined;
let tempHome: string | undefined;
const rmDirWithRetries = async (dir: string): Promise<void> => {
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
await fs.rm(dir, { recursive: true, force: true });
return;
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
await new Promise((resolve) => setTimeout(resolve, 25));
continue;
}
throw err;
}
}
await fs.rm(dir, { recursive: true, force: true });
};
beforeEach(async () => {
previousHome = process.env.HOME;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-web-home-"));
@@ -44,7 +66,7 @@ beforeEach(async () => {
afterEach(async () => {
process.env.HOME = previousHome;
if (tempHome) {
await fs.rm(tempHome, { recursive: true, force: true });
await rmDirWithRetries(tempHome);
tempHome = undefined;
}
});
-1
View File
@@ -1 +0,0 @@
{"fileNames":["../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../../../../noLine truncated
-752
View File
@@ -1,752 +0,0 @@
export * as Events from "./events/events.js";
export * as Types from "./types/types.js";
export * as Primitives from "./types/primitives.js";
export * as Styles from "./styles/index.js";
import * as Guards from "./data/guards.js";
import { create as createSignalA2uiMessageProcessor } from "./data/signal-model-processor.js";
import { A2uiMessageProcessor } from "./data/model-processor.js";
export declare const Data: {
createSignalA2uiMessageProcessor: typeof createSignalA2uiMessageProcessor;
A2uiMessageProcessor: typeof A2uiMessageProcessor;
Guards: typeof Guards;
};
export declare const Schemas: {
A2UIClientEventMessage: {
title: string;
description: string;
type: string;
additionalProperties: boolean;
properties: {
beginRendering: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
surfaceId: {
type: string;
description: string;
};
root: {
type: string;
description: string;
};
styles: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
font: {
type: string;
description: string;
};
primaryColor: {
type: string;
description: string;
pattern: string;
};
};
};
};
required: string[];
};
surfaceUpdate: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
surfaceId: {
type: string;
description: string;
};
components: {
type: string;
description: string;
minItems: number;
items: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
id: {
type: string;
description: string;
};
weight: {
type: string;
description: string;
};
component: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
Text: {
type: string;
additionalProperties: boolean;
properties: {
text: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
usageHint: {
type: string;
description: string;
enum: string[];
};
};
required: string[];
};
Image: {
type: string;
additionalProperties: boolean;
properties: {
url: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
fit: {
type: string;
description: string;
enum: string[];
};
usageHint: {
type: string;
description: string;
enum: string[];
};
};
required: string[];
};
Icon: {
type: string;
additionalProperties: boolean;
properties: {
name: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
enum: string[];
};
path: {
type: string;
};
};
};
};
required: string[];
};
Video: {
type: string;
additionalProperties: boolean;
properties: {
url: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
};
required: string[];
};
AudioPlayer: {
type: string;
additionalProperties: boolean;
properties: {
url: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
description: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
};
required: string[];
};
Row: {
type: string;
additionalProperties: boolean;
properties: {
children: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
explicitList: {
type: string;
items: {
type: string;
};
};
template: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
componentId: {
type: string;
};
dataBinding: {
type: string;
};
};
required: string[];
};
};
};
distribution: {
type: string;
description: string;
enum: string[];
};
alignment: {
type: string;
description: string;
enum: string[];
};
};
required: string[];
};
Column: {
type: string;
additionalProperties: boolean;
properties: {
children: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
explicitList: {
type: string;
items: {
type: string;
};
};
template: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
componentId: {
type: string;
};
dataBinding: {
type: string;
};
};
required: string[];
};
};
};
distribution: {
type: string;
description: string;
enum: string[];
};
alignment: {
type: string;
description: string;
enum: string[];
};
};
required: string[];
};
List: {
type: string;
additionalProperties: boolean;
properties: {
children: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
explicitList: {
type: string;
items: {
type: string;
};
};
template: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
componentId: {
type: string;
};
dataBinding: {
type: string;
};
};
required: string[];
};
};
};
direction: {
type: string;
description: string;
enum: string[];
};
alignment: {
type: string;
description: string;
enum: string[];
};
};
required: string[];
};
Card: {
type: string;
additionalProperties: boolean;
properties: {
child: {
type: string;
description: string;
};
};
required: string[];
};
Tabs: {
type: string;
additionalProperties: boolean;
properties: {
tabItems: {
type: string;
description: string;
items: {
type: string;
additionalProperties: boolean;
properties: {
title: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
child: {
type: string;
};
};
required: string[];
};
};
};
required: string[];
};
Divider: {
type: string;
additionalProperties: boolean;
properties: {
axis: {
type: string;
description: string;
enum: string[];
};
};
};
Modal: {
type: string;
additionalProperties: boolean;
properties: {
entryPointChild: {
type: string;
description: string;
};
contentChild: {
type: string;
description: string;
};
};
required: string[];
};
Button: {
type: string;
additionalProperties: boolean;
properties: {
child: {
type: string;
description: string;
};
primary: {
type: string;
description: string;
};
action: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
name: {
type: string;
};
context: {
type: string;
items: {
type: string;
additionalProperties: boolean;
properties: {
key: {
type: string;
};
value: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
path: {
type: string;
};
literalString: {
type: string;
};
literalNumber: {
type: string;
};
literalBoolean: {
type: string;
};
};
};
};
required: string[];
};
};
};
required: string[];
};
};
required: string[];
};
CheckBox: {
type: string;
additionalProperties: boolean;
properties: {
label: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
value: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalBoolean: {
type: string;
};
path: {
type: string;
};
};
};
};
required: string[];
};
TextField: {
type: string;
additionalProperties: boolean;
properties: {
label: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
text: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
textFieldType: {
type: string;
description: string;
enum: string[];
};
validationRegexp: {
type: string;
description: string;
};
};
required: string[];
};
DateTimeInput: {
type: string;
additionalProperties: boolean;
properties: {
value: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
enableDate: {
type: string;
description: string;
};
enableTime: {
type: string;
description: string;
};
outputFormat: {
type: string;
description: string;
};
};
required: string[];
};
MultipleChoice: {
type: string;
additionalProperties: boolean;
properties: {
selections: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalArray: {
type: string;
items: {
type: string;
};
};
path: {
type: string;
};
};
};
options: {
type: string;
description: string;
items: {
type: string;
additionalProperties: boolean;
properties: {
label: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalString: {
type: string;
};
path: {
type: string;
};
};
};
value: {
type: string;
description: string;
};
};
required: string[];
};
};
maxAllowedSelections: {
type: string;
description: string;
};
};
required: string[];
};
Slider: {
type: string;
additionalProperties: boolean;
properties: {
value: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
literalNumber: {
type: string;
};
path: {
type: string;
};
};
};
minValue: {
type: string;
description: string;
};
maxValue: {
type: string;
description: string;
};
};
required: string[];
};
};
};
};
required: string[];
};
};
};
required: string[];
};
dataModelUpdate: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
surfaceId: {
type: string;
description: string;
};
path: {
type: string;
description: string;
};
contents: {
type: string;
description: string;
items: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
key: {
type: string;
description: string;
};
valueString: {
type: string;
};
valueNumber: {
type: string;
};
valueBoolean: {
type: string;
};
valueMap: {
description: string;
type: string;
items: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
key: {
type: string;
};
valueString: {
type: string;
};
valueNumber: {
type: string;
};
valueBoolean: {
type: string;
};
};
required: string[];
};
};
};
required: string[];
};
};
};
required: string[];
};
deleteSurface: {
type: string;
description: string;
additionalProperties: boolean;
properties: {
surfaceId: {
type: string;
description: string;
};
};
required: string[];
};
};
};
};
//# sourceMappingURL=core.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../../src/0.8/core.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAC;AAC7C,OAAO,KAAK,KAAK,MAAM,kBAAkB,CAAC;AAC1C,OAAO,KAAK,UAAU,MAAM,uBAAuB,CAAC;AACpD,OAAO,KAAK,MAAM,MAAM,mBAAmB,CAAC;AAC5C,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAE3C,OAAO,EAAE,MAAM,IAAI,gCAAgC,EAAE,MAAM,kCAAkC,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAGjE,eAAO,MAAM,IAAI;;;;CAIhB,CAAC;AAEF,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAEnB,CAAC"}
-32
View File
@@ -1,32 +0,0 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export * as Events from "./events/events.js";
export * as Types from "./types/types.js";
export * as Primitives from "./types/primitives.js";
export * as Styles from "./styles/index.js";
import * as Guards from "./data/guards.js";
import { create as createSignalA2uiMessageProcessor } from "./data/signal-model-processor.js";
import { A2uiMessageProcessor } from "./data/model-processor.js";
import A2UIClientEventMessage from "./schemas/server_to_client_with_standard_catalog.json" with { type: "json" };
export const Data = {
createSignalA2uiMessageProcessor,
A2uiMessageProcessor,
Guards,
};
export const Schemas = {
A2UIClientEventMessage,
};
//# sourceMappingURL=core.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"core.js","sourceRoot":"","sources":["../../../src/0.8/core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAC;AAC7C,OAAO,KAAK,KAAK,MAAM,kBAAkB,CAAC;AAC1C,OAAO,KAAK,UAAU,MAAM,uBAAuB,CAAC;AACpD,OAAO,KAAK,MAAM,MAAM,mBAAmB,CAAC;AAC5C,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAE3C,OAAO,EAAE,MAAM,IAAI,gCAAgC,EAAE,MAAM,kCAAkC,CAAC;AAC9F,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,sBAAsB,MAAM,uDAAuD,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAEjH,MAAM,CAAC,MAAM,IAAI,GAAG;IAClB,gCAAgC;IAChC,oBAAoB;IACpB,MAAM;CACP,CAAC;AAEF,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,sBAAsB;CACvB,CAAC"}
-24
View File
@@ -1,24 +0,0 @@
import { ComponentArrayReference, ResolvedAudioPlayer, ResolvedButton, ResolvedCard, ResolvedCheckbox, ResolvedColumn, ResolvedDateTimeInput, ResolvedDivider, ResolvedIcon, ResolvedImage, ResolvedList, ResolvedModal, ResolvedMultipleChoice, ResolvedRow, ResolvedSlider, ResolvedTabs, ResolvedText, ResolvedTextField, ResolvedVideo, ValueMap } from "../types/types";
export declare function isValueMap(value: unknown): value is ValueMap;
export declare function isPath(key: string, value: unknown): value is string;
export declare function isObject(value: unknown): value is Record<string, unknown>;
export declare function isComponentArrayReference(value: unknown): value is ComponentArrayReference;
export declare function isResolvedAudioPlayer(props: unknown): props is ResolvedAudioPlayer;
export declare function isResolvedButton(props: unknown): props is ResolvedButton;
export declare function isResolvedCard(props: unknown): props is ResolvedCard;
export declare function isResolvedCheckbox(props: unknown): props is ResolvedCheckbox;
export declare function isResolvedColumn(props: unknown): props is ResolvedColumn;
export declare function isResolvedDateTimeInput(props: unknown): props is ResolvedDateTimeInput;
export declare function isResolvedDivider(props: unknown): props is ResolvedDivider;
export declare function isResolvedImage(props: unknown): props is ResolvedImage;
export declare function isResolvedIcon(props: unknown): props is ResolvedIcon;
export declare function isResolvedList(props: unknown): props is ResolvedList;
export declare function isResolvedModal(props: unknown): props is ResolvedModal;
export declare function isResolvedMultipleChoice(props: unknown): props is ResolvedMultipleChoice;
export declare function isResolvedRow(props: unknown): props is ResolvedRow;
export declare function isResolvedSlider(props: unknown): props is ResolvedSlider;
export declare function isResolvedTabs(props: unknown): props is ResolvedTabs;
export declare function isResolvedText(props: unknown): props is ResolvedText;
export declare function isResolvedTextField(props: unknown): props is ResolvedTextField;
export declare function isResolvedVideo(props: unknown): props is ResolvedVideo;
//# sourceMappingURL=guards.d.ts.map
@@ -1 +0,0 @@
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../../../src/0.8/data/guards.ts"],"names":[],"mappings":"AAiBA,OAAO,EAEL,uBAAuB,EACvB,mBAAmB,EACnB,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,cAAc,EACd,qBAAqB,EACrB,eAAe,EACf,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,aAAa,EACb,sBAAsB,EACtB,WAAW,EACX,cAAc,EAEd,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,QAAQ,EACT,MAAM,gBAAgB,CAAC;AAExB,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,QAAQ,CAE5D;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAEnE;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,uBAAuB,CAGlC;AAqCD,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,mBAAmB,CAE9B;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAOxE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAcpE;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAQ5E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAOxE;AAED,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,qBAAqB,CAEhC;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,eAAe,CAI1E;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAEtE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAEpE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAOpE;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAQtE;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,sBAAsB,CAEjC;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,CAOlE;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAExE;AAYD,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAOpE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAEpE;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,iBAAiB,CAE5B;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAEtE"}
-153
View File
@@ -1,153 +0,0 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function isValueMap(value) {
return isObject(value) && "key" in value;
}
export function isPath(key, value) {
return key === "path" && typeof value === "string";
}
export function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isComponentArrayReference(value) {
if (!isObject(value))
return false;
return "explicitList" in value || "template" in value;
}
function isStringValue(value) {
return (isObject(value) &&
("path" in value ||
("literal" in value && typeof value.literal === "string") ||
"literalString" in value));
}
function isNumberValue(value) {
return (isObject(value) &&
("path" in value ||
("literal" in value && typeof value.literal === "number") ||
"literalNumber" in value));
}
function isBooleanValue(value) {
return (isObject(value) &&
("path" in value ||
("literal" in value && typeof value.literal === "boolean") ||
"literalBoolean" in value));
}
function isAnyComponentNode(value) {
if (!isObject(value))
return false;
const hasBaseKeys = "id" in value && "type" in value && "properties" in value;
if (!hasBaseKeys)
return false;
return true;
}
export function isResolvedAudioPlayer(props) {
return isObject(props) && "url" in props && isStringValue(props.url);
}
export function isResolvedButton(props) {
return (isObject(props) &&
"child" in props &&
isAnyComponentNode(props.child) &&
"action" in props);
}
export function isResolvedCard(props) {
if (!isObject(props))
return false;
if (!("child" in props)) {
if (!("children" in props)) {
return false;
}
else {
return (Array.isArray(props.children) &&
props.children.every(isAnyComponentNode));
}
}
return isAnyComponentNode(props.child);
}
export function isResolvedCheckbox(props) {
return (isObject(props) &&
"label" in props &&
isStringValue(props.label) &&
"value" in props &&
isBooleanValue(props.value));
}
export function isResolvedColumn(props) {
return (isObject(props) &&
"children" in props &&
Array.isArray(props.children) &&
props.children.every(isAnyComponentNode));
}
export function isResolvedDateTimeInput(props) {
return isObject(props) && "value" in props && isStringValue(props.value);
}
export function isResolvedDivider(props) {
// Dividers can have all optional properties, so just checking if
// it's an object is enough.
return isObject(props);
}
export function isResolvedImage(props) {
return isObject(props) && "url" in props && isStringValue(props.url);
}
export function isResolvedIcon(props) {
return isObject(props) && "name" in props && isStringValue(props.name);
}
export function isResolvedList(props) {
return (isObject(props) &&
"children" in props &&
Array.isArray(props.children) &&
props.children.every(isAnyComponentNode));
}
export function isResolvedModal(props) {
return (isObject(props) &&
"entryPointChild" in props &&
isAnyComponentNode(props.entryPointChild) &&
"contentChild" in props &&
isAnyComponentNode(props.contentChild));
}
export function isResolvedMultipleChoice(props) {
return isObject(props) && "selections" in props;
}
export function isResolvedRow(props) {
return (isObject(props) &&
"children" in props &&
Array.isArray(props.children) &&
props.children.every(isAnyComponentNode));
}
export function isResolvedSlider(props) {
return isObject(props) && "value" in props && isNumberValue(props.value);
}
function isResolvedTabItem(item) {
return (isObject(item) &&
"title" in item &&
isStringValue(item.title) &&
"child" in item &&
isAnyComponentNode(item.child));
}
export function isResolvedTabs(props) {
return (isObject(props) &&
"tabItems" in props &&
Array.isArray(props.tabItems) &&
props.tabItems.every(isResolvedTabItem));
}
export function isResolvedText(props) {
return isObject(props) && "text" in props && isStringValue(props.text);
}
export function isResolvedTextField(props) {
return isObject(props) && "label" in props && isStringValue(props.label);
}
export function isResolvedVideo(props) {
return isObject(props) && "url" in props && isStringValue(props.url);
}
//# sourceMappingURL=guards.js.map
@@ -1 +0,0 @@
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../../../../src/0.8/data/guards.ts"],"names":[],"mappings":"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"}
@@ -1,33 +0,0 @@
import { ServerToClientMessage, AnyComponentNode, DataValue, Surface, MessageProcessor } from "../types/types";
/**
* Processes and consolidates A2UIProtocolMessage objects into a structured,
* hierarchical model of UI surfaces.
*/
export declare class A2uiMessageProcessor implements MessageProcessor {
#private;
readonly opts: {
mapCtor: MapConstructor;
arrayCtor: ArrayConstructor;
setCtor: SetConstructor;
objCtor: ObjectConstructor;
};
static readonly DEFAULT_SURFACE_ID = "@default";
constructor(opts?: {
mapCtor: MapConstructor;
arrayCtor: ArrayConstructor;
setCtor: SetConstructor;
objCtor: ObjectConstructor;
});
getSurfaces(): ReadonlyMap<string, Surface>;
clearSurfaces(): void;
processMessages(messages: ServerToClientMessage[]): void;
/**
* Retrieves the data for a given component node and a relative path string.
* This correctly handles the special `.` path, which refers to the node's
* own data context.
*/
getData(node: AnyComponentNode, relativePath: string, surfaceId?: string): DataValue | null;
setData(node: AnyComponentNode | null, relativePath: string, value: DataValue, surfaceId?: string): void;
resolvePath(path: string, dataContextPath?: string): string;
}
//# sourceMappingURL=model-processor.d.ts.map
@@ -1 +0,0 @@
{"version":3,"file":"model-processor.d.ts","sourceRoot":"","sources":["../../../../src/0.8/data/model-processor.ts"],"names":[],"mappings":"AAgBA,OAAO,EACL,qBAAqB,EACrB,gBAAgB,EAKhB,SAAS,EAIT,OAAO,EAGP,gBAAgB,EAGjB,MAAM,gBAAgB,CAAC;AA0BxB;;;GAGG;AACH,qBAAa,oBAAqB,YAAW,gBAAgB;;IAUzD,QAAQ,CAAC,IAAI,EAAE;QACb,OAAO,EAAE,cAAc,CAAC;QACxB,SAAS,EAAE,gBAAgB,CAAC;QAC5B,OAAO,EAAE,cAAc,CAAC;QACxB,OAAO,EAAE,iBAAiB,CAAC;KAC5B;IAdH,MAAM,CAAC,QAAQ,CAAC,kBAAkB,cAAc;gBASrC,IAAI,GAAE;QACb,OAAO,EAAE,cAAc,CAAC;QACxB,SAAS,EAAE,gBAAgB,CAAC;QAC5B,OAAO,EAAE,cAAc,CAAC;QACxB,OAAO,EAAE,iBAAiB,CAAC;KACwC;IAUvE,WAAW,IAAI,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC;IAI3C,aAAa;IAIb,eAAe,CAAC,QAAQ,EAAE,qBAAqB,EAAE,GAAG,IAAI;IA6BxD;;;;OAIG;IACH,OAAO,CACL,IAAI,EAAE,gBAAgB,EACtB,YAAY,EAAE,MAAM,EACpB,SAAS,SAA0C,GAClD,SAAS,GAAG,IAAI;IAkBnB,OAAO,CACL,IAAI,EAAE,gBAAgB,GAAG,IAAI,EAC7B,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,SAAS,EAChB,SAAS,SAA0C,GAClD,IAAI;IAuBP,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,MAAM;CA8qB5D"}
@@ -1,632 +0,0 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { isComponentArrayReference, isObject, isPath, isResolvedAudioPlayer, isResolvedButton, isResolvedCard, isResolvedCheckbox, isResolvedColumn, isResolvedDateTimeInput, isResolvedDivider, isResolvedIcon, isResolvedImage, isResolvedList, isResolvedModal, isResolvedMultipleChoice, isResolvedRow, isResolvedSlider, isResolvedTabs, isResolvedText, isResolvedTextField, isResolvedVideo, } from "./guards.js";
/**
* Processes and consolidates A2UIProtocolMessage objects into a structured,
* hierarchical model of UI surfaces.
*/
export class A2uiMessageProcessor {
static { this.DEFAULT_SURFACE_ID = "@default"; }
#mapCtor = Map;
#arrayCtor = Array;
#setCtor = Set;
#objCtor = Object;
#surfaces;
constructor(opts = { mapCtor: Map, arrayCtor: Array, setCtor: Set, objCtor: Object }) {
this.opts = opts;
this.#arrayCtor = opts.arrayCtor;
this.#mapCtor = opts.mapCtor;
this.#setCtor = opts.setCtor;
this.#objCtor = opts.objCtor;
this.#surfaces = new opts.mapCtor();
}
getSurfaces() {
return this.#surfaces;
}
clearSurfaces() {
this.#surfaces.clear();
}
processMessages(messages) {
for (const message of messages) {
if (message.beginRendering) {
this.#handleBeginRendering(message.beginRendering, message.beginRendering.surfaceId);
}
if (message.surfaceUpdate) {
this.#handleSurfaceUpdate(message.surfaceUpdate, message.surfaceUpdate.surfaceId);
}
if (message.dataModelUpdate) {
this.#handleDataModelUpdate(message.dataModelUpdate, message.dataModelUpdate.surfaceId);
}
if (message.deleteSurface) {
this.#handleDeleteSurface(message.deleteSurface);
}
}
}
/**
* Retrieves the data for a given component node and a relative path string.
* This correctly handles the special `.` path, which refers to the node's
* own data context.
*/
getData(node, relativePath, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) {
const surface = this.#getOrCreateSurface(surfaceId);
if (!surface)
return null;
let finalPath;
// The special `.` path means the final path is the node's data context
// path and so we return the dataContextPath as-is.
if (relativePath === "." || relativePath === "") {
finalPath = node.dataContextPath ?? "/";
}
else {
// For all other paths, resolve them against the node's context.
finalPath = this.resolvePath(relativePath, node.dataContextPath);
}
return this.#getDataByPath(surface.dataModel, finalPath);
}
setData(node, relativePath, value, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) {
if (!node) {
console.warn("No component node set");
return;
}
const surface = this.#getOrCreateSurface(surfaceId);
if (!surface)
return;
let finalPath;
// The special `.` path means the final path is the node's data context
// path and so we return the dataContextPath as-is.
if (relativePath === "." || relativePath === "") {
finalPath = node.dataContextPath ?? "/";
}
else {
// For all other paths, resolve them against the node's context.
finalPath = this.resolvePath(relativePath, node.dataContextPath);
}
this.#setDataByPath(surface.dataModel, finalPath, value);
}
resolvePath(path, dataContextPath) {
// If the path is absolute, it overrides any context.
if (path.startsWith("/")) {
return path;
}
if (dataContextPath && dataContextPath !== "/") {
// Ensure there's exactly one slash between the context and the path.
return dataContextPath.endsWith("/")
? `${dataContextPath}${path}`
: `${dataContextPath}/${path}`;
}
// Fallback for no context or root context: make it an absolute path.
return `/${path}`;
}
#parseIfJsonString(value) {
if (typeof value !== "string") {
return value;
}
const trimmedValue = value.trim();
if ((trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) ||
(trimmedValue.startsWith("[") && trimmedValue.endsWith("]"))) {
try {
// It looks like JSON, attempt to parse it.
return JSON.parse(value);
}
catch (e) {
// It looked like JSON but wasn't. Keep the original string.
console.warn(`Failed to parse potential JSON string: "${value.substring(0, 50)}..."`, e);
return value; // Return original string
}
}
// It's a string, but not JSON-like.
return value;
}
/**
* Converts a specific array format [{key: "...", value_string: "..."}, ...]
* into a standard Map. It also attempts to parse any string values that
* appear to be stringified JSON.
*/
#convertKeyValueArrayToMap(arr) {
const map = new this.#mapCtor();
for (const item of arr) {
if (!isObject(item) || !("key" in item))
continue;
const key = item.key;
// Find the value, which is in a property prefixed with "value".
const valueKey = this.#findValueKey(item);
if (!valueKey)
continue;
let value = item[valueKey];
// It's a valueMap. We must recursively convert it.
if (valueKey === "valueMap" && Array.isArray(value)) {
value = this.#convertKeyValueArrayToMap(value);
}
else if (typeof value === "string") {
value = this.#parseIfJsonString(value);
}
this.#setDataByPath(map, key, value);
}
return map;
}
#setDataByPath(root, path, value) {
// Check if the incoming value is the special key-value array format.
if (Array.isArray(value) &&
(value.length === 0 || (isObject(value[0]) && "key" in value[0]))) {
// Check for "set primitive at path" convention:
// path: "/messages/123", contents: [{ key: ".", valueString: "hi" }]
if (value.length === 1 && isObject(value[0]) && value[0].key === ".") {
const item = value[0];
const valueKey = this.#findValueKey(item);
if (valueKey) {
// Extract the primitive value
value = item[valueKey];
// We must still process this value in case it's a valueMap or
// a JSON string.
if (valueKey === "valueMap" && Array.isArray(value)) {
value = this.#convertKeyValueArrayToMap(value);
}
else if (typeof value === "string") {
value = this.#parseIfJsonString(value);
}
// Now, `value` is the primitive (e.g., "hi"), and we continue
// the function.
}
else {
// Malformed, but fall back to existing behavior.
value = this.#convertKeyValueArrayToMap(value);
}
}
else {
value = this.#convertKeyValueArrayToMap(value);
}
}
const segments = this.#normalizePath(path)
.split("/")
.filter((s) => s);
if (segments.length === 0) {
// Root data can either be a Map or an Object. If we receive an Object,
// however, we will normalize it to a proper Map.
if (value instanceof Map || isObject(value)) {
// Normalize an Object to a Map.
if (!(value instanceof Map) && isObject(value)) {
value = new this.#mapCtor(Object.entries(value));
}
root.clear();
for (const [key, v] of value.entries()) {
root.set(key, v);
}
}
else {
console.error("Cannot set root of DataModel to a non-Map value.");
}
return;
}
let current = root;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
let target;
if (current instanceof Map) {
target = current.get(segment);
}
else if (Array.isArray(current) && /^\d+$/.test(segment)) {
target = current[parseInt(segment, 10)];
}
if (target === undefined ||
typeof target !== "object" ||
target === null) {
target = new this.#mapCtor();
if (current instanceof this.#mapCtor) {
current.set(segment, target);
}
else if (Array.isArray(current)) {
current[parseInt(segment, 10)] = target;
}
}
current = target;
}
const finalSegment = segments[segments.length - 1];
const storedValue = value;
if (current instanceof this.#mapCtor) {
current.set(finalSegment, storedValue);
}
else if (Array.isArray(current) && /^\d+$/.test(finalSegment)) {
current[parseInt(finalSegment, 10)] = storedValue;
}
}
/**
* Normalizes a path string into a consistent, slash-delimited format.
* Converts bracket notation and dot notation in a two-pass.
* e.g., "bookRecommendations[0].title" -> "/bookRecommendations/0/title"
* e.g., "book.0.title" -> "/book/0/title"
*/
#normalizePath(path) {
// 1. Replace all bracket accessors `[index]` with dot accessors `.index`
const dotPath = path.replace(/\[(\d+)\]/g, ".$1");
// 2. Split by dots
const segments = dotPath.split(".");
// 3. Join with slashes and ensure it starts with a slash
return "/" + segments.filter((s) => s.length > 0).join("/");
}
#getDataByPath(root, path) {
const segments = this.#normalizePath(path)
.split("/")
.filter((s) => s);
let current = root;
for (const segment of segments) {
if (current === undefined || current === null)
return null;
if (current instanceof Map) {
current = current.get(segment);
}
else if (Array.isArray(current) && /^\d+$/.test(segment)) {
current = current[parseInt(segment, 10)];
}
else if (isObject(current)) {
current = current[segment];
}
else {
// If we need to traverse deeper but `current` is a primitive, the path is invalid.
return null;
}
}
return current;
}
#getOrCreateSurface(surfaceId) {
let surface = this.#surfaces.get(surfaceId);
if (!surface) {
surface = new this.#objCtor({
rootComponentId: null,
componentTree: null,
dataModel: new this.#mapCtor(),
components: new this.#mapCtor(),
styles: new this.#objCtor(),
});
this.#surfaces.set(surfaceId, surface);
}
return surface;
}
#handleBeginRendering(message, surfaceId) {
const surface = this.#getOrCreateSurface(surfaceId);
surface.rootComponentId = message.root;
surface.styles = message.styles ?? {};
this.#rebuildComponentTree(surface);
}
#handleSurfaceUpdate(message, surfaceId) {
const surface = this.#getOrCreateSurface(surfaceId);
for (const component of message.components) {
surface.components.set(component.id, component);
}
this.#rebuildComponentTree(surface);
}
#handleDataModelUpdate(message, surfaceId) {
const surface = this.#getOrCreateSurface(surfaceId);
const path = message.path ?? "/";
this.#setDataByPath(surface.dataModel, path, message.contents);
this.#rebuildComponentTree(surface);
}
#handleDeleteSurface(message) {
this.#surfaces.delete(message.surfaceId);
}
/**
* Starts at the root component of the surface and builds out the tree
* recursively. This process involves resolving all properties of the child
* components, and expanding on any explicit children lists or templates
* found in the structure.
*
* @param surface The surface to be built.
*/
#rebuildComponentTree(surface) {
if (!surface.rootComponentId) {
surface.componentTree = null;
return;
}
// Track visited nodes to avoid circular references.
const visited = new this.#setCtor();
surface.componentTree = this.#buildNodeRecursive(surface.rootComponentId, surface, visited, "/", "" // Initial idSuffix.
);
}
/** Finds a value key in a map. */
#findValueKey(value) {
return Object.keys(value).find((k) => k.startsWith("value"));
}
/**
* Builds out the nodes recursively.
*/
#buildNodeRecursive(baseComponentId, surface, visited, dataContextPath, idSuffix = "") {
const fullId = `${baseComponentId}${idSuffix}`; // Construct the full ID
const { components } = surface;
if (!components.has(baseComponentId)) {
return null;
}
if (visited.has(fullId)) {
throw new Error(`Circular dependency for component "${fullId}".`);
}
visited.add(fullId);
const componentData = components.get(baseComponentId);
const componentProps = componentData.component ?? {};
const componentType = Object.keys(componentProps)[0];
const unresolvedProperties = componentProps[componentType];
// Manually build the resolvedProperties object by resolving each value in
// the component's properties.
const resolvedProperties = new this.#objCtor();
if (isObject(unresolvedProperties)) {
for (const [key, value] of Object.entries(unresolvedProperties)) {
resolvedProperties[key] = this.#resolvePropertyValue(value, surface, visited, dataContextPath, idSuffix, key);
}
}
visited.delete(fullId);
// Now that we have the resolved properties in place we can go ahead and
// ensure that they meet expectations in terms of types and so forth,
// casting them into the specific shape for usage.
const baseNode = {
id: fullId,
dataContextPath,
weight: componentData.weight ?? "initial",
};
switch (componentType) {
case "Text":
if (!isResolvedText(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Text",
properties: resolvedProperties,
});
case "Image":
if (!isResolvedImage(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Image",
properties: resolvedProperties,
});
case "Icon":
if (!isResolvedIcon(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Icon",
properties: resolvedProperties,
});
case "Video":
if (!isResolvedVideo(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Video",
properties: resolvedProperties,
});
case "AudioPlayer":
if (!isResolvedAudioPlayer(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "AudioPlayer",
properties: resolvedProperties,
});
case "Row":
if (!isResolvedRow(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Row",
properties: resolvedProperties,
});
case "Column":
if (!isResolvedColumn(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Column",
properties: resolvedProperties,
});
case "List":
if (!isResolvedList(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "List",
properties: resolvedProperties,
});
case "Card":
if (!isResolvedCard(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Card",
properties: resolvedProperties,
});
case "Tabs":
if (!isResolvedTabs(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Tabs",
properties: resolvedProperties,
});
case "Divider":
if (!isResolvedDivider(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Divider",
properties: resolvedProperties,
});
case "Modal":
if (!isResolvedModal(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Modal",
properties: resolvedProperties,
});
case "Button":
if (!isResolvedButton(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Button",
properties: resolvedProperties,
});
case "CheckBox":
if (!isResolvedCheckbox(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "CheckBox",
properties: resolvedProperties,
});
case "TextField":
if (!isResolvedTextField(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "TextField",
properties: resolvedProperties,
});
case "DateTimeInput":
if (!isResolvedDateTimeInput(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "DateTimeInput",
properties: resolvedProperties,
});
case "MultipleChoice":
if (!isResolvedMultipleChoice(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "MultipleChoice",
properties: resolvedProperties,
});
case "Slider":
if (!isResolvedSlider(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Slider",
properties: resolvedProperties,
});
default:
// Catch-all for other custom component types.
return new this.#objCtor({
...baseNode,
type: componentType,
properties: resolvedProperties,
});
}
}
/**
* Recursively resolves an individual property value. If a property indicates
* a child node (a string that matches a component ID), an explicitList of
* children, or a template, these will be built out here.
*/
#resolvePropertyValue(value, surface, visited, dataContextPath, idSuffix = "", propertyKey = null) {
const isComponentIdReferenceKey = (key) => key === "child" || key.endsWith("Child");
// 1. If it's a string that matches a component ID, build that node.
if (typeof value === "string" &&
propertyKey &&
isComponentIdReferenceKey(propertyKey) &&
surface.components.has(value)) {
return this.#buildNodeRecursive(value, surface, visited, dataContextPath, idSuffix);
}
// 2. If it's a ComponentArrayReference (e.g., a `children` property),
// resolve the list and return an array of nodes.
if (isComponentArrayReference(value)) {
if (value.explicitList) {
return value.explicitList.map((id) => this.#buildNodeRecursive(id, surface, visited, dataContextPath, idSuffix));
}
if (value.template) {
const fullDataPath = this.resolvePath(value.template.dataBinding, dataContextPath);
const data = this.#getDataByPath(surface.dataModel, fullDataPath);
const template = value.template;
// Handle Array data.
if (Array.isArray(data)) {
return data.map((_, index) => {
// Create a synthetic ID based on the template ID and the
// full index path of the data (e.g., template-id:0:1)
const parentIndices = dataContextPath
.split("/")
.filter((segment) => /^\d+$/.test(segment));
const newIndices = [...parentIndices, index];
const newSuffix = `:${newIndices.join(":")}`;
const childDataContextPath = `${fullDataPath}/${index}`;
return this.#buildNodeRecursive(template.componentId, // baseId
surface, visited, childDataContextPath, newSuffix // new suffix
);
});
}
// Handle Map data.
const mapCtor = this.#mapCtor;
if (data instanceof mapCtor) {
return Array.from(data.keys(), (key) => {
const newSuffix = `:${key}`;
const childDataContextPath = `${fullDataPath}/${key}`;
return this.#buildNodeRecursive(template.componentId, // baseId
surface, visited, childDataContextPath, newSuffix // new suffix
);
});
}
// Return empty array if the data is not ready yet.
return new this.#arrayCtor();
}
}
// 3. If it's a plain array, resolve each of its items.
if (Array.isArray(value)) {
return value.map((item) => this.#resolvePropertyValue(item, surface, visited, dataContextPath, idSuffix, propertyKey));
}
// 4. If it's a plain object, resolve each of its properties.
if (isObject(value)) {
const newObj = new this.#objCtor();
for (const [key, propValue] of Object.entries(value)) {
// Special case for paths. Here we might get /item/ or ./ on the front
// of the path which isn't what we want. In this case we check the
// dataContextPath and if 1) it's not the default and 2) we also see the
// path beginning with /item/ or ./we trim it.
let propertyValue = propValue;
if (isPath(key, propValue) && dataContextPath !== "/") {
propertyValue = propValue
.replace(/^\.?\/item/, "")
.replace(/^\.?\/text/, "")
.replace(/^\.?\/label/, "")
.replace(/^\.?\//, "");
newObj[key] = propertyValue;
continue;
}
newObj[key] = this.#resolvePropertyValue(propertyValue, surface, visited, dataContextPath, idSuffix, key);
}
return newObj;
}
// 5. Otherwise, it's a primitive value.
return value;
}
}
//# sourceMappingURL=model-processor.js.map
@@ -1 +0,0 @@
{"version":3,"file":"model-processor.js","sourceRoot":"","sources":["../../../../src/0.8/data/model-processor.ts"],"names":[],"mappings":"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 truncated
@@ -1,3 +0,0 @@
import { A2uiMessageProcessor } from "./model-processor.js";
export declare function create(): A2uiMessageProcessor;
//# sourceMappingURL=signal-model-processor.d.ts.map
@@ -1 +0,0 @@
{"version":3,"file":"signal-model-processor.d.ts","sourceRoot":"","sources":["../../../../src/0.8/data/signal-model-processor.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAO5D,wBAAgB,MAAM,yBAOrB"}
@@ -1,29 +0,0 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { A2uiMessageProcessor } from "./model-processor.js";
import { SignalArray } from "signal-utils/array";
import { SignalMap } from "signal-utils/map";
import { SignalObject } from "signal-utils/object";
import { SignalSet } from "signal-utils/set";
export function create() {
return new A2uiMessageProcessor({
arrayCtor: SignalArray,
mapCtor: SignalMap,
objCtor: SignalObject,
setCtor: SignalSet,
});
}
//# sourceMappingURL=signal-model-processor.js.map
@@ -1 +0,0 @@
{"version":3,"file":"signal-model-processor.js","sourceRoot":"","sources":["../../../../src/0.8/data/signal-model-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAE5D,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE7C,MAAM,UAAU,MAAM;IACpB,OAAO,IAAI,oBAAoB,CAAC;QAC9B,SAAS,EAAE,WAA0C;QACrD,OAAO,EAAE,SAAsC;QAC/C,OAAO,EAAE,YAA4C;QACrD,OAAO,EAAE,SAAsC;KAChD,CAAC,CAAC;AACL,CAAC"}
-12
View File
@@ -1,12 +0,0 @@
import { Action } from "../types/components.js";
import { AnyComponentNode } from "../types/types.js";
import { BaseEventDetail } from "./base.js";
type Namespace = "a2ui";
export interface A2UIAction extends BaseEventDetail<`${Namespace}.action`> {
readonly action: Action;
readonly dataContextPath: string;
readonly sourceComponentId: string;
readonly sourceComponent: AnyComponentNode | null;
}
export {};
//# sourceMappingURL=a2ui.d.ts.map
@@ -1 +0,0 @@
{"version":3,"file":"a2ui.d.ts","sourceRoot":"","sources":["../../../../src/0.8/events/a2ui.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAE5C,KAAK,SAAS,GAAG,MAAM,CAAC;AAExB,MAAM,WAAW,UAAW,SAAQ,eAAe,CAAC,GAAG,SAAS,SAAS,CAAC;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,eAAe,EAAE,gBAAgB,GAAG,IAAI,CAAC;CACnD"}
-17
View File
@@ -1,17 +0,0 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export {};
//# sourceMappingURL=a2ui.js.map
@@ -1 +0,0 @@
{"version":3,"file":"a2ui.js","sourceRoot":"","sources":["../../../../src/0.8/events/a2ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
@@ -1,4 +0,0 @@
export interface BaseEventDetail<EventType extends string> {
readonly eventType: EventType;
}
//# sourceMappingURL=base.d.ts.map
@@ -1 +0,0 @@
{"version":3,"file":"base.d.ts","sourceRoot":"","sources":["../../../../src/0.8/events/base.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,eAAe,CAAC,SAAS,SAAS,MAAM;IACvD,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;CAC/B"}
-17
View File
@@ -1,17 +0,0 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export {};
//# sourceMappingURL=base.js.map
@@ -1 +0,0 @@
{"version":3,"file":"base.js","sourceRoot":"","sources":["../../../../src/0.8/events/base.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
@@ -1,20 +0,0 @@
import type * as A2UI from "./a2ui.js";
import { BaseEventDetail } from "./base.js";
type EnforceEventTypeMatch<T extends Record<string, BaseEventDetail<string>>> = {
[K in keyof T]: T[K] extends BaseEventDetail<infer EventType> ? EventType extends K ? T[K] : never : never;
};
export type StateEventDetailMap = EnforceEventTypeMatch<{
"a2ui.action": A2UI.A2UIAction;
}>;
export declare class StateEvent<T extends keyof StateEventDetailMap> extends CustomEvent<StateEventDetailMap[T]> {
readonly payload: StateEventDetailMap[T];
static eventName: string;
constructor(payload: StateEventDetailMap[T]);
}
declare global {
interface HTMLElementEventMap {
a2uiaction: StateEvent<"a2ui.action">;
}
}
export {};
//# sourceMappingURL=events.d.ts.map
@@ -1 +0,0 @@
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../../src/0.8/events/events.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAQ5C,KAAK,qBAAqB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,MAAM,CAAC,CAAC,IAC1E;KACG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,eAAe,CAAC,MAAM,SAAS,CAAC,GACzD,SAAS,SAAS,CAAC,GACjB,CAAC,CAAC,CAAC,CAAC,GACJ,KAAK,GACP,KAAK;CACV,CAAC;AAEJ,MAAM,MAAM,mBAAmB,GAAG,qBAAqB,CAAC;IACtD,aAAa,EAAE,IAAI,CAAC,UAAU,CAAC;CAChC,CAAC,CAAC;AAEH,qBAAa,UAAU,CACrB,CAAC,SAAS,MAAM,mBAAmB,CACnC,SAAQ,WAAW,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC;IAG/B,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC;IAFpD,MAAM,CAAC,SAAS,SAAgB;gBAEX,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC;CAGrD;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,mBAAmB;QAC3B,UAAU,EAAE,UAAU,CAAC,aAAa,CAAC,CAAC;KACvC;CACF"}
-28
View File
@@ -1,28 +0,0 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const eventInit = {
bubbles: true,
cancelable: true,
composed: true,
};
export class StateEvent extends CustomEvent {
static { this.eventName = "a2uiaction"; }
constructor(payload) {
super(StateEvent.eventName, { detail: payload, ...eventInit });
this.payload = payload;
}
}
//# sourceMappingURL=events.js.map
@@ -1 +0,0 @@
{"version":3,"file":"events.js","sourceRoot":"","sources":["../../../../src/0.8/events/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,MAAM,SAAS,GAAG;IAChB,OAAO,EAAE,IAAI;IACb,UAAU,EAAE,IAAI;IAChB,QAAQ,EAAE,IAAI;CACf,CAAC;AAeF,MAAM,OAAO,UAEX,SAAQ,WAAmC;aACpC,cAAS,GAAG,YAAY,CAAC;IAEhC,YAAqB,OAA+B;QAClD,KAAK,CAAC,UAAU,CAAC,SAAS,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,SAAS,EAAE,CAAC,CAAC;QAD5C,YAAO,GAAP,OAAO,CAAwB;IAEpD,CAAC"}
-3
View File
@@ -1,3 +0,0 @@
export * from "./core.js";
export * as UI from "./ui/ui.js";
//# sourceMappingURL=index.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/0.8/index.ts"],"names":[],"mappings":"AAgBA,cAAc,WAAW,CAAC;AAC1B,OAAO,KAAK,EAAE,MAAM,YAAY,CAAC"}
-18
View File
@@ -1,18 +0,0 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export * from "./core.js";
export * as UI from "./ui/ui.js";
//# sourceMappingURL=index.js.map
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff. Show more