@@ -27,6 +27,7 @@ import soy.iko.opencode.data.repo.MessageCacheStore
27import soy.iko.opencode.data.repo.OutboxMessage
27import soy.iko.opencode.data.repo.OutboxMessage
28import soy.iko.opencode.data.repo.OutboxStore
28import soy.iko.opencode.data.repo.OutboxStore
29import soy.iko.opencode.data.repo.SessionPrefsStore
29import soy.iko.opencode.data.repo.SessionPrefsStore
30+import soy.iko.opencode.data.repo.SessionRepository
30import soy.iko.opencode.data.repo.ProfileStore
31import soy.iko.opencode.data.repo.ProfileStore
31import soy.iko.opencode.data.repo.SettingsStore
32import soy.iko.opencode.data.repo.SettingsStore
32import soy.iko.opencode.data.repo.classifyError
33import soy.iko.opencode.data.repo.classifyError
@@ -58,6 +59,7 @@ import kotlinx.coroutines.flow.emptyFlow
58import kotlinx.coroutines.flow.first
59import kotlinx.coroutines.flow.first
59import kotlinx.coroutines.Job
60import kotlinx.coroutines.Job
60import kotlinx.coroutines.flow.flatMapLatest
61import kotlinx.coroutines.flow.flatMapLatest
62+import kotlinx.coroutines.flow.map
61import kotlinx.coroutines.flow.update
63import kotlinx.coroutines.flow.update
62import kotlinx.coroutines.isActive
64import kotlinx.coroutines.isActive
63import kotlinx.coroutines.launch
65import kotlinx.coroutines.launch
@@ -450,14 +452,23 @@ open class AppContainer private constructor(
450 while (isActive) {
452 while (isActive) {
451 runCatchingCancellable {
453 runCatchingCancellable {
452 activeConnection
454 activeConnection
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.
453− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
463+ conn?.events?.events?.map { conn to it } ?: emptyFlow()
454− .collect { event ->
465+ .collect { (conn, event) ->
455 // A permission request needs the user's approval. Post a heads-up
466 // A permission request needs the user's approval. Post a heads-up
456 // notification (with Allow/Reject actions) unless the user is actively
467 // notification (with Allow/Reject actions) unless the user is actively
457 // viewing this session, where the in-app permission dialog handles it.
468 // viewing this session, where the in-app permission dialog handles it.
458 if (event is PermissionUpdated) {
469 if (event is PermissionUpdated) {
459 val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
470 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) }
461 return@collect
472 return@collect
463 // The permission was answered (in-app, from the notification, or
474 // The permission was answered (in-app, from the notification, or
@@ -477,7 +488,7 @@ open class AppContainer private constructor(
479 if (wasRunning && !isActivelyViewing(esid)) {
490 if (wasRunning && !isActivelyViewing(esid)) {
480− appScope.launch { notifySessionError(esid) }
491+ appScope.launch { notifySessionError(esid, conn.repository) }
482 return@collect
493 return@collect
@@ -502,7 +513,7 @@ open class AppContainer private constructor(
502 // the shared-event collector here would stall the SharedFlow,
513 // the shared-event collector here would stall the SharedFlow,
503 // overflowing its DROP_OLDEST buffer and silently dropping live
514 // overflowing its DROP_OLDEST buffer and silently dropping live
504 // parts/updates for every other subscriber (e.g. the message reducer).
515 // parts/updates for every other subscriber (e.g. the message reducer).
505− appScope.launch { notifySessionCompleted(idleSid) }
516+ appScope.launch { notifySessionCompleted(idleSid, conn.repository) }
507 return@collect
518 return@collect
@@ -597,15 +608,18 @@ open class AppContainer private constructor(
597 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
608 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
600− /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id. */
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). */
601− private suspend fun resolveSessionTitle(sessionId: String): String =
615+ private suspend fun resolveSessionTitle(sessionId: String, originRepo: SessionRepository): String =
602 runCatchingCancellable {
616 runCatchingCancellable {
603− activeConnection.value?.repository?.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
617+ originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
604 }.getOrNull() ?: sessionId
618 }.getOrNull() ?: sessionId
606 /** 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. */
607− private suspend fun notifySessionCompleted(sessionId: String) {
621+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
608− val title = resolveSessionTitle(sessionId)
622+ val title = resolveSessionTitle(sessionId, originRepo)
609 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
623 // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
610 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
624 // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
611 // If the user opened the session in that gap, setCurrentSession already cancelled the
625 // If the user opened the session in that gap, setCurrentSession already cancelled the
@@ -615,18 +629,20 @@ open class AppContainer private constructor(
615 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
629 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
618− /** Post a heads-up permission notification for [permission], resolving its session title. */
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. */
619− private suspend fun notifyPermission(permission: Permission) {
635+ private suspend fun notifyPermission(permission: Permission, originConn: OpencodeConnection) {
620 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
636 val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
621− val title = resolveSessionTitle(sessionId)
637+ val title = resolveSessionTitle(sessionId, originConn.repository)
622 // Re-check viewing just before posting — see notifySessionCompleted for the race.
638 // Re-check viewing just before posting — see notifySessionCompleted for the race.
623 if (isActivelyViewing(sessionId)) return
639 if (isActivelyViewing(sessionId)) return
624− appContext?.let { SessionNotifications.postPermission(it, permission, title) }
640+ appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
627 /** Post an error notification for a failed background run. */
643 /** Post an error notification for a failed background run. */
628− private suspend fun notifySessionError(sessionId: String) {
644+ private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository) {
629− val title = resolveSessionTitle(sessionId)
645+ val title = resolveSessionTitle(sessionId, originRepo)
630 // Re-check viewing just before posting — see notifySessionCompleted for the race.
646 // Re-check viewing just before posting — see notifySessionCompleted for the race.
631 if (isActivelyViewing(sessionId)) return
647 if (isActivelyViewing(sessionId)) return
632 appContext?.let { SessionNotifications.postError(it, sessionId, title) }
648 appContext?.let { SessionNotifications.postError(it, sessionId, title) }
@@ -638,11 +654,19 @@ open class AppContainer private constructor(
638 * connection existed AND respondPermission succeeded — the receiver uses it to decide
654 * connection existed AND respondPermission succeeded — the receiver uses it to decide
639 * whether to dismiss the notification. A missing connection makes the respond a no-op,
655 * whether to dismiss the notification. A missing connection makes the respond a no-op,
640 * so it must report failure (not success) rather than let the notification vanish with
656 * so it must report failure (not success) rather than let the notification vanish with
641− * the tool left unanswered. */
657+ * the tool left unanswered.
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. */
642 open fun respondToPermissionFromNotification(
665 open fun respondToPermissionFromNotification(
643 sessionId: String,
666 sessionId: String,
644 permissionId: String,
667 permissionId: String,
645 response: PermissionResponse,
668 response: PermissionResponse,
646 onDone: (Boolean) -> Unit,
670 onDone: (Boolean) -> Unit,
648 appScope.launch {
672 appScope.launch {
@@ -653,8 +677,12 @@ open class AppContainer private constructor(
653 var success = false
677 var success = false
655 success = runCatchingCancellable {
679 success = runCatchingCancellable {
656− val api = activeConnection.value?.api ?: return@runCatchingCancellable false
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
657− api.respondPermission(sessionId, permissionId, response)
685+ conn.api.respondPermission(sessionId, permissionId, response)
659 }.onFailure {
687 }.onFailure {
660 Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
688 Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")