Skip to content

fix(opencode-plugin): propagate surface_id on feed events so notifications target opencode pane - #6504

Closed
e-jung wants to merge 2 commits into
manaflow-ai:mainfrom
e-jung:fm/cmux-2302-r2
Closed

e-jung wants to merge 2 commits into
manaflow-ai:mainfrom
e-jung:fm/cmux-2302-r2

Conversation

@e-jung

@e-jung e-jung commented Jun 20, 2026 •

Copy link
Copy Markdown
Contributor

Refs #2302.

Problem

When opencode runs in a split workspace (opencode in one pane, another terminal in the other), blocking events from the bundled opencode plugin (Resources/opencode-plugin.js) — permission requests, plan approvals, questions — surface their "Needs input" attention overlay and bell on whichever pane is currently focused, not the pane where opencode is actually running.

Root cause

The plugin's base() event builder propagates workspace_id (from CMUX_WORKSPACE_ID, which cmux injects into every agent shell) so the correct workspace/tab is targeted — but it never propagated a surface id. With only a workspace id, FeedCoordinator resolves the workspace correctly but can't resolve the surface; it then falls back to tab.focusedPanelId (Sources/Feed/FeedCoordinator.swift, surfaceBlockingDecisionAttention → resolvePanelId ?? tab.focusedPanelId). In a split layout that is the wrong pane.

The surface could otherwise only be recovered from the per-agent hook session store (~/.cmuxterm/opencode-hook-sessions.json, written by cmux opencode-hook session-start); when that isn't populated for the opencode session, the surface is unresolvable and routing lands on the focused pane — the symptom in #2302.

Fix

Read CMUX_SURFACE_ID (cmux injects it alongside CMUX_WORKSPACE_ID, see the environment setup in Sources/AppDelegate.swift) and propagate it as surface_id on every feed event, mirroring the existing workspace_id handling in base():

const surfaceId =
  typeof process.env.CMUX_SURFACE_ID === "string" && process.env.CMUX_SURFACE_ID.trim()
    ? process.env.CMUX_SURFACE_ID.trim()
    : null;
...
if (surfaceId) event.surface_id = surfaceId;

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. This is the direct analog of the fix recipe in #2302 (pass surface info from env), applied to the bundled socket-based plugin path.

Validation caveat (Linux-prepared, macOS validation needed)

