Skip to content
Merged
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
189 changes: 171 additions & 18 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1863,12 +1863,36 @@ struct CMUXCLI {

case "trigger-flash":
let tfWsFlag = optionValue(commandArgs, name: "--workspace")
let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (tfWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let explicitWorkspaceArg = tfWsFlag
let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil

@cubic-dev-ai cubic-dev-ai Bot Mar 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The new TMUX gate clears CMUX_WORKSPACE_ID/CMUX_SURFACE_ID defaults, so notify/trigger-flash can misroute in tmux when tty fallback is unavailable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 1867:

<comment>The new `TMUX` gate clears `CMUX_WORKSPACE_ID`/`CMUX_SURFACE_ID` defaults, so `notify`/`trigger-flash` can misroute in tmux when tty fallback is unavailable.</comment>

<file context>
@@ -1864,10 +1864,13 @@ struct CMUXCLI {
             let tfWsFlag = optionValue(commandArgs, name: "--workspace")
             let explicitWorkspaceArg = tfWsFlag
-            let callerWorkspaceArg = windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil
+            let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil
+            let callerWorkspaceArg = preferTTYFallback
+                ? nil
</file context>
Fix with Cubic

let callerWorkspaceArg = preferTTYFallback
? nil
: (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg
let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel")
let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && windowId == nil
? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
: nil
let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
let wsId = try {
if explicitWorkspaceArg != nil {
return try normalizeWorkspaceHandle(workspaceArg, client: client)
}
return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client)
}()
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
let sfId = try {
if explicitSurfaceArg != nil {
return try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
}
guard let wsId else { return nil }
return try resolveSurfaceIdAllowingFallback(
surfaceArg,
workspaceId: wsId,
client: client
)
}()
Comment on lines +1866 to +1895

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't let --window fall back to the caller TTY.

These branches correctly clear env-derived caller IDs when windowId != nil, but then they call resolveWorkspaceIdAllowingFallback(nil, ...), which reintroduces caller-TTY routing. cmux --window ... notify / trigger-flash can therefore target the originating workspace instead of the selected workspace in the explicit window.

Suggested fix

Apply the same change in both call sites, and gate the helper's TTY fallback behind a flag.

-                return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client)
+                return try resolveWorkspaceIdAllowingFallback(
+                    workspaceArg,
+                    client: client,
+                    allowCallerTTYFallback: windowId == nil
+                )

Also applies to: 2086-2110

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 1866 - 1892, The code currently lets
resolveWorkspaceIdAllowingFallback/resolveSurfaceIdAllowingFallback fall back to
the caller TTY even when an explicit window (windowId) was provided; change the
calls so that when windowId != nil (or explicitWorkspaceArg/explicitSurfaceArg
is non-nil) you call the non-fallback behavior (or pass a flag disabling TTY
fallback) instead of resolve*AllowingFallback; specifically, in the wsId and
sfId closure blocks replace the fallback-allowing helper with the variant that
does not use the CMUX_WORKSPACE_ID/CMUX_SURFACE_ID environment (or add and pass
a disableTTYFallback parameter to
resolveWorkspaceIdAllowingFallback/resolveSurfaceIdAllowingFallback), and ensure
normalizeWorkspaceHandle/normalizeSurfaceHandle remain used when
explicitWorkspaceArg/explicitSurfaceArg are present so explicit values still
normalize correctly.

if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.trigger_flash", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
Expand Down Expand Up @@ -2062,12 +2086,34 @@ struct CMUXCLI {
let subtitle = optionValue(commandArgs, name: "--subtitle") ?? ""
let body = optionValue(commandArgs, name: "--body") ?? ""

let notifyWsFlag = optionValue(commandArgs, name: "--workspace")
let workspaceArg = notifyWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (notifyWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)

let targetWorkspace = try resolveWorkspaceId(workspaceArg, client: client)
let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client)
let explicitWorkspaceArg = optionValue(commandArgs, name: "--workspace")
let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil
let callerWorkspaceArg = preferTTYFallback
? nil
: (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg
let explicitSurfaceArg = optionValue(commandArgs, name: "--surface")
let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && windowId == nil
? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
: nil
let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg

let targetWorkspace = try {
if explicitWorkspaceArg != nil {
return try resolveWorkspaceId(workspaceArg, client: client)
}
return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client)
}()
let targetSurface = try {
if explicitSurfaceArg != nil {
return try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client)
}
return try resolveSurfaceIdAllowingFallback(
surfaceArg,
workspaceId: targetWorkspace,
client: client
)
}()

let payload = "\(title)|\(subtitle)|\(body)"
let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client)
Expand Down Expand Up @@ -10482,26 +10528,133 @@ struct CMUXCLI {
}

private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String {
if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) {
let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])
if probe != nil {
return candidate
}
}
return try resolveWorkspaceId(nil, client: client)
try resolveWorkspaceIdAllowingFallback(raw, client: client)
}

private func resolveSurfaceIdForClaudeHook(
_ raw: String?,
workspaceId: String,
client: SocketClient
) throws -> String {
if let raw, !raw.isEmpty, let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client) {
try resolveSurfaceIdAllowingFallback(raw, workspaceId: workspaceId, client: client)
}
Comment on lines 10534 to +10540

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Route the session-end fallback through resolveSurfaceIdForClaudeHook.

