diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cad9f980..2bb01b047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ - Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. +- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. - Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. diff --git a/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt b/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt index 4d688a11f..021ebf587 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt @@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val remoteAddress: StateFlow = runtime.remoteAddress val isForeground: StateFlow = runtime.isForeground val seamColorArgb: StateFlow = runtime.seamColorArgb + val mainSessionKey: StateFlow = runtime.mainSessionKey val cameraHud: StateFlow = runtime.cameraHud val cameraFlashToken: StateFlow = runtime.cameraFlashToken @@ -138,7 +139,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.handleCanvasA2UIActionFromWebView(payloadJson) } - fun loadChat(sessionKey: String = "main") { + fun loadChat(sessionKey: String) { runtime.loadChat(sessionKey) } diff --git a/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt b/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt index 8e753ff95..469c7c3ba 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt @@ -78,7 +78,7 @@ class NodeRuntime(context: Context) { payloadJson = buildJsonObject { put("message", JsonPrimitive(command)) - put("sessionKey", JsonPrimitive(mainSessionKey.value)) + put("sessionKey", JsonPrimitive(resolveMainSessionKey())) put("thinking", JsonPrimitive(chatThinkingLevel.value)) put("deliver", JsonPrimitive(false)) }.toString(), @@ -142,12 +142,13 @@ class NodeRuntime(context: Context) { private val session = BridgeSession( scope = scope, - onConnected = { name, remote -> + onConnected = { name, remote, mainSessionKey -> _statusText.value = "Connected" _serverName.value = name _remoteAddress.value = remote _isConnected.value = true _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + applyMainSessionKey(mainSessionKey) scope.launch { refreshBrandingFromGateway() } scope.launch { refreshWakeWordsFromGateway() } maybeNavigateToA2uiOnConnect() @@ -172,11 +173,31 @@ class NodeRuntime(context: Context) { _remoteAddress.value = null _isConnected.value = false _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB - _mainSessionKey.value = "main" + if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { + _mainSessionKey.value = "main" + } + val mainKey = resolveMainSessionKey() + talkMode.setMainSessionKey(mainKey) + chat.applyMainSessionKey(mainKey) chat.onDisconnected(message) showLocalCanvasOnDisconnect() } + private fun applyMainSessionKey(candidate: String?) { + val trimmed = candidate?.trim().orEmpty() + if (trimmed.isEmpty()) return + if (isCanonicalMainSessionKey(_mainSessionKey.value)) return + if (_mainSessionKey.value == trimmed) return + _mainSessionKey.value = trimmed + talkMode.setMainSessionKey(trimmed) + chat.applyMainSessionKey(trimmed) + } + + private fun resolveMainSessionKey(): String { + val trimmed = _mainSessionKey.value.trim() + return if (trimmed.isEmpty()) "main" else trimmed + } + private fun maybeNavigateToA2uiOnConnect() { val a2uiUrl = resolveA2uiHostUrl() ?: return val current = canvas.currentUrl()?.trim().orEmpty() @@ -559,7 +580,7 @@ class NodeRuntime(context: Context) { (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } val contextJson = (userActionObj["context"] as? JsonObject)?.toString() - val sessionKey = "main" + val sessionKey = resolveMainSessionKey() val message = ClawdbotCanvasA2UIAction.formatAgentMessage( actionName = name, @@ -607,8 +628,9 @@ class NodeRuntime(context: Context) { } } - fun loadChat(sessionKey: String = "main") { - chat.load(sessionKey) + fun loadChat(sessionKey: String) { + val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() } + chat.load(key) } fun refreshChat() { @@ -701,7 +723,7 @@ class NodeRuntime(context: Context) { val raw = ui?.get("seamColor").asStringOrNull()?.trim() val sessionCfg = config?.get("session").asObjectOrNull() val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - _mainSessionKey.value = mainKey + applyMainSessionKey(mainKey) val parsed = parseHexColorArgb(raw) _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB diff --git a/apps/android/app/src/main/java/com/clawdbot/android/SessionKey.kt b/apps/android/app/src/main/java/com/clawdbot/android/SessionKey.kt index f1897e7b4..e1aae9ec0 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/SessionKey.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/SessionKey.kt @@ -4,3 +4,10 @@ internal fun normalizeMainKey(raw: String?): String { val trimmed = raw?.trim() return if (!trimmed.isNullOrEmpty()) trimmed else "main" } + +internal fun isCanonicalMainSessionKey(raw: String?): Boolean { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return false + if (trimmed == "global") return true + return trimmed.startsWith("agent:") +} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeSession.kt b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeSession.kt index c9633db3a..1135068cc 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeSession.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/bridge/BridgeSession.kt @@ -31,7 +31,7 @@ import java.util.concurrent.ConcurrentHashMap class BridgeSession( private val scope: CoroutineScope, - private val onConnected: (serverName: String, remoteAddress: String?) -> Unit, + private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onDisconnected: (message: String) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onInvoke: suspend (InvokeRequest) -> InvokeResult, @@ -64,6 +64,7 @@ class BridgeSession( private val writeLock = Mutex() private val pending = ConcurrentHashMap>() @Volatile private var canvasHostUrl: String? = null + @Volatile private var mainSessionKey: String? = null private var desired: Pair? = null private var job: Job? = null @@ -90,11 +91,13 @@ class BridgeSession( job?.cancelAndJoin() job = null canvasHostUrl = null + mainSessionKey = null onDisconnected("Offline") } } fun currentCanvasHostUrl(): String? = canvasHostUrl + fun currentMainSessionKey(): String? = mainSessionKey suspend fun sendEvent(event: String, payloadJson: String?) { val conn = currentConnection ?: return @@ -212,7 +215,9 @@ class BridgeSession( "hello-ok" -> { val name = first["serverName"].asStringOrNull() ?: "Bridge" val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null } + val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null } canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint) + mainSessionKey = rawMainSessionKey if (BuildConfig.DEBUG) { // Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked". runCatching { @@ -222,7 +227,7 @@ class BridgeSession( ) } } - onConnected(name, conn.remoteAddress) + onConnected(name, conn.remoteAddress, rawMainSessionKey) } "error" -> { val code = first["code"].asStringOrNull() ?: "UNAVAILABLE" diff --git a/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatController.kt b/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatController.kt index 01041b08d..794bd9edf 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatController.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatController.kt @@ -71,12 +71,21 @@ class ChatController( _sessionId.value = null } - fun load(sessionKey: String = "main") { + fun load(sessionKey: String) { val key = sessionKey.trim().ifEmpty { "main" } _sessionKey.value = key scope.launch { bootstrap(forceHealth = true) } } + fun applyMainSessionKey(mainSessionKey: String) { + val trimmed = mainSessionKey.trim() + if (trimmed.isEmpty()) return + if (_sessionKey.value == trimmed) return + if (_sessionKey.value != "main") return + _sessionKey.value = trimmed + scope.launch { bootstrap(forceHealth = true) } + } + fun refresh() { scope.launch { bootstrap(forceHealth = true) } } diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt index ab23a4980..1f30938e0 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt @@ -44,6 +44,7 @@ import com.clawdbot.android.chat.ChatSessionEntry fun ChatComposer( sessionKey: String, sessions: List, + mainSessionKey: String, healthOk: Boolean, thinkingLevel: String, pendingRunCount: Int, @@ -61,7 +62,7 @@ fun ChatComposer( var showThinkingMenu by remember { mutableStateOf(false) } var showSessionMenu by remember { mutableStateOf(false) } - val sessionOptions = resolveSessionChoices(sessionKey, sessions) + val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) val currentSessionLabel = sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt index 6b791d130..2b58c626b 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt @@ -33,13 +33,14 @@ fun ChatSheetContent(viewModel: MainViewModel) { val pendingRunCount by viewModel.pendingRunCount.collectAsState() val healthOk by viewModel.chatHealthOk.collectAsState() val sessionKey by viewModel.chatSessionKey.collectAsState() + val mainSessionKey by viewModel.mainSessionKey.collectAsState() val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val sessions by viewModel.chatSessions.collectAsState() - LaunchedEffect(Unit) { - viewModel.loadChat("main") + LaunchedEffect(mainSessionKey) { + viewModel.loadChat(mainSessionKey) viewModel.refreshChatSessions(limit = 200) } @@ -85,6 +86,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { ChatComposer( sessionKey = sessionKey, sessions = sessions, + mainSessionKey = mainSessionKey, healthOk = healthOk, thinkingLevel = thinkingLevel, pendingRunCount = pendingRunCount, diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/SessionFilters.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/SessionFilters.kt index 8ba2b8c84..da08dbd1e 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/SessionFilters.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/SessionFilters.kt @@ -2,20 +2,23 @@ package com.clawdbot.android.ui.chat import com.clawdbot.android.chat.ChatSessionEntry -private const val MAIN_SESSION_KEY = "main" private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L fun resolveSessionChoices( currentSessionKey: String, sessions: List, + mainSessionKey: String, nowMs: Long = System.currentTimeMillis(), ): List { - val current = currentSessionKey.trim() + val mainKey = mainSessionKey.trim().ifEmpty { "main" } + val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it } + val aliasKey = if (mainKey == "main") null else "main" val cutoff = nowMs - RECENT_WINDOW_MS val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L } val recent = mutableListOf() val seen = mutableSetOf() for (entry in sorted) { + if (aliasKey != null && entry.key == aliasKey) continue if (!seen.add(entry.key)) continue if ((entry.updatedAtMs ?: 0L) < cutoff) continue recent.add(entry) @@ -23,13 +26,13 @@ fun resolveSessionChoices( val result = mutableListOf() val included = mutableSetOf() - val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY } + val mainEntry = sorted.firstOrNull { it.key == mainKey } if (mainEntry != null) { result.add(mainEntry) - included.add(MAIN_SESSION_KEY) - } else if (current == MAIN_SESSION_KEY) { - result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null)) - included.add(MAIN_SESSION_KEY) + included.add(mainKey) + } else if (current == mainKey) { + result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null)) + included.add(mainKey) } for (entry in recent) { diff --git a/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkModeManager.kt index 18a4b5965..919a0ce3c 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkModeManager.kt @@ -21,6 +21,7 @@ import android.speech.tts.UtteranceProgressListener import android.util.Log import androidx.core.content.ContextCompat import com.clawdbot.android.bridge.BridgeSession +import com.clawdbot.android.isCanonicalMainSessionKey import com.clawdbot.android.normalizeMainKey import java.net.HttpURLConnection import java.net.URL @@ -116,6 +117,13 @@ class TalkModeManager( chatSubscribedSessionKey = null } + fun setMainSessionKey(sessionKey: String?) { + val trimmed = sessionKey?.trim().orEmpty() + if (trimmed.isEmpty()) return + if (isCanonicalMainSessionKey(mainSessionKey)) return + mainSessionKey = trimmed + } + fun setEnabled(enabled: Boolean) { if (_isEnabled.value == enabled) return _isEnabled.value = enabled @@ -827,7 +835,9 @@ class TalkModeManager( val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() - mainSessionKey = mainKey + if (!isCanonicalMainSessionKey(mainSessionKey)) { + mainSessionKey = mainKey + } defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } voiceAliases = aliases if (!voiceOverrideActive) currentVoiceId = defaultVoiceId diff --git a/apps/ios/Sources/Bridge/BridgeSession.swift b/apps/ios/Sources/Bridge/BridgeSession.swift index 9e0f20604..41ccf413d 100644 --- a/apps/ios/Sources/Bridge/BridgeSession.swift +++ b/apps/ios/Sources/Bridge/BridgeSession.swift @@ -26,6 +26,7 @@ actor BridgeSession { private(set) var state: State = .idle private var canvasHostUrl: String? + private var mainSessionKey: String? func currentCanvasHostUrl() -> String? { self.canvasHostUrl @@ -68,7 +69,7 @@ actor BridgeSession { func connect( endpoint: NWEndpoint, hello: BridgeHello, - onConnected: (@Sendable (String) async -> Void)? = nil, + onConnected: (@Sendable (String, String?) async -> Void)? = nil, onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws { @@ -107,7 +108,9 @@ actor BridgeSession { let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) self.state = .connected(serverName: ok.serverName) self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) - await onConnected?(ok.serverName) + let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil + await onConnected?(ok.serverName, self.mainSessionKey) } else if base.type == "error" { let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) self.state = .failed(message: "\(err.code): \(err.message)") @@ -217,6 +220,7 @@ actor BridgeSession { self.queue = nil self.buffer = Data() self.canvasHostUrl = nil + self.mainSessionKey = nil let pending = self.pendingRPC.values self.pendingRPC.removeAll() @@ -234,6 +238,10 @@ actor BridgeSession { self.state = .idle } + func currentMainSessionKey() -> String? { + self.mainSessionKey + } + private func beginRPC( id: String, request: BridgeRPCRequest, diff --git a/apps/ios/Sources/Chat/ChatSheet.swift b/apps/ios/Sources/Chat/ChatSheet.swift index a7c85bdf1..0db033238 100644 --- a/apps/ios/Sources/Chat/ChatSheet.swift +++ b/apps/ios/Sources/Chat/ChatSheet.swift @@ -6,7 +6,7 @@ struct ChatSheet: View { @State private var viewModel: ClawdbotChatViewModel private let userAccent: Color? - init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) { + init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) { let transport = IOSBridgeChatTransport(bridge: bridge) self._viewModel = State( initialValue: ClawdbotChatViewModel( diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index e44e0d113..683c090ae 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -109,7 +109,7 @@ final class NodeAppModel { let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"]) - let sessionKey = "main" + let sessionKey = self.mainSessionKey let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext( actionName: name, @@ -232,12 +232,15 @@ final class NodeAppModel { try await self.bridge.connect( endpoint: endpoint, hello: hello, - onConnected: { [weak self] serverName in + onConnected: { [weak self] serverName, mainSessionKey in guard let self else { return } await MainActor.run { self.bridgeStatusText = "Connected" self.bridgeServerName = serverName } + await MainActor.run { + self.applyMainSessionKey(mainSessionKey) + } if let addr = await self.bridge.currentRemoteAddress() { await MainActor.run { self.bridgeRemoteAddress = addr @@ -286,7 +289,10 @@ final class NodeAppModel { self.bridgeRemoteAddress = nil self.connectedBridgeID = nil self.seamColorHex = nil - self.mainSessionKey = "main" + if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { + self.mainSessionKey = "main" + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } self.showLocalCanvasOnDisconnect() } } @@ -303,10 +309,23 @@ final class NodeAppModel { self.bridgeRemoteAddress = nil self.connectedBridgeID = nil self.seamColorHex = nil - self.mainSessionKey = "main" + if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { + self.mainSessionKey = "main" + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } self.showLocalCanvasOnDisconnect() } + private func applyMainSessionKey(_ key: String?) { + let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + if SessionKey.isCanonicalMainSessionKey(current) { return } + if trimmed == current { return } + self.mainSessionKey = trimmed + self.talkMode.updateMainSessionKey(trimmed) + } + var seamColor: Color { Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor } @@ -335,7 +354,10 @@ final class NodeAppModel { let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) await MainActor.run { self.seamColorHex = raw.isEmpty ? nil : raw - self.mainSessionKey = mainKey + if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { + self.mainSessionKey = mainKey + self.talkMode.updateMainSessionKey(mainKey) + } } } catch { // ignore diff --git a/apps/ios/Sources/SessionKey.swift b/apps/ios/Sources/SessionKey.swift index 16a57d5d5..bac73f670 100644 --- a/apps/ios/Sources/SessionKey.swift +++ b/apps/ios/Sources/SessionKey.swift @@ -5,4 +5,11 @@ enum SessionKey { let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "main" : trimmed } + + static func isCanonicalMainSessionKey(_ value: String?) -> Bool { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return false } + if trimmed == "global" { return true } + return trimmed.hasPrefix("agent:") + } } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 943f18604..9c0e1303d 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -53,6 +53,13 @@ final class TalkModeManager: NSObject { self.bridge = bridge } + func updateMainSessionKey(_ sessionKey: String?) { + let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return } + self.mainSessionKey = trimmed + } + func setEnabled(_ enabled: Bool) { self.isEnabled = enabled if enabled { @@ -649,7 +656,10 @@ final class TalkModeManager: NSObject { guard let config = json["config"] as? [String: Any] else { return } let talk = config["talk"] as? [String: Any] let session = config["session"] as? [String: Any] - self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) + let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) + if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { + self.mainSessionKey = mainKey + } self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) if let aliases = talk?["voiceAliases"] as? [String: Any] { var resolved: [String: String] = [:] diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift index 003fe5993..ebe753e5d 100644 --- a/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift +++ b/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift @@ -282,7 +282,12 @@ actor BridgeConnectionHandler { do { try await self.send(BridgePairOk(type: "pair-ok", token: token)) self.isAuthenticated = true - try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName)) + let mainSessionKey = await GatewayConnection.shared.mainSessionKey() + try await self.send( + BridgeHelloOk( + type: "hello-ok", + serverName: serverName, + mainSessionKey: mainSessionKey)) } catch { self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)") } @@ -298,7 +303,12 @@ actor BridgeConnectionHandler { case .ok: self.isAuthenticated = true do { - try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName)) + let mainSessionKey = await GatewayConnection.shared.mainSessionKey() + try await self.send( + BridgeHelloOk( + type: "hello-ok", + serverName: serverName, + mainSessionKey: mainSessionKey)) } catch { self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)") } diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 68ff474dd..7e30cd3a0 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -237,6 +237,13 @@ actor GatewayConnection { return trimmed.isEmpty ? nil : trimmed } + func cachedMainSessionKey() -> String? { + guard let snapshot = self.lastSnapshot else { return nil } + let trimmed = snapshot.snapshot.sessiondefaults?.mainsessionkey + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + func snapshotPaths() -> (configPath: String?, stateDir: String?) { guard let snapshot = self.lastSnapshot else { return (nil, nil) } let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -268,12 +275,35 @@ actor GatewayConnection { private func broadcast(_ push: GatewayPush) { if case let .snapshot(snapshot) = push { self.lastSnapshot = snapshot + if let mainSessionKey = self.cachedMainSessionKey() { + Task { @MainActor in + WorkActivityStore.shared.setMainSessionKey(mainSessionKey) + } + } } for (_, continuation) in self.subscribers { continuation.yield(push) } } + private func canonicalizeSessionKey(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed } + let mainSessionKey = defaults.mainsessionkey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !mainSessionKey.isEmpty else { return trimmed } + let mainKey = defaults.mainkey.trimmingCharacters(in: .whitespacesAndNewlines) + let defaultAgentId = defaults.defaultagentid.trimmingCharacters(in: .whitespacesAndNewlines) + let isMainAlias = + trimmed == "main" || + (!mainKey.isEmpty && trimmed == mainKey) || + trimmed == mainSessionKey || + (!defaultAgentId.isEmpty && + (trimmed == "agent:\(defaultAgentId):main" || + (mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)"))) + return isMainAlias ? mainSessionKey : trimmed + } + private func configure(url: URL, token: String?, password: String?) async { if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password @@ -332,6 +362,9 @@ extension GatewayConnection { } func mainSessionKey(timeoutMs: Double = 15000) async -> String { + if let cached = self.cachedMainSessionKey() { + return cached + } do { let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) return try Self.mainSessionKey(fromConfigGetData: data) @@ -362,10 +395,11 @@ extension GatewayConnection { func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return (false, "message empty") } + let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey) var params: [String: AnyCodable] = [ "message": AnyCodable(trimmed), - "sessionKey": AnyCodable(invocation.sessionKey), + "sessionKey": AnyCodable(sessionKey), "thinking": AnyCodable(invocation.thinking ?? "default"), "deliver": AnyCodable(invocation.deliver), "to": AnyCodable(invocation.to ?? ""), @@ -469,7 +503,8 @@ extension GatewayConnection { limit: Int? = nil, timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload { - var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)] + let resolvedKey = self.canonicalizeSessionKey(sessionKey) + var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)] if let limit { params["limit"] = AnyCodable(limit) } let timeout = timeoutMs.map { Double($0) } return try await self.requestDecoded( @@ -486,8 +521,9 @@ extension GatewayConnection { attachments: [ClawdbotChatAttachmentPayload], timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse { + let resolvedKey = self.canonicalizeSessionKey(sessionKey) var params: [String: AnyCodable] = [ - "sessionKey": AnyCodable(sessionKey), + "sessionKey": AnyCodable(resolvedKey), "message": AnyCodable(message), "thinking": AnyCodable(thinking), "idempotencyKey": AnyCodable(idempotencyKey), @@ -513,10 +549,11 @@ extension GatewayConnection { } func chatAbort(sessionKey: String, runId: String) async throws -> Bool { + let resolvedKey = self.canonicalizeSessionKey(sessionKey) 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)]) + params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)]) return res.aborted ?? false } diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Clawdbot/MenuContentView.swift index 063008e66..8848475ce 100644 --- a/apps/macos/Sources/Clawdbot/MenuContentView.swift +++ b/apps/macos/Sources/Clawdbot/MenuContentView.swift @@ -102,11 +102,14 @@ struct MenuContent: View { } if self.state.canvasEnabled { Button { - if self.state.canvasPanelVisible { - CanvasManager.shared.hideAll() - } else { - // Don't force a navigation on re-open: preserve the current web view state. - _ = try? CanvasManager.shared.show(sessionKey: "main", path: nil) + Task { @MainActor in + if self.state.canvasPanelVisible { + CanvasManager.shared.hideAll() + } else { + let sessionKey = await GatewayConnection.shared.mainSessionKey() + // Don't force a navigation on re-open: preserve the current web view state. + _ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil) + } } } label: { Label( diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index 34e21f643..da6600698 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -103,6 +103,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { extension MenuSessionsInjector { // MARK: - Injection + private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey } private func inject(into menu: NSMenu) { // Remove any previous injected items. @@ -120,13 +121,15 @@ extension MenuSessionsInjector { if let snapshot = self.cachedSnapshot { let now = Date() + let mainKey = self.mainSessionKey let rows = snapshot.rows.filter { row in - if row.key == "main" { return true } + if row.key == "main", mainKey != "main" { return false } + if row.key == mainKey { return true } guard let updatedAt = row.updatedAt else { return false } return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds }.sorted { lhs, rhs in - if lhs.key == "main" { return true } - if rhs.key == "main" { return false } + if lhs.key == mainKey { return true } + if rhs.key == mainKey { return false } return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) } @@ -645,7 +648,7 @@ extension MenuSessionsInjector { compact.representedObject = row.key menu.addItem(compact) - if row.key != "main", row.key != "global" { + if row.key != self.mainSessionKey, row.key != "global" { let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") del.target = self del.representedObject = row.key diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift index 5762fac28..9e9e45331 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift @@ -36,7 +36,7 @@ actor MacNodeBridgeSession { func connect( endpoint: NWEndpoint, hello: BridgeHello, - onConnected: (@Sendable (String) async -> Void)? = nil, + onConnected: (@Sendable (String, String?) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil, onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws @@ -98,7 +98,8 @@ actor MacNodeBridgeSession { let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) self.state = .connected(serverName: ok.serverName) self.startPingLoop() - await onConnected?(ok.serverName) + let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil) } else if base.type == "error" { let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) self.state = .failed(message: "\(err.code): \(err.message)") diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index da416534f..9a4975525 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -67,8 +67,11 @@ final class MacNodeModeCoordinator { try await self.session.connect( endpoint: endpoint, hello: hello, - onConnected: { [weak self] serverName in + onConnected: { [weak self] serverName, mainSessionKey in self?.logger.info("mac node connected to \(serverName, privacy: .public)") + if let mainSessionKey { + await self?.runtime.updateMainSessionKey(mainSessionKey) + } }, onDisconnected: { reason in await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 250c755ae..5d8063cf9 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -7,6 +7,7 @@ actor MacNodeRuntime { private let cameraCapture = CameraCaptureService() private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? + private var mainSessionKey: String = "main" init( makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = { @@ -16,6 +17,12 @@ actor MacNodeRuntime { self.makeMainActorServices = makeMainActorServices } + func updateMainSessionKey(_ sessionKey: String) { + let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.mainSessionKey = trimmed + } + func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command if self.isCanvasCommand(command), !Self.canvasEnabled() { @@ -72,28 +79,32 @@ actor MacNodeRuntime { let placement = params.placement.map { CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height) } + let sessionKey = self.mainSessionKey try await MainActor.run { _ = try CanvasManager.shared.showDetailed( - sessionKey: "main", + sessionKey: sessionKey, target: url, placement: placement) } return BridgeInvokeResponse(id: req.id, ok: true) case ClawdbotCanvasCommand.hide.rawValue: + let sessionKey = self.mainSessionKey await MainActor.run { - CanvasManager.shared.hide(sessionKey: "main") + CanvasManager.shared.hide(sessionKey: sessionKey) } return BridgeInvokeResponse(id: req.id, ok: true) case ClawdbotCanvasCommand.navigate.rawValue: let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON) + let sessionKey = self.mainSessionKey try await MainActor.run { - _ = try CanvasManager.shared.show(sessionKey: "main", path: params.url) + _ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url) } return BridgeInvokeResponse(id: req.id, ok: true) case ClawdbotCanvasCommand.evalJS.rawValue: let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON) + let sessionKey = self.mainSessionKey let result = try await CanvasManager.shared.eval( - sessionKey: "main", + sessionKey: sessionKey, javaScript: params.javaScript) let payload = try Self.encodePayload(["result": result] as [String: String]) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) @@ -109,7 +120,8 @@ actor MacNodeRuntime { }() let quality = params?.quality ?? 0.9 - let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil) + let sessionKey = self.mainSessionKey + let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil) defer { try? FileManager.default.removeItem(atPath: path) } let data = try Data(contentsOf: URL(fileURLWithPath: path)) guard let image = NSImage(data: data) else { @@ -319,7 +331,8 @@ actor MacNodeRuntime { private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { try await self.ensureA2UIHost() - let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """ + let sessionKey = self.mainSessionKey + let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """ (() => { if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" }); return JSON.stringify(globalThis.clawdbotA2UI.reset()); @@ -358,7 +371,8 @@ actor MacNodeRuntime { } })() """ - let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js) + let sessionKey = self.mainSessionKey + let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) } @@ -369,8 +383,9 @@ actor MacNodeRuntime { NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", ]) } + let sessionKey = self.mainSessionKey _ = try await MainActor.run { - try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl) + try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl) } if await self.isA2UIReady(poll: true) { return } throw NSError(domain: "Canvas", code: 31, userInfo: [ @@ -389,7 +404,8 @@ actor MacNodeRuntime { let deadline = poll ? Date().addingTimeInterval(6.0) : Date() while true { do { - let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """ + let sessionKey = self.mainSessionKey + let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """ (() => String(Boolean(globalThis.clawdbotA2UI)))() """) let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/macos/Sources/Clawdbot/WorkActivityStore.swift b/apps/macos/Sources/Clawdbot/WorkActivityStore.swift index 8ee9aeda9..88ee332bb 100644 --- a/apps/macos/Sources/Clawdbot/WorkActivityStore.swift +++ b/apps/macos/Sources/Clawdbot/WorkActivityStore.swift @@ -28,9 +28,11 @@ final class WorkActivityStore { private var currentSessionKey: String? private var toolSeqBySession: [String: Int] = [:] - private let mainSessionKey = "main" + private var mainSessionKeyStorage = "main" private let toolResultGrace: TimeInterval = 2.0 + var mainSessionKey: String { self.mainSessionKeyStorage } + func handleJob(sessionKey: String, state: String) { let isStart = state.lowercased() == "started" || state.lowercased() == "streaming" if isStart { @@ -129,6 +131,17 @@ final class WorkActivityStore { self.refreshDerivedState() } + func setMainSessionKey(_ sessionKey: String) { + let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard trimmed != self.mainSessionKeyStorage else { return } + self.mainSessionKeyStorage = trimmed + if let current = self.currentSessionKey, !self.isActive(sessionKey: current) { + self.pickNextSession() + } + self.refreshDerivedState() + } + private func clearJob(sessionKey: String) { guard self.jobs[sessionKey] != nil else { return } self.jobs.removeValue(forKey: sessionKey) @@ -151,8 +164,8 @@ final class WorkActivityStore { private func pickNextSession() { // Prefer main if present. - if self.isActive(sessionKey: self.mainSessionKey) { - self.currentSessionKey = self.mainSessionKey + if self.isActive(sessionKey: self.mainSessionKeyStorage) { + self.currentSessionKey = self.mainSessionKeyStorage return } @@ -163,7 +176,7 @@ final class WorkActivityStore { } private func role(for sessionKey: String) -> SessionRole { - sessionKey == self.mainSessionKey ? .main : .other + sessionKey == self.mainSessionKeyStorage ? .main : .other } private func isActive(sessionKey: String) -> Bool { diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 92562e71b..5218c3212 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -245,6 +245,31 @@ public struct StateVersion: Codable, Sendable { } } +public struct SessionDefaults: Codable, Sendable { + public let defaultagentid: String + public let mainkey: String + public let mainsessionkey: String + public let scope: String? + + public init( + defaultagentid: String, + mainkey: String, + mainsessionkey: String, + scope: String? + ) { + self.defaultagentid = defaultagentid + self.mainkey = mainkey + self.mainsessionkey = mainsessionkey + self.scope = scope + } + private enum CodingKeys: String, CodingKey { + case defaultagentid = "defaultAgentId" + case mainkey = "mainKey" + case mainsessionkey = "mainSessionKey" + case scope + } +} + public struct Snapshot: Codable, Sendable { public let presence: [PresenceEntry] public let health: AnyCodable @@ -252,6 +277,7 @@ public struct Snapshot: Codable, Sendable { public let uptimems: Int public let configpath: String? public let statedir: String? + public let sessiondefaults: SessionDefaults? public init( presence: [PresenceEntry], @@ -259,7 +285,8 @@ public struct Snapshot: Codable, Sendable { stateversion: StateVersion, uptimems: Int, configpath: String?, - statedir: String? + statedir: String?, + sessiondefaults: SessionDefaults? ) { self.presence = presence self.health = health @@ -267,6 +294,7 @@ public struct Snapshot: Codable, Sendable { self.uptimems = uptimems self.configpath = configpath self.statedir = statedir + self.sessiondefaults = sessiondefaults } private enum CodingKeys: String, CodingKey { case presence @@ -275,6 +303,7 @@ public struct Snapshot: Codable, Sendable { case uptimems = "uptimeMs" case configpath = "configPath" case statedir = "stateDir" + case sessiondefaults = "sessionDefaults" } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift index 345aedbe4..833a3c96b 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift @@ -100,11 +100,18 @@ public struct BridgeHelloOk: Codable, Sendable { public let type: String public let serverName: String public let canvasHostUrl: String? + public let mainSessionKey: String? - public init(type: String = "hello-ok", serverName: String, canvasHostUrl: String? = nil) { + public init( + type: String = "hello-ok", + serverName: String, + canvasHostUrl: String? = nil, + mainSessionKey: String? = nil) + { self.type = type self.serverName = serverName self.canvasHostUrl = canvasHostUrl + self.mainSessionKey = mainSessionKey } }