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
102 changes: 95 additions & 7 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9015,6 +9015,18 @@ struct CMUXCLI {
) throws -> (workspaceId: String, paneId: String?, surfaceId: String) {
if tmuxPaneSelector(from: raw) != nil {
let resolved = try tmuxResolvePaneTarget(raw, client: client)
// When the target pane matches the caller's pane, prefer the caller's
// exact surface (CMUX_SURFACE_ID) over the pane's currently selected
// surface. The selected surface can change (e.g. tab switches) after
// claude-teams started, but the caller surface stays fixed.
let callerPane = tmuxCallerPaneHandle()
let callerSurface = tmuxCallerSurfaceHandle()
let canonicalCallerPane = callerPane.flatMap { try? tmuxCanonicalPaneId($0, workspaceId: resolved.workspaceId, client: client) }
let paneMatch = callerPane != nil && (resolved.paneId == callerPane! || resolved.paneId == canonicalCallerPane)
let canonicalSurface = callerSurface.flatMap { try? tmuxCanonicalSurfaceId($0, workspaceId: resolved.workspaceId, client: client) }
if paneMatch, let surfaceId = canonicalSurface {
return (resolved.workspaceId, resolved.paneId, surfaceId)
}
let surfaceId = try tmuxSelectedSurfaceId(
workspaceId: resolved.workspaceId,
paneId: resolved.paneId,
Expand Down Expand Up @@ -9548,23 +9560,62 @@ struct CMUXCLI {
valueFlags: ["-c", "-F", "-l", "-t"],
boolFlags: ["-P", "-b", "-d", "-h", "-v"]
)
let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
let direction: String
var target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
var direction: String
if parsed.hasFlag("-h") {
direction = parsed.hasFlag("-b") ? "left" : "right"
} else {
direction = parsed.hasFlag("-b") ? "up" : "down"
}

// Claude's agent teams targets arbitrary panes (from list-panes),
// not necessarily the leader pane from TMUX_PANE. Override the
// target to anchor all teammate splits to the leader surface.
let store = loadTmuxCompatStore()
if let callerSurface = tmuxCallerSurfaceHandle(),
let callerWorkspace = tmuxCallerWorkspaceHandle() {
let wsId = (try? resolveWorkspaceId(callerWorkspace, client: client)) ?? target.workspaceId

@cubic-dev-ai cubic-dev-ai Bot Mar 26, 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 workspace fallback can pair target.workspaceId with callerSurface, creating an invalid cross-workspace surface.split request.

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

<comment>The new workspace fallback can pair `target.workspaceId` with `callerSurface`, creating an invalid cross-workspace `surface.split` request.</comment>

<file context>
@@ -9556,17 +9568,23 @@ struct CMUXCLI {
-                direction = "down"
+            if let callerSurface = tmuxCallerSurfaceHandle(),
+               let callerWorkspace = tmuxCallerWorkspaceHandle() {
+                let wsId = (try? resolveWorkspaceId(callerWorkspace, client: client)) ?? target.workspaceId
+                if let mvState = store.mainVerticalLayouts[wsId],
+                   let lastColumn = mvState.lastColumnSurfaceId {
</file context>
Fix with Cubic

if let mvState = store.mainVerticalLayouts[wsId],
let lastColumn = mvState.lastColumnSurfaceId {
// Main-vertical active: stack in right column.
target = (wsId, nil, lastColumn)
direction = "down"
} else {
// First teammate: split the leader surface to the right.
target = (wsId, nil, callerSurface)
direction = "right"
}
}

// Keep the leader pane focused while Claude starts teammates beside it.
let created = try client.sendV2(method: "surface.split", params: [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId,
"direction": direction
"direction": direction,
"focus": false
])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
guard let surfaceId = created["surface_id"] as? String else {
throw CLIError(message: "surface.split did not return surface_id")
}
let paneId = created["pane_id"] as? String
// Keep the leader pane focused while Claude starts teammates beside it.

// Track the newly created pane for main-vertical layout.
do {
var updatedStore = loadTmuxCompatStore()
updatedStore.lastSplitSurface[target.workspaceId] = surfaceId
if updatedStore.mainVerticalLayouts[target.workspaceId] != nil {
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId
} else if direction == "right", let callerSurface = tmuxCallerSurfaceHandle() {
// First right split created the column; seed main-vertical
// state so subsequent splits stack downward.
updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
mainSurfaceId: callerSurface,
Comment on lines +9608 to +9612

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 Badge Initialize main-vertical state only after layout command

This block creates mainVerticalLayouts on any first right split, even when select-layout main-vertical was never issued. Because later split-window calls check only whether mainVerticalLayouts[wsId] exists, the second split in the workspace is redirected to down unexpectedly, changing normal tmux split behavior and causing unintended vertical stacking in flows that never opted into main-vertical.

Useful? React with 👍 / 👎.

lastColumnSurfaceId: surfaceId
)
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
try saveTmuxCompatStore(updatedStore)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +9603 to +9616

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

Protect tmux-compat store updates with a lock.

These are now cross-process load → mutate → save sequences on shared JSON state. Two overlapping shim invocations can lose lastSplitSurface / lastColumnSurfaceId updates and make later split routing nondeterministic. The lock-file pattern already used by ClaudeHookSessionStore earlier in this file fits here.

Also applies to: 9845-9855

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

In `@CLI/cmux.swift` around lines 9603 - 9616, The load→mutate→save sequence for
tmux-compat state (loadTmuxCompatStore → mutate
lastSplitSurface/mainVerticalLayouts → saveTmuxCompatStore) must be protected
with the same lock-file pattern used by ClaudeHookSessionStore to prevent
cross-process races; acquire the lock before calling loadTmuxCompatStore,
perform the mutations (including the MainVerticalState seeding that uses
tmuxCallerSurfaceHandle and updates lastSplitSurface and
mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId), then
saveTmuxCompatStore and release the lock; apply the same locking change to the
other similar block around lines 9845–9855.

}

if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
_ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": target.workspaceId,
Expand Down Expand Up @@ -9785,22 +9836,59 @@ struct CMUXCLI {
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
_ = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)

case "select-layout", "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
case "select-layout":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let layoutName = parsed.positional.first ?? ""
if layoutName == "main-vertical" {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
Comment on lines +9842 to +9843

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 Badge Reset main-vertical redirect state for other layouts

This branch only updates state when layoutName == "main-vertical" and never clears prior mainVerticalLayouts state when another layout is selected. After one main-vertical call, a later select-layout to a different layout still leaves redirection active, so subsequent split-window -h calls from the leader can be incorrectly forced into down splits using stale column state.

Useful? React with 👍 / 👎.

if let callerSurface = tmuxCallerSurfaceHandle() {
var store = loadTmuxCompatStore()
// Seed lastColumnSurfaceId from the most recent split if
// this is the first time main-vertical is set and a split
// already happened (the normal flow: split then layout).
let existingColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId
let seedColumn = existingColumn ?? store.lastSplitSurface[workspaceId]
store.mainVerticalLayouts[workspaceId] = MainVerticalState(
mainSurfaceId: callerSurface,
lastColumnSurfaceId: seedColumn
)
try saveTmuxCompatStore(store)
}
Comment on lines +9839 to +9856

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

Clear persisted main-vertical state when another layout is selected.

This branch only mutates state for main-vertical. If the workspace later runs select-layout tiled/even-*, the old mainVerticalLayouts[workspaceId] entry survives and future split-window -h calls still get redirected into the remembered right column even though the layout changed.

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

In `@CLI/cmux.swift` around lines 9798 - 9815, When handling the "select-layout"
case, if layoutName is not "main-vertical" you must clear any persisted
main-vertical state for that workspace so future splits don't reuse the old
column; after resolving workspaceId (tmuxResolveWorkspaceTarget) and loading the
store (loadTmuxCompatStore()), remove store.mainVerticalLayouts[workspaceId] (or
set it to nil) and save with try saveTmuxCompatStore(store); keep the existing
main-vertical branch unchanged but ensure non-main-vertical paths explicitly
delete the entry to avoid stale redirection on later split-window -h calls.

}

case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
return

default:
throw CLIError(message: "Unsupported tmux compatibility command: \(command)")
}
}

private struct MainVerticalState: Codable {
/// The surface ID of the "main" (leader) pane on the left side.
var mainSurfaceId: String
/// The surface ID of the bottom-most pane in the right column.
/// Subsequent teammate splits target this pane with direction "down".
var lastColumnSurfaceId: String?
}

private struct TmuxCompatStore: Codable {
var buffers: [String: String] = [:]
var hooks: [String: String] = [:]
/// Tracks main-vertical layout state per workspace, keyed by workspace ID.
var mainVerticalLayouts: [String: MainVerticalState] = [:]

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 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.

P1: Add backward-compatible decoding defaults for the new tmux-compat store fields. Older store files that do not contain these keys should still decode instead of resetting persisted state.

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

<comment>Add backward-compatible decoding defaults for the new tmux-compat store fields. Older store files that do not contain these keys should still decode instead of resetting persisted state.</comment>

<file context>
@@ -9769,22 +9795,59 @@ struct CMUXCLI {
         var buffers: [String: String] = [:]
         var hooks: [String: String] = [:]
+        /// Tracks main-vertical layout state per workspace, keyed by workspace ID.
+        var mainVerticalLayouts: [String: MainVerticalState] = [:]
+        /// Tracks the last surface created by split-window per workspace.
+        /// Used to seed lastColumnSurfaceId when select-layout main-vertical
</file context>
Fix with Cubic

/// Tracks the last surface created by split-window per workspace.
/// Used to seed lastColumnSurfaceId when select-layout main-vertical
/// is called after the first split.
var lastSplitSurface: [String: String] = [:]
Comment on lines +9879 to +9883

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 Preserve backward decoding of tmux compat store

The two new non-optional Codable properties make older tmux-compat-store.json files (written before this commit) fail to decode, and loadTmuxCompatStore() then silently falls back to an empty store. That drops previously persisted buffers/hooks on first read after upgrade, which can break tmux-compat behavior until the store is rebuilt. Please decode these new fields with backward-compatible defaults (e.g., optional fields or a custom init(from:)).

Useful? React with 👍 / 👎.

}
Comment on lines 9877 to 9884

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 Store entries never purged on workspace close

mainVerticalLayouts and lastSplitSurface are keyed by workspace UUID and written on every split-window and select-layout main-vertical call, but there is no code path that removes entries when a workspace is closed (the kill-window / killw handler only calls workspace.close and returns). With UUIDs as keys the chance of a collision is negligible, but over a long-lived install the JSON store file will grow monotonically.

Consider pruning the stale entries inside the existing kill-window / killw handler after the workspace.close call, mirroring the pattern already used elsewhere in the file for buffer/hook cleanup.


private func tmuxCompatStoreURL() -> URL {
let root = NSString(string: "~/.cmuxterm").expandingTildeInPath
return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json")
let homePath = ProcessInfo.processInfo.environment["HOME"]
?? NSString(string: "~").expandingTildeInPath
return URL(fileURLWithPath: homePath)
.appendingPathComponent(".cmuxterm")
.appendingPathComponent("tmux-compat-store.json")
}

private func loadTmuxCompatStore() -> TmuxCompatStore {
Expand Down
3 changes: 2 additions & 1 deletion Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4565,7 +4565,8 @@ class TerminalController {
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)

if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) {
let focus = v2Bool(params, "focus") ?? true

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 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.

P1: surface.split bypasses focus-mutation policy by using raw focus instead of v2FocusAllowed(...), which can unexpectedly move focus during non-focus socket commands.

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

<comment>`surface.split` bypasses focus-mutation policy by using raw `focus` instead of `v2FocusAllowed(...)`, which can unexpectedly move focus during non-focus socket commands.</comment>

<file context>
@@ -4566,7 +4566,8 @@ class TerminalController {
             v2MaybeSelectWorkspace(tabManager, workspace: ws)
 
-            if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) {
+            let focus = v2Bool(params, "focus") ?? true
+            if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {
                 let paneUUID = ws.paneId(forPanelId: newId)?.id
</file context>
Suggested change
let focus = v2Bool(params, "focus") ?? true
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
Fix with Cubic

if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {
Comment on lines +4568 to +4569

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

Gate split focus through v2FocusAllowed.

Line 4569 forwards the raw focus flag into tabManager.newSplit(...), and that flag ultimately drives Workspace.newTerminalSplit(..., focus:), which changes the focused panel. Since surface.split is not a focus-intent socket command, this still lets callers steal in-app focus by omitting focus or sending true.

Suggested fix
-            let focus = v2Bool(params, "focus") ?? true
+            let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
             if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {

Based on learnings: Socket/CLI commands must not steal macOS app focus. Only explicit focus-intent commands may mutate in-app focus/selection.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let focus = v2Bool(params, "focus") ?? true
if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 4569 - 4570, The code
currently passes the raw focus flag from v2Bool into tabManager.newSplit(...),
allowing non-focus-intent socket/CLI commands (surface.split) to change app
focus; instead, gate focus through v2FocusAllowed by computing focusAllowed =
v2FocusAllowed(params) and only honoring the v2Bool(params, "focus") (or default
true) when focusAllowed is true, otherwise pass false into
tabManager.newSplit/tabManager.newTerminalSplit; update the call site (the block
with v2Bool(...), tabManager.newSplit(tabId: ws.id, ...)) to use this guarded
focus value so only explicit focus-intent commands can mutate in-app
focus/selection.

let paneUUID = ws.paneId(forPanelId: newId)?.id
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
Expand Down
Loading
Loading