@@ -27,6 +27,7 @@ import soy.iko.opencode.data.repo.MessageCacheStore
2727import soy.iko.opencode.data.repo.OutboxMessage
2828import soy.iko.opencode.data.repo.OutboxStore
2929import soy.iko.opencode.data.repo.SessionPrefsStore
30+import soy.iko.opencode.data.repo.SessionRepository
3031import soy.iko.opencode.data.repo.ProfileStore
3132import soy.iko.opencode.data.repo.SettingsStore
3233import soy.iko.opencode.data.repo.classifyError
@@ -58,6 +59,7 @@ import kotlinx.coroutines.flow.emptyFlow
5859import kotlinx.coroutines.flow.first
5960import kotlinx.coroutines.Job
6061import kotlinx.coroutines.flow.flatMapLatest
62+import kotlinx.coroutines.flow.map
6163import kotlinx.coroutines.flow.update
6264import kotlinx.coroutines.isActive
6365import kotlinx.coroutines.launch
@@ -450,14 +452,23 @@ open class AppContainer private constructor(
450452 while (isActive) {
451453 runCatchingCancellable {
452454 activeConnection
453− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
454− .collect { event ->
455+ .flatMapLatest { conn ->
456+ // Capture the originating connection so notification coroutines
457+ // resolve titles and route permission responses against THIS
458+ // server, not whichever happens to be active when they resume.
459+ // A server switch during a listSessions() round-trip would
460+ // otherwise resolve the title (or POST the permission reply)
461+ // against the new server, where the session/permission doesn't
462+ // exist — showing an opaque id or silently losing the reply.
463+ conn?.events?.events?.map { conn to it } ?: emptyFlow()
464+ }
465+ .collect { (conn, event) ->
455466 // A permission request needs the user's approval. Post a heads-up
456467 // notification (with Allow/Reject actions) unless the user is actively
457468 // viewing this session, where the in-app permission dialog handles it.
458469 if (event is PermissionUpdated) {
459470 val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
460− if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties) }
471+ if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties, conn) }
461472 return@collect
462473 }
463474 // The permission was answered (in-app, from the notification, or
@@ -477,7 +488,7 @@ open class AppContainer private constructor(
477488 }
478489 }
479490 if (wasRunning && !isActivelyViewing(esid)) {
480− appScope.launch { notifySessionError(esid) }
491+ appScope.launch { notifySessionError(esid, conn.repository) }
481492 }
482493 return@collect
483494 }
@@ -502,7 +513,7 @@ open class AppContainer private constructor(
502513 // the shared-event collector here would stall the SharedFlow,
503514 // overflowing its DROP_OLDEST buffer and silently dropping live
504515 // parts/updates for every other subscriber (e.g. the message reducer).
505− appScope.launch { notifySessionCompleted(idleSid) }
516+ appScope.launch { notifySessionCompleted(idleSid, conn.repository) }
506517 }
507518 return@collect
508519 }
@@ -597,15 +608,18 @@ open class AppContainer private constructor(
597608 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
598609 }
599610
600− /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id. */
601− private suspend fun resolveSessionTitle(sessionId: String): String =
611+ /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id.
612+ * Uses [originRepo] — the repository of the connection that emitted the event — so a
613+ * server switch during the listSessions() round-trip doesn't resolve the title against
614+ * an unrelated server (showing an opaque id, or worse, a colliding session's title). */
615+ private suspend fun resolveSessionTitle(sessionId: String, originRepo: SessionRepository): String =
602616 runCatchingCancellable {
603− activeConnection.value?.repository?.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
617+ originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
604618 }.getOrNull() ?: sessionId
605619
606620 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
607− private suspend fun notifySessionCompleted(sessionId: String) {
608− val title = resolveSessionTitle(sessionId)
621+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
622+ val title = resolveSessionTitle(sessionId, originRepo)
609623 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
610624 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
611625 // If the user opened the session in that gap, setCurrentSession already cancelled the
@@ -615,18 +629,20 @@ open class AppContainer private constructor(
615629 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
616630 }
617631
618− /** Post a heads-up permission notification for [permission], resolving its session title. */
619− private suspend fun notifyPermission(permission: Permission) {
632+ /** Post a heads-up permission notification for [permission], resolving its session title.
633+ * [originConn] is the connection that emitted the permission event — its profile id is
634+ * embedded in the notification so the receiver routes the response back to THIS server. */
635+ private suspend fun notifyPermission(permission: Permission, originConn: OpencodeConnection) {
620636 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
621− val title = resolveSessionTitle(sessionId)
637+ val title = resolveSessionTitle(sessionId, originConn.repository)
622638 // Re-check viewing just before posting — see notifySessionCompleted for the race.
623639 if (isActivelyViewing(sessionId)) return
624− appContext?.let { SessionNotifications.postPermission(it, permission, title) }
640+ appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
625641 }
626642
627643 /** Post an error notification for a failed background run. */
628− private suspend fun notifySessionError(sessionId: String) {
629− val title = resolveSessionTitle(sessionId)
644+ private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository) {
645+ val title = resolveSessionTitle(sessionId, originRepo)
630646 // Re-check viewing just before posting — see notifySessionCompleted for the race.
631647 if (isActivelyViewing(sessionId)) return
632648 appContext?.let { SessionNotifications.postError(it, sessionId, title) }
@@ -638,11 +654,19 @@ open class AppContainer private constructor(
638654 * connection existed AND respondPermission succeeded — the receiver uses it to decide
639655 * whether to dismiss the notification. A missing connection makes the respond a no-op,
640656 * so it must report failure (not success) rather than let the notification vanish with
641− * the tool left unanswered. */
657+ * the tool left unanswered.
658+ *
659+ * [profileId] is the id of the server that posted the permission request (embedded in
660+ * the notification's PendingIntent). If it doesn't match the currently active
661+ * connection's profile, the response is NOT sent: posting it to a different server
662+ * would 404 (treated as "already resolved" success by respondPermission), dismissing
663+ * the notification while the original server's tool stays paused forever. Reporting
664+ * failure instead leaves the notification up so the user can switch back and retry. */
642665 open fun respondToPermissionFromNotification(
643666 sessionId: String,
644667 permissionId: String,
645668 response: PermissionResponse,
669+ profileId: String?,
646670 onDone: (Boolean) -> Unit,
647671 ) {
648672 appScope.launch {
@@ -653,8 +677,12 @@ open class AppContainer private constructor(
653677 var success = false
654678 try {
655679 success = runCatchingCancellable {
656− val api = activeConnection.value?.api ?: return@runCatchingCancellable false
657− api.respondPermission(sessionId, permissionId, response)
680+ val conn = activeConnection.value ?: return@runCatchingCancellable false
681+ // Route to the originating server only. A mismatch means the user
682+ // switched servers while the permission was pending; sending the reply
683+ // to the wrong server would be silently swallowed as a 404-success.
684+ if (profileId != null && conn.profile.id != profileId) return@runCatchingCancellable false
685+ conn.api.respondPermission(sessionId, permissionId, response)
658686 true
659687 }.onFailure {
660688 Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")