From 697f4ba4977f858689e3ac61a3a1864ba12255c3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 19:59:22 -0700 Subject: [PATCH 01/18] Add `cmux omo` command for OpenCode + oh-my-openagent integration Same pattern as `cmux claude-teams`: creates a tmux shim so oh-my-openagent's TmuxSessionManager spawns agents as native cmux splits instead of tmux panes. Sets TMUX/TMUX_PANE env vars, prepends shim to PATH, and execs into opencode. Closes https://github.com/manaflow-ai/cmux/issues/2085 --- CLI/cmux.swift | 160 ++++++++++++++++++++++++++++++++ Resources/Localizable.xcstrings | 17 ++++ 2 files changed, 177 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 17279ef63..9479b669d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1413,6 +1413,7 @@ struct CMUXCLI { // so help text is available even when cmux is not running. if command != "__tmux-compat", command != "claude-teams", + command != "omo", (commandArgs.contains("--help") || commandArgs.contains("-h")) { if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) { return @@ -1463,6 +1464,15 @@ struct CMUXCLI { return } + if command == "omo" { + try runOMO( + commandArgs: commandArgs, + socketPath: resolvedSocketPath, + explicitPassword: socketPasswordArg + ) + return + } + let client = SocketClient(path: resolvedSocketPath) if resolvedSocketPath != socketPath { cliTelemetry.breadcrumb( @@ -6073,6 +6083,29 @@ struct CMUXCLI { cmux claude-teams --continue cmux claude-teams --model sonnet """) + case "omo": + return String(localized: "cli.omo.usage", defaultValue: """ + Usage: cmux omo [opencode-args...] + + Launch OpenCode with oh-my-openagent in a cmux-aware environment. + + oh-my-openagent orchestrates multiple AI models as specialized agents in + parallel. This command sets up a tmux shim so agent panes become native + cmux splits with sidebar metadata and notifications. + + This command: + - sets a tmux-like environment so oh-my-openagent uses cmux splits + - prepends a private tmux shim to PATH + - forwards all remaining arguments to opencode + + The tmux shim translates tmux window/pane commands into cmux workspace + and split operations in the current cmux session. + + Examples: + cmux omo + cmux omo --continue + cmux omo --model claude-sonnet-4-6 + """) case "identify": return """ Usage: cmux identify [--workspace ] [--surface ] [--no-caller] @@ -9380,6 +9413,132 @@ struct CMUXCLI { throw CLIError(message: "Failed to launch claude: \(String(cString: strerror(code)))") } + // MARK: - cmux omo (OpenCode + oh-my-openagent) + + private func resolveOpenCodeExecutable(searchPath: String?) -> String? { + let entries = searchPath?.split(separator: ":").map(String.init) ?? [] + for entry in entries where !entry.isEmpty { + let candidate = URL(fileURLWithPath: entry, isDirectory: true) + .appendingPathComponent("opencode", isDirectory: false) + .path + guard FileManager.default.isExecutableFile(atPath: candidate) else { continue } + return candidate + } + return nil + } + + private func createOMOShimDirectory() throws -> URL { + let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + let root = URL(fileURLWithPath: homePath, isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("omo-bin", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) + let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false) + let script = """ + #!/usr/bin/env bash + set -euo pipefail + exec "${CMUX_OMO_CMUX_BIN:-cmux}" __tmux-compat "$@" + """ + let normalizedScript = script.trimmingCharacters(in: .whitespacesAndNewlines) + let existingScript = try? String(contentsOf: tmuxURL, encoding: .utf8) + if existingScript?.trimmingCharacters(in: .whitespacesAndNewlines) != normalizedScript { + try script.write(to: tmuxURL, atomically: false, encoding: .utf8) + } + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmuxURL.path) + return root + } + + private func configureOMOEnvironment( + processEnvironment: [String: String], + shimDirectory: URL, + executablePath: String, + socketPath: String, + explicitPassword: String?, + focusedContext: ClaudeTeamsFocusedContext? + ) { + let updatedPath = prependPathEntries( + [shimDirectory.path], + to: processEnvironment["PATH"] + ) + let fakeTmuxValue: String = { + if let focusedContext { + let windowToken = focusedContext.windowId ?? focusedContext.workspaceId + return "/tmp/cmux-omo/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)" + } + return processEnvironment["TMUX"] ?? "/tmp/cmux-omo/default,0,0" + }() + let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" } + ?? processEnvironment["TMUX_PANE"] + ?? "%1" + let fakeTerm = processEnvironment["CMUX_OMO_TERM"] ?? "screen-256color" + + setenv("CMUX_OMO_CMUX_BIN", executablePath, 1) + setenv("PATH", updatedPath, 1) + setenv("TMUX", fakeTmuxValue, 1) + setenv("TMUX_PANE", fakeTmuxPane, 1) + setenv("TERM", fakeTerm, 1) + setenv("CMUX_SOCKET_PATH", socketPath, 1) + setenv("CMUX_SOCKET", socketPath, 1) + if let explicitPassword, + !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1) + } + unsetenv("TERM_PROGRAM") + if let focusedContext { + setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1) + if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty { + setenv("CMUX_SURFACE_ID", surfaceId, 1) + } + } + } + + private func runOMO( + commandArgs: [String], + socketPath: String, + explicitPassword: String? + ) throws { + let processEnvironment = ProcessInfo.processInfo.environment + var launcherEnvironment = processEnvironment + launcherEnvironment["CMUX_SOCKET_PATH"] = socketPath + launcherEnvironment["CMUX_SOCKET"] = socketPath + if let explicitPassword, + !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + launcherEnvironment["CMUX_SOCKET_PASSWORD"] = explicitPassword + } + let shimDirectory = try createOMOShimDirectory() + let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux") + let focusedContext = claudeTeamsFocusedContext( + processEnvironment: launcherEnvironment, + explicitPassword: explicitPassword + ) + let openCodeExecutablePath = resolveOpenCodeExecutable(searchPath: launcherEnvironment["PATH"]) + configureOMOEnvironment( + processEnvironment: launcherEnvironment, + shimDirectory: shimDirectory, + executablePath: executablePath, + socketPath: socketPath, + explicitPassword: explicitPassword, + focusedContext: focusedContext + ) + + let launchPath = openCodeExecutablePath ?? "opencode" + var argv = ([launchPath] + commandArgs).map { strdup($0) } + defer { + for item in argv { + free(item) + } + } + argv.append(nil) + + if openCodeExecutablePath != nil { + execv(launchPath, &argv) + } else { + execvp("opencode", &argv) + } + let code = errno + throw CLIError(message: "Failed to launch opencode: \(String(cString: strerror(code)))") + } + private func runClaudeTeamsTmuxCompat( commandArgs: [String], client: SocketClient, @@ -11361,6 +11520,7 @@ struct CMUXCLI { feedback [--email --body [--image ...]] themes [list|set|clear] claude-teams [claude-args...] + omo [opencode-args...] ping version capabilities diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index be46bab00..e86c4e5c6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -132,6 +132,23 @@ } } }, + "cli.omo.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux omo [opencode-args...]\n\nLaunch OpenCode with oh-my-openagent in a cmux-aware environment.\n\noh-my-openagent orchestrates multiple AI models as specialized agents in\nparallel. This command sets up a tmux shim so agent panes become native\ncmux splits with sidebar metadata and notifications.\n\nThis command:\n - sets a tmux-like environment so oh-my-openagent uses cmux splits\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to opencode\n\nThe tmux shim translates tmux window/pane commands into cmux workspace\nand split operations in the current cmux session.\n\nExamples:\n cmux omo\n cmux omo --continue\n cmux omo --model claude-sonnet-4-6" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux omo [opencode-args...]\n\ncmux 対応の環境で OpenCode と oh-my-openagent を起動します。\n\noh-my-openagent は複数の AI モデルを専門エージェントとして並列に\nオーケストレーションします。このコマンドは tmux shim を設定し、\nエージェントのペインをネイティブの cmux split に変換します。\n\nこのコマンドは次を行います:\n - oh-my-openagent が cmux の split を使うよう tmux 風の環境を設定\n - 専用の tmux shim を PATH の先頭に追加\n - 残りの引数をそのまま opencode に渡す\n\ntmux shim は、tmux の window/pane コマンドを、現在の cmux セッション内の\nworkspace と split 操作に変換します。\n\n例:\n cmux omo\n cmux omo --continue\n cmux omo --model claude-sonnet-4-6" + } + } + } + }, "applescript.error.disabled": { "extractionState": "manual", "localizations": { From 60aca2af5e40f08d8a7c8e353c3e5aa1ae07ad31 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 21:05:12 -0700 Subject: [PATCH 02/18] Auto-install oh-my-opencode plugin when running cmux omo Before launching opencode, cmux omo now: - Checks if oh-my-opencode is registered in ~/.config/opencode/opencode.json - If not, creates/updates the config with the plugin entry - Checks if the npm package is installed in node_modules - If not, runs bun add (or npm install) to install it - Then proceeds with tmux shim setup and exec --- CLI/cmux.swift | 114 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9479b669d..61c832dfd 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -9448,6 +9448,117 @@ struct CMUXCLI { return root } + private static let omoPluginName = "oh-my-opencode" + + private func omoOpenCodeConfigURL() -> URL { + let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + return URL(fileURLWithPath: homePath, isDirectory: true) + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + } + + /// Returns true if `oh-my-opencode` is listed in the opencode.json plugin array. + private func omoPluginIsRegistered(configDir: URL) -> Bool { + let jsonURL = configDir.appendingPathComponent("opencode.json") + guard let data = try? Data(contentsOf: jsonURL), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let plugins = obj["plugin"] as? [String] else { + return false + } + return plugins.contains { $0 == Self.omoPluginName || $0.hasPrefix("\(Self.omoPluginName)@") } + } + + /// Adds `oh-my-opencode` to the opencode.json plugin array, creating the file if needed. + private func omoRegisterPlugin(configDir: URL) throws { + try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true, attributes: nil) + let jsonURL = configDir.appendingPathComponent("opencode.json") + + var config: [String: Any] + if let data = try? Data(contentsOf: jsonURL), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + config = existing + } else { + config = [:] + } + + var plugins = (config["plugin"] as? [String]) ?? [] + let alreadyPresent = plugins.contains { + $0 == Self.omoPluginName || $0.hasPrefix("\(Self.omoPluginName)@") + } + if !alreadyPresent { + plugins.append(Self.omoPluginName) + } + config["plugin"] = plugins + + let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) + try output.write(to: jsonURL, options: .atomic) + } + + /// Returns true if the oh-my-opencode package exists in node_modules. + private func omoPackageIsInstalled(configDir: URL) -> Bool { + let packageDir = configDir + .appendingPathComponent("node_modules", isDirectory: true) + .appendingPathComponent(Self.omoPluginName, isDirectory: true) + return FileManager.default.fileExists(atPath: packageDir.path) + } + + /// Installs the oh-my-opencode npm package in the config directory using bun or npm. + private func omoInstallPackage(configDir: URL) throws { + let process = Process() + process.currentDirectoryURL = configDir + + // Prefer bun, fall back to npm + if let bunPath = resolveExecutableInPath("bun") { + process.executableURL = URL(fileURLWithPath: bunPath) + process.arguments = ["add", Self.omoPluginName] + } else if let npmPath = resolveExecutableInPath("npm") { + process.executableURL = URL(fileURLWithPath: npmPath) + process.arguments = ["install", Self.omoPluginName] + } else { + throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install") + } + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + FileHandle.standardError.write("Installing oh-my-opencode plugin...\n".data(using: .utf8)!) + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let errText = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw CLIError(message: "Failed to install oh-my-opencode: \(errText)") + } + } + + private func resolveExecutableInPath(_ name: String) -> String? { + let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? [] + for entry in entries where !entry.isEmpty { + let candidate = URL(fileURLWithPath: entry, isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + .path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + /// Ensures oh-my-opencode plugin is registered and installed before launching opencode. + private func omoEnsurePlugin() throws { + let configDir = omoOpenCodeConfigURL() + if !omoPluginIsRegistered(configDir: configDir) { + try omoRegisterPlugin(configDir: configDir) + FileHandle.standardError.write("Registered oh-my-opencode plugin in opencode.json\n".data(using: .utf8)!) + } + if !omoPackageIsInstalled(configDir: configDir) { + try omoInstallPackage(configDir: configDir) + FileHandle.standardError.write("oh-my-opencode plugin installed\n".data(using: .utf8)!) + } + } + private func configureOMOEnvironment( processEnvironment: [String: String], shimDirectory: URL, @@ -9497,6 +9608,9 @@ struct CMUXCLI { socketPath: String, explicitPassword: String? ) throws { + // Ensure oh-my-opencode plugin is registered and installed + try omoEnsurePlugin() + let processEnvironment = ProcessInfo.processInfo.environment var launcherEnvironment = processEnvironment launcherEnvironment["CMUX_SOCKET_PATH"] = socketPath From 1b43d33f21c160b5e68f60d2662a20d8b681ad40 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 22:03:57 -0700 Subject: [PATCH 03/18] Use shadow config dir to avoid modifying user's opencode setup Instead of writing directly to ~/.config/opencode/opencode.json, cmux omo now creates a shadow config at ~/.cmuxterm/omo-config/ that layers oh-my-opencode on top of the user's existing config. Symlinks node_modules, package.json, bun.lock, and plugin config from the original dir. Sets OPENCODE_CONFIG_DIR to the shadow directory. Running plain `opencode` remains unaffected. --- CLI/cmux.swift | 171 +++++++++++++++++++++++++++++-------------------- 1 file changed, 100 insertions(+), 71 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 61c832dfd..724762b28 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -9450,31 +9450,49 @@ struct CMUXCLI { private static let omoPluginName = "oh-my-opencode" - private func omoOpenCodeConfigURL() -> URL { + private func resolveExecutableInPath(_ name: String) -> String? { + let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? [] + for entry in entries where !entry.isEmpty { + let candidate = URL(fileURLWithPath: entry, isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + .path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private func omoUserConfigDir() -> URL { let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() return URL(fileURLWithPath: homePath, isDirectory: true) .appendingPathComponent(".config", isDirectory: true) .appendingPathComponent("opencode", isDirectory: true) } - /// Returns true if `oh-my-opencode` is listed in the opencode.json plugin array. - private func omoPluginIsRegistered(configDir: URL) -> Bool { - let jsonURL = configDir.appendingPathComponent("opencode.json") - guard let data = try? Data(contentsOf: jsonURL), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let plugins = obj["plugin"] as? [String] else { - return false - } - return plugins.contains { $0 == Self.omoPluginName || $0.hasPrefix("\(Self.omoPluginName)@") } + private func omoShadowConfigDir() -> URL { + let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + return URL(fileURLWithPath: homePath, isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("omo-config", isDirectory: true) } - /// Adds `oh-my-opencode` to the opencode.json plugin array, creating the file if needed. - private func omoRegisterPlugin(configDir: URL) throws { - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true, attributes: nil) - let jsonURL = configDir.appendingPathComponent("opencode.json") + /// Creates a shadow config directory that layers oh-my-opencode on top of the user's + /// existing opencode config without modifying the original. Sets OPENCODE_CONFIG_DIR + /// to point at the shadow directory. + private func omoEnsurePlugin() throws { + let userDir = omoUserConfigDir() + let shadowDir = omoShadowConfigDir() + let fm = FileManager.default + + try fm.createDirectory(at: shadowDir, withIntermediateDirectories: true, attributes: nil) + + // Read the user's opencode.json (if any), add the plugin, write to shadow dir + let userJsonURL = userDir.appendingPathComponent("opencode.json") + let shadowJsonURL = shadowDir.appendingPathComponent("opencode.json") var config: [String: Any] - if let data = try? Data(contentsOf: jsonURL), + if let data = try? Data(contentsOf: userJsonURL), let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { config = existing } else { @@ -9491,72 +9509,83 @@ struct CMUXCLI { config["plugin"] = plugins let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) - try output.write(to: jsonURL, options: .atomic) - } - - /// Returns true if the oh-my-opencode package exists in node_modules. - private func omoPackageIsInstalled(configDir: URL) -> Bool { - let packageDir = configDir - .appendingPathComponent("node_modules", isDirectory: true) - .appendingPathComponent(Self.omoPluginName, isDirectory: true) - return FileManager.default.fileExists(atPath: packageDir.path) - } + try output.write(to: shadowJsonURL, options: .atomic) - /// Installs the oh-my-opencode npm package in the config directory using bun or npm. - private func omoInstallPackage(configDir: URL) throws { - let process = Process() - process.currentDirectoryURL = configDir - - // Prefer bun, fall back to npm - if let bunPath = resolveExecutableInPath("bun") { - process.executableURL = URL(fileURLWithPath: bunPath) - process.arguments = ["add", Self.omoPluginName] - } else if let npmPath = resolveExecutableInPath("npm") { - process.executableURL = URL(fileURLWithPath: npmPath) - process.arguments = ["install", Self.omoPluginName] - } else { - throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install") + // Symlink node_modules from the user's config dir so installed packages resolve + let shadowNodeModules = shadowDir.appendingPathComponent("node_modules") + let userNodeModules = userDir.appendingPathComponent("node_modules") + if fm.fileExists(atPath: userNodeModules.path) { + // Remove stale symlink or directory if it exists + if let attrs = try? fm.attributesOfItem(atPath: shadowNodeModules.path), + attrs[.type] as? FileAttributeType == .typeSymbolicLink { + let target = try? fm.destinationOfSymbolicLink(atPath: shadowNodeModules.path) + if target != userNodeModules.path { + try? fm.removeItem(at: shadowNodeModules) + } + } + if !fm.fileExists(atPath: shadowNodeModules.path) { + try fm.createSymbolicLink(at: shadowNodeModules, withDestinationURL: userNodeModules) + } } - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - FileHandle.standardError.write("Installing oh-my-opencode plugin...\n".data(using: .utf8)!) - try process.run() - process.waitUntilExit() - - if process.terminationStatus != 0 { - let errText = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - throw CLIError(message: "Failed to install oh-my-opencode: \(errText)") + // Symlink package.json and bun.lock so bun/npm can resolve in the shadow dir + for filename in ["package.json", "bun.lock"] { + let userFile = userDir.appendingPathComponent(filename) + let shadowFile = shadowDir.appendingPathComponent(filename) + if fm.fileExists(atPath: userFile.path) && !fm.fileExists(atPath: shadowFile.path) { + try fm.createSymbolicLink(at: shadowFile, withDestinationURL: userFile) + } } - } - private func resolveExecutableInPath(_ name: String) -> String? { - let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? [] - for entry in entries where !entry.isEmpty { - let candidate = URL(fileURLWithPath: entry, isDirectory: true) - .appendingPathComponent(name, isDirectory: false) - .path - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate + // Copy oh-my-opencode plugin config (jsonc) if the user has one + for filename in ["oh-my-opencode.json", "oh-my-opencode.jsonc"] { + let userFile = userDir.appendingPathComponent(filename) + let shadowFile = shadowDir.appendingPathComponent(filename) + if fm.fileExists(atPath: userFile.path) && !fm.fileExists(atPath: shadowFile.path) { + try fm.createSymbolicLink(at: shadowFile, withDestinationURL: userFile) } } - return nil - } - /// Ensures oh-my-opencode plugin is registered and installed before launching opencode. - private func omoEnsurePlugin() throws { - let configDir = omoOpenCodeConfigURL() - if !omoPluginIsRegistered(configDir: configDir) { - try omoRegisterPlugin(configDir: configDir) - FileHandle.standardError.write("Registered oh-my-opencode plugin in opencode.json\n".data(using: .utf8)!) - } - if !omoPackageIsInstalled(configDir: configDir) { - try omoInstallPackage(configDir: configDir) + // Install the package if not available via the symlinked node_modules + let pluginPackageDir = shadowNodeModules.appendingPathComponent(Self.omoPluginName) + if !fm.fileExists(atPath: pluginPackageDir.path) { + // Need to install into the real user config dir so the symlink picks it up + let installDir = fm.fileExists(atPath: userNodeModules.path) ? userDir : shadowDir + // If installing into shadow dir, remove the symlink first + if installDir == shadowDir { + try? fm.removeItem(at: shadowNodeModules) + } + let process = Process() + process.currentDirectoryURL = installDir + if let bunPath = resolveExecutableInPath("bun") { + process.executableURL = URL(fileURLWithPath: bunPath) + process.arguments = ["add", Self.omoPluginName] + } else if let npmPath = resolveExecutableInPath("npm") { + process.executableURL = URL(fileURLWithPath: npmPath) + process.arguments = ["install", Self.omoPluginName] + } else { + throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install") + } + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + FileHandle.standardError.write("Installing oh-my-opencode plugin...\n".data(using: .utf8)!) + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + let errText = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw CLIError(message: "Failed to install oh-my-opencode: \(errText)") + } FileHandle.standardError.write("oh-my-opencode plugin installed\n".data(using: .utf8)!) + // Re-create symlink if we installed into user dir + if installDir == userDir && !fm.fileExists(atPath: shadowNodeModules.path) { + try fm.createSymbolicLink(at: shadowNodeModules, withDestinationURL: userNodeModules) + } } + + // Point OpenCode at the shadow config + setenv("OPENCODE_CONFIG_DIR", shadowDir.path, 1) } private func configureOMOEnvironment( From 763d7b14c34805dbf33e640067693559c48433df Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 22:20:35 -0700 Subject: [PATCH 04/18] Add Agent Integrations docs section with Claude Code Teams and oh-my-opencode pages Adds sectioned sidebar navigation to the docs site. The new Agent Integrations section contains separate pages for cmux claude-teams and cmux omo, documenting usage, tmux shim mechanics, directory layout, environment variables, and the shadow config approach. Both pages include a nightly-only warning. Full English and Japanese translations, nav item keys added to all 19 locales. --- web/app/[locale]/components/docs-nav-items.ts | 38 ++++++-- web/app/[locale]/components/docs-pager.tsx | 9 +- web/app/[locale]/components/docs-sidebar.tsx | 74 +++++++++++---- .../claude-code-teams/page.tsx | 84 +++++++++++++++++ .../oh-my-opencode/page.tsx | 90 +++++++++++++++++++ web/messages/ar.json | 3 + web/messages/bs.json | 3 + web/messages/da.json | 3 + web/messages/de.json | 3 + web/messages/en.json | 78 ++++++++++++++++ web/messages/es.json | 3 + web/messages/fr.json | 3 + web/messages/it.json | 3 + web/messages/ja.json | 78 ++++++++++++++++ web/messages/km.json | 3 + web/messages/ko.json | 3 + web/messages/no.json | 3 + web/messages/pl.json | 3 + web/messages/pt-BR.json | 3 + web/messages/ru.json | 3 + web/messages/th.json | 3 + web/messages/tr.json | 3 + web/messages/zh-CN.json | 3 + web/messages/zh-TW.json | 3 + 24 files changed, 474 insertions(+), 28 deletions(-) create mode 100644 web/app/[locale]/docs/agent-integrations/claude-code-teams/page.tsx create mode 100644 web/app/[locale]/docs/agent-integrations/oh-my-opencode/page.tsx diff --git a/web/app/[locale]/components/docs-nav-items.ts b/web/app/[locale]/components/docs-nav-items.ts index 631cb7dcd..e457f0336 100644 --- a/web/app/[locale]/components/docs-nav-items.ts +++ b/web/app/[locale]/components/docs-nav-items.ts @@ -1,10 +1,30 @@ -export const navItems = [ - { titleKey: "gettingStarted" as const, href: "/docs/getting-started" }, - { titleKey: "concepts" as const, href: "/docs/concepts" }, - { titleKey: "configuration" as const, href: "/docs/configuration" }, - { titleKey: "keyboardShortcuts" as const, href: "/docs/keyboard-shortcuts" }, - { titleKey: "apiReference" as const, href: "/docs/api" }, - { titleKey: "browserAutomation" as const, href: "/docs/browser-automation" }, - { titleKey: "notifications" as const, href: "/docs/notifications" }, - { titleKey: "changelog" as const, href: "/docs/changelog" }, +export type NavLink = { titleKey: string; href: string }; +export type NavSection = { sectionKey: string; children: NavLink[] }; +export type NavEntry = NavLink | NavSection; + +export function isSection(entry: NavEntry): entry is NavSection { + return "sectionKey" in entry; +} + +/** Flatten sections into an ordered list of links (for pager prev/next). */ +export function flatNavItems(entries: NavEntry[]): NavLink[] { + return entries.flatMap((e) => (isSection(e) ? e.children : [e])); +} + +export const navItems: NavEntry[] = [ + { titleKey: "gettingStarted", href: "/docs/getting-started" }, + { titleKey: "concepts", href: "/docs/concepts" }, + { titleKey: "configuration", href: "/docs/configuration" }, + { titleKey: "keyboardShortcuts", href: "/docs/keyboard-shortcuts" }, + { titleKey: "apiReference", href: "/docs/api" }, + { titleKey: "browserAutomation", href: "/docs/browser-automation" }, + { titleKey: "notifications", href: "/docs/notifications" }, + { + sectionKey: "agentIntegrations", + children: [ + { titleKey: "claudeCodeTeams", href: "/docs/agent-integrations/claude-code-teams" }, + { titleKey: "ohMyOpenCode", href: "/docs/agent-integrations/oh-my-opencode" }, + ], + }, + { titleKey: "changelog", href: "/docs/changelog" }, ]; diff --git a/web/app/[locale]/components/docs-pager.tsx b/web/app/[locale]/components/docs-pager.tsx index aac910e40..dcfb987ea 100644 --- a/web/app/[locale]/components/docs-pager.tsx +++ b/web/app/[locale]/components/docs-pager.tsx @@ -2,14 +2,15 @@ import { useTranslations } from "next-intl"; import { Link, usePathname } from "../../../i18n/navigation"; -import { navItems } from "./docs-nav-items"; +import { navItems, flatNavItems } from "./docs-nav-items"; export function DocsPager() { const pathname = usePathname(); const t = useTranslations("docs.navItems"); - const index = navItems.findIndex((item) => item.href === pathname); - const prev = index > 0 ? navItems[index - 1] : null; - const next = index < navItems.length - 1 ? navItems[index + 1] : null; + const flat = flatNavItems(navItems); + const index = flat.findIndex((item) => item.href === pathname); + const prev = index > 0 ? flat[index - 1] : null; + const next = index < flat.length - 1 ? flat[index + 1] : null; if (!prev && !next) return null; diff --git a/web/app/[locale]/components/docs-sidebar.tsx b/web/app/[locale]/components/docs-sidebar.tsx index b8df41544..7b104a1ff 100644 --- a/web/app/[locale]/components/docs-sidebar.tsx +++ b/web/app/[locale]/components/docs-sidebar.tsx @@ -2,7 +2,38 @@ import { useTranslations } from "next-intl"; import { Link, usePathname } from "../../../i18n/navigation"; -import { navItems } from "./docs-nav-items"; +import { navItems, isSection, type NavLink } from "./docs-nav-items"; + +function SidebarLink({ + item, + pathname, + onNavigate, + indent, + t, +}: { + item: NavLink; + pathname: string; + onNavigate?: () => void; + indent?: boolean; + t: (key: string) => string; +}) { + const active = pathname === item.href; + return ( + + {t(item.titleKey)} + + ); +} export function DocsSidebar({ onNavigate }: { onNavigate?: () => void }) { const pathname = usePathname(); @@ -10,21 +41,34 @@ export function DocsSidebar({ onNavigate }: { onNavigate?: () => void }) { return ( diff --git a/web/app/[locale]/docs/agent-integrations/claude-code-teams/page.tsx b/web/app/[locale]/docs/agent-integrations/claude-code-teams/page.tsx new file mode 100644 index 000000000..0de12ef1d --- /dev/null +++ b/web/app/[locale]/docs/agent-integrations/claude-code-teams/page.tsx @@ -0,0 +1,84 @@ +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { CodeBlock } from "../../../components/code-block"; +import { Callout } from "../../../components/callout"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "docs.claudeCodeTeams" }); + return { + title: t("metaTitle"), + description: t("metaDescription"), + }; +} + +export default function ClaudeCodeTeamsPage() { + const t = useTranslations("docs.claudeCodeTeams"); + + return ( + <> +

