From c1711d093d25be5ec66198b869763d02722ec896 Mon Sep 17 00:00:00 2001 From: e-jung Date: Sat, 20 Jun 2026 13:34:03 +0000 Subject: [PATCH 1/2] fix(opencode-plugin): target opencode's pane for notifications, not the focused pane The bundled opencode plugin (Resources/opencode-plugin.js) bridges opencode's plugin event bus to cmux's `feed.push` socket verb. Blocking events (permission requests, plan approvals, questions) surface an in-app "Needs input" attention overlay plus a desktop notification banner. The attention overlay resolves its target surface and, when no surface can be resolved, falls back to the workspace's focused panel (FeedCoordinator surfaceBlockingDecisionAttention -> resolvePanelId ?? tab.focusedPanelId). In a split layout this routes the attention/bell to whichever pane happens to be focused instead of opencode's own pane. Root cause: the plugin's `base()` event builder propagated `workspace_id` from `CMUX_WORKSPACE_ID` (so the correct workspace/tab was targeted) but never propagated a surface id, so the surface could only come from the per-agent hook session store (~/.cmuxterm/opencode-hook-sessions.json, written by `cmux opencode-hook session-start`). When that store isn't populated for the opencode session, the surface is unresolvable and routing falls through to the focused pane -- the symptom described in #2302. Fix: read `CMUX_SURFACE_ID` (which cmux injects into every agent shell alongside `CMUX_WORKSPACE_ID`, AppDelegate.swift environment setup) and propagate it as `surface_id` on every feed event, mirroring the existing `workspace_id` handling. This gives cmux a live, authoritative surface id for the running opencode pane so attention/notifications can target it directly instead of the focused pane. Refs #2302 --- Resources/opencode-plugin.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Resources/opencode-plugin.js b/Resources/opencode-plugin.js index 6e8a9a63402f..039c214c5028 100644 --- a/Resources/opencode-plugin.js +++ b/Resources/opencode-plugin.js @@ -408,6 +408,10 @@ export const CMUXFeed = async (ctx) => { typeof process.env.CMUX_WORKSPACE_ID === "string" && process.env.CMUX_WORKSPACE_ID.trim() ? process.env.CMUX_WORKSPACE_ID.trim() : null; + const surfaceId = + typeof process.env.CMUX_SURFACE_ID === "string" && process.env.CMUX_SURFACE_ID.trim() + ? process.env.CMUX_SURFACE_ID.trim() + : null; const event = { session_id: `opencode-${sessionId}`, _source: "opencode", @@ -416,6 +420,7 @@ export const CMUXFeed = async (ctx) => { ...extra, }; if (workspaceId) event.workspace_id = workspaceId; + if (surfaceId) event.surface_id = surfaceId; if (context) event.context = context; return event; }; From 516f058ac33c97d7c04bf0a0202d574d52d4d784 Mon Sep 17 00:00:00 2001 From: e-jung Date: Sat, 20 Jun 2026 07:58:35 -0700 Subject: [PATCH 2/2] fix(opencode-plugin): add surfaceId to WorkstreamEvent + wire FeedCoordinator.resolveAttentionTarget --- .../Workstream/WorkstreamEvent.swift | 10 ++++++++-- Sources/Feed/FeedCoordinator.swift | 16 ++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift index b3c1ee31cd79..53ce252eb359 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift @@ -5,13 +5,14 @@ import Foundation /// /// Field names mirror Vibe Island's hook payload format exactly so existing /// agent payloads pass through untouched: `session_id`, `hook_event_name`, -/// `workspace_id`, `cwd`, `tool_name`, `tool_input`, `_source`, `_ppid`, -/// `_opencode_request_id`. `context` is cmux-specific and optional. +/// `workspace_id`, `surface_id`, `cwd`, `tool_name`, `tool_input`, `_source`, +/// `_ppid`, `_opencode_request_id`. `context` is cmux-specific and optional. public struct WorkstreamEvent: Codable, Sendable, Equatable { public let sessionId: String public let hookEventName: HookEventName public let source: String public let workspaceId: String? + public let surfaceId: String? public let cwd: String? public let toolName: String? public let toolInputJSON: String? @@ -26,6 +27,7 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { hookEventName: HookEventName, source: String, workspaceId: String? = nil, + surfaceId: String? = nil, cwd: String? = nil, toolName: String? = nil, toolInputJSON: String? = nil, @@ -39,6 +41,7 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { self.hookEventName = hookEventName self.source = source self.workspaceId = workspaceId + self.surfaceId = surfaceId self.cwd = cwd self.toolName = toolName self.toolInputJSON = toolInputJSON @@ -72,6 +75,7 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { case hookEventName = "hook_event_name" case source = "_source" case workspaceId = "workspace_id" + case surfaceId = "surface_id" case cwd case toolName = "tool_name" case toolInputJSON = "tool_input" @@ -87,6 +91,7 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { self.hookEventName = try c.decode(HookEventName.self, forKey: .hookEventName) self.source = try c.decode(String.self, forKey: .source) self.workspaceId = try c.decodeIfPresent(String.self, forKey: .workspaceId) + self.surfaceId = try c.decodeIfPresent(String.self, forKey: .surfaceId) self.cwd = try c.decodeIfPresent(String.self, forKey: .cwd) self.toolName = try c.decodeIfPresent(String.self, forKey: .toolName) self.context = try c.decodeIfPresent(WorkstreamContext.self, forKey: .context) @@ -117,6 +122,7 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { try c.encode(hookEventName, forKey: .hookEventName) try c.encode(source, forKey: .source) try c.encodeIfPresent(workspaceId, forKey: .workspaceId) + try c.encodeIfPresent(surfaceId, forKey: .surfaceId) try c.encodeIfPresent(cwd, forKey: .cwd) try c.encodeIfPresent(toolName, forKey: .toolName) try c.encodeIfPresent(context, forKey: .context) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 2d0ae80a7521..3e7cc50a09e9 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -464,9 +464,11 @@ extension FeedCoordinator { /// The workspace prefers the event's live `workspace_id` (the running /// terminal's CMUX_WORKSPACE_ID, a raw UUID) so a stale hook-session map /// can't redirect attention to the wrong workspace; it falls back to the - /// session store when the event omits a parseable id. The surface comes - /// from the session store only when its workspace matches the resolved - /// workspace, so a stale entry can't point the panel elsewhere. + /// session store when the event omits a parseable id. The surface prefers + /// the event's live `surface_id` (CMUX_SURFACE_ID) so the overlay lands on + /// the pane the agent actually ran in; it falls back to the session store + /// only when that store's workspace matches the resolved workspace, so a + /// stale entry can't point the panel elsewhere. private static func resolveAttentionTarget( event: WorkstreamEvent ) -> (workspaceId: UUID, surfaceId: UUID?)? { @@ -481,13 +483,15 @@ extension FeedCoordinator { let eventWorkspaceId = event.workspaceId.flatMap { UUID(uuidString: $0.trimmingCharacters(in: .whitespacesAndNewlines)) } + let eventSurfaceId = event.surfaceId.flatMap { + UUID(uuidString: $0.trimmingCharacters(in: .whitespacesAndNewlines)) + } guard let workspaceId = eventWorkspaceId ?? sessionMatch?.workspaceId else { return nil } - // Only trust the session store's surface if it belongs to the - // workspace we're actually targeting. - let surfaceId = (sessionMatch?.workspaceId == workspaceId) ? sessionMatch?.surfaceId : nil + let surfaceId = eventSurfaceId + ?? ((sessionMatch?.workspaceId == workspaceId) ? sessionMatch?.surfaceId : nil) return (workspaceId, surfaceId) }