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/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; }; 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) }