cmux is a Swift/Xcode macOS app; this change was prepared and lint-checked on Linux only. The JS plugin passes a Node ESM syntax check. (Resources/opencode-plugin.js is outside biome.json's files.includes scope, so bun run biome:check does not cover it.)

Note for the maintainer: this PR is the plugin-side half of the fix — it makes a live surface_id available on every feed event. FeedCoordinator.resolveAttentionTarget currently sources the surface only from the per-agent hook session store, not from the event, so a matching one-line change on the Swift side (prefer event.surfaceId the same way it already prefers event.workspaceId) is needed to complete pane-level routing for this path. I scoped this PR to the JS plugin per the task; the Swift follow-up needs macOS/Xcode validation, which I could not perform here.

No Swift/Xcode build was attempted.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Route attention overlays and notifications to the opencode pane instead of the focused pane in split layouts. Propagates surface_id from CMUX_SURFACE_ID in Resources/opencode-plugin.js and wires it through WorkstreamEvent and FeedCoordinator.resolveAttentionTarget to prefer the event’s pane (with session-store fallback), addressing #2302.

Written for commit 516f058. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Enhanced event tracking with an additional surface identifier, included when provided.
  • Bug Fixes

    • Improved attention targeting to prefer the surface identifier from incoming events (with trimming/parse validation), falling back to the session-derived surface only when appropriate.
  • Chores

    • Extended event payload support to include the new optional surface identifier and updated corresponding serialization/deserialization behavior.

…he 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 manaflow-ai#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 manaflow-ai#2302
@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown

@e-jung is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d6e30876-f1c4-4e29-b587-daa894189707

📥 Commits

Reviewing files that changed from the base of the PR and between c1711d0 and 516f058.

📒 Files selected for processing (2)
  • Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift
  • Sources/Feed/FeedCoordinator.swift

📝 Walkthrough

Walkthrough

The PR adds surface_id support across three coordinated layers: Resources/opencode-plugin.js reads CMUX_SURFACE_ID from the environment and injects it into the generated event; WorkstreamEvent.swift models the field with full Codable support; and FeedCoordinator.swift updates the resolver to prefer the live event.surfaceId over session-store fallback.

Changes

surface_id field addition and consumption

Layer / File(s) Summary
Event generation with surface_id from environment
Resources/opencode-plugin.js
base() reads CMUX_SURFACE_ID (trimmed or null) from the environment and conditionally attaches it as surface_id to the generated event object.
WorkstreamEvent struct with surface_id property and Codable support
Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift
WorkstreamEvent gains an optional surfaceId stored property, public initializer parameter, CodingKeys mapping, and conditional decodeIfPresent and encodeIfPresent implementations for serialization.
FeedCoordinator resolver prefers event surface_id over session store
Sources/Feed/FeedCoordinator.swift
resolveAttentionTarget now trims and parses event.surfaceId first, using it when present; falls back to session-store surface only when the store's workspace matches the resolved workspace. Documentation updated to reflect the new preference order.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • manaflow-ai/cmux#5313: Updates Sources/Feed/FeedCoordinator.swift's attention-targeting flow—main PR makes resolveAttentionTarget prefer WorkstreamEvent.surfaceId, and this PR adds the "needs input" attention overlay for feed-blocking decisions that relies on resolving the same attention target.

Poem

🐇 A surface awakens from the plugin's keen eye,
Read from the env, trimmed and held high.
The event carries it, WorkstreamEvent stores it tight,
FeedCoordinator listens—preference made right!
Surface to panel, the path burns bright. 🌟


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error WorkstreamEvent is a pure Codable, Sendable data struct with no explicit isolation marker, violating Swift 6 actor isolation rules. Per the guidance, it should be marked nonisolated to avoid impl... Add nonisolated modifier to WorkstreamEvent struct declaration: nonisolated public struct WorkstreamEvent: Codable, Sendable, Equatable
Cmux Swift File And Package Boundaries ❌ Error FeedCoordinator.swift is a new 1,262-line production file in the app target (Sources/Feed/) that exceeds the 800-line threshold and mixes multiple responsibilities: blocking hook state management,... Extract attention/notification orchestration into a smaller focused package or refactor to separate state/UI/notification concerns; FeedCoordinator should coordinate high-level flow, not implement all blocking-decision lifecycle details...
Cmux No Test Or Debug Seam In Production Source ❌ Error FeedCoordinator.swift (Sources/Feed/) introduces FeedCoordinatorTestHooks enum with test-only members (afterBlockingEventIngested, isAppActiveOverride, notificationPostObserver, `attentionS... Move FeedCoordinatorTestHooks to test target. Widen necessary internal state private→internal. Use @testable import for test access, per PR #6452 pattern.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (19 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: propagating surface_id in the opencode plugin to fix notification routing in split layouts, matching the core fix described in the PR.
Description check ✅ Passed The PR description comprehensively covers problem, root cause, and fix, but the checklist section is incomplete with no checkboxes marked and no demo video provided for UI/behavior changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Blocking Runtime ✅ Passed Swift changes add surfaceId property to WorkstreamEvent struct and update FeedCoordinator.resolveAttentionTarget for event-based routing. No blocking synchronization primitives (semaphores, sleeps,...
Cmux Expensive Synchronous Load ✅ Passed FeedCoordinator.resolveAttentionTarget's call to FeedJumpResolver.lookup() (disk I/O) was pre-existing and intentionally scheduled off-main before DispatchQueue.main.sync. This PR doesn't worsen th...
Cmux Cache Substitution Correctness ✅ Passed The PR prefers fresh event.surfaceId (from CMUX_SURFACE_ID env) over cached session store, with workspace-match gating (line 494) preventing stale cache misrouting per documented specification (lin...
Cmux No Hacky Sleeps ✅ Passed PR introduces no new hacky sleeps; changes are straightforward env var reading and event field propagation to fix split-pane notification routing. Pre-existing setTimeout is part of a proper timeou...
Cmux Algorithmic Complexity ✅ Passed All changes operate on fixed-size event data (single WorkstreamEvent) with constant-time operations: env var reading, string UUID parsing with bounded inputs, and single conditional assignments. No...
Cmux Swift Concurrency ✅ Passed PR contains only data structure additions and synchronous logic changes; no legacy async patterns (Dispatch queues, Combine, completion handlers, fire-and-forget Tasks) were introduced or materiall...
Cmux Swift @Concurrent ✅ Passed Swift changes add surfaceId property and update resolveAttentionTarget synchronously; no async functions introduced, no @concurrent violations, file I/O properly placed outside main-thread critical...
Cmux Swiftpm Lockfiles ✅ Passed PR modifies only source code files (opencode-plugin.js, WorkstreamEvent.swift, FeedCoordinator.swift), not SwiftPM configuration, .gitignore, workflows, or dependency files. Check applies only to s...
Cmux Swift Logging ✅ Passed The PR introduces no logging statements in Swift production code or elsewhere. Changes only add surfaceId property handling and event routing logic without any print, debugPrint, dump, NSLog, or Lo...
Cmux User-Facing Error Privacy ✅ Passed Changes are internal implementation details that read environment variables and pass surface IDs through data structures and functions. No user-facing error messages, alerts, or recovery copy expos...
Cmux Full Internationalization ✅ Passed All user-facing text properly localized: feed.status.needsInput has complete translations for 20 locales in Localizable.xcstrings; plugin and data-structure changes contain no user-facing strings.
Cmux Swiftui State Layout ✅ Passed Changes are limited to non-SwiftUI model/coordinator code: WorkstreamEvent is a Codable data struct, FeedCoordinator is a non-view coordinator class. No SwiftUI patterns, state mutations, or view l...
Cmux Architecture Rethink ✅ Passed Swift changes add surfaceId property to WorkstreamEvent and update FeedCoordinator to prefer live event.surfaceId—small correctness fixes following existing workspaceId patterns with clear owners,...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR contains no user-visible NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup code; changes are data model additions and routing logic only.
Cmux Source Artifacts ✅ Passed All three changed files are hand-written source code in standard source directories (Resources/, Packages/.../Sources/, Sources/) with no artifact, cache, or generated directory patterns detected.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 20, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR completes the surface-routing fix for split-pane opencode workspaces. When opencode runs in one pane and another terminal occupies the other, blocking events (permission requests, plan approvals) previously fired their attention overlay on the focused pane instead of the opencode pane.

  • Resources/opencode-plugin.js: Reads CMUX_SURFACE_ID from the environment and emits it as surface_id on every feed event, exactly mirroring the existing workspace_id / CMUX_WORKSPACE_ID handling.
  • WorkstreamEvent.swift: Adds surfaceId: String? with the correct CodingKey = "surface_id" so the field is decoded from incoming JSON as a typed property rather than falling through to extraFieldsJSON.
  • FeedCoordinator.swift: Updates resolveAttentionTarget to parse event.surfaceId as a UUID and prefer it over the hook-session store, mirroring the established precedence logic for workspaceId.

Confidence Score: 5/5

Safe to merge — the three-file change is internally consistent and the new surface routing path is a direct, symmetric extension of the existing workspace routing path.

All three layers of the fix (JS event emission, Swift model decoding, FeedCoordinator resolution) are aligned and consistent. The env-var override correctly wins over the ...extra spread. The UUID parse in resolveAttentionTarget silently falls back to the session store on invalid input, matching the established workspaceId behavior. No pre-existing invariants are broken and no new bad states are introduced.

No files require special attention; the changes are minimal and each file's diff is self-contained.

Important Files Changed

Filename Overview
Resources/opencode-plugin.js Reads CMUX_SURFACE_ID and attaches surface_id to every feed event, mirroring the established workspace_id pattern. The env-var override is applied after the ...extra spread, so live env values correctly win over any caller-supplied value.
Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift Adds surfaceId: String? with CodingKey "surface_id" to init, decode, encode, and CodingKeys — a complete and symmetric addition that matches the existing workspaceId pattern throughout.
Sources/Feed/FeedCoordinator.swift resolveAttentionTarget now parses event.surfaceId as a UUID and prefers it over the session store, correctly extending the existing workspace-precedence logic to surfaces. No other call sites are changed.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant P as opencode-plugin.js
    participant S as cmux socket
    participant W as WorkstreamEvent (Swift)
    participant F as FeedCoordinator
    participant UI as Attention Overlay

    P->>P: base() reads CMUX_WORKSPACE_ID + CMUX_SURFACE_ID
    P->>S: "emit JSON {workspace_id, surface_id, hook_event_name, …}"
    S->>W: decode JSON → WorkstreamEvent(.workspaceId, .surfaceId)
    W->>F: resolveAttentionTarget(event)
    F->>F: parse event.surfaceId as UUID (eventSurfaceId)
    F->>F: "surfaceId = eventSurfaceId ?? sessionStore fallback"
    F->>UI: target correct pane (opencode pane, not focused pane)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant P as opencode-plugin.js
    participant S as cmux socket
    participant W as WorkstreamEvent (Swift)
    participant F as FeedCoordinator
    participant UI as Attention Overlay

    P->>P: base() reads CMUX_WORKSPACE_ID + CMUX_SURFACE_ID
    P->>S: "emit JSON {workspace_id, surface_id, hook_event_name, …}"
    S->>W: decode JSON → WorkstreamEvent(.workspaceId, .surfaceId)
    W->>F: resolveAttentionTarget(event)
    F->>F: parse event.surfaceId as UUID (eventSurfaceId)
    F->>F: "surfaceId = eventSurfaceId ?? sessionStore fallback"
    F->>UI: target correct pane (opencode pane, not focused pane)
Loading

Reviews (2): Last reviewed commit: "fix(opencode-plugin): add surfaceId to W..." | Re-trigger Greptile

Comment on lines 411 to +423
@@ -416,6 +420,7 @@ export const CMUXFeed = async (ctx) => {
...extra,
};
if (workspaceId) event.workspace_id = workspaceId;
if (surfaceId) event.surface_id = surfaceId;

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.

Comment on lines +411 to +414
const surfaceId =
typeof process.env.CMUX_SURFACE_ID === "string" && process.env.CMUX_SURFACE_ID.trim()
? process.env.CMUX_SURFACE_ID.trim()
: null;

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.

@e-jung

e-jung commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Good catch from the Greptile review — agreed this is half the fix. The JS side (propagating CMUX_SURFACE_ID into the feed event payload) is done, but the Swift consumer side is the missing half: WorkstreamEvent needs a surfaceId property and FeedCoordinator.resolveAttentionTarget needs to read it for the routing to actually change.

I have access to an M1 Mac for building/testing — happy to tackle the Swift side (add the surfaceId property + wire resolveAttentionTarget to use it) and push to this branch. Or if you'd prefer to handle the consumer-side yourself, I'll scope this PR to the JS emit and open a follow-up. Let me know which you'd prefer.

@e-jung

e-jung commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

Update on the 'missing half' I flagged earlier: the Swift consumer side is now wired in 516f058 — surfaceId added to WorkstreamEvent and FeedCoordinator routes on it.

We also independently verified the build on a fresh Xcode 26.5: it builds clean (0 Swift errors), and the relevant test suites pass — FeedCoordinatorTests 13/13 and CMUXAgentLaunch 142/142. Happy to address any further review feedback.

teamleaderleo added a commit that referenced this pull request Sep 26, 2026
Reads CMUX_SURFACE_ID in the opencode feed plugin and sets surface_id on feed events, the plugin half of #6504.

Co-authored-by: e-jung <e-jung@users.noreply.github.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator

Thanks @e-jung! The Swift side of this landed in #6798, and the plugin half (sending surface_id from CMUX_SURFACE_ID) was still missing, so it just landed in #14781 with you as co-author. You opened this before either one. Closing since it's all on main now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants