@@ -513,7 +513,7 @@ open class AppContainer private constructor(
513 // the shared-event collector here would stall the SharedFlow,
513 // the shared-event collector here would stall the SharedFlow,
514 // overflowing its DROP_OLDEST buffer and silently dropping live
514 // overflowing its DROP_OLDEST buffer and silently dropping live
515 // parts/updates for every other subscriber (e.g. the message reducer).
515 // 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) }
518 return@collect
518 return@collect
@@ -617,8 +617,11 @@ open class AppContainer private constructor(
617 originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
617 originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
618 }.getOrNull() ?: sessionId
618 }.getOrNull() ?: sessionId
620− /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
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]). */
621− private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
624+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
622 val title = resolveSessionTitle(sessionId, originRepo)
625 val title = resolveSessionTitle(sessionId, originRepo)
623 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
626 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
624 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
627 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
@@ -626,7 +629,7 @@ open class AppContainer private constructor(
626 // prior notification — posting a fresh one here would leave a "session ready" heads-up
629 // prior notification — posting a fresh one here would leave a "session ready" heads-up
627 // lingering while they're already looking at the finished run.
630 // lingering while they're already looking at the finished run.
628 if (isActivelyViewing(sessionId)) return
631 if (isActivelyViewing(sessionId)) return
629− appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
632+ appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
632 /** Post a heads-up permission notification for [permission], resolving its session title.
635 /** Post a heads-up permission notification for [permission], resolving its session title.
@@ -701,15 +704,19 @@ open class AppContainer private constructor(
701 * immediate send when a connection is live, so an online reply still goes out now. [onDone]
704 * immediate send when a connection is live, so an online reply still goes out now. [onDone]
702 * reports whether the reply was durably enqueued so the receiver only dismisses the
705 * reports whether the reply was durably enqueued so the receiver only dismisses the
703 * notification on success. */
706 * 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) {
705 appScope.launch {
708 appScope.launch {
706 // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
709 // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
707 // scope is cancelled mid-call — see respondToPermissionFromNotification above.
710 // scope is cancelled mid-call — see respondToPermissionFromNotification above.
708 var enqueued = false
711 var enqueued = false
710− // Attribute to the active (or most-recent) profile so a reconnect to a different
713+ // Attribute to the profile that POSTED the notification (embedded in the reply
711− // server doesn't misfire this against a session id that only exists elsewhere.
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
712− val profileId = activeConnection.value?.profile?.id
719+ ?: activeConnection.value?.profile?.id
713 ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
720 ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
714 enqueued = if (profileId == null) {
721 enqueued = if (profileId == null) {
@@ -748,28 +755,24 @@ open class AppContainer private constructor(
748 .getOrDefault(emptyList())
755 .getOrDefault(emptyList())
749 .firstOrNull() ?: return
756 .firstOrNull() ?: return
750 // The profile read above suspends on DataStore. A manual connect() to a different
757 // 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
758+ // server during that window establishes a connection the user explicitly chose; we must
752− // must not clobber it by connecting to `recent` on top. connect() unconditionally
759+ // not clobber it by connecting to `recent` on top. connectIfIdle() checks "is anything
753− // closes the active connection, so guard here: if anything is already active, the
760+ // active?" and connects atomically under connectionMutex, returning null if the cold-start
754− // user (or another connector) already took the cold-start slot — abort autoConnect.
761+ // slot was already taken — closing the TOCTOU window an unlocked null-check plus connect()'s
755− // (disconnectIf below similarly guards the failed-ping teardown by identity.)
762+ // unconditional close would leave. (disconnectIf below similarly guards the failed-ping
756− if (_activeConnection.value != null) return
763+ // teardown by identity.)
757 _reconnecting.value = true
764 _reconnecting.value = true
758 var conn: OpencodeConnection? = null
765 var conn: OpencodeConnection? = null
759 val ok = runCatchingCancellable {
766 val ok = runCatchingCancellable {
760− conn = connect(recent)
767+ conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
761 conn!!.api.ping()
768 conn!!.api.ping()
762− }.isSuccess
769+ true
770+ }.getOrDefault(false)
763 _reconnecting.value = false
771 _reconnecting.value = false
764− if (ok) {
772+ when {
773+ conn == null -> Unit // slot already taken by a manual connect — leave it be
765− _autoConnectDone.value = true
774+ ok -> _autoConnectDone.value = true
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)
775+ else -> disconnectIf(conn!!)
@@ -788,12 +791,21 @@ open class AppContainer private constructor(
788 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
791 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
789 val callback = object : ConnectivityManager.NetworkCallback() {
792 val callback = object : ConnectivityManager.NetworkCallback() {
790 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
793 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
794+ // A matched (internet-capable) network was lost. Don't eagerly flip offline: during a
791− // Don't flip offline on onLost: during a Wi-Fi→cellular handoff, onLost(WiFi) fires
795+ // Wi-Fi→cellular handoff onLost(WiFi) fires before onAvailable(cellular) and
792− // before onAvailable(cellular), and cm.activeNetwork is transiently null in that
796+ // cm.activeNetwork is transiently null in that window — flashing offline there would
793− // window — eagerly marking offline would skip outbox flushing and flash an offline
797+ // skip outbox flushing and blink the indicator. Instead re-check activeNetwork after a
794− // indicator. onUnavailable (no networks left at all) is the reliable offline signal.
798+ // short grace period and only report offline if there's still no active network.
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.
795− override fun onLost(network: Network) { /* handled by onUnavailable */ }
803+ override fun onLost(network: Network) {
796− override fun onUnavailable() { _isOnline.value = false }
804+ appScope.launch {
805+ delay(NetworkConfig.networkOfflineGraceMs)
806+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
798 // Only match networks that actually provide internet — a capability-less request
810 // Only match networks that actually provide internet — a capability-less request
799 // fires onAvailable for transports that can't reach the server (and would trigger
811 // fires onAvailable for transports that can't reach the server (and would trigger
@@ -810,26 +822,39 @@ open class AppContainer private constructor(
810 * Only persists the updated `lastUsed` timestamp if it's stale (older than a
822 * Only persists the updated `lastUsed` timestamp if it's stale (older than a
811 * minute), so rapid reconnect storms don't each trigger a DataStore write. */
823 * minute), so rapid reconnect storms don't each trigger a DataStore write. */
812 open suspend fun connect(profile: ServerProfile): OpencodeConnection =
824 open suspend fun connect(profile: ServerProfile): OpencodeConnection =
825+ connectionMutex.withLock { connectLocked(profile) }
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? =
813 connectionMutex.withLock {
832 connectionMutex.withLock {
833+ if (_activeConnection.value != null) null else connectLocked(profile)
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 {
814− _activeConnection.value?.close()
839+ _activeConnection.value?.close()
815− _activeConnection.value = null
840+ _activeConnection.value = null
816− // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
841+ // 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
842+ // 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-
843+ // 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.
844+ // down connection and no SessionIdle to clear it — a stuck foreground service.
820− synchronized(activeRuns) {
845+ synchronized(activeRuns) {
821− activeRuns.clear()
846+ activeRuns.clear()
822− _anyRunActive.value = false
847+ _anyRunActive.value = false
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 }
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 }
834 open suspend fun disconnect() {
859 open suspend fun disconnect() {
835 // Acquire the connection mutex so disconnect is serialized with connect().
860 // Acquire the connection mutex so disconnect is serialized with connect().