Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions Resources/opencode-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +411 to +414

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 System notification path hard-codes surfaceId: nil

Even after the Swift model is updated, the TerminalNotificationPolicyPayload built in FeedCoordinator (line 1067 of FeedCoordinator.swift) hard-codes surfaceId: nil, so push notifications generated for blocking events will still not carry a surface target. This path is separate from the attention-overlay routing, but it means Bell/notification-centre alerts will remain workspace-level only until that site is also updated to read the event's surfaceId.

const event = {
session_id: `opencode-${sessionId}`,
_source: "opencode",
Expand All @@ -416,6 +420,7 @@ export const CMUXFeed = async (ctx) => {
...extra,
};
if (workspaceId) event.workspace_id = workspaceId;
if (surfaceId) event.surface_id = surfaceId;
Comment on lines 411 to +423

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fix is inert — surface_id is discarded before it can reach the resolver

WorkstreamEvent (defined in Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift) has no surfaceId property and no "surface_id" CodingKey. When the Swift socket handler decodes the incoming JSON, surface_id falls through to the extraFieldsJSON catch-all and is never surfaced as a typed property. FeedCoordinator.resolveAttentionTarget reads event.workspaceId (a first-class property) but sources surfaceId exclusively from the hook-session store — it never inspects extraFieldsJSON. The attention overlay therefore still falls back to tab.focusedPanelId, leaving the bug fully intact.

The PR description mentions "a matching one-line change on the Swift side," but the actual minimum required is two changes: (1) add a public let surfaceId: String? field with CodingKey = "surface_id" to WorkstreamEvent, and (2) update resolveAttentionTarget to prefer event.surfaceId over sessionMatch?.surfaceId, exactly as it already prefers event.workspaceId.

if (context) event.context = context;
return event;
};
Expand Down
16 changes: 10 additions & 6 deletions Sources/Feed/FeedCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?)? {
Expand All @@ -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)
}

Expand Down