{t("title")}

+ + {t("nightlyWarning")} + +

{t("intro")}

+ +

{t("usage")}

+ {`cmux claude-teams +cmux claude-teams --continue +cmux claude-teams --model sonnet`} +

{t("usageDesc")}

+ +

{t("howItWorks")}

+

{t("howItWorksDesc")}

+
    +
  • {t("shimStep1")}
  • +
  • {t("shimStep2")}
  • +
  • {t("shimStep3")}
  • +
  • {t("shimStep4")}
  • +
+ +

{t("envVars")}

+ + + + + + + + + + + + + +
{t("envVarName")}{t("envVarPurpose")}
TMUX{t("envTmux")}
TMUX_PANE{t("envTmuxPane")}
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS{t("envTeams")}
CMUX_SOCKET_PATH{t("envSocket")}
+ +

{t("directories")}

+ + + + + + + + + + + +
{t("dirPath")}{t("dirPurpose")}
~/.cmuxterm/claude-teams-bin/{t("dirShim")}
~/.cmuxterm/tmux-compat-store.json{t("dirStore")}
+ +

{t("tmuxCommands")}

+

{t("tmuxCommandsDesc")}

+
    +
  • new-session, new-window → {t("mapWorkspace")}
  • +
  • split-window → {t("mapSplit")}
  • +
  • send-keys → {t("mapSendText")}
  • +
  • capture-pane → {t("mapReadText")}
  • +
  • select-pane, select-window → {t("mapFocus")}
  • +
  • kill-pane, kill-window → {t("mapClose")}
  • +
  • list-panes, list-windows → {t("mapList")}
  • +
