@@ -513,7 +513,7 @@ open class AppContainer private constructor(
513513 // the shared-event collector here would stall the SharedFlow,
514514 // overflowing its DROP_OLDEST buffer and silently dropping live
515515 // parts/updates for every other subscriber (e.g. the message reducer).
516− appScope.launch { notifySessionCompleted(idleSid, conn.repository) }
516+ appScope.launch { notifySessionCompleted(idleSid, conn.repository, conn.profile.id) }
517517 }
518518 return@collect
519519 }
@@ -617,8 +617,11 @@ open class AppContainer private constructor(
617617 originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
618618 }.getOrNull() ?: sessionId
619619
620− /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
621− private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
620+ /** Resolve the title for [sessionId] (best-effort) and post a completion notification.
621+ * [originProfileId] is the id of the server that ran the session; it's embedded in the
622+ * inline-reply action so a follow-up routes back to THIS server even after the user switches
623+ * connections (mirrors [notifyPermission]). */
624+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
622625 val title = resolveSessionTitle(sessionId, originRepo)
623626 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
624627 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
@@ -626,7 +629,7 @@ open class AppContainer private constructor(
626629 // prior notification — posting a fresh one here would leave a "session ready" heads-up
627630 // lingering while they're already looking at the finished run.
628631 if (isActivelyViewing(sessionId)) return
629− appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
632+ appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
630633 }
631634
632635 /** Post a heads-up permission notification for [permission], resolving its session title.
@@ -701,15 +704,19 @@ open class AppContainer private constructor(
701704 * immediate send when a connection is live, so an online reply still goes out now. [onDone]
702705 * reports whether the reply was durably enqueued so the receiver only dismisses the
703706 * notification on success. */
704− open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
707+ open fun sendPromptFromNotification(sessionId: String, text: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
705708 appScope.launch {
706709 // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
707710 // scope is cancelled mid-call — see respondToPermissionFromNotification above.
708711 var enqueued = false
709712 try {
710− // Attribute to the active (or most-recent) profile so a reconnect to a different
711− // server doesn't misfire this against a session id that only exists elsewhere.
712− val profileId = activeConnection.value?.profile?.id
713+ // Attribute to the profile that POSTED the notification (embedded in the reply
714+ // intent) so the follow-up is always enqueued against the server that owns
715+ // [sessionId] — never whichever server happens to be active now, which would POST
716+ // A's session id to B, 404, and silently drop the reply. Fall back to the active/
717+ // most-recent profile only for a legacy intent that predates the embedded id.
718+ val profileId = originProfileId
719+ ?: activeConnection.value?.profile?.id
713720 ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
714721 enqueued = if (profileId == null) {
715722 false
@@ -748,28 +755,24 @@ open class AppContainer private constructor(
748755 .getOrDefault(emptyList())
749756 .firstOrNull() ?: return
750757 // The profile read above suspends on DataStore. A manual connect() to a different
751− // server during that window establishes a connection the user explicitly chose; we
752− // must not clobber it by connecting to `recent` on top. connect() unconditionally
753− // closes the active connection, so guard here: if anything is already active, the
754− // user (or another connector) already took the cold-start slot — abort autoConnect.
755− // (disconnectIf below similarly guards the failed-ping teardown by identity.)
756− if (_activeConnection.value != null) return
758+ // server during that window establishes a connection the user explicitly chose; we must
759+ // not clobber it by connecting to `recent` on top. connectIfIdle() checks "is anything
760+ // active?" and connects atomically under connectionMutex, returning null if the cold-start
761+ // slot was already taken — closing the TOCTOU window an unlocked null-check plus connect()'s
762+ // unconditional close would leave. (disconnectIf below similarly guards the failed-ping
763+ // teardown by identity.)
757764 _reconnecting.value = true
758765 var conn: OpencodeConnection? = null
759766 val ok = runCatchingCancellable {
760− conn = connect(recent)
767+ conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
761768 conn!!.api.ping()
762− }.isSuccess
769+ true
770+ }.getOrDefault(false)
763771 _reconnecting.value = false
764− if (ok) {
765− _autoConnectDone.value = true
766− } else {
767− val created = conn
768− // Only tear down the connection we created: a manual connect to a different server
769− // during the ping window may have already replaced it, and disconnect() closes
770− // whatever is currently active — which would silently drop that new connection.
771− // disconnectIf re-checks identity under the mutex to close the check-then-close race.
772− if (created != null) disconnectIf(created)
772+ when {
773+ conn == null -> Unit // slot already taken by a manual connect — leave it be
774+ ok -> _autoConnectDone.value = true
775+ else -> disconnectIf(conn!!)
773776 }
774777 }
775778
@@ -788,12 +791,21 @@ open class AppContainer private constructor(
788791 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
789792 val callback = object : ConnectivityManager.NetworkCallback() {
790793 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
791− // Don't flip offline on onLost: during a Wi-Fi→cellular handoff, onLost(WiFi) fires
792− // before onAvailable(cellular), and cm.activeNetwork is transiently null in that
793− // window — eagerly marking offline would skip outbox flushing and flash an offline
794− // indicator. onUnavailable (no networks left at all) is the reliable offline signal.
795− override fun onLost(network: Network) { /* handled by onUnavailable */ }
796− override fun onUnavailable() { _isOnline.value = false }
794+ // A matched (internet-capable) network was lost. Don't eagerly flip offline: during a
795+ // Wi-Fi→cellular handoff onLost(WiFi) fires before onAvailable(cellular) and
796+ // cm.activeNetwork is transiently null in that window — flashing offline there would
797+ // skip outbox flushing and blink the indicator. Instead re-check activeNetwork after a
798+ // short grace period and only report offline if there's still no active network.
799+ //
800+ // This MUST live in onLost, not onUnavailable: onUnavailable is delivered only for
801+ // requestNetwork() with a timeout, never for registerNetworkCallback() — relying on it
802+ // (as this once did) meant offline was never detected after the first onAvailable.
803+ override fun onLost(network: Network) {
804+ appScope.launch {
805+ delay(NetworkConfig.networkOfflineGraceMs)
806+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
807+ }
808+ }
797809 }
798810 // Only match networks that actually provide internet — a capability-less request
799811 // fires onAvailable for transports that can't reach the server (and would trigger
@@ -810,26 +822,39 @@ open class AppContainer private constructor(
810822 * Only persists the updated `lastUsed` timestamp if it's stale (older than a
811823 * minute), so rapid reconnect storms don't each trigger a DataStore write. */
812824 open suspend fun connect(profile: ServerProfile): OpencodeConnection =
825+ connectionMutex.withLock { connectLocked(profile) }
826+
827+ /** Connect to [profile] only if nothing is currently active, checking-and-connecting
828+ * atomically under [connectionMutex]. Returns null (touching nothing) when a connection is
829+ * already active, so autoConnect() can't clobber a manual connect() the user made during its
830+ * suspending profile read — closing the TOCTOU window an unlocked null-check would leave. */
831+ private suspend fun connectIfIdle(profile: ServerProfile): OpencodeConnection? =
813832 connectionMutex.withLock {
814− _activeConnection.value?.close()
815− _activeConnection.value = null
816− // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
817− // event handler adds ids under the same lock, so an unlocked clear() here could
818− // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
819− // down connection and no SessionIdle to clear it — a stuck foreground service.
820− synchronized(activeRuns) {
821− activeRuns.clear()
822− _anyRunActive.value = false
823− }
824− _unread.value = emptyMap()
825− unreadMessageIds.clear()
826− val now = System.currentTimeMillis()
827− val resolved = profileStore.resolve(profile)
828− val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
829− val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
830− if (needsSave) profileStore.save(finalProfile)
831− OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
833+ if (_activeConnection.value != null) null else connectLocked(profile)
834+ }
835+
836+ /** Build and install a connection to [profile], replacing any current one. Caller MUST hold
837+ * [connectionMutex]. */
838+ private suspend fun connectLocked(profile: ServerProfile): OpencodeConnection {
839+ _activeConnection.value?.close()
840+ _activeConnection.value = null
841+ // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
842+ // event handler adds ids under the same lock, so an unlocked clear() here could
843+ // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
844+ // down connection and no SessionIdle to clear it — a stuck foreground service.
845+ synchronized(activeRuns) {
846+ activeRuns.clear()
847+ _anyRunActive.value = false
832848 }
849+ _unread.value = emptyMap()
850+ unreadMessageIds.clear()
851+ val now = System.currentTimeMillis()
852+ val resolved = profileStore.resolve(profile)
853+ val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
854+ val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
855+ if (needsSave) profileStore.save(finalProfile)
856+ return OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
857+ }
833858
834859 open suspend fun disconnect() {
835860 // Acquire the connection mutex so disconnect is serialized with connect().