The new TTY fallback only helps call sites that use this helper. Line 10171 still calls resolveSurfaceId(...) directly, so a session-end without session_id can still consume the focused surface's session instead of the caller surface when multiple Claude panes share a workspace.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 10528 - 10534, A call site is still invoking
resolveSurfaceId(...) directly for session-end events, bypassing the new TTY
fallback; update that handler to call resolveSurfaceIdForClaudeHook(_ raw:
String?, workspaceId: String, client: SocketClient) instead of resolveSurfaceId
so the session-end path goes through resolveSurfaceIdAllowingFallback. Locate
the code that resolves surfaces for "session-end" events and replace the direct
resolveSurfaceId(...) invocation with resolveSurfaceIdForClaudeHook(...),
ensuring you pass the same raw, workspaceId and client arguments and propagate
any throws or errors as the original call did.


private func resolveWorkspaceIdAllowingFallback(
_ raw: String?,
client: SocketClient
) throws -> String {
if let raw,
!raw.isEmpty,
let candidate = try? resolveWorkspaceId(raw, client: client),
(try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])) != nil {
return candidate
Comment on lines +10546 to 10550

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prefer caller TTY before inherited workspace fallback

When --workspace is omitted, this branch accepts any resolvable workspace from inherited env (CMUX_WORKSPACE_ID) before consulting debug.terminals. If that env value is stale-but-still-valid (for example, from another tmux pane/workspace that still exists), notify/trigger-flash will be routed to the wrong workspace even though a caller TTY mapping is available. The TTY-derived binding should be tried before trusting inherited workspace IDs in this fallback path.

Useful? React with 👍 / 👎.

}
if let callerWorkspaceId = resolveCallerWorkspaceIdByTTY(client: client),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
(try? client.sendV2(method: "surface.list", params: ["workspace_id": callerWorkspaceId])) != nil {
return callerWorkspaceId
Comment on lines +10552 to +10554

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor --window before TTY fallback workspace resolution

resolveWorkspaceIdAllowingFallback now prefers resolveCallerWorkspaceIdByTTY whenever the workspace arg is omitted, but notify/trigger-flash intentionally omit env fallback when --window is used and rely on window.focus + current workspace routing. With this change, cmux --window <other> notify (or trigger-flash) can still resolve to the caller terminal’s workspace instead of the targeted window’s workspace, so attention events are sent to the wrong pane/window. The TTY fallback should be skipped when a window override is active.

Useful? React with 👍 / 👎.

}
return try resolveWorkspaceId(nil, client: client)
}

private func resolveSurfaceIdAllowingFallback(
_ raw: String?,
workspaceId: String,
client: SocketClient
) throws -> String {
if let raw,
!raw.isEmpty,
let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client),
let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) {
let items = listed["surfaces"] as? [[String: Any]] ?? []
if items.contains(where: {
($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate
}) {
return candidate
}
Comment on lines +10569 to +10573

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve surface from caller TTY before env surface IDs

This candidate-return path runs before the TTY fallback, so a stale-but-existing CMUX_SURFACE_ID is treated as authoritative as long as it appears in surface.list. In multi-pane tmux sessions, that can direct notify/surface.trigger_flash to a different pane than the caller terminal, defeating the new caller-routing logic. The resolver should prioritize the TTY-derived surface binding when no explicit --surface/--panel was provided.

Useful? React with 👍 / 👎.

}
if let callerSurfaceId = resolveCallerSurfaceIdByTTY(workspaceId: workspaceId, client: client),
let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) {
let items = listed["surfaces"] as? [[String: Any]] ?? []
if items.contains(where: {
($0["id"] as? String) == callerSurfaceId || ($0["ref"] as? String) == callerSurfaceId
}) {
return callerSurfaceId
}
}
return try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
}

private struct CallerTerminalBinding {
let workspaceId: String
let surfaceId: String
}

private func resolveCallerWorkspaceIdByTTY(client: SocketClient) -> String? {
resolveCallerTerminalBindingByTTY(client: client)?.workspaceId
}

private func resolveCallerSurfaceIdByTTY(workspaceId: String, client: SocketClient) -> String? {
guard let binding = resolveCallerTerminalBindingByTTY(client: client),
binding.workspaceId == workspaceId else {
return nil
}
return binding.surfaceId
}

private func resolveCallerTerminalBindingByTTY(client: SocketClient) -> CallerTerminalBinding? {
guard let ttyName = resolveCallerTTYName() else {
return nil
}
guard let payload = try? client.sendV2(method: "debug.terminals") else {
return nil
}
let terminals = payload["terminals"] as? [[String: Any]] ?? []
for terminal in terminals {
guard normalizedTTYName(terminal["tty"] as? String) == ttyName,
let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String),
let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else {
continue
}
return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId)
}
return nil
}

private func resolveCallerTTYName() -> String? {
let env = ProcessInfo.processInfo.environment
for key in ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME", "TTY", "SSH_TTY"] {
if let ttyName = normalizedTTYName(env[key]) {
return ttyName
}
}
for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
if let rawTTYName = ttyname(fileDescriptor),
let ttyName = normalizedTTYName(String(cString: rawTTYName)) {
return ttyName
}
}
return nil
}

private func normalizedTTYName(_ raw: String?) -> String? {
guard let trimmed = normalizedHandleValue(raw == "not a tty" ? nil : raw) else {
return nil
}
let components = trimmed.split(separator: "/")
if let last = components.last, !last.isEmpty {
return String(last)
}
return trimmed
}

private func normalizedHandleValue(_ raw: String?) -> String? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
return nil
}
return raw
}

private func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput {
let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
Expand Down
Loading
Loading