+ + ); +} diff --git a/web/app/[locale]/docs/agent-integrations/oh-my-opencode/page.tsx b/web/app/[locale]/docs/agent-integrations/oh-my-opencode/page.tsx new file mode 100644 index 000000000..668781d50 --- /dev/null +++ b/web/app/[locale]/docs/agent-integrations/oh-my-opencode/page.tsx @@ -0,0 +1,90 @@ +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { CodeBlock } from "../../../components/code-block"; +import { Callout } from "../../../components/callout"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "docs.ohMyOpenCode" }); + return { + title: t("metaTitle"), + description: t("metaDescription"), + }; +} + +export default function OhMyOpenCodePage() { + const t = useTranslations("docs.ohMyOpenCode"); + + return ( + <> +

{t("title")}

+ + {t("nightlyWarning")} + +

{t("intro")}

+ +

{t("usage")}

+ {`cmux omo +cmux omo --continue +cmux omo --model claude-sonnet-4-6`} +

{t("usageDesc")}

+ +

{t("firstRun")}

+

{t("firstRunDesc")}

+
    +
  1. {t("firstRunStep1")}
  2. +
  3. {t("firstRunStep2")}
  4. +
  5. {t("firstRunStep3")}
  6. +
+

{t("firstRunSafe")}

+ +

{t("howItWorks")}

+

{t("howItWorksDesc")}

+
    +
  • {t("shimStep1")}
  • +
  • {t("shimStep2")}
  • +
  • {t("shimStep3")}
  • +
  • {t("shimStep4")}
  • +
+ +

{t("directories")}

+ + + + + + + + + + + + +
{t("dirPath")}{t("dirPurpose")}
~/.cmuxterm/omo-bin/{t("dirShim")}
~/.cmuxterm/omo-config/{t("dirShadow")}
~/.cmuxterm/tmux-compat-store.json{t("dirStore")}
+ +

{t("shadowConfig")}

+

{t("shadowConfigDesc")}

+
    +
  • {t("shadowStep1")}
  • +
  • {t("shadowStep2")}
  • +
  • {t("shadowStep3")}
  • +
+ +

{t("envVars")}

+ + + + + + + + + + + + + +
{t("envVarName")}{t("envVarPurpose")}
TMUX{t("envTmux")}
TMUX_PANE{t("envTmuxPane")}
OPENCODE_CONFIG_DIR{t("envConfigDir")}
CMUX_SOCKET_PATH{t("envSocket")}
+ + ); +} diff --git a/web/messages/ar.json b/web/messages/ar.json index 099b3578c..70878078f 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -551,6 +551,9 @@ "apiReference": "مرجع الواجهة البرمجية", "browserAutomation": "أتمتة المتصفح", "notifications": "الإشعارات", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "سجل التغييرات" } }, diff --git a/web/messages/bs.json b/web/messages/bs.json index 6210c4b9a..cb81407fb 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -551,6 +551,9 @@ "apiReference": "API Referenca", "browserAutomation": "Automatizacija preglednika", "notifications": "Notifikacije", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Zapisnik promjena" } }, diff --git a/web/messages/da.json b/web/messages/da.json index eeaf4a932..e6c9de880 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -551,6 +551,9 @@ "apiReference": "API-reference", "browserAutomation": "Browserautomatisering", "notifications": "Notifikationer", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Ændringslog" } }, diff --git a/web/messages/de.json b/web/messages/de.json index df68123fe..be19b23a6 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -551,6 +551,9 @@ "apiReference": "API-Referenz", "browserAutomation": "Browser-Automatisierung", "notifications": "Benachrichtigungen", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Changelog" } }, diff --git a/web/messages/en.json b/web/messages/en.json index 44437938f..ecde4f61a 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -543,6 +543,81 @@ "metaTitle": "Changelog", "metaDescription": "cmux release notes and version history. New features, bug fixes, and changes for the native macOS terminal." }, + "claudeCodeTeams": { + "title": "Claude Code Teams", + "metaTitle": "Claude Code Teams - cmux", + "metaDescription": "Run Claude Code with agent teams inside cmux. Teammate agents spawn as native cmux splits instead of tmux panes.", + "nightlyWarning": "This feature is available in nightly builds. Download the latest nightly from GitHub Releases.", + "intro": "cmux claude-teams launches Claude Code with agent teams enabled. When Claude spawns teammate agents, they appear as native cmux splits instead of tmux panes, with full sidebar metadata and notifications.", + "usage": "Usage", + "usageDesc": "All arguments after claude-teams are forwarded to Claude Code. The command defaults teammate mode to auto and sets the environment so Claude uses cmux splits.", + "howItWorks": "How it works", + "howItWorksDesc": "cmux claude-teams creates a tmux shim script and configures the environment so Claude Code thinks it's running inside tmux. When Claude issues tmux commands to manage teammate panes, the shim translates them into cmux socket API calls.", + "shimStep1": "Creates a tmux shim at ~/.cmuxterm/claude-teams-bin/tmux that redirects to cmux __tmux-compat", + "shimStep2": "Sets TMUX and TMUX_PANE environment variables to simulate a tmux session", + "shimStep3": "Prepends the shim directory to PATH so Claude finds the shim before real tmux", + "shimStep4": "Enables CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 and sets teammate mode to auto", + "envVars": "Environment variables", + "envVarName": "Variable", + "envVarPurpose": "Purpose", + "envTmux": "Fake tmux socket path encoding the current cmux workspace and pane", + "envTmuxPane": "Fake tmux pane identifier mapped to the current cmux pane", + "envTeams": "Enables Claude Code agent teams feature", + "envSocket": "Path to the cmux control socket for the shim to connect to", + "directories": "Directories", + "dirPath": "Path", + "dirPurpose": "Purpose", + "dirShim": "Contains the tmux shim script that translates tmux commands to cmux API calls", + "dirStore": "Persistent storage for tmux-compat buffers and hooks", + "tmuxCommands": "Supported tmux commands", + "tmuxCommandsDesc": "The shim translates these tmux commands into cmux operations:", + "mapWorkspace": "creates a new cmux workspace", + "mapSplit": "splits the current cmux pane", + "mapSendText": "sends text to a cmux surface", + "mapReadText": "reads terminal text from a cmux surface", + "mapFocus": "focuses a cmux pane or workspace", + "mapClose": "closes a cmux surface or workspace", + "mapList": "lists cmux panes or workspaces" + }, + "ohMyOpenCode": { + "title": "oh-my-opencode", + "metaTitle": "oh-my-opencode - cmux", + "metaDescription": "Run OpenCode with oh-my-openagent inside cmux. Multi-model agent orchestration with native cmux splits.", + "nightlyWarning": "This feature is available in nightly builds. Download the latest nightly from GitHub Releases.", + "intro": "cmux omo launches OpenCode with the oh-my-openagent plugin in a cmux-aware environment. oh-my-openagent orchestrates multiple AI models (Claude, GPT, Gemini, Grok) as specialized agents working in parallel. When it spawns agent panes, they become native cmux splits.", + "usage": "Usage", + "usageDesc": "All arguments after omo are forwarded to OpenCode.", + "firstRun": "First run", + "firstRunDesc": "On first run, cmux omo automatically sets up the oh-my-opencode plugin:", + "firstRunStep1": "Creates a shadow config at ~/.cmuxterm/omo-config/ with oh-my-opencode registered in the plugin array", + "firstRunStep2": "Installs the oh-my-opencode npm package using bun or npm if not already present", + "firstRunStep3": "Symlinks node_modules, package.json, and plugin config from your original ~/.config/opencode/ directory", + "firstRunSafe": "Your original ~/.config/opencode/ config is never modified. Running plain opencode works the same as before.", + "howItWorks": "How it works", + "howItWorksDesc": "Same pattern as cmux claude-teams. A tmux shim intercepts tmux commands from oh-my-openagent's TmuxSessionManager and translates them into cmux API calls.", + "shimStep1": "Creates a tmux shim at ~/.cmuxterm/omo-bin/tmux", + "shimStep2": "Sets TMUX and TMUX_PANE environment variables", + "shimStep3": "Points OPENCODE_CONFIG_DIR at the shadow config with oh-my-opencode enabled", + "shimStep4": "Prepends the shim directory to PATH and execs into opencode", + "directories": "Directories", + "dirPath": "Path", + "dirPurpose": "Purpose", + "dirShim": "Contains the tmux shim script", + "dirShadow": "Shadow OpenCode config with oh-my-opencode plugin registered (symlinks to your original config)", + "dirStore": "Persistent storage for tmux-compat buffers and hooks", + "shadowConfig": "Shadow config", + "shadowConfigDesc": "cmux omo uses a shadow config directory so your original OpenCode setup is unaffected:", + "shadowStep1": "Copies your ~/.config/opencode/opencode.json with oh-my-opencode added to the plugin array", + "shadowStep2": "Symlinks node_modules, package.json, and bun.lock from the original directory", + "shadowStep3": "Sets OPENCODE_CONFIG_DIR to the shadow directory before launching opencode", + "envVars": "Environment variables", + "envVarName": "Variable", + "envVarPurpose": "Purpose", + "envTmux": "Fake tmux socket path encoding the current cmux workspace and pane", + "envTmuxPane": "Fake tmux pane identifier mapped to the current cmux pane", + "envConfigDir": "Points to the shadow config directory with oh-my-opencode enabled", + "envSocket": "Path to the cmux control socket for the shim to connect to" + }, "navItems": { "gettingStarted": "Getting Started", "concepts": "Concepts", @@ -551,6 +626,9 @@ "apiReference": "API Reference", "browserAutomation": "Browser Automation", "notifications": "Notifications", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Changelog" } }, diff --git a/web/messages/es.json b/web/messages/es.json index 6913eaaaa..0efea2d56 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -551,6 +551,9 @@ "apiReference": "Referencia de API", "browserAutomation": "Automatización del navegador", "notifications": "Notificaciones", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Changelog" } }, diff --git a/web/messages/fr.json b/web/messages/fr.json index 61426dd90..daeeeee16 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -551,6 +551,9 @@ "apiReference": "Référence API", "browserAutomation": "Automatisation du navigateur", "notifications": "Notifications", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Changelog" } }, diff --git a/web/messages/it.json b/web/messages/it.json index b002fd816..f2d6776f2 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -551,6 +551,9 @@ "apiReference": "Riferimento API", "browserAutomation": "Automazione del browser", "notifications": "Notifiche", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Changelog" } }, diff --git a/web/messages/ja.json b/web/messages/ja.json index ea5d43515..0d4e2a4e1 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -543,6 +543,81 @@ "metaDescription": "cmuxのリリースノートとバージョン履歴。ネイティブmacOSターミナルの新機能、バグ修正、変更点。", "metaTitle": "変更履歴" }, + "claudeCodeTeams": { + "title": "Claude Code Teams", + "metaTitle": "Claude Code Teams - cmux", + "metaDescription": "cmux内でClaude Codeのエージェントチームを実行。チームメイトエージェントはtmuxペインではなくネイティブのcmuxスプリットとして表示されます。", + "nightlyWarning": "この機能はナイトリービルドで利用可能です。最新のナイトリーはGitHub Releasesからダウンロードできます。", + "intro": "cmux claude-teamsはエージェントチームを有効にしてClaude Codeを起動します。Claudeがチームメイトエージェントを生成すると、tmuxペインではなくネイティブのcmuxスプリットとして表示され、サイドバーのメタデータや通知が利用できます。", + "usage": "使い方", + "usageDesc": "claude-teamsの後のすべての引数はClaude Codeに転送されます。コマンドはteammate modeをautoに設定し、Claudeがcmuxスプリットを使用する環境を構成します。", + "howItWorks": "仕組み", + "howItWorksDesc": "cmux claude-teamsはtmux shimスクリプトを作成し、Claude Codeがtmux内で動作していると認識するよう環境を構成します。Claudeがチームメイトペインを管理するためにtmuxコマンドを発行すると、shimがそれをcmuxソケットAPIコールに変換します。", + "shimStep1": "~/.cmuxterm/claude-teams-bin/tmuxにtmux shimを作成し、cmux __tmux-compatにリダイレクト", + "shimStep2": "TMUXとTMUX_PANE環境変数を設定してtmuxセッションをシミュレート", + "shimStep3": "shimディレクトリをPATHの先頭に追加し、Claudeが本物のtmuxより先にshimを見つけるようにする", + "shimStep4": "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1を有効にし、teammate modeをautoに設定", + "envVars": "環境変数", + "envVarName": "変数", + "envVarPurpose": "目的", + "envTmux": "現在のcmuxワークスペースとペインをエンコードした偽のtmuxソケットパス", + "envTmuxPane": "現在のcmuxペインにマッピングされた偽のtmuxペイン識別子", + "envTeams": "Claude Codeエージェントチーム機能を有効にする", + "envSocket": "shimが接続するcmuxコントロールソケットのパス", + "directories": "ディレクトリ", + "dirPath": "パス", + "dirPurpose": "目的", + "dirShim": "tmuxコマンドをcmux APIコールに変換するtmux shimスクリプトを含む", + "dirStore": "tmux-compatバッファとフックの永続ストレージ", + "tmuxCommands": "対応するtmuxコマンド", + "tmuxCommandsDesc": "shimは以下のtmuxコマンドをcmux操作に変換します:", + "mapWorkspace": "新しいcmuxワークスペースを作成", + "mapSplit": "現在のcmuxペインを分割", + "mapSendText": "cmuxサーフェスにテキストを送信", + "mapReadText": "cmuxサーフェスからターミナルテキストを読み取り", + "mapFocus": "cmuxペインまたはワークスペースにフォーカス", + "mapClose": "cmuxサーフェスまたはワークスペースを閉じる", + "mapList": "cmuxペインまたはワークスペースを一覧表示" + }, + "ohMyOpenCode": { + "title": "oh-my-opencode", + "metaTitle": "oh-my-opencode - cmux", + "metaDescription": "cmux内でOpenCodeとoh-my-openagentを実行。マルチモデルエージェントオーケストレーションをネイティブcmuxスプリットで。", + "nightlyWarning": "この機能はナイトリービルドで利用可能です。最新のナイトリーはGitHub Releasesからダウンロードできます。", + "intro": "cmux omoはoh-my-openagentプラグインを有効にしてcmux対応環境でOpenCodeを起動します。oh-my-openagentは複数のAIモデル(Claude、GPT、Gemini、Grok)を専門エージェントとして並列にオーケストレーションします。エージェントペインはネイティブのcmuxスプリットになります。", + "usage": "使い方", + "usageDesc": "omoの後のすべての引数はOpenCodeに転送されます。", + "firstRun": "初回実行", + "firstRunDesc": "初回実行時、cmux omoはoh-my-opencodeプラグインを自動的にセットアップします:", + "firstRunStep1": "~/.cmuxterm/omo-config/にoh-my-opencodeをプラグイン配列に登録したシャドウ設定を作成", + "firstRunStep2": "oh-my-opencode npmパッケージがない場合、bunまたはnpmでインストール", + "firstRunStep3": "元の~/.config/opencode/ディレクトリからnode_modules、package.json、プラグイン設定をシンボリックリンク", + "firstRunSafe": "元の~/.config/opencode/設定は変更されません。通常のopencode実行は以前と同じように動作します。", + "howItWorks": "仕組み", + "howItWorksDesc": "cmux claude-teamsと同じパターンです。tmux shimがoh-my-openagentのTmuxSessionManagerからのtmuxコマンドを傍受し、cmux APIコールに変換します。", + "shimStep1": "~/.cmuxterm/omo-bin/tmuxにtmux shimを作成", + "shimStep2": "TMUXとTMUX_PANE環境変数を設定", + "shimStep3": "OPENCODE_CONFIG_DIRをoh-my-opencodeが有効なシャドウ設定に指定", + "shimStep4": "shimディレクトリをPATHの先頭に追加してopencodeにexec", + "directories": "ディレクトリ", + "dirPath": "パス", + "dirPurpose": "目的", + "dirShim": "tmux shimスクリプトを含む", + "dirShadow": "oh-my-opencodeプラグインが登録されたOpenCodeのシャドウ設定(元の設定へのシンボリックリンク)", + "dirStore": "tmux-compatバッファとフックの永続ストレージ", + "shadowConfig": "シャドウ設定", + "shadowConfigDesc": "cmux omoはシャドウ設定ディレクトリを使用するため、元のOpenCode設定には影響しません:", + "shadowStep1": "~/.config/opencode/opencode.jsonをコピーし、プラグイン配列にoh-my-opencodeを追加", + "shadowStep2": "元のディレクトリからnode_modules、package.json、bun.lockをシンボリックリンク", + "shadowStep3": "opencode起動前にOPENCODE_CONFIG_DIRをシャドウディレクトリに設定", + "envVars": "環境変数", + "envVarName": "変数", + "envVarPurpose": "目的", + "envTmux": "現在のcmuxワークスペースとペインをエンコードした偽のtmuxソケットパス", + "envTmuxPane": "現在のcmuxペインにマッピングされた偽のtmuxペイン識別子", + "envConfigDir": "oh-my-opencodeが有効なシャドウ設定ディレクトリを指す", + "envSocket": "shimが接続するcmuxコントロールソケットのパス" + }, "navItems": { "gettingStarted": "はじめに", "concepts": "コンセプト", @@ -551,6 +626,9 @@ "apiReference": "APIリファレンス", "browserAutomation": "ブラウザ自動化", "notifications": "通知", + "agentIntegrations": "エージェント連携", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "変更履歴" } }, diff --git a/web/messages/km.json b/web/messages/km.json index 128e21423..d107df2ce 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -551,6 +551,9 @@ "apiReference": "ឯកសារយោង API", "browserAutomation": "ស្វ័យប្រវត្តិកម្មកម្មវិធីរុករក", "notifications": "ជូនដំណឹង", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "កំណត់ត្រាផ្លាស់ប្ដូរ" } }, diff --git a/web/messages/ko.json b/web/messages/ko.json index 156534fd9..971c366d3 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -551,6 +551,9 @@ "apiReference": "API 레퍼런스", "browserAutomation": "브라우저 자동화", "notifications": "알림", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "변경 로그" } }, diff --git a/web/messages/no.json b/web/messages/no.json index b289095e6..e07f83675 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -551,6 +551,9 @@ "apiReference": "API-referanse", "browserAutomation": "Nettleserautomatisering", "notifications": "Varsler", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Endringslogg" } }, diff --git a/web/messages/pl.json b/web/messages/pl.json index bb52ccbf5..4f82f17a2 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -551,6 +551,9 @@ "apiReference": "Dokumentacja API", "browserAutomation": "Automatyzacja przeglądarki", "notifications": "Powiadomienia", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Dziennik zmian" } }, diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 8b2b65f32..9886d3b03 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -551,6 +551,9 @@ "apiReference": "Referência da API", "browserAutomation": "Automação do Navegador", "notifications": "Notificações", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Changelog" } }, diff --git a/web/messages/ru.json b/web/messages/ru.json index ed2c38c2a..dcb242c76 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -551,6 +551,9 @@ "apiReference": "Справочник API", "browserAutomation": "Автоматизация браузера", "notifications": "Уведомления", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Изменения" } }, diff --git a/web/messages/th.json b/web/messages/th.json index 7627ba687..b47ac0eed 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -551,6 +551,9 @@ "apiReference": "เอกสาร API", "browserAutomation": "Browser Automation", "notifications": "การแจ้งเตือน", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Changelog" } }, diff --git a/web/messages/tr.json b/web/messages/tr.json index cd6c4da07..0d99d624a 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -551,6 +551,9 @@ "apiReference": "API Referansı", "browserAutomation": "Tarayıcı Otomasyonu", "notifications": "Bildirimler", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "Değişiklik Günlüğü" } }, diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index e970d95d5..ebf9494d0 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -551,6 +551,9 @@ "apiReference": "API 参考", "browserAutomation": "浏览器自动化", "notifications": "通知", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "更新日志" } }, diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index c6136785f..5f284cfb8 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -551,6 +551,9 @@ "apiReference": "API 參考", "browserAutomation": "瀏覽器自動化", "notifications": "通知", + "agentIntegrations": "Agent Integrations", + "claudeCodeTeams": "Claude Code Teams", + "ohMyOpenCode": "oh-my-opencode", "changelog": "更新日誌" } }, From 102519670ef1ecb5168befc096e07bd1e420572f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 22:49:00 -0700 Subject: [PATCH 05/18] Remove uppercase from sidebar section headers --- web/app/[locale]/components/docs-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/[locale]/components/docs-sidebar.tsx b/web/app/[locale]/components/docs-sidebar.tsx index 7b104a1ff..23779cf4a 100644 --- a/web/app/[locale]/components/docs-sidebar.tsx +++ b/web/app/[locale]/components/docs-sidebar.tsx @@ -45,7 +45,7 @@ export function DocsSidebar({ onNavigate }: { onNavigate?: () => void }) { if (isSection(entry)) { return (
-
+
{t(entry.sectionKey)}
{entry.children.map((child) => ( From ca5497eb378de1c5781fffe5dec742082da765f4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 22:51:21 -0700 Subject: [PATCH 06/18] Add more spacing above and below sidebar section headers --- web/app/[locale]/components/docs-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/[locale]/components/docs-sidebar.tsx b/web/app/[locale]/components/docs-sidebar.tsx index 23779cf4a..b66f85a77 100644 --- a/web/app/[locale]/components/docs-sidebar.tsx +++ b/web/app/[locale]/components/docs-sidebar.tsx @@ -44,7 +44,7 @@ export function DocsSidebar({ onNavigate }: { onNavigate?: () => void }) { {navItems.map((entry) => { if (isSection(entry)) { return ( -
+
{t(entry.sectionKey)}
From b2a0bc7f80097dab129f1f55d5abff3102a8ad5e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 23:46:00 -0700 Subject: [PATCH 07/18] Enable tmux mode in oh-my-opencode config, improve docs - cmux omo now writes tmux.enabled=true to the shadow oh-my-opencode.json config. Without this, oh-my-openagent's TmuxSessionManager won't spawn visual panes even though $TMUX is set (the config defaults to false). - Nightly warnings now link to /nightly instead of generic text. - Added "What you get" section to oh-my-opencode docs explaining the visual pane behavior (auto-layout, idle cleanup, queueing). - Added tmux.enabled step to first-run and how-it-works sections. --- CLI/cmux.swift | 33 +++++++++++++++++++ .../claude-code-teams/page.tsx | 7 +++- .../oh-my-opencode/page.tsx | 20 ++++++++++- web/messages/en.json | 28 +++++++++++----- web/messages/ja.json | 4 +-- 5 files changed, 79 insertions(+), 13 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 724762b28..a8fedced4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -9584,6 +9584,39 @@ struct CMUXCLI { } } + // Ensure tmux mode is enabled in oh-my-opencode config. + // Without this, the TmuxSessionManager won't spawn visual panes even though + // $TMUX is set (tmux.enabled defaults to false). + let omoConfigURL = shadowDir.appendingPathComponent("oh-my-opencode.json") + var omoConfig: [String: Any] + if let data = try? Data(contentsOf: omoConfigURL), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + omoConfig = existing + } else { + // Check if user has a config we symlinked, read from source + let userOmoConfig = userDir.appendingPathComponent("oh-my-opencode.json") + if let data = try? Data(contentsOf: userOmoConfig), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + omoConfig = existing + // Remove the symlink so we can write our own copy + try? fm.removeItem(at: omoConfigURL) + } else { + omoConfig = [:] + } + } + var tmuxConfig = (omoConfig["tmux"] as? [String: Any]) ?? [:] + if tmuxConfig["enabled"] as? Bool != true { + tmuxConfig["enabled"] = true + omoConfig["tmux"] = tmuxConfig + // Remove symlink if it exists (we need a real file) + if let attrs = try? fm.attributesOfItem(atPath: omoConfigURL.path), + attrs[.type] as? FileAttributeType == .typeSymbolicLink { + try? fm.removeItem(at: omoConfigURL) + } + let output = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys]) + try output.write(to: omoConfigURL, options: .atomic) + } + // Point OpenCode at the shadow config setenv("OPENCODE_CONFIG_DIR", shadowDir.path, 1) } diff --git a/web/app/[locale]/docs/agent-integrations/claude-code-teams/page.tsx b/web/app/[locale]/docs/agent-integrations/claude-code-teams/page.tsx index 0de12ef1d..93922cba8 100644 --- a/web/app/[locale]/docs/agent-integrations/claude-code-teams/page.tsx +++ b/web/app/[locale]/docs/agent-integrations/claude-code-teams/page.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; import { CodeBlock } from "../../../components/code-block"; import { Callout } from "../../../components/callout"; +import { Link } from "../../../../../i18n/navigation"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; @@ -19,7 +20,11 @@ export default function ClaudeCodeTeamsPage() { <>

{t("title")}

- {t("nightlyWarning")} + + {t.rich("nightlyWarning", { + nightly: (chunks) => {chunks}, + })} +

{t("intro")}

diff --git a/web/app/[locale]/docs/agent-integrations/oh-my-opencode/page.tsx b/web/app/[locale]/docs/agent-integrations/oh-my-opencode/page.tsx index 668781d50..f2dcc7e5a 100644 --- a/web/app/[locale]/docs/agent-integrations/oh-my-opencode/page.tsx +++ b/web/app/[locale]/docs/agent-integrations/oh-my-opencode/page.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; import { CodeBlock } from "../../../components/code-block"; import { Callout } from "../../../components/callout"; +import { Link } from "../../../../../i18n/navigation"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; @@ -19,7 +20,11 @@ export default function OhMyOpenCodePage() { <>

{t("title")}

- {t("nightlyWarning")} + + {t.rich("nightlyWarning", { + nightly: (chunks) => {chunks}, + })} +

{t("intro")}

@@ -29,12 +34,23 @@ cmux omo --continue cmux omo --model claude-sonnet-4-6`}

{t("usageDesc")}

+

{t("whatYouGet")}

+

{t("whatYouGetDesc")}

+
    +
  • {t("whatYouGet1")}
  • +
  • {t("whatYouGet2")}
  • +
  • {t("whatYouGet3")}
  • +
  • {t("whatYouGet4")}
  • +
  • {t("whatYouGet5")}
  • +
+

{t("firstRun")}

{t("firstRunDesc")}

  1. {t("firstRunStep1")}
  2. {t("firstRunStep2")}
  3. {t("firstRunStep3")}
  4. +
  5. {t("firstRunStep4")}

{t("firstRunSafe")}

@@ -45,6 +61,7 @@ cmux omo --model claude-sonnet-4-6`}
  • {t("shimStep2")}
  • {t("shimStep3")}
  • {t("shimStep4")}
  • +
  • {t("shimStep5")}
  • {t("directories")}

    @@ -68,6 +85,7 @@ cmux omo --model claude-sonnet-4-6`}
  • {t("shadowStep1")}
  • {t("shadowStep2")}
  • {t("shadowStep3")}
  • +
  • {t("shadowStep4")}
  • {t("envVars")}

    diff --git a/web/messages/en.json b/web/messages/en.json index ecde4f61a..402a24584 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -547,7 +547,7 @@ "title": "Claude Code Teams", "metaTitle": "Claude Code Teams - cmux", "metaDescription": "Run Claude Code with agent teams inside cmux. Teammate agents spawn as native cmux splits instead of tmux panes.", - "nightlyWarning": "This feature is available in nightly builds. Download the latest nightly from GitHub Releases.", + "nightlyWarning": "Available in nightly builds only.", "intro": "cmux claude-teams launches Claude Code with agent teams enabled. When Claude spawns teammate agents, they appear as native cmux splits instead of tmux panes, with full sidebar metadata and notifications.", "usage": "Usage", "usageDesc": "All arguments after claude-teams are forwarded to Claude Code. The command defaults teammate mode to auto and sets the environment so Claude uses cmux splits.", @@ -583,33 +583,43 @@ "title": "oh-my-opencode", "metaTitle": "oh-my-opencode - cmux", "metaDescription": "Run OpenCode with oh-my-openagent inside cmux. Multi-model agent orchestration with native cmux splits.", - "nightlyWarning": "This feature is available in nightly builds. Download the latest nightly from GitHub Releases.", + "nightlyWarning": "Available in nightly builds only.", "intro": "cmux omo launches OpenCode with the oh-my-openagent plugin in a cmux-aware environment. oh-my-openagent orchestrates multiple AI models (Claude, GPT, Gemini, Grok) as specialized agents working in parallel. When it spawns agent panes, they become native cmux splits.", "usage": "Usage", "usageDesc": "All arguments after omo are forwarded to OpenCode.", + "whatYouGet": "What you get", + "whatYouGetDesc": "oh-my-openagent's TmuxSessionManager spawns each background agent in its own pane. With cmux omo, those panes become native cmux splits instead of tmux panes:", + "whatYouGet1": "Each subagent (Hephaestus, Atlas, Oracle, etc.) gets its own cmux split, visible in the workspace", + "whatYouGet2": "Auto-layout management: agents are arranged in a grid (main-vertical by default) and resized as agents come and go", + "whatYouGet3": "Idle agents are automatically cleaned up after 3 consecutive idle polls with no new messages", + "whatYouGet4": "If the window is too small for a new agent pane, it queues and retries every 2 seconds until space is available", + "whatYouGet5": "Your main session stays in the primary pane while agents work beside it", "firstRun": "First run", - "firstRunDesc": "On first run, cmux omo automatically sets up the oh-my-opencode plugin:", + "firstRunDesc": "On first run, cmux omo automatically sets up everything:", "firstRunStep1": "Creates a shadow config at ~/.cmuxterm/omo-config/ with oh-my-opencode registered in the plugin array", "firstRunStep2": "Installs the oh-my-opencode npm package using bun or npm if not already present", "firstRunStep3": "Symlinks node_modules, package.json, and plugin config from your original ~/.config/opencode/ directory", + "firstRunStep4": "Enables tmux mode in the oh-my-opencode config (tmux.enabled defaults to false, cmux omo turns it on)", "firstRunSafe": "Your original ~/.config/opencode/ config is never modified. Running plain opencode works the same as before.", "howItWorks": "How it works", "howItWorksDesc": "Same pattern as cmux claude-teams. A tmux shim intercepts tmux commands from oh-my-openagent's TmuxSessionManager and translates them into cmux API calls.", - "shimStep1": "Creates a tmux shim at ~/.cmuxterm/omo-bin/tmux", - "shimStep2": "Sets TMUX and TMUX_PANE environment variables", - "shimStep3": "Points OPENCODE_CONFIG_DIR at the shadow config with oh-my-opencode enabled", - "shimStep4": "Prepends the shim directory to PATH and execs into opencode", + "shimStep1": "Creates a tmux shim at ~/.cmuxterm/omo-bin/tmux that redirects to cmux __tmux-compat", + "shimStep2": "Sets TMUX and TMUX_PANE to simulate a tmux session", + "shimStep3": "Enables tmux.enabled in the oh-my-opencode config (required for visual pane spawning)", + "shimStep4": "Points OPENCODE_CONFIG_DIR at the shadow config", + "shimStep5": "Prepends the shim directory to PATH and execs into opencode", "directories": "Directories", "dirPath": "Path", "dirPurpose": "Purpose", "dirShim": "Contains the tmux shim script", - "dirShadow": "Shadow OpenCode config with oh-my-opencode plugin registered (symlinks to your original config)", + "dirShadow": "Shadow OpenCode config with oh-my-opencode plugin registered and tmux enabled (symlinks to your original config)", "dirStore": "Persistent storage for tmux-compat buffers and hooks", "shadowConfig": "Shadow config", "shadowConfigDesc": "cmux omo uses a shadow config directory so your original OpenCode setup is unaffected:", "shadowStep1": "Copies your ~/.config/opencode/opencode.json with oh-my-opencode added to the plugin array", "shadowStep2": "Symlinks node_modules, package.json, and bun.lock from the original directory", - "shadowStep3": "Sets OPENCODE_CONFIG_DIR to the shadow directory before launching opencode", + "shadowStep3": "Writes oh-my-opencode.json with tmux.enabled set to true", + "shadowStep4": "Sets OPENCODE_CONFIG_DIR to the shadow directory before launching opencode", "envVars": "Environment variables", "envVarName": "Variable", "envVarPurpose": "Purpose", diff --git a/web/messages/ja.json b/web/messages/ja.json index 0d4e2a4e1..9fbdb4ffd 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -547,7 +547,7 @@ "title": "Claude Code Teams", "metaTitle": "Claude Code Teams - cmux", "metaDescription": "cmux内でClaude Codeのエージェントチームを実行。チームメイトエージェントはtmuxペインではなくネイティブのcmuxスプリットとして表示されます。", - "nightlyWarning": "この機能はナイトリービルドで利用可能です。最新のナイトリーはGitHub Releasesからダウンロードできます。", + "nightlyWarning": "ナイトリービルドでのみ利用可能です。", "intro": "cmux claude-teamsはエージェントチームを有効にしてClaude Codeを起動します。Claudeがチームメイトエージェントを生成すると、tmuxペインではなくネイティブのcmuxスプリットとして表示され、サイドバーのメタデータや通知が利用できます。", "usage": "使い方", "usageDesc": "claude-teamsの後のすべての引数はClaude Codeに転送されます。コマンドはteammate modeをautoに設定し、Claudeがcmuxスプリットを使用する環境を構成します。", @@ -583,7 +583,7 @@ "title": "oh-my-opencode", "metaTitle": "oh-my-opencode - cmux", "metaDescription": "cmux内でOpenCodeとoh-my-openagentを実行。マルチモデルエージェントオーケストレーションをネイティブcmuxスプリットで。", - "nightlyWarning": "この機能はナイトリービルドで利用可能です。最新のナイトリーはGitHub Releasesからダウンロードできます。", + "nightlyWarning": "ナイトリービルドでのみ利用可能です。", "intro": "cmux omoはoh-my-openagentプラグインを有効にしてcmux対応環境でOpenCodeを起動します。oh-my-openagentは複数のAIモデル(Claude、GPT、Gemini、Grok)を専門エージェントとして並列にオーケストレーションします。エージェントペインはネイティブのcmuxスプリットになります。", "usage": "使い方", "usageDesc": "omoの後のすべての引数はOpenCodeに転送されます。", From 6b42f9f12005794e7dbc0f8c62eec03a62d4921c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 23:54:58 -0700 Subject: [PATCH 08/18] Add terminal-notifier shim to route oh-my-openagent notifications to cmux oh-my-openagent sends macOS notifications via terminal-notifier (args: -title -message [-activate ]). The shim in ~/.cmuxterm/omo-bin/terminal-notifier intercepts these calls and routes them through cmux notify, so notifications appear in cmux's sidebar panel instead of as raw macOS notifications. --- CLI/cmux.swift | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a8fedced4..04cb3aab0 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -9433,21 +9433,46 @@ struct CMUXCLI { .appendingPathComponent(".cmuxterm", isDirectory: true) .appendingPathComponent("omo-bin", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) + + // tmux shim: redirects tmux commands to cmux __tmux-compat let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false) - let script = """ + let tmuxScript = """ #!/usr/bin/env bash set -euo pipefail exec "${CMUX_OMO_CMUX_BIN:-cmux}" __tmux-compat "$@" """ - let normalizedScript = script.trimmingCharacters(in: .whitespacesAndNewlines) - let existingScript = try? String(contentsOf: tmuxURL, encoding: .utf8) - if existingScript?.trimmingCharacters(in: .whitespacesAndNewlines) != normalizedScript { - try script.write(to: tmuxURL, atomically: false, encoding: .utf8) - } - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmuxURL.path) + try writeShimIfChanged(tmuxScript, to: tmuxURL) + + // terminal-notifier shim: intercepts macOS notifications and routes to cmux notify + let notifierURL = root.appendingPathComponent("terminal-notifier", isDirectory: false) + let notifierScript = """ + #!/usr/bin/env bash + # Intercept terminal-notifier calls and route through cmux notify. + # oh-my-openagent calls: terminal-notifier -title -message [-activate ] + TITLE="" BODY="" + while [[ $# -gt 0 ]]; do + case "$1" in + -title) TITLE="$2"; shift 2 ;; + -message) BODY="$2"; shift 2 ;; + *) shift ;; + esac + done + exec "${CMUX_OMO_CMUX_BIN:-cmux}" notify --title "${TITLE:-OpenCode}" --body "${BODY:-}" + """ + try writeShimIfChanged(notifierScript, to: notifierURL) + return root } + private func writeShimIfChanged(_ script: String, to url: URL) throws { + let normalized = script.trimmingCharacters(in: .whitespacesAndNewlines) + let existing = try? String(contentsOf: url, encoding: .utf8) + if existing?.trimmingCharacters(in: .whitespacesAndNewlines) != normalized { + try script.write(to: url, atomically: false, encoding: .utf8) + } + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + private static let omoPluginName = "oh-my-opencode" private func resolveExecutableInPath(_ name: String) -> String? { From 45a0c37b76651df56d55eb70ebea88d4e04dfa6d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 00:29:44 -0700 Subject: [PATCH 09/18] Add pane geometry to tmux-compat for oh-my-openagent grid planning oh-my-openagent's TmuxSessionManager needs pane geometry (columns, rows, position, window dimensions) to decide where to spawn agent panes. Without this data, agents run headlessly. Server side: - pane.list v2 response now includes pixel_frame, cell_size, columns, rows per pane, plus container_frame at the top level - Uses BonsplitController.layoutSnapshot() for pixel geometry and ghostty_surface_size() for terminal grid dimensions CLI side: - tmuxEnrichContextWithGeometry() computes character-cell positions from pixel frames and cell dimensions for tmux format variables (pane_width, pane_height, pane_left, pane_top, pane_active, window_width, window_height) - list-panes now resolves pane targets (%uuid) via tmuxResolvePaneTarget instead of failing with "Workspace not found" - display-message enriched with geometry for format strings like #{pane_width},#{window_width} - tmux -V now returns "tmux 3.4" (needed by oh-my-openagent's tmux-path-resolver verification) --- CLI/cmux.swift | 73 ++++++++++++++++++++++++++++++-- Sources/TerminalController.swift | 39 ++++++++++++++++- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 04cb3aab0..55cea290b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8712,6 +8712,7 @@ struct CMUXCLI { private func splitTmuxCommand(_ args: [String]) throws -> (command: String, args: [String]) { var index = 0 let globalValueFlags: Set = ["-L", "-S", "-f"] + let globalBoolFlags: Set = ["-V", "-v"] while index < args.count { let arg = args[index] @@ -8721,6 +8722,10 @@ struct CMUXCLI { if arg == "--" { break } + // Handle -V (version) as a pseudo-command + if globalBoolFlags.contains(arg) { + return (arg, []) + } if let flag = globalValueFlags.first(where: { arg == $0 || arg.hasPrefix($0) }) { if arg == flag { index += 1 @@ -9088,6 +9093,44 @@ struct CMUXCLI { return context } + /// Enrich a tmux format context dictionary with pane geometry data from the + /// enriched pane.list response. Computes character-cell positions from pixel + /// frames and cell dimensions so tmux format variables like #{pane_width}, + /// #{pane_height}, #{pane_left}, #{pane_top}, #{window_width}, #{window_height} + /// render correctly. + private func tmuxEnrichContextWithGeometry( + _ context: inout [String: String], + pane: [String: Any], + containerFrame: [String: Any]? + ) { + let isFocused = (pane["focused"] as? Bool) == true + context["pane_active"] = isFocused ? "1" : "0" + + guard let columns = pane["columns"] as? Int, + let rows = pane["rows"] as? Int else { return } + + context["pane_width"] = String(columns) + context["pane_height"] = String(rows) + + let cellW = pane["cell_width_px"] as? Int ?? 0 + let cellH = pane["cell_height_px"] as? Int ?? 0 + guard cellW > 0, cellH > 0 else { return } + + if let frame = pane["pixel_frame"] as? [String: Any] { + let px = frame["x"] as? Double ?? 0 + let py = frame["y"] as? Double ?? 0 + context["pane_left"] = String(Int(px) / cellW) + context["pane_top"] = String(Int(py) / cellH) + } + + if let cf = containerFrame { + let cw = cf["width"] as? Double ?? 0 + let ch = cf["height"] as? Double ?? 0 + context["window_width"] = String(max(Int(cw) / cellW, 1)) + context["window_height"] = String(max(Int(ch) / cellH, 1)) + } + } + private func tmuxShellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } @@ -9934,12 +9977,22 @@ struct CMUXCLI { case "display-message", "display", "displayp": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: ["-p"]) let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) - let context = try tmuxFormatContext( + var context = try tmuxFormatContext( workspaceId: target.workspaceId, paneId: target.paneId, surfaceId: target.surfaceId, client: client ) + // Enrich with geometry for format strings like #{pane_width},#{window_width} + let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": target.workspaceId]) + let panesList = panePayload["panes"] as? [[String: Any]] ?? [] + let containerFrame = panePayload["container_frame"] as? [String: Any] + if let targetPaneId = target.paneId, + let matchingPane = panesList.first(where: { ($0["id"] as? String) == targetPaneId }) { + tmuxEnrichContextWithGeometry(&context, pane: matchingPane, containerFrame: containerFrame) + } else if let firstPane = panesList.first(where: { ($0["focused"] as? Bool) == true }) ?? panesList.first { + tmuxEnrichContextWithGeometry(&context, pane: firstPane, containerFrame: containerFrame) + } let format = parsed.positional.isEmpty ? parsed.value("-F") : parsed.positional.joined(separator: " ") let rendered = tmuxRenderFormat(format, context: context, fallback: "") if parsed.hasFlag("-p") || !rendered.isEmpty { @@ -9961,12 +10014,22 @@ struct CMUXCLI { case "list-panes", "lsp": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: []) - let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + // Resolve target: can be a pane (%uuid) or workspace. In tmux, + // list-panes -t % lists all panes in the window containing that pane. + let workspaceId: String + if let target = parsed.value("-t"), tmuxPaneSelector(from: target) != nil { + let paneTarget = try tmuxResolvePaneTarget(target, client: client) + workspaceId = paneTarget.workspaceId + } else { + workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + } let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) let panes = payload["panes"] as? [[String: Any]] ?? [] + let containerFrame = payload["container_frame"] as? [String: Any] for pane in panes { guard let paneId = pane["id"] as? String else { continue } - let context = try tmuxFormatContext(workspaceId: workspaceId, paneId: paneId, client: client) + var context = try tmuxFormatContext(workspaceId: workspaceId, paneId: paneId, client: client) + tmuxEnrichContextWithGeometry(&context, pane: pane, containerFrame: containerFrame) let fallback = context["pane_id"] ?? paneId print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) } @@ -10070,6 +10133,10 @@ struct CMUXCLI { case "select-layout", "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client": return + case "-V", "-v": + print("tmux 3.4") + return + default: throw CLIError(message: "Unsupported tmux compatibility command: \(command)") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c6509f464..c18787cc1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5719,12 +5719,19 @@ class TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } let focusedPaneId = ws.bonsplitController.focusedPaneId + let snapshot = ws.bonsplitController.layoutSnapshot() + let geometryByPaneId = Dictionary( + snapshot.panes.map { ($0.paneId, $0.frame) }, + uniquingKeysWith: { first, _ in first } + ) + let panes: [[String: Any]] = ws.bonsplitController.allPaneIds.enumerated().map { index, paneId in let tabs = ws.bonsplitController.tabs(inPane: paneId) let surfaceUUIDs: [UUID] = tabs.compactMap { ws.panelIdFromSurfaceId($0.id) } let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId) let selectedSurfaceUUID = selectedTab.flatMap { ws.panelIdFromSurfaceId($0.id) } - return [ + + var dict: [String: Any] = [ "id": paneId.id.uuidString, "ref": v2Ref(kind: .pane, uuid: paneId.id), "index": index, @@ -5735,16 +5742,44 @@ class TerminalController { "selected_surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceUUID), "surface_count": surfaceUUIDs.count ] + + if let frame = geometryByPaneId[paneId.id.uuidString] { + dict["pixel_frame"] = [ + "x": frame.x, "y": frame.y, + "width": frame.width, "height": frame.height + ] + } + + // Get terminal grid size from the selected surface + if let panelUUID = selectedSurfaceUUID, + let panel = ws.panels[panelUUID] as? TerminalPanel, + panel.surface.hasLiveSurface, + let ghosttySurface = panel.surface.surface { + let size = ghostty_surface_size(ghosttySurface) + if size.columns > 0 && size.rows > 0 { + dict["columns"] = Int(size.columns) + dict["rows"] = Int(size.rows) + dict["cell_width_px"] = Int(size.cell_width_px) + dict["cell_height_px"] = Int(size.cell_height_px) + } + } + + return dict } let windowId = v2ResolveWindowId(tabManager: tabManager) - payload = [ + var payloadDict: [String: Any] = [ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "panes": panes, "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId) ] + payloadDict["container_frame"] = [ + "width": snapshot.containerFrame.width, + "height": snapshot.containerFrame.height + ] + payload = payloadDict } guard let payload else { From 29135b4db02c450e8a60e9179bd00094d79d977e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 00:44:12 -0700 Subject: [PATCH 10/18] Add socket tests for tmux-compat pane geometry 6 tests verifying the geometry enrichment works end-to-end: - pane.list returns pixel_frame, columns, rows, cell_size, container_frame - tmux -V returns version string - list-panes -F renders geometry format variables as integers - list-panes -t % resolves pane targets - display -p renders pane_width and window_width - After split, two panes have different positions and halved widths All 6 pass on macmini (cmux-macmini). --- tests_v2/test_tmux_compat_geometry.py | 247 ++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 tests_v2/test_tmux_compat_geometry.py diff --git a/tests_v2/test_tmux_compat_geometry.py b/tests_v2/test_tmux_compat_geometry.py new file mode 100644 index 000000000..ac2d088b2 --- /dev/null +++ b/tests_v2/test_tmux_compat_geometry.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Tests for tmux-compat pane geometry support (oh-my-openagent integration). + +Verifies that: +1. pane.list v2 API returns geometry fields (pixel_frame, columns, rows, cell_size, container_frame) +2. tmux-compat list-panes renders geometry format variables correctly +3. tmux-compat display -p renders geometry format variables +4. tmux-compat list-panes resolves pane targets (%uuid) +5. tmux -V returns a version string +6. Multi-pane geometry reflects actual split layout +""" + +import glob +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import List + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + candidates = glob.glob(os.path.expanduser( + "~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux" + ), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_tmux_compat(cli: str, args: List[str]) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env["CMUX_SOCKET_PATH"] = SOCKET_PATH + env["CMUX_OMO_CMUX_BIN"] = cli + cmd = [cli, "--socket", SOCKET_PATH, "__tmux-compat"] + args + return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + + +def test_pane_list_geometry_fields(c: cmux) -> None: + """pane.list response includes geometry fields for each pane.""" + print(" test_pane_list_geometry_fields ... ", end="", flush=True) + panes_raw = c.list_panes() + _must(len(panes_raw) >= 1, "Expected at least 1 pane") + + payload = c._call("pane.list", {}) + panes = payload.get("panes", []) + _must(len(panes) >= 1, f"Expected panes in payload, got {payload}") + + pane = panes[0] + _must("pixel_frame" in pane, f"Missing pixel_frame in pane: {list(pane.keys())}") + _must("columns" in pane, f"Missing columns in pane: {list(pane.keys())}") + _must("rows" in pane, f"Missing rows in pane: {list(pane.keys())}") + _must("cell_width_px" in pane, f"Missing cell_width_px in pane: {list(pane.keys())}") + _must("cell_height_px" in pane, f"Missing cell_height_px in pane: {list(pane.keys())}") + + frame = pane["pixel_frame"] + _must(frame["width"] > 0, f"pixel_frame.width should be > 0, got {frame['width']}") + _must(frame["height"] > 0, f"pixel_frame.height should be > 0, got {frame['height']}") + _must(pane["columns"] > 0, f"columns should be > 0, got {pane['columns']}") + _must(pane["rows"] > 0, f"rows should be > 0, got {pane['rows']}") + _must(pane["cell_width_px"] > 0, f"cell_width_px should be > 0, got {pane['cell_width_px']}") + _must(pane["cell_height_px"] > 0, f"cell_height_px should be > 0, got {pane['cell_height_px']}") + + _must("container_frame" in payload, f"Missing container_frame in payload: {list(payload.keys())}") + cf = payload["container_frame"] + _must(cf["width"] > 0, f"container_frame.width should be > 0, got {cf['width']}") + _must(cf["height"] > 0, f"container_frame.height should be > 0, got {cf['height']}") + print("PASS") + + +def test_tmux_version(cli: str) -> None: + """tmux -V returns a version string.""" + print(" test_tmux_version ... ", end="", flush=True) + proc = _run_tmux_compat(cli, ["-V"]) + _must(proc.returncode == 0, f"tmux -V failed with rc={proc.returncode}: {proc.stderr}") + output = proc.stdout.strip() + _must(output.startswith("tmux"), f"Expected 'tmux ...' output, got: {output!r}") + print("PASS") + + +def test_list_panes_geometry_format(cli: str) -> None: + """list-panes with oh-my-openagent format string renders integer geometry.""" + print(" test_list_panes_geometry_format ... ", end="", flush=True) + fmt = "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}" + proc = _run_tmux_compat(cli, ["list-panes", "-F", fmt]) + _must(proc.returncode == 0, f"list-panes failed: {proc.stderr}") + + lines = [l for l in proc.stdout.strip().split("\n") if l.strip()] + _must(len(lines) >= 1, f"Expected at least 1 line, got {len(lines)}") + + for line in lines: + # The line uses literal \t (backslash-t) from format rendering + parts = line.split("\\t") if "\\t" in line else line.split("\t") + _must(len(parts) >= 8, f"Expected >= 8 tab-separated fields, got {len(parts)}: {line!r}") + + pane_id = parts[0] + _must(pane_id.startswith("%"), f"pane_id should start with %, got: {pane_id!r}") + + # Validate integer fields (width, height, left, top, active, window_w, window_h) + for i, name in [(1, "pane_width"), (2, "pane_height"), (3, "pane_left"), + (4, "pane_top"), (5, "pane_active"), (6, "window_width"), (7, "window_height")]: + _must(parts[i].isdigit(), f"{name} should be integer, got: {parts[i]!r} in line: {line!r}") + + _must(int(parts[1]) > 0, f"pane_width should be > 0, got {parts[1]}") + _must(int(parts[2]) > 0, f"pane_height should be > 0, got {parts[2]}") + _must(parts[5] in ("0", "1"), f"pane_active should be 0 or 1, got {parts[5]!r}") + _must(int(parts[6]) > 0, f"window_width should be > 0, got {parts[6]}") + _must(int(parts[7]) > 0, f"window_height should be > 0, got {parts[7]}") + print("PASS") + + +def test_list_panes_pane_target(cli: str, c: cmux) -> None: + """list-panes -t % resolves pane target to workspace.""" + print(" test_list_panes_pane_target ... ", end="", flush=True) + panes_raw = c.list_panes() + _must(len(panes_raw) >= 1, "No panes found") + pane_id = panes_raw[0][1] + + proc = _run_tmux_compat(cli, ["list-panes", "-t", f"%{pane_id}", "-F", "#{pane_id}"]) + _must(proc.returncode == 0, f"list-panes -t %{pane_id} failed: {proc.stderr}") + output = proc.stdout.strip() + _must(len(output) > 0, "Expected output from list-panes with pane target") + _must(output.startswith("%"), f"Expected pane_id starting with %, got: {output!r}") + print("PASS") + + +def test_display_geometry_format(cli: str) -> None: + """display -p renders pane_width and window_width as integers.""" + print(" test_display_geometry_format ... ", end="", flush=True) + proc = _run_tmux_compat(cli, ["display", "-p", "#{pane_width},#{window_width}"]) + _must(proc.returncode == 0, f"display failed: {proc.stderr}") + output = proc.stdout.strip() + parts = output.split(",") + _must(len(parts) == 2, f"Expected 'N,M' output, got: {output!r}") + _must(parts[0].isdigit() and int(parts[0]) > 0, f"pane_width not a positive int: {parts[0]!r}") + _must(parts[1].isdigit() and int(parts[1]) > 0, f"window_width not a positive int: {parts[1]!r}") + print("PASS") + + +def test_multi_pane_geometry(cli: str, c: cmux) -> None: + """After splitting, two panes have different pane_left values and halved widths.""" + print(" test_multi_pane_geometry ... ", end="", flush=True) + ws = c.new_workspace() + c.select_workspace(ws) + time.sleep(0.3) + + # Get single-pane geometry first + payload_before = c._call("pane.list", {"workspace_id": ws}) + panes_before = payload_before.get("panes", []) + _must(len(panes_before) == 1, f"Expected 1 pane before split, got {len(panes_before)}") + single_cols = panes_before[0].get("columns", 0) + + # Split horizontally + c.new_split("right") + time.sleep(0.3) + + payload_after = c._call("pane.list", {"workspace_id": ws}) + panes_after = payload_after.get("panes", []) + _must(len(panes_after) == 2, f"Expected 2 panes after split, got {len(panes_after)}") + + p1, p2 = panes_after[0], panes_after[1] + _must("pixel_frame" in p1 and "pixel_frame" in p2, "Missing pixel_frame after split") + _must("columns" in p1 and "columns" in p2, "Missing columns after split") + + # Pane left positions should differ (horizontal split) + left1 = p1["pixel_frame"]["x"] + left2 = p2["pixel_frame"]["x"] + _must(left1 != left2, f"Panes should have different x positions, got {left1} and {left2}") + + # Each pane should be roughly half the original width + cols1 = p1["columns"] + cols2 = p2["columns"] + _must(cols1 > 0 and cols2 > 0, f"Columns should be > 0, got {cols1} and {cols2}") + _must(cols1 < single_cols, f"Split pane cols ({cols1}) should be less than original ({single_cols})") + + # Verify tmux-compat format also shows two lines with different pane_left + fmt = "#{pane_id}\t#{pane_width}\t#{pane_left}" + proc = _run_tmux_compat(cli, ["list-panes", "-t", f"%{p1['id']}", "-F", fmt]) + _must(proc.returncode == 0, f"list-panes after split failed: {proc.stderr}") + lines = [l for l in proc.stdout.strip().split("\n") if l.strip()] + _must(len(lines) == 2, f"Expected 2 lines after split, got {len(lines)}: {proc.stdout!r}") + + # Clean up + c.close_workspace(ws) + print("PASS") + + +def main() -> int: + cli = _find_cli_binary() + print(f"Using CLI: {cli}") + print(f"Socket: {SOCKET_PATH}") + + passed = 0 + failed = 0 + errors = [] + + with cmux(SOCKET_PATH) as c: + tests = [ + ("test_pane_list_geometry_fields", lambda: test_pane_list_geometry_fields(c)), + ("test_tmux_version", lambda: test_tmux_version(cli)), + ("test_list_panes_geometry_format", lambda: test_list_panes_geometry_format(cli)), + ("test_list_panes_pane_target", lambda: test_list_panes_pane_target(cli, c)), + ("test_display_geometry_format", lambda: test_display_geometry_format(cli)), + ("test_multi_pane_geometry", lambda: test_multi_pane_geometry(cli, c)), + ] + + for name, test_fn in tests: + try: + test_fn() + passed += 1 + except Exception as e: + failed += 1 + errors.append((name, str(e))) + print(f"FAIL: {e}") + + print(f"\n{'=' * 60}") + print(f"Results: {passed} passed, {failed} failed, {passed + failed} total") + if errors: + print("\nFailures:") + for name, err in errors: + print(f" {name}: {err}") + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 44da195961155c21d44c1aa4c03db8bc26f297c2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 00:57:54 -0700 Subject: [PATCH 11/18] Handle tmux -V in shim script directly (no socket needed) oh-my-openagent's tmux-path-resolver runs tmux -V to verify the binary works. The __tmux-compat handler requires a socket connection, which may not be established at verification time. Handle -V in the bash shim directly to avoid the socket dependency. --- CLI/cmux.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 55cea290b..f2676ea49 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -9478,10 +9478,16 @@ struct CMUXCLI { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) // tmux shim: redirects tmux commands to cmux __tmux-compat + // Handle -V locally (no socket needed) since __tmux-compat requires a connection. let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false) let tmuxScript = """ #!/usr/bin/env bash set -euo pipefail + for arg in "$@"; do + case "$arg" in + -V|-v) echo "tmux 3.4"; exit 0 ;; + esac + done exec "${CMUX_OMO_CMUX_BIN:-cmux}" __tmux-compat "$@" """ try writeShimIfChanged(tmuxScript, to: tmuxURL) From 7d3b53f42068c8b4baf0777a9ce6fe122811648e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 14:41:56 -0700 Subject: [PATCH 12/18] Lower default tmux pane min widths for cmux omo oh-my-openagent defaults: main_pane_min_width=120, agent_pane_min_width=40, requiring 161+ columns. Most terminal windows are narrower, causing decideSpawnActions to return canSpawn=false and defer agents forever. cmux omo now sets: main_pane_min_width=60, agent_pane_min_width=30, main_pane_size=50, requiring only 91 columns. Also moved tmux -V handling into the bash shim to avoid needing a socket connection for the version check. --- CLI/cmux.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f2676ea49..485455d5f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -9679,8 +9679,27 @@ struct CMUXCLI { } } var tmuxConfig = (omoConfig["tmux"] as? [String: Any]) ?? [:] + var needsWrite = false if tmuxConfig["enabled"] as? Bool != true { tmuxConfig["enabled"] = true + needsWrite = true + } + // Lower the default min widths so agent panes spawn in normal-sized windows. + // oh-my-openagent defaults: main_pane_min_width=120, agent_pane_min_width=40, + // requiring 161+ columns. Most terminal windows are narrower. + if tmuxConfig["main_pane_min_width"] == nil { + tmuxConfig["main_pane_min_width"] = 60 + needsWrite = true + } + if tmuxConfig["agent_pane_min_width"] == nil { + tmuxConfig["agent_pane_min_width"] = 30 + needsWrite = true + } + if tmuxConfig["main_pane_size"] == nil { + tmuxConfig["main_pane_size"] = 50 + needsWrite = true + } + if needsWrite { omoConfig["tmux"] = tmuxConfig // Remove symlink if it exists (we need a real file) if let attrs = try? fm.attributesOfItem(atPath: omoConfigURL.path), From 1804a315cb0e572f65f8460d73226d066341e045 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 14:54:39 -0700 Subject: [PATCH 13/18] Resolve merge conflicts with main (main-vertical layout, focus param) - Keep upstream main-vertical layout anchoring from #2119 - Keep upstream focus param (v2Bool) instead of no_focus - Combine with our -d flag handling: -d sets focus=false - Include customCommands nav item from main --- CLI/cmux.swift | 30 ++++++++++++++++++++++-------- scripts/reload.sh | 5 +++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index eed4682d9..61c7913f7 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -9573,11 +9573,11 @@ struct CMUXCLI { let tmuxScript = """ #!/usr/bin/env bash set -euo pipefail - for arg in "$@"; do - case "$arg" in - -V|-v) echo "tmux 3.4"; exit 0 ;; - esac - done + # Only match -V/-v as the first arg (top-level tmux flag). + # -v inside subcommands (e.g. split-window -v) is a vertical split flag. + case "${1:-}" in + -V|-v) echo "tmux 3.4"; exit 0 ;; + esac exec "${CMUX_OMO_CMUX_BIN:-cmux}" __tmux-compat "$@" """ try writeShimIfChanged(tmuxScript, to: tmuxURL) @@ -9840,6 +9840,10 @@ struct CMUXCLI { setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1) } unsetenv("TERM_PROGRAM") + // Tell oh-my-opencode the API server port so subagent attach works + if processEnvironment["OPENCODE_PORT"] == nil { + setenv("OPENCODE_PORT", "4096", 1) + } if let focusedContext { setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1) if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty { @@ -9881,7 +9885,15 @@ struct CMUXCLI { ) let launchPath = openCodeExecutablePath ?? "opencode" - var argv = ([launchPath] + commandArgs).map { strdup($0) } + // oh-my-openagent needs the OpenCode API server running to attach + // subagent sessions to tmux panes. Inject --port 4096 unless the + // user already specified a port. + var effectiveArgs = commandArgs + if !commandArgs.contains("--port") { + effectiveArgs.append("--port") + effectiveArgs.append("4096") + } + var argv = ([launchPath] + effectiveArgs).map { strdup($0) } defer { for item in argv { free(item) @@ -10015,12 +10027,14 @@ struct CMUXCLI { } } - // Keep the leader pane focused while Claude starts teammates beside it. + // Keep the leader pane focused while agents spawn beside it. + // -d explicitly means "don't focus the new pane". + let focusNewPane = !parsed.hasFlag("-d") let created = try client.sendV2(method: "surface.split", params: [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, "direction": direction, - "focus": false + "focus": focusNewPane ]) guard let surfaceId = created["surface_id"] as? String else { throw CLIError(message: "surface.split did not return surface_id") diff --git a/scripts/reload.sh b/scripts/reload.sh index 4c383da21..ba647cc25 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -288,6 +288,11 @@ if [[ -z "$TAG" ]]; then PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" ) fi +# Forward CMUX_SKIP_ZIG_BUILD to xcodebuild run script phases (e.g. macOS +# Tahoe where zig 0.15.2 can't link the ghostty CLI helper). +if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then + XCODEBUILD_ARGS+=(CMUX_SKIP_ZIG_BUILD=1) +fi XCODEBUILD_ARGS+=(build) XCODE_LOG="/tmp/cmux-xcodebuild-${TAG_SLUG}.log" From e5799e58e1fe33d185a4fea2d5b72a9cfd60fe1d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 15:19:15 -0700 Subject: [PATCH 14/18] Implement select-layout equalize and resize-pane absolute width When oh-my-openagent spawns agent panes, it calls select-layout main-vertical after each split to redistribute panes evenly, then resize-pane -x to set the main pane width. Both were previously no-ops, causing cascading uneven splits. Server side: - Add workspace.equalize_splits v2 API that calls the existing TabManager.equalizeSplits (sets all dividers to 0.5) CLI side: - select-layout now calls workspace.equalize_splits before tracking main-vertical state - resize-pane -x without directional flags now computes the pixel delta from current to desired width and resizes accordingly --- CLI/cmux.swift | 67 ++++++++++++++++++++------------ Sources/TerminalController.swift | 21 ++++++++++ 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 61c7913f7..045a6f14d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10224,29 +10224,47 @@ struct CMUXCLI { || parsed.hasFlag("-R") || parsed.hasFlag("-U") || parsed.hasFlag("-D") - if !hasDirectionalFlags { - return - } let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) - let direction: String - if parsed.hasFlag("-L") { - direction = "left" - } else if parsed.hasFlag("-U") { - direction = "up" - } else if parsed.hasFlag("-D") { - direction = "down" - } else { - direction = "right" + + if !hasDirectionalFlags, let absWidth = parsed.value("-x").flatMap({ Int($0.replacingOccurrences(of: "%", with: "")) }) { + // Absolute width: resize-pane -t -x + // Compute pixel delta from current width to desired width. + let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": target.workspaceId]) + let panes = panePayload["panes"] as? [[String: Any]] ?? [] + if let matchingPane = panes.first(where: { ($0["id"] as? String) == target.paneId }), + let cellW = matchingPane["cell_width_px"] as? Int, cellW > 0, + let currentCols = matchingPane["columns"] as? Int { + let delta = absWidth - currentCols + if delta != 0 { + _ = try? client.sendV2(method: "pane.resize", params: [ + "workspace_id": target.workspaceId, + "pane_id": target.paneId, + "direction": delta > 0 ? "right" : "left", + "amount": abs(delta) * cellW + ]) + } + } + } else if hasDirectionalFlags { + let direction: String + if parsed.hasFlag("-L") { + direction = "left" + } else if parsed.hasFlag("-U") { + direction = "up" + } else if parsed.hasFlag("-D") { + direction = "down" + } else { + direction = "right" + } + let rawAmount = (parsed.value("-x") ?? parsed.value("-y") ?? "5") + .replacingOccurrences(of: "%", with: "") + let amount = Int(rawAmount) ?? 5 + _ = try client.sendV2(method: "pane.resize", params: [ + "workspace_id": target.workspaceId, + "pane_id": target.paneId, + "direction": direction, + "amount": max(1, amount) + ]) } - let rawAmount = (parsed.value("-x") ?? parsed.value("-y") ?? "5") - .replacingOccurrences(of: "%", with: "") - let amount = Int(rawAmount) ?? 5 - _ = try client.sendV2(method: "pane.resize", params: [ - "workspace_id": target.workspaceId, - "pane_id": target.paneId, - "direction": direction, - "amount": max(1, amount) - ]) case "wait-for": try runTmuxCompatCommand( @@ -10301,13 +10319,12 @@ struct CMUXCLI { case "select-layout": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) let layoutName = parsed.positional.first ?? "" + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + // Equalize all splits in the workspace (redistributes evenly) + _ = try? client.sendV2(method: "workspace.equalize_splits", params: ["workspace_id": workspaceId]) if layoutName == "main-vertical" { - let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) 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( diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 37962dff1..c057a883c 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2071,6 +2071,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspacePrevious(params: params)) case "workspace.last": return v2Result(id: id, self.v2WorkspaceLast(params: params)) + case "workspace.equalize_splits": + return v2Result(id: id, self.v2WorkspaceEqualizeSplits(params: params)) case "workspace.remote.configure": return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params)) case "workspace.remote.reconnect": @@ -2443,6 +2445,7 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", + "workspace.equalize_splits", "workspace.remote.configure", "workspace.remote.reconnect", "workspace.remote.disconnect", @@ -3672,6 +3675,24 @@ class TerminalController { return result } + private func v2WorkspaceEqualizeSplits(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + let success = tabManager.equalizeSplits(tabId: ws.id) + if success { + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id)]) + } else { + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "note": "no splits to equalize"]) + } + } + return result + } + private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { From 91deb4494854a621689af3ba61203aa9a4121d5f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 15:24:17 -0700 Subject: [PATCH 15/18] Fix equalize to use proportional divider positions The previous equalize set all dividers to 0.5, which in a right- recursive binary tree (from successive splits) gives 50/25/12.5/6.25% instead of equal sizes. New algorithm counts leaf panes on each side of each split and sets the divider to N_left / (N_left + N_right). For 5 panes in a chain: 1/5, 1/4, 1/3, 1/2, giving each pane exactly 20%. --- Sources/TerminalController.swift | 43 +++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c057a883c..31e0f1ac8 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3683,16 +3683,47 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - let success = tabManager.equalizeSplits(tabId: ws.id) - if success { - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id)]) - } else { - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "note": "no splits to equalize"]) - } + let tree = ws.bonsplitController.treeSnapshot() + let success = v2ProportionalEqualize(node: tree, controller: ws.bonsplitController) + result = .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "equalized": success + ]) } return result } + /// Count leaf panes in a tree node. + private func v2CountLeaves(_ node: ExternalTreeNode) -> Int { + switch node { + case .pane: + return 1 + case .split(let s): + return v2CountLeaves(s.first) + v2CountLeaves(s.second) + } + } + + /// Proportionally equalize splits so each leaf pane gets equal space. + /// For a split with N1 leaves on the left and N2 on the right, + /// the divider is set to N1/(N1+N2). + @discardableResult + private func v2ProportionalEqualize(node: ExternalTreeNode, controller: BonsplitController) -> Bool { + guard case .split(let s) = node else { return false } + guard let splitId = UUID(uuidString: s.id) else { return false } + + let leftLeaves = v2CountLeaves(s.first) + let rightLeaves = v2CountLeaves(s.second) + let total = leftLeaves + rightLeaves + let position = CGFloat(leftLeaves) / CGFloat(total) + controller.setDividerPosition(position, forSplit: splitId, fromExternal: true) + + // Recurse into children + v2ProportionalEqualize(node: s.first, controller: controller) + v2ProportionalEqualize(node: s.second, controller: controller) + return true + } + private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { From 31c3c4d536af8b50660768a149e2c2317597960d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 15:33:52 -0700 Subject: [PATCH 16/18] Fix select-layout main-vertical to only equalize vertical splits The proportional equalize was treating the top-level horizontal split (main vs agent column) the same as vertical splits, setting the main pane to 1/6 of the window with 5 agents. For main-vertical layout, only equalize vertical splits (the agent column), leaving the horizontal main/agent divider untouched. The subsequent resize-pane -x handles the main pane width. workspace.equalize_splits now accepts an optional orientation filter ("vertical" or "horizontal") to scope which splits get equalized. --- CLI/cmux.swift | 14 ++++++++++++-- Sources/TerminalController.swift | 33 +++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 045a6f14d..8c67d53cc 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10320,8 +10320,18 @@ struct CMUXCLI { let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) let layoutName = parsed.positional.first ?? "" let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) - // Equalize all splits in the workspace (redistributes evenly) - _ = try? client.sendV2(method: "workspace.equalize_splits", params: ["workspace_id": workspaceId]) + if layoutName == "main-vertical" || layoutName == "main-horizontal" { + // For main-* layouts, only equalize the agent column (vertical splits), + // not the top-level horizontal split between main and agents. + let orientation = layoutName == "main-vertical" ? "vertical" : "horizontal" + _ = try? client.sendV2(method: "workspace.equalize_splits", params: [ + "workspace_id": workspaceId, + "orientation": orientation + ]) + } else { + // For tiled/even-* layouts, equalize everything + _ = try? client.sendV2(method: "workspace.equalize_splits", params: ["workspace_id": workspaceId]) + } if layoutName == "main-vertical" { if let callerSurface = tmuxCallerSurfaceHandle() { var store = loadTmuxCompatStore() diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 31e0f1ac8..01adda4ff 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3679,12 +3679,13 @@ class TerminalController { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } + let orientationFilter = v2String(params, "orientation") var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } let tree = ws.bonsplitController.treeSnapshot() - let success = v2ProportionalEqualize(node: tree, controller: ws.bonsplitController) + let success = v2ProportionalEqualize(node: tree, controller: ws.bonsplitController, orientationFilter: orientationFilter) result = .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), @@ -3707,21 +3708,31 @@ class TerminalController { /// Proportionally equalize splits so each leaf pane gets equal space. /// For a split with N1 leaves on the left and N2 on the right, /// the divider is set to N1/(N1+N2). + /// When orientationFilter is set (e.g. "vertical"), only splits matching + /// that orientation are equalized. This lets main-vertical layout equalize + /// the agent column without squishing the main pane. @discardableResult - private func v2ProportionalEqualize(node: ExternalTreeNode, controller: BonsplitController) -> Bool { + private func v2ProportionalEqualize( + node: ExternalTreeNode, + controller: BonsplitController, + orientationFilter: String? = nil + ) -> Bool { guard case .split(let s) = node else { return false } guard let splitId = UUID(uuidString: s.id) else { return false } - let leftLeaves = v2CountLeaves(s.first) - let rightLeaves = v2CountLeaves(s.second) - let total = leftLeaves + rightLeaves - let position = CGFloat(leftLeaves) / CGFloat(total) - controller.setDividerPosition(position, forSplit: splitId, fromExternal: true) + var didEqualize = false + if orientationFilter == nil || s.orientation == orientationFilter { + let leftLeaves = v2CountLeaves(s.first) + let rightLeaves = v2CountLeaves(s.second) + let total = leftLeaves + rightLeaves + let position = CGFloat(leftLeaves) / CGFloat(total) + controller.setDividerPosition(position, forSplit: splitId, fromExternal: true) + didEqualize = true + } - // Recurse into children - v2ProportionalEqualize(node: s.first, controller: controller) - v2ProportionalEqualize(node: s.second, controller: controller) - return true + let l = v2ProportionalEqualize(node: s.first, controller: controller, orientationFilter: orientationFilter) + let r = v2ProportionalEqualize(node: s.second, controller: controller, orientationFilter: orientationFilter) + return didEqualize || l || r } private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { From 714fe002c2e1ec3dfdb8c8be596203baad0c3b18 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 15:34:35 -0700 Subject: [PATCH 17/18] Re-equalize agent column after kill-pane --- CLI/cmux.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8c67d53cc..1084a743f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10104,6 +10104,11 @@ struct CMUXCLI { "workspace_id": target.workspaceId, "surface_id": target.surfaceId ]) + // Re-equalize the agent column after removing a pane + _ = try? client.sendV2(method: "workspace.equalize_splits", params: [ + "workspace_id": target.workspaceId, + "orientation": "vertical" + ]) case "send-keys", "send": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: ["-l"]) From 5487d5ebb3d919607a52d73b273b3833d8d00e2a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 15:58:54 -0700 Subject: [PATCH 18/18] Address PR review comments - Fix cmux omo --help: remove omo from the help-bypass guard so --help shows usage text instead of trying to launch opencode - Don't overwrite unreadable opencode.json: fail with an error instead of silently resetting to empty config - Drain installer pipes concurrently before waitUntilExit to prevent deadlock from full pipe buffers during bun/npm install --- CLI/cmux.swift | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1084a743f..e070e62e8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1413,7 +1413,6 @@ struct CMUXCLI { // so help text is available even when cmux is not running. if command != "__tmux-compat", command != "claude-teams", - command != "omo", command != "codex", (commandArgs.contains("--help") || commandArgs.contains("-h")) { if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) { @@ -9656,8 +9655,10 @@ struct CMUXCLI { let shadowJsonURL = shadowDir.appendingPathComponent("opencode.json") var config: [String: Any] - if let data = try? Data(contentsOf: userJsonURL), - let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let data = try? Data(contentsOf: userJsonURL) { + guard let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CLIError(message: "Failed to parse \(userJsonURL.path). Fix the JSON syntax and retry.") + } config = existing } else { config = [:] @@ -9736,9 +9737,23 @@ struct CMUXCLI { process.standardError = stderrPipe FileHandle.standardError.write("Installing oh-my-opencode plugin...\n".data(using: .utf8)!) try process.run() + // Drain pipes concurrently to prevent deadlock from full buffers + var stderrData = Data() + let drainGroup = DispatchGroup() + drainGroup.enter() + DispatchQueue.global().async { + stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + drainGroup.leave() + } + drainGroup.enter() + DispatchQueue.global().async { + _ = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + drainGroup.leave() + } + drainGroup.wait() process.waitUntilExit() if process.terminationStatus != 0 { - let errText = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let errText = String(data: stderrData, encoding: .utf8) ?? "" throw CLIError(message: "Failed to install oh-my-opencode: \(errText)") } FileHandle.standardError.write("oh-my-opencode plugin installed\n".data(using: .utf8)!)