From 27fa3873beada48e9c9861e93ab81ac7c8ae81e8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:04:24 -0700 Subject: [PATCH] Add claude-teams, omo, and __tmux-compat to Go relay CLI for SSH sessions (#2238) * Add claude-teams, omo, and __tmux-compat to Go relay CLI These commands previously only existed in the Swift CLI which uses Unix domain sockets and can't connect over TCP relay. The Go relay CLI already handles TCP connections, so adding the commands here makes them work inside `cmux ssh` sessions. - `cmux claude-teams`: creates tmux shim scripts, configures environment (fake TMUX/TMUX_PANE, socket path, workspace/surface IDs), and execs into `claude --teammate-mode auto` - `cmux omo`: same pattern for OpenCode with terminal-notifier shim - `cmux __tmux-compat`: translates tmux commands (split-window, send-keys, capture-pane, display-message, list-panes, etc.) into cmux JSON-RPC calls over the relay socket. Includes main-vertical layout tracking, wait-for signaling, and format string rendering. * Fix: search original PATH before environment modification findExecutable was called after configureAgentEnvironment prepended the shim directory to PATH. The Swift CLI searches the original PATH before modification. Renamed to findExecutableInPath with explicit PATH arg and moved the search before configureAgentEnvironment. * Fix cmux omo hang and port oh-my-opencode plugin setup Root cause: socketRoundTripV2 had no read timeout. When connecting to a stale relay port (accepted TCP but never responded), the read blocked forever. This caused getFocusedContext to hang, blocking agent launch. Fixes: - Add 15s read deadline to socketRoundTripV2 (affects all v2 RPC calls) - Add 5s timeout to getFocusedContext so agent launch proceeds even if system.identify is slow - Port omoEnsurePlugin from Swift: creates shadow config dir, adds oh-my-opencode to plugin list, symlinks node_modules/package.json, installs plugin via bun/npm if missing, configures tmux settings (enabled=true, lower min widths), sets OPENCODE_CONFIG_DIR * Fix: use bun as runtime for node-script opencode when node is missing opencode is installed via bun as a #!/usr/bin/env node script, but on some systems (like the macmini) bun is installed without a standalone node binary. Detect node scripts and fall back to bun as the runtime since bun is node-compatible. * Fix subagent pane theme: preserve COLORTERM, keep TERM_PROGRAM The cmux ssh bootstrap exports COLORTERM=truecolor and TERM_PROGRAM=ghostty. Our configureAgentEnvironment was unsetting TERM_PROGRAM and not setting COLORTERM, causing subagent panes (created via split-window) to lose truecolor detection and render with wrong theme colors. * Restore TERM_PROGRAM unset, keep COLORTERM=truecolor * Force dark colorScheme in opencode shadow config for SSH * Remove hardcoded dark colorScheme, let opencode detect naturally * Detect system color scheme for opencode over SSH * Remove color scheme detection workaround, let opencode handle natively --------- Co-authored-by: Lawrence Chen --- .../remote/cmd/cmuxd-remote/agent_launch.go | 584 ++++++ daemon/remote/cmd/cmuxd-remote/cli.go | 16 + daemon/remote/cmd/cmuxd-remote/tmux_compat.go | 1680 +++++++++++++++++ .../cmd/cmuxd-remote/tmux_compat_test.go | 431 +++++ 4 files changed, 2711 insertions(+) create mode 100644 daemon/remote/cmd/cmuxd-remote/agent_launch.go create mode 100644 daemon/remote/cmd/cmuxd-remote/tmux_compat.go create mode 100644 daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go new file mode 100644 index 00000000..81c520f9 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -0,0 +1,584 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +// runClaudeTeamsRelay implements `cmux claude-teams` on the remote side. +// It creates tmux shim scripts, sets up environment variables, gets the +// focused context via system.identify, and exec's into `claude`. +func runClaudeTeamsRelay(socketPath string, args []string, refreshAddr func() string) int { + rc := &rpcContext{socketPath: socketPath, refreshAddr: refreshAddr} + + shimDir, err := createTmuxShimDir("claude-teams-bin", claudeTeamsShimScript) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux claude-teams: failed to create shim directory: %v\n", err) + return 1 + } + + // Resolve the agent executable BEFORE modifying PATH (so the shim + // directory doesn't shadow anything). Matches the Swift CLI behavior. + originalPath := os.Getenv("PATH") + claudePath := findExecutableInPath("claude", originalPath, shimDir) + + focused := getFocusedContext(rc) + + configureAgentEnvironment(agentConfig{ + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-claude-teams", + cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", + termEnvVar: "CMUX_CLAUDE_TEAMS_TERM", + extraEnv: map[string]string{ + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", + }, + }) + + launchArgs := claudeTeamsLaunchArgs(args) + + if claudePath == "" { + fmt.Fprintf(os.Stderr, "cmux claude-teams: claude not found in PATH\n") + return 1 + } + argv := append([]string{claudePath}, launchArgs...) + execErr := syscall.Exec(claudePath, argv, os.Environ()) + fmt.Fprintf(os.Stderr, "cmux claude-teams: exec failed: %v\n", execErr) + return 1 +} + +// runOMORelay implements `cmux omo` on the remote side. +func runOMORelay(socketPath string, args []string, refreshAddr func() string) int { + rc := &rpcContext{socketPath: socketPath, refreshAddr: refreshAddr} + + shimDir, err := createOMOShimDir() + if err != nil { + fmt.Fprintf(os.Stderr, "cmux omo: failed to create shim directory: %v\n", err) + return 1 + } + + // Resolve the agent executable BEFORE modifying PATH. + originalPath := os.Getenv("PATH") + opencodePath := findExecutableInPath("opencode", originalPath, shimDir) + if opencodePath == "" { + fmt.Fprintf(os.Stderr, "cmux omo: opencode not found in PATH\n"+ + "Install it first:\n npm install -g opencode-ai\n # or\n bun install -g opencode-ai\n") + return 1 + } + + // Ensure oh-my-opencode plugin is set up + if err := omoEnsurePlugin(originalPath); err != nil { + fmt.Fprintf(os.Stderr, "cmux omo: plugin setup: %v\n", err) + return 1 + } + + focused := getFocusedContext(rc) + + configureAgentEnvironment(agentConfig{ + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-omo", + cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", + termEnvVar: "CMUX_OMO_TERM", + extraEnv: map[string]string{}, + }) + + // Set OPENCODE_PORT if not already set + if os.Getenv("OPENCODE_PORT") == "" { + os.Setenv("OPENCODE_PORT", "4096") + } + + // Build launch arguments + launchArgs := args + hasPort := false + for _, arg := range launchArgs { + if arg == "--port" || strings.HasPrefix(arg, "--port=") { + hasPort = true + break + } + } + if !hasPort { + port := os.Getenv("OPENCODE_PORT") + if port == "" { + port = "4096" + } + launchArgs = append([]string{"--port", port}, launchArgs...) + } + + launchPath, launchArgv := resolveNodeScriptExec(opencodePath, launchArgs, originalPath, shimDir) + execErr := syscall.Exec(launchPath, launchArgv, os.Environ()) + fmt.Fprintf(os.Stderr, "cmux omo: exec failed: %v\n", execErr) + return 1 +} + +// --- Shim creation --- + +const claudeTeamsShimScript = `#!/usr/bin/env bash +set -euo pipefail +exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@" +` + +const omoTmuxShimScript = `#!/usr/bin/env bash +set -euo pipefail +# 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 "$@" +` + +const omoNotifierShimScript = `#!/usr/bin/env bash +# Intercept terminal-notifier calls and route through cmux notify. +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:-}" +` + +func createTmuxShimDir(dirName string, tmuxScript string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".cmuxterm", dirName) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + tmuxPath := filepath.Join(dir, "tmux") + if err := writeShimIfChanged(tmuxPath, tmuxScript); err != nil { + return "", err + } + return dir, nil +} + +func createOMOShimDir() (string, error) { + dir, err := createTmuxShimDir("omo-bin", omoTmuxShimScript) + if err != nil { + return "", err + } + notifierPath := filepath.Join(dir, "terminal-notifier") + if err := writeShimIfChanged(notifierPath, omoNotifierShimScript); err != nil { + return "", err + } + return dir, nil +} + +func writeShimIfChanged(path string, content string) error { + existing, err := os.ReadFile(path) + if err == nil && string(existing) == content { + return nil + } + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + return err + } + return nil +} + +// --- Focused context --- + +type focusedContext struct { + workspaceId string + windowId string + paneHandle string + surfaceId string +} + +func getFocusedContext(rc *rpcContext) *focusedContext { + // Use a goroutine with timeout so a slow/stale relay doesn't block agent launch. + type result struct { + payload map[string]any + err error + } + ch := make(chan result, 1) + go func() { + p, e := rc.call("system.identify", nil) + ch <- result{p, e} + }() + var payload map[string]any + select { + case r := <-ch: + if r.err != nil { + return nil + } + payload = r.payload + case <-time.After(5 * time.Second): + return nil + } + focused, _ := payload["focused"].(map[string]any) + if focused == nil { + return nil + } + + wsId := stringFromAny(focused["workspace_id"], focused["workspace_ref"]) + paneId := stringFromAny(focused["pane_id"], focused["pane_ref"]) + if wsId == "" || paneId == "" { + return nil + } + + return &focusedContext{ + workspaceId: wsId, + windowId: stringFromAny(focused["window_id"], focused["window_ref"]), + paneHandle: strings.TrimSpace(paneId), + surfaceId: stringFromAny(focused["surface_id"], focused["surface_ref"]), + } +} + +func stringFromAny(values ...any) string { + for _, v := range values { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + return "" +} + +// --- Environment configuration --- + +type agentConfig struct { + shimDir string + socketPath string + focused *focusedContext + tmuxPathPrefix string + cmuxBinEnvVar string + termEnvVar string + extraEnv map[string]string +} + +func configureAgentEnvironment(cfg agentConfig) { + // Find our own executable path for the shim to call back + selfPath, _ := os.Executable() + if selfPath == "" { + selfPath = "cmux" + } + os.Setenv(cfg.cmuxBinEnvVar, selfPath) + + // Prepend shim directory to PATH + currentPath := os.Getenv("PATH") + os.Setenv("PATH", cfg.shimDir+":"+currentPath) + + // Set fake TMUX/TMUX_PANE + fakeTmux := fmt.Sprintf("/tmp/%s/default,0,0", cfg.tmuxPathPrefix) + fakeTmuxPane := "%1" + if cfg.focused != nil { + windowToken := cfg.focused.windowId + if windowToken == "" { + windowToken = cfg.focused.workspaceId + } + fakeTmux = fmt.Sprintf("/tmp/%s/%s,%s,%s", + cfg.tmuxPathPrefix, cfg.focused.workspaceId, windowToken, cfg.focused.paneHandle) + fakeTmuxPane = "%" + cfg.focused.paneHandle + } + os.Setenv("TMUX", fakeTmux) + os.Setenv("TMUX_PANE", fakeTmuxPane) + + // Terminal settings + fakeTerm := os.Getenv(cfg.termEnvVar) + if fakeTerm == "" { + fakeTerm = "screen-256color" + } + os.Setenv("TERM", fakeTerm) + + // Socket path + os.Setenv("CMUX_SOCKET_PATH", cfg.socketPath) + os.Setenv("CMUX_SOCKET", cfg.socketPath) + + // Unset TERM_PROGRAM so apps don't detect the host terminal and + // override tmux-compatible behavior (e.g. opencode switches to + // light theme when it sees TERM_PROGRAM=ghostty). + os.Unsetenv("TERM_PROGRAM") + + // Preserve COLORTERM for truecolor support in subagent panes. + if os.Getenv("COLORTERM") == "" { + os.Setenv("COLORTERM", "truecolor") + } + + // Set workspace/surface IDs from focused context + if cfg.focused != nil { + os.Setenv("CMUX_WORKSPACE_ID", cfg.focused.workspaceId) + if cfg.focused.surfaceId != "" { + os.Setenv("CMUX_SURFACE_ID", cfg.focused.surfaceId) + } + } + + // Extra environment variables + for k, v := range cfg.extraEnv { + os.Setenv(k, v) + } +} + +// --- oh-my-opencode plugin setup --- + +const omoPluginName = "oh-my-opencode" + +func omoUserConfigDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "opencode") +} + +func omoShadowConfigDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cmuxterm", "omo-config") +} + +// omoEnsurePlugin creates a shadow config directory that layers the +// oh-my-opencode plugin on top of the user's opencode config, installs +// the plugin if needed, and sets OPENCODE_CONFIG_DIR. +func omoEnsurePlugin(searchPath string) error { + userDir := omoUserConfigDir() + shadowDir := omoShadowConfigDir() + + if err := os.MkdirAll(shadowDir, 0755); err != nil { + return fmt.Errorf("create shadow config dir: %w", err) + } + + // Read user's opencode.json, add the plugin, write to shadow dir + userJsonPath := filepath.Join(userDir, "opencode.json") + shadowJsonPath := filepath.Join(shadowDir, "opencode.json") + + var config map[string]any + if data, err := os.ReadFile(userJsonPath); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", userJsonPath) + } + } else { + config = map[string]any{} + } + + // Add oh-my-opencode to the plugins list + var plugins []string + if raw, ok := config["plugin"].([]any); ok { + for _, p := range raw { + if s, ok := p.(string); ok { + plugins = append(plugins, s) + } + } + } + alreadyPresent := false + for _, p := range plugins { + if p == omoPluginName || strings.HasPrefix(p, omoPluginName+"@") { + alreadyPresent = true + break + } + } + if !alreadyPresent { + plugins = append(plugins, omoPluginName) + } + config["plugin"] = plugins + + output, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(shadowJsonPath, output, 0644); err != nil { + return err + } + + // Symlink node_modules from user config dir + shadowNodeModules := filepath.Join(shadowDir, "node_modules") + userNodeModules := filepath.Join(userDir, "node_modules") + if dirExists(userNodeModules) { + target, _ := os.Readlink(shadowNodeModules) + if target != userNodeModules { + os.Remove(shadowNodeModules) + os.Symlink(userNodeModules, shadowNodeModules) + } + } + + // Symlink package.json and bun.lock + for _, filename := range []string{"package.json", "bun.lock"} { + userFile := filepath.Join(userDir, filename) + shadowFile := filepath.Join(shadowDir, filename) + if fileExists(userFile) && !fileExists(shadowFile) { + os.Symlink(userFile, shadowFile) + } + } + + // Symlink oh-my-opencode config files + for _, filename := range []string{"oh-my-opencode.json", "oh-my-opencode.jsonc"} { + userFile := filepath.Join(userDir, filename) + shadowFile := filepath.Join(shadowDir, filename) + if fileExists(userFile) && !fileExists(shadowFile) { + os.Symlink(userFile, shadowFile) + } + } + + // Install the plugin if not available + pluginPackageDir := filepath.Join(shadowNodeModules, omoPluginName) + if !dirExists(pluginPackageDir) { + installDir := userDir + if !dirExists(userNodeModules) { + installDir = shadowDir + os.Remove(shadowNodeModules) // Remove symlink so we can install directly + } + os.MkdirAll(installDir, 0755) + + bunPath := findExecutableInPath("bun", searchPath, "") + npmPath := findExecutableInPath("npm", searchPath, "") + if bunPath == "" && npmPath == "" { + return fmt.Errorf("neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install") + } + + fmt.Fprintf(os.Stderr, "Installing oh-my-opencode plugin...\n") + var cmd *exec.Cmd + if bunPath != "" { + cmd = exec.Command(bunPath, "add", omoPluginName) + } else { + cmd = exec.Command(npmPath, "install", omoPluginName) + } + cmd.Dir = installDir + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install oh-my-opencode: %v\nTry manually: npm install -g oh-my-opencode", err) + } + fmt.Fprintf(os.Stderr, "oh-my-opencode plugin installed\n") + + // Re-create symlink if we installed into user dir + if installDir == userDir && !fileExists(shadowNodeModules) { + os.Symlink(userNodeModules, shadowNodeModules) + } + } + + // Configure oh-my-opencode.json with tmux settings + omoConfigPath := filepath.Join(shadowDir, "oh-my-opencode.json") + var omoConfig map[string]any + if data, err := os.ReadFile(omoConfigPath); err == nil { + json.Unmarshal(data, &omoConfig) + } + if omoConfig == nil { + // Check if user had one we symlinked + userOmoConfig := filepath.Join(userDir, "oh-my-opencode.json") + if data, err := os.ReadFile(userOmoConfig); err == nil { + json.Unmarshal(data, &omoConfig) + os.Remove(omoConfigPath) // Remove symlink so we can write our own copy + } + } + if omoConfig == nil { + omoConfig = map[string]any{} + } + + tmuxConfig, _ := omoConfig["tmux"].(map[string]any) + if tmuxConfig == nil { + tmuxConfig = map[string]any{} + } + needsWrite := false + if enabled, _ := tmuxConfig["enabled"].(bool); !enabled { + tmuxConfig["enabled"] = true + needsWrite = true + } + 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 + if target, err := os.Readlink(omoConfigPath); err == nil && target != "" { + os.Remove(omoConfigPath) + } + data, _ := json.MarshalIndent(omoConfig, "", " ") + os.WriteFile(omoConfigPath, data, 0644) + } + + os.Setenv("OPENCODE_CONFIG_DIR", shadowDir) + return nil +} + +func fileExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// --- Node script resolution --- + +// resolveNodeScriptExec checks if the target binary is a #!/usr/bin/env node +// script. If node isn't in PATH but bun is, it rewrites the exec to use bun +// as the runtime (bun is node-compatible). +func resolveNodeScriptExec(binPath string, args []string, searchPath string, skipDir string) (string, []string) { + if !isNodeScript(binPath) { + return binPath, append([]string{binPath}, args...) + } + + // node in PATH? Use the script directly. + if findExecutableInPath("node", searchPath, skipDir) != "" { + return binPath, append([]string{binPath}, args...) + } + + // Fall back to bun as a node-compatible runtime. + bunPath := findExecutableInPath("bun", searchPath, skipDir) + if bunPath != "" { + return bunPath, append([]string{bunPath, binPath}, args...) + } + + // No node or bun; exec the script directly and let the OS error. + return binPath, append([]string{binPath}, args...) +} + +func isNodeScript(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + buf := make([]byte, 64) + n, _ := f.Read(buf) + line := string(buf[:n]) + return strings.Contains(line, "/env node") || strings.Contains(line, "/bin/node") +} + +// --- Executable resolution --- + +// findExecutableInPath searches the given PATH string for an executable, +// skipping skipDir (the shim directory). Takes an explicit PATH to ensure +// we search the original PATH before environment modifications. +func findExecutableInPath(name string, pathEnv string, skipDir string) string { + for _, dir := range filepath.SplitList(pathEnv) { + if dir == "" || dir == skipDir { + continue + } + candidate := filepath.Join(dir, name) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Mode()&0111 != 0 { + return candidate + } + } + return "" +} + +// --- Claude Teams launch args --- + +func claudeTeamsLaunchArgs(args []string) []string { + // Check if --teammate-mode is already specified + for _, arg := range args { + if arg == "--teammate-mode" || strings.HasPrefix(arg, "--teammate-mode=") { + return args + } + } + return append([]string{"--teammate-mode", "auto"}, args...) +} diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index 2b2bf585..f9601c68 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -147,6 +147,19 @@ doneFlags: return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr) } + // Agent launch commands + if cmdName == "claude-teams" { + return runClaudeTeamsRelay(socketPath, cmdArgs, refreshAddr) + } + if cmdName == "omo" { + return runOMORelay(socketPath, cmdArgs, refreshAddr) + } + + // Tmux compatibility layer (used by agent shims) + if cmdName == "__tmux-compat" { + return runTmuxCompat(socketPath, cmdArgs, refreshAddr) + } + spec, ok := commandIndex[cmdName] if !ok { fmt.Fprintf(os.Stderr, "cmux: unknown command %q\n", cmdName) @@ -697,6 +710,7 @@ func socketRoundTripV2(socketPath, method string, params map[string]any, refresh return "", fmt.Errorf("failed to send request: %w", err) } + _ = conn.SetReadDeadline(time.Now().Add(15 * time.Second)) reader := bufio.NewReader(conn) line, err := reader.ReadString('\n') if err != nil { @@ -754,5 +768,7 @@ func cliUsage() { fmt.Fprintln(os.Stderr, " send-key Send a key to a surface") fmt.Fprintln(os.Stderr, " notify Create a notification") fmt.Fprintln(os.Stderr, " browser Browser commands (open, navigate, back, forward, reload, get-url)") + fmt.Fprintln(os.Stderr, " claude-teams [args...] Launch Claude Code in teammate mode") + fmt.Fprintln(os.Stderr, " omo [args...] Launch OpenCode with cmux integration") fmt.Fprintln(os.Stderr, " rpc [json-params] Send arbitrary JSON-RPC") } diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat.go new file mode 100644 index 00000000..5045ec00 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat.go @@ -0,0 +1,1680 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// runTmuxCompat handles `cmux __tmux-compat `, translating tmux +// commands into cmux JSON-RPC calls over the relay socket. +func runTmuxCompat(socketPath string, args []string, refreshAddr func() string) int { + command, cmdArgs, err := splitTmuxCmd(args) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux __tmux-compat: %v\n", err) + return 1 + } + + rc := &rpcContext{socketPath: socketPath, refreshAddr: refreshAddr} + if err := dispatchTmuxCommand(rc, command, cmdArgs); err != nil { + fmt.Fprintf(os.Stderr, "cmux __tmux-compat: %v\n", err) + return 1 + } + return 0 +} + +// rpcContext holds connection info for making JSON-RPC calls. +type rpcContext struct { + socketPath string + refreshAddr func() string +} + +// call makes a JSON-RPC call and returns the parsed result. +func (rc *rpcContext) call(method string, params map[string]any) (map[string]any, error) { + resp, err := socketRoundTripV2(rc.socketPath, method, params, rc.refreshAddr) + if err != nil { + return nil, err + } + var result map[string]any + if err := json.Unmarshal([]byte(resp), &result); err != nil { + // Some responses are bare values (string, null) + return nil, nil + } + return result, nil +} + +// --- Tmux argument parsing --- + +type tmuxParsed struct { + flags map[string]bool // boolean flags like -d, -P + options map[string][]string // value flags like -t + positional []string +} + +func (p *tmuxParsed) hasFlag(f string) bool { + return p.flags[f] +} + +func (p *tmuxParsed) value(f string) string { + vals := p.options[f] + if len(vals) == 0 { + return "" + } + return vals[len(vals)-1] +} + +func splitTmuxCmd(args []string) (string, []string, error) { + globalValueFlags := map[string]bool{"-L": true, "-S": true, "-f": true} + globalBoolFlags := map[string]bool{"-V": true, "-v": true} + + i := 0 + for i < len(args) { + arg := args[i] + if !strings.HasPrefix(arg, "-") || arg == "-" { + return strings.ToLower(arg), args[i+1:], nil + } + if arg == "--" { + break + } + if globalBoolFlags[arg] { + return arg, nil, nil + } + if globalValueFlags[arg] { + // Skip the value + i++ + } + i++ + } + return "", nil, fmt.Errorf("tmux shim requires a command") +} + +func parseTmuxArgs(args []string, valueFlags, boolFlags []string) *tmuxParsed { + vSet := make(map[string]bool, len(valueFlags)) + for _, f := range valueFlags { + vSet[f] = true + } + bSet := make(map[string]bool, len(boolFlags)) + for _, f := range boolFlags { + bSet[f] = true + } + + p := &tmuxParsed{ + flags: make(map[string]bool), + options: make(map[string][]string), + } + pastTerminator := false + + for i := 0; i < len(args); i++ { + arg := args[i] + if pastTerminator { + p.positional = append(p.positional, arg) + continue + } + if arg == "--" { + pastTerminator = true + continue + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + p.positional = append(p.positional, arg) + continue + } + if strings.HasPrefix(arg, "--") { + p.positional = append(p.positional, arg) + continue + } + + // Cluster parsing: -dPh etc. + cluster := []rune(arg[1:]) + cursor := 0 + recognized := false + for cursor < len(cluster) { + flag := "-" + string(cluster[cursor]) + if bSet[flag] { + p.flags[flag] = true + cursor++ + recognized = true + continue + } + if vSet[flag] { + remainder := string(cluster[cursor+1:]) + var value string + if remainder != "" { + value = remainder + } else if i+1 < len(args) { + i++ + value = args[i] + } + p.options[flag] = append(p.options[flag], value) + recognized = true + cursor = len(cluster) + continue + } + recognized = false + break + } + if !recognized { + p.positional = append(p.positional, arg) + } + } + return p +} + +// --- Format string rendering --- + +var tmuxFormatVarRe = regexp.MustCompile(`#\{[^}]+\}`) + +func tmuxRenderFormat(format string, context map[string]string, fallback string) string { + if format == "" { + return fallback + } + rendered := format + for key, value := range context { + rendered = strings.ReplaceAll(rendered, "#{"+key+"}", value) + } + // Remove any remaining unresolved #{...} variables + rendered = tmuxFormatVarRe.ReplaceAllString(rendered, "") + rendered = strings.TrimSpace(rendered) + if rendered == "" { + return fallback + } + return rendered +} + +// --- Format context building --- + +func tmuxFormatContext(rc *rpcContext, workspaceId string, paneId string, surfaceId string) (map[string]string, error) { + canonicalWsId, err := tmuxResolveWorkspaceId(rc, workspaceId) + if err != nil { + return nil, err + } + + ctx := map[string]string{ + "session_name": "cmux", + "session_id": "$0", + "window_id": "@" + canonicalWsId, + "window_uuid": canonicalWsId, + "window_active": "1", + "window_flags": "*", + "pane_active": "1", + } + + // Get workspace list for index/title + workspaces, err := tmuxWorkspaceItems(rc) + if err == nil { + for _, ws := range workspaces { + wsId, _ := ws["id"].(string) + wsRef, _ := ws["ref"].(string) + if wsId == canonicalWsId || wsRef == workspaceId { + if idx := intFromAnyGo(ws["index"]); idx >= 0 { + ctx["window_index"] = fmt.Sprintf("%d", idx) + } + if title, _ := ws["title"].(string); strings.TrimSpace(title) != "" { + ctx["window_name"] = strings.TrimSpace(title) + } + if paneCount := intFromAnyGo(ws["pane_count"]); paneCount >= 0 { + ctx["window_panes"] = fmt.Sprintf("%d", paneCount) + } + break + } + } + } + + // Get current surface info + currentPayload, err := rc.call("surface.current", map[string]any{"workspace_id": canonicalWsId}) + if err != nil { + return ctx, nil + } + + resolvedPaneId := paneId + if resolvedPaneId == "" { + if pid, ok := currentPayload["pane_id"].(string); ok { + resolvedPaneId = pid + } else if pref, ok := currentPayload["pane_ref"].(string); ok { + resolvedPaneId = pref + } + } + + resolvedSurfaceId := surfaceId + if resolvedSurfaceId == "" && resolvedPaneId != "" { + if sid, err := tmuxSelectedSurfaceId(rc, canonicalWsId, resolvedPaneId); err == nil { + resolvedSurfaceId = sid + } + } + if resolvedSurfaceId == "" { + if sid, ok := currentPayload["surface_id"].(string); ok { + resolvedSurfaceId = sid + } + } + + if resolvedPaneId != "" { + ctx["pane_id"] = "%" + resolvedPaneId + ctx["pane_uuid"] = resolvedPaneId + + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": canonicalWsId}) + if err == nil { + panes, _ := panePayload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == resolvedPaneId { + if idx := intFromAnyGo(pane["index"]); idx >= 0 { + ctx["pane_index"] = fmt.Sprintf("%d", idx) + } + break + } + } + } + } + + if resolvedSurfaceId != "" { + ctx["surface_id"] = resolvedSurfaceId + surfacePayload, err := rc.call("surface.list", map[string]any{"workspace_id": canonicalWsId}) + if err == nil { + surfaces, _ := surfacePayload["surfaces"].([]any) + for _, s := range surfaces { + surface, _ := s.(map[string]any) + if surface == nil { + continue + } + if sid, _ := surface["id"].(string); sid == resolvedSurfaceId { + if title, _ := surface["title"].(string); strings.TrimSpace(title) != "" { + ctx["pane_title"] = strings.TrimSpace(title) + if _, ok := ctx["window_name"]; !ok { + ctx["window_name"] = strings.TrimSpace(title) + } + } + break + } + } + } + } + + return ctx, nil +} + +func tmuxEnrichContextWithGeometry(ctx map[string]string, pane map[string]any, containerFrame map[string]any) { + isFocused, _ := pane["focused"].(bool) + if isFocused { + ctx["pane_active"] = "1" + } else { + ctx["pane_active"] = "0" + } + + columns := intFromAnyGo(pane["columns"]) + rows := intFromAnyGo(pane["rows"]) + if columns < 0 || rows < 0 { + return + } + ctx["pane_width"] = fmt.Sprintf("%d", columns) + ctx["pane_height"] = fmt.Sprintf("%d", rows) + + cellW := intFromAnyGo(pane["cell_width_px"]) + cellH := intFromAnyGo(pane["cell_height_px"]) + if cellW <= 0 || cellH <= 0 { + return + } + + if frame, ok := pane["pixel_frame"].(map[string]any); ok { + px := floatFromAny(frame["x"]) + py := floatFromAny(frame["y"]) + ctx["pane_left"] = fmt.Sprintf("%d", int(px)/cellW) + ctx["pane_top"] = fmt.Sprintf("%d", int(py)/cellH) + } + + if containerFrame != nil { + cw := floatFromAny(containerFrame["width"]) + ch := floatFromAny(containerFrame["height"]) + ww := int(cw) / cellW + wh := int(ch) / cellH + if ww < 1 { + ww = 1 + } + if wh < 1 { + wh = 1 + } + ctx["window_width"] = fmt.Sprintf("%d", ww) + ctx["window_height"] = fmt.Sprintf("%d", wh) + } +} + +func floatFromAny(v any) float64 { + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case json.Number: + f, _ := t.Float64() + return f + } + return 0 +} + +func intFromAnyGo(v any) int { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case json.Number: + i, err := t.Int64() + if err != nil { + return -1 + } + return int(i) + } + return -1 +} + +// --- Target resolution --- + +func tmuxCallerWorkspaceHandle() string { + return strings.TrimSpace(os.Getenv("CMUX_WORKSPACE_ID")) +} + +func tmuxCallerSurfaceHandle() string { + return strings.TrimSpace(os.Getenv("CMUX_SURFACE_ID")) +} + +func tmuxCallerPaneHandle() string { + for _, key := range []string{"TMUX_PANE", "CMUX_PANE_ID"} { + v := strings.TrimSpace(os.Getenv(key)) + if v != "" { + return strings.TrimPrefix(v, "%") + } + } + return "" +} + +func tmuxWorkspaceItems(rc *rpcContext) ([]map[string]any, error) { + payload, err := rc.call("workspace.list", nil) + if err != nil { + return nil, err + } + items, _ := payload["workspaces"].([]any) + var result []map[string]any + for _, item := range items { + if m, ok := item.(map[string]any); ok { + result = append(result, m) + } + } + return result, nil +} + +func isUUIDish(s string) bool { + // Simple UUID check: 8-4-4-4-12 hex + if len(s) != 36 { + return false + } + for i, c := range s { + if i == 8 || i == 13 || i == 18 || i == 23 { + if c != '-' { + return false + } + } else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +func tmuxResolveWorkspaceId(rc *rpcContext, raw string) (string, error) { + if raw == "" || raw == "current" { + if caller := tmuxCallerWorkspaceHandle(); caller != "" { + if isUUIDish(caller) { + return caller, nil + } + // Resolve ref + return tmuxResolveWorkspaceId(rc, caller) + } + payload, err := rc.call("workspace.current", nil) + if err != nil { + return "", fmt.Errorf("no workspace selected: %w", err) + } + if wsId, ok := payload["workspace_id"].(string); ok { + return wsId, nil + } + return "", fmt.Errorf("no workspace selected") + } + + if isUUIDish(raw) { + return raw, nil + } + + // Try to resolve as ref or index + items, err := tmuxWorkspaceItems(rc) + if err != nil { + return "", err + } + for _, item := range items { + if ref, _ := item["ref"].(string); ref == raw { + if id, _ := item["id"].(string); id != "" { + return id, nil + } + } + } + + // Try name match + needle := strings.TrimSpace(raw) + for _, item := range items { + title, _ := item["title"].(string) + if strings.TrimSpace(title) == needle { + if id, _ := item["id"].(string); id != "" { + return id, nil + } + } + } + + return "", fmt.Errorf("workspace not found: %s", raw) +} + +func tmuxResolveWorkspaceTarget(rc *rpcContext, raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + if caller := tmuxCallerWorkspaceHandle(); caller != "" { + return tmuxResolveWorkspaceId(rc, caller) + } + return tmuxResolveWorkspaceId(rc, "") + } + + if raw == "!" || raw == "^" || raw == "-" { + payload, err := rc.call("workspace.last", nil) + if err != nil { + return "", fmt.Errorf("previous workspace not found: %w", err) + } + if wsId, ok := payload["workspace_id"].(string); ok { + return wsId, nil + } + return "", fmt.Errorf("previous workspace not found") + } + + // Strip session:window.pane format + token := raw + if dot := strings.LastIndex(token, "."); dot >= 0 { + token = token[:dot] + } + if colon := strings.LastIndex(token, ":"); colon >= 0 { + suffix := token[colon+1:] + if suffix != "" { + token = suffix + } else { + token = token[:colon] + } + } + token = strings.TrimPrefix(token, "@") + + return tmuxResolveWorkspaceId(rc, token) +} + +func tmuxPaneSelector(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "%") { + return raw[1:] + } + if strings.HasPrefix(raw, "pane:") { + return raw + } + if dot := strings.LastIndex(raw, "."); dot >= 0 { + return raw[dot+1:] + } + return "" +} + +func tmuxWindowSelector(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "%") || strings.HasPrefix(raw, "pane:") { + return "" + } + if dot := strings.LastIndex(raw, "."); dot >= 0 { + return raw[:dot] + } + return raw +} + +func tmuxCanonicalPaneId(rc *rpcContext, handle string, workspaceId string) (string, error) { + if isUUIDish(handle) { + return handle, nil + } + payload, err := rc.call("pane.list", map[string]any{"workspace_id": workspaceId}) + if err != nil { + return "", err + } + panes, _ := payload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if ref, _ := pane["ref"].(string); ref == handle { + if id, _ := pane["id"].(string); id != "" { + return id, nil + } + } + if id, _ := pane["id"].(string); id == handle { + return id, nil + } + } + return "", fmt.Errorf("pane not found: %s", handle) +} + +func tmuxFocusedPaneId(rc *rpcContext, workspaceId string) (string, error) { + payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) + if err != nil { + return "", err + } + if pid, ok := payload["pane_id"].(string); ok { + return pid, nil + } + if pref, ok := payload["pane_ref"].(string); ok { + return tmuxCanonicalPaneId(rc, pref, workspaceId) + } + return "", fmt.Errorf("pane not found") +} + +func tmuxWorkspaceIdForPaneHandle(rc *rpcContext, handle string) (string, error) { + if !isUUIDish(handle) { + return "", fmt.Errorf("not a UUID") + } + workspaces, err := tmuxWorkspaceItems(rc) + if err != nil { + return "", err + } + for _, ws := range workspaces { + wsId, _ := ws["id"].(string) + if wsId == "" { + continue + } + payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + continue + } + panes, _ := payload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == handle { + return wsId, nil + } + if pref, _ := pane["ref"].(string); pref == handle { + return wsId, nil + } + } + } + return "", fmt.Errorf("pane not found in any workspace") +} + +func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, err error) { + raw = strings.TrimSpace(raw) + paneSelector := tmuxPaneSelector(raw) + windowSelector := tmuxWindowSelector(raw) + + if windowSelector != "" { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, windowSelector) + if err != nil { + return "", "", err + } + } else if paneSelector != "" { + workspaceId, err = tmuxWorkspaceIdForPaneHandle(rc, paneSelector) + if err != nil { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") + if err != nil { + return "", "", err + } + } + } else { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") + if err != nil { + return "", "", err + } + } + + if paneSelector != "" { + paneId, err = tmuxCanonicalPaneId(rc, paneSelector, workspaceId) + if err != nil { + return "", "", err + } + } else if callerWs := tmuxCallerWorkspaceHandle(); callerWs == workspaceId { + if callerPane := tmuxCallerPaneHandle(); callerPane != "" { + if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil { + paneId = pid + } + } + } + + if paneId == "" { + paneId, err = tmuxFocusedPaneId(rc, workspaceId) + if err != nil { + return "", "", err + } + } + return workspaceId, paneId, nil +} + +func tmuxSelectedSurfaceId(rc *rpcContext, workspaceId string, paneId string) (string, error) { + payload, err := rc.call("pane.surfaces", map[string]any{"workspace_id": workspaceId, "pane_id": paneId}) + if err != nil { + return "", err + } + surfaces, _ := payload["surfaces"].([]any) + for _, s := range surfaces { + surface, _ := s.(map[string]any) + if surface == nil { + continue + } + if sel, _ := surface["selected"].(bool); sel { + if id, _ := surface["id"].(string); id != "" { + return id, nil + } + } + } + // Fall back to first surface + if len(surfaces) > 0 { + if surface, ok := surfaces[0].(map[string]any); ok { + if id, _ := surface["id"].(string); id != "" { + return id, nil + } + } + } + return "", fmt.Errorf("pane has no surface") +} + +func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, surfaceId string, err error) { + raw = strings.TrimSpace(raw) + + if tmuxPaneSelector(raw) != "" { + workspaceId, paneId, err = tmuxResolvePaneTarget(rc, raw) + if err != nil { + return "", "", "", err + } + // When target pane matches caller's pane, prefer caller's surface + callerPane := tmuxCallerPaneHandle() + callerSurface := tmuxCallerSurfaceHandle() + if callerPane != "" && callerSurface != "" { + canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId) + if paneId == callerPane || paneId == canonicalCallerPane { + surfaceId = callerSurface + return + } + } + surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId) + return + } + + winSel := tmuxWindowSelector(raw) + workspaceId, err = tmuxResolveWorkspaceTarget(rc, winSel) + if err != nil { + return "", "", "", err + } + + // When no explicit target and caller workspace matches, use caller's surface + if winSel == "" { + if callerWs := tmuxCallerWorkspaceHandle(); callerWs == workspaceId { + if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { + surfaceId = callerSurface + return + } + } + } + + // Fall back to focused surface + payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) + if err == nil { + if sid, ok := payload["surface_id"].(string); ok { + surfaceId = sid + return + } + } + + // Last resort: first surface in the workspace + surfPayload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) + if err == nil { + surfs, _ := surfPayload["surfaces"].([]any) + for _, s := range surfs { + surf, _ := s.(map[string]any) + if surf == nil { + continue + } + if focused, _ := surf["focused"].(bool); focused { + if id, _ := surf["id"].(string); id != "" { + surfaceId = id + return workspaceId, "", surfaceId, nil + } + } + } + if len(surfs) > 0 { + if surf, ok := surfs[0].(map[string]any); ok { + if id, _ := surf["id"].(string); id != "" { + surfaceId = id + return workspaceId, "", surfaceId, nil + } + } + } + } + + return "", "", "", fmt.Errorf("unable to resolve surface") +} + +// --- TmuxCompatStore (local JSON state) --- + +type mainVerticalState struct { + MainSurfaceId string `json:"mainSurfaceId"` + LastColumnSurfaceId string `json:"lastColumnSurfaceId,omitempty"` +} + +type tmuxCompatStore struct { + Buffers map[string]string `json:"buffers,omitempty"` + Hooks map[string]string `json:"hooks,omitempty"` + MainVerticalLayouts map[string]mainVerticalState `json:"mainVerticalLayouts,omitempty"` + LastSplitSurface map[string]string `json:"lastSplitSurface,omitempty"` +} + +func tmuxCompatStoreURL() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cmuxterm", "tmux-compat-store.json") +} + +func loadTmuxCompatStore() tmuxCompatStore { + data, err := os.ReadFile(tmuxCompatStoreURL()) + if err != nil { + return tmuxCompatStore{ + Buffers: make(map[string]string), + Hooks: make(map[string]string), + MainVerticalLayouts: make(map[string]mainVerticalState), + LastSplitSurface: make(map[string]string), + } + } + var store tmuxCompatStore + if err := json.Unmarshal(data, &store); err != nil { + return tmuxCompatStore{ + Buffers: make(map[string]string), + Hooks: make(map[string]string), + MainVerticalLayouts: make(map[string]mainVerticalState), + LastSplitSurface: make(map[string]string), + } + } + if store.Buffers == nil { + store.Buffers = make(map[string]string) + } + if store.Hooks == nil { + store.Hooks = make(map[string]string) + } + if store.MainVerticalLayouts == nil { + store.MainVerticalLayouts = make(map[string]mainVerticalState) + } + if store.LastSplitSurface == nil { + store.LastSplitSurface = make(map[string]string) + } + return store +} + +func saveTmuxCompatStore(store tmuxCompatStore) error { + path := tmuxCompatStoreURL() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.Marshal(store) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// --- Special key translation --- + +func tmuxSpecialKeyText(token string) string { + switch strings.ToLower(token) { + case "enter", "c-m", "kpenter": + return "\r" + case "tab", "c-i": + return "\t" + case "space": + return " " + case "bspace", "backspace": + return "\x7f" + case "escape", "esc", "c-[": + return "\x1b" + case "c-c": + return "\x03" + case "c-d": + return "\x04" + case "c-z": + return "\x1a" + case "c-l": + return "\x0c" + default: + return "" + } +} + +func tmuxSendKeysText(tokens []string, literal bool) string { + if literal { + return strings.Join(tokens, " ") + } + var result strings.Builder + pendingSpace := false + for _, token := range tokens { + if special := tmuxSpecialKeyText(token); special != "" { + result.WriteString(special) + pendingSpace = false + continue + } + if pendingSpace { + result.WriteByte(' ') + } + result.WriteString(token) + pendingSpace = true + } + return result.String() +} + +func tmuxShellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func tmuxShellCommandText(positional []string, cwd string) string { + cwd = strings.TrimSpace(cwd) + cmd := strings.TrimSpace(strings.Join(positional, " ")) + if cwd == "" && cmd == "" { + return "" + } + var pieces []string + if cwd != "" { + pieces = append(pieces, "cd -- "+tmuxShellQuote(cwd)) + } + if cmd != "" { + pieces = append(pieces, cmd) + } + return strings.Join(pieces, " && ") + "\r" +} + +// --- Wait-for (filesystem-based signaling) --- + +func tmuxWaitForSignalPath(name string) string { + var sanitized strings.Builder + for _, c := range name { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-' { + sanitized.WriteRune(c) + } else { + sanitized.WriteByte('_') + } + } + return fmt.Sprintf("/tmp/cmux-wait-for-%s.sig", sanitized.String()) +} + +// --- Main dispatch --- + +func dispatchTmuxCommand(rc *rpcContext, command string, args []string) error { + switch command { + case "-v", "-V": + fmt.Println("tmux 3.4") + return nil + + case "new-session", "new": + return tmuxNewSession(rc, args) + case "new-window", "neww": + return tmuxNewWindow(rc, args) + case "split-window", "splitw": + return tmuxSplitWindow(rc, args) + case "select-window", "selectw": + return tmuxSelectWindow(rc, args) + case "select-pane", "selectp": + return tmuxSelectPane(rc, args) + case "kill-window", "killw": + return tmuxKillWindow(rc, args) + case "kill-pane", "killp": + return tmuxKillPane(rc, args) + case "send-keys", "send": + return tmuxSendKeys(rc, args) + case "capture-pane", "capturep": + return tmuxCapturePane(rc, args) + case "display-message", "display", "displayp": + return tmuxDisplayMessage(rc, args) + case "list-windows", "lsw": + return tmuxListWindows(rc, args) + case "list-panes", "lsp": + return tmuxListPanes(rc, args) + case "rename-window", "renamew": + return tmuxRenameWindow(rc, args) + case "resize-pane", "resizep": + return tmuxResizePane(rc, args) + case "wait-for": + return tmuxWaitFor(rc, args) + case "last-pane": + return tmuxLastPane(rc, args) + case "has-session", "has": + return tmuxHasSession(rc, args) + case "select-layout": + return tmuxSelectLayout(rc, args) + case "show-buffer", "showb": + return tmuxShowBuffer(args) + case "save-buffer", "saveb": + return tmuxSaveBuffer(args) + + // No-ops + case "set-option", "set", "set-window-option", "setw", "source-file", + "refresh-client", "attach-session", "detach-client", + "last-window", "next-window", "previous-window", + "set-hook", "set-buffer", "list-buffers": + return nil + + default: + return fmt.Errorf("unsupported tmux command: %s", command) + } +} + +// --- Command implementations --- + +func tmuxNewSession(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-s"}, []string{"-A", "-d", "-P"}) + if p.hasFlag("-A") { + return fmt.Errorf("new-session -A is not supported") + } + params := map[string]any{"focus": false} + if cwd := p.value("-c"); cwd != "" { + params["cwd"] = cwd + } + created, err := rc.call("workspace.create", params) + if err != nil { + return err + } + wsId, _ := created["workspace_id"].(string) + if wsId == "" { + return fmt.Errorf("workspace.create did not return workspace_id") + } + if title := firstNonEmpty(p.value("-n"), p.value("-s")); strings.TrimSpace(title) != "" { + rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) + } + if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { + surfaceId, err := tmuxGetFirstSurface(rc, wsId) + if err == nil { + rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) + } + } + if p.hasFlag("-P") { + ctx, err := tmuxFormatContext(rc, wsId, "", "") + if err != nil { + fmt.Printf("@%s\n", wsId) + return nil + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) + } + return nil +} + +func tmuxNewWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-t"}, []string{"-d", "-P"}) + params := map[string]any{"focus": false} + if cwd := p.value("-c"); cwd != "" { + params["cwd"] = cwd + } + created, err := rc.call("workspace.create", params) + if err != nil { + return err + } + wsId, _ := created["workspace_id"].(string) + if wsId == "" { + return fmt.Errorf("workspace.create did not return workspace_id") + } + if title := p.value("-n"); strings.TrimSpace(title) != "" { + rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) + } + if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { + surfaceId, err := tmuxGetFirstSurface(rc, wsId) + if err == nil { + rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) + } + } + if p.hasFlag("-P") { + ctx, err := tmuxFormatContext(rc, wsId, "", "") + if err != nil { + fmt.Printf("@%s\n", wsId) + return nil + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) + } + return nil +} + +func tmuxSplitWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-l", "-t"}, []string{"-P", "-b", "-d", "-h", "-v"}) + + targetWs, _, targetSurface, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + + direction := "down" + if p.hasFlag("-h") { + direction = "right" + if p.hasFlag("-b") { + direction = "left" + } + } else if p.hasFlag("-b") { + direction = "up" + } + + // Anchor splits to the leader surface for agent teams + callerSurface := tmuxCallerSurfaceHandle() + callerWorkspace := tmuxCallerWorkspaceHandle() + if callerSurface != "" && callerWorkspace != "" { + if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil { + store := loadTmuxCompatStore() + if mvState, ok := store.MainVerticalLayouts[wsId]; ok && mvState.LastColumnSurfaceId != "" { + targetWs = wsId + targetSurface = mvState.LastColumnSurfaceId + direction = "down" + } else { + targetWs = wsId + targetSurface = callerSurface + direction = "right" + } + } + } + + focusNewPane := !p.hasFlag("-d") + created, err := rc.call("surface.split", map[string]any{ + "workspace_id": targetWs, + "surface_id": targetSurface, + "direction": direction, + "focus": focusNewPane, + }) + if err != nil { + return err + } + surfaceId, _ := created["surface_id"].(string) + if surfaceId == "" { + return fmt.Errorf("surface.split did not return surface_id") + } + newPaneId, _ := created["pane_id"].(string) + + // Track for main-vertical layout + store := loadTmuxCompatStore() + store.LastSplitSurface[targetWs] = surfaceId + if _, ok := store.MainVerticalLayouts[targetWs]; ok { + mvs := store.MainVerticalLayouts[targetWs] + mvs.LastColumnSurfaceId = surfaceId + store.MainVerticalLayouts[targetWs] = mvs + } else if direction == "right" && callerSurface != "" { + store.MainVerticalLayouts[targetWs] = mainVerticalState{ + MainSurfaceId: callerSurface, + LastColumnSurfaceId: surfaceId, + } + } + saveTmuxCompatStore(store) + + // Equalize vertical splits + rc.call("workspace.equalize_splits", map[string]any{ + "workspace_id": targetWs, + "orientation": "vertical", + }) + + if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { + rc.call("surface.send_text", map[string]any{ + "workspace_id": targetWs, + "surface_id": surfaceId, + "text": text, + }) + } + + if p.hasFlag("-P") { + ctx, err := tmuxFormatContext(rc, targetWs, newPaneId, surfaceId) + if err != nil { + fmt.Println(surfaceId) + return nil + } + fallback := surfaceId + if pid, ok := ctx["pane_id"]; ok { + fallback = pid + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxSelectWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.select", map[string]any{"workspace_id": wsId}) + return err +} + +func tmuxSelectPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-P", "-T", "-t"}, nil) + // -P (style) and -T (title) are no-ops + if p.value("-P") != "" || p.value("-T") != "" { + return nil + } + wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("pane.focus", map[string]any{"workspace_id": wsId, "pane_id": paneId}) + return err +} + +func tmuxKillWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId}) + return err +} + +func tmuxKillPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("surface.close", map[string]any{"workspace_id": wsId, "surface_id": surfId}) + if err != nil { + return err + } + // Re-equalize after removal + rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId, "orientation": "vertical"}) + return nil +} + +func tmuxSendKeys(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, []string{"-l"}) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + text := tmuxSendKeysText(p.positional, p.hasFlag("-l")) + if text != "" { + _, err = rc.call("surface.send_text", map[string]any{ + "workspace_id": wsId, + "surface_id": surfId, + "text": text, + }) + } + return err +} + +func tmuxCapturePane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-E", "-S", "-t"}, []string{"-J", "-N", "-p"}) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + params := map[string]any{ + "workspace_id": wsId, + "surface_id": surfId, + "scrollback": true, + } + if start := p.value("-S"); start != "" { + if lines := parseInt(start); lines < 0 { + params["lines"] = int(math.Abs(float64(lines))) + } + } + payload, err := rc.call("surface.read_text", params) + if err != nil { + return err + } + text, _ := payload["text"].(string) + if p.hasFlag("-p") { + fmt.Print(text) + } else { + store := loadTmuxCompatStore() + store.Buffers["default"] = text + saveTmuxCompatStore(store) + } + return nil +} + +func tmuxDisplayMessage(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, []string{"-p"}) + wsId, paneId, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + ctx, err := tmuxFormatContext(rc, wsId, paneId, surfId) + if err != nil { + ctx = map[string]string{} + } + + // Enrich with geometry + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err == nil { + panes, _ := panePayload["panes"].([]any) + containerFrame, _ := panePayload["container_frame"].(map[string]any) + var matchingPane map[string]any + if paneId != "" { + for _, p := range panes { + pn, _ := p.(map[string]any) + if pid, _ := pn["id"].(string); pid == paneId { + matchingPane = pn + break + } + } + } + if matchingPane == nil { + for _, p := range panes { + pn, _ := p.(map[string]any) + if focused, _ := pn["focused"].(bool); focused { + matchingPane = pn + break + } + } + } + if matchingPane == nil && len(panes) > 0 { + matchingPane, _ = panes[0].(map[string]any) + } + if matchingPane != nil { + tmuxEnrichContextWithGeometry(ctx, matchingPane, containerFrame) + } + } + + format := p.value("-F") + if len(p.positional) > 0 { + format = strings.Join(p.positional, " ") + } + rendered := tmuxRenderFormat(format, ctx, "") + if p.hasFlag("-p") || rendered != "" { + fmt.Println(rendered) + } + return nil +} + +func tmuxListWindows(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) + items, err := tmuxWorkspaceItems(rc) + if err != nil { + return err + } + for _, item := range items { + wsId, _ := item["id"].(string) + if wsId == "" { + continue + } + ctx, err := tmuxFormatContext(rc, wsId, "", "") + if err != nil { + continue + } + fallback := "" + if idx, ok := ctx["window_index"]; ok { + fallback = idx + } else { + fallback = "?" + } + if name, ok := ctx["window_name"]; ok { + fallback += " " + name + } else { + fallback += " " + wsId + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxListPanes(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) + + target := p.value("-t") + var wsId string + var err error + + if target != "" && tmuxPaneSelector(target) != "" { + wsId, _, err = tmuxResolvePaneTarget(rc, target) + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, target) + } + if err != nil { + return err + } + + payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + return err + } + panes, _ := payload["panes"].([]any) + containerFrame, _ := payload["container_frame"].(map[string]any) + + for _, p2 := range panes { + pane, _ := p2.(map[string]any) + if pane == nil { + continue + } + paneId, _ := pane["id"].(string) + if paneId == "" { + continue + } + ctx, err := tmuxFormatContext(rc, wsId, paneId, "") + if err != nil { + continue + } + tmuxEnrichContextWithGeometry(ctx, pane, containerFrame) + fallback := "%" + paneId + if pid, ok := ctx["pane_id"]; ok { + fallback = pid + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxRenameWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + title := strings.TrimSpace(strings.Join(p.positional, " ")) + if title == "" { + return fmt.Errorf("rename-window requires a title") + } + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) + return err +} + +func tmuxResizePane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t", "-x", "-y"}, []string{"-D", "-L", "-R", "-U"}) + wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) + if err != nil { + return err + } + + hasDirectional := p.hasFlag("-L") || p.hasFlag("-R") || p.hasFlag("-U") || p.hasFlag("-D") + + if !hasDirectional { + if absWidthStr := p.value("-x"); absWidthStr != "" { + absWidth := parseInt(strings.ReplaceAll(absWidthStr, "%", "")) + // Get current width to compute delta + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + return err + } + panes, _ := panePayload["panes"].([]any) + for _, pp := range panes { + pane, _ := pp.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == paneId { + cellW := intFromAnyGo(pane["cell_width_px"]) + currentCols := intFromAnyGo(pane["columns"]) + if cellW > 0 && currentCols >= 0 { + delta := absWidth - currentCols + if delta != 0 { + dir := "right" + if delta < 0 { + dir = "left" + delta = -delta + } + rc.call("pane.resize", map[string]any{ + "workspace_id": wsId, + "pane_id": paneId, + "direction": dir, + "amount": delta * cellW, + }) + } + } + break + } + } + return nil + } + } + + if hasDirectional { + dir := "right" + if p.hasFlag("-L") { + dir = "left" + } else if p.hasFlag("-U") { + dir = "up" + } else if p.hasFlag("-D") { + dir = "down" + } + rawAmount := firstNonEmpty(p.value("-x"), p.value("-y"), "5") + rawAmount = strings.ReplaceAll(rawAmount, "%", "") + amount := parseInt(rawAmount) + if amount <= 0 { + amount = 5 + } + _, err := rc.call("pane.resize", map[string]any{ + "workspace_id": wsId, + "pane_id": paneId, + "direction": dir, + "amount": amount, + }) + return err + } + return nil +} + +func tmuxWaitFor(_ *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"--timeout"}, []string{"-S"}) + name := "" + for _, pos := range p.positional { + if !strings.HasPrefix(pos, "-") { + name = pos + break + } + } + if name == "" { + return fmt.Errorf("wait-for requires a name") + } + + signalPath := tmuxWaitForSignalPath(name) + + if p.hasFlag("-S") { + // Signal mode: create the file + os.WriteFile(signalPath, []byte{}, 0644) + fmt.Println("OK") + return nil + } + + // Wait mode: poll for the file + timeoutStr := p.value("--timeout") + timeout := 30.0 + if timeoutStr != "" { + if t := parseFloat(timeoutStr); t > 0 { + timeout = t + } + } + + deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) + for time.Now().Before(deadline) { + if _, err := os.Stat(signalPath); err == nil { + os.Remove(signalPath) + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("wait-for timeout: %s", name) +} + +func tmuxLastPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("pane.last", map[string]any{"workspace_id": wsId}) + return err +} + +func tmuxHasSession(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + _, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + return err +} + +func tmuxSelectLayout(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + layoutName := "" + if len(p.positional) > 0 { + layoutName = p.positional[0] + } + + // Resolve workspace from target (may be a pane reference) + var wsId string + var err error + if target := p.value("-t"); target != "" { + if tmuxPaneSelector(target) != "" { + wsId, _, err = tmuxResolvePaneTarget(rc, target) + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, target) + } + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, "") + } + if err != nil { + return err + } + + if layoutName == "main-vertical" || layoutName == "main-horizontal" { + orientation := "vertical" + if layoutName == "main-horizontal" { + orientation = "horizontal" + } + rc.call("workspace.equalize_splits", map[string]any{ + "workspace_id": wsId, + "orientation": orientation, + }) + } else { + rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId}) + } + + if layoutName == "main-vertical" { + if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { + store := loadTmuxCompatStore() + existingColumn := "" + if existing, ok := store.MainVerticalLayouts[wsId]; ok { + existingColumn = existing.LastColumnSurfaceId + } + seedColumn := existingColumn + if seedColumn == "" { + seedColumn = store.LastSplitSurface[wsId] + } + store.MainVerticalLayouts[wsId] = mainVerticalState{ + MainSurfaceId: callerSurface, + LastColumnSurfaceId: seedColumn, + } + saveTmuxCompatStore(store) + } + } else if layoutName != "" { + store := loadTmuxCompatStore() + changed := false + if _, ok := store.MainVerticalLayouts[wsId]; ok { + delete(store.MainVerticalLayouts, wsId) + changed = true + } + if _, ok := store.LastSplitSurface[wsId]; ok { + delete(store.LastSplitSurface, wsId) + changed = true + } + if changed { + saveTmuxCompatStore(store) + } + } + + return nil +} + +func tmuxShowBuffer(args []string) error { + p := parseTmuxArgs(args, []string{"-b"}, nil) + name := p.value("-b") + if name == "" { + name = "default" + } + store := loadTmuxCompatStore() + if buf, ok := store.Buffers[name]; ok { + fmt.Print(buf) + } + return nil +} + +func tmuxSaveBuffer(args []string) error { + p := parseTmuxArgs(args, []string{"-b"}, nil) + name := p.value("-b") + if name == "" { + name = "default" + } + store := loadTmuxCompatStore() + buf, ok := store.Buffers[name] + if !ok { + return fmt.Errorf("buffer not found: %s", name) + } + if len(p.positional) > 0 { + outputPath := strings.TrimSpace(p.positional[len(p.positional)-1]) + if outputPath != "" { + return os.WriteFile(outputPath, []byte(buf), 0644) + } + } + fmt.Print(buf) + return nil +} + +// --- Helpers --- + +func tmuxGetFirstSurface(rc *rpcContext, workspaceId string) (string, error) { + payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) + if err != nil { + return "", err + } + surfaces, _ := payload["surfaces"].([]any) + if len(surfaces) == 0 { + return "", fmt.Errorf("workspace has no surfaces") + } + // Prefer focused surface + for _, s := range surfaces { + surf, _ := s.(map[string]any) + if focused, _ := surf["focused"].(bool); focused { + if id, _ := surf["id"].(string); id != "" { + return id, nil + } + } + } + if surf, ok := surfaces[0].(map[string]any); ok { + if id, _ := surf["id"].(string); id != "" { + return id, nil + } + } + return "", fmt.Errorf("workspace has no surfaces") +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func parseInt(s string) int { + s = strings.TrimSpace(s) + var n int + fmt.Sscanf(s, "%d", &n) + return n +} + +func parseFloat(s string) float64 { + s = strings.TrimSpace(s) + var f float64 + fmt.Sscanf(s, "%f", &f) + return f +} diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go new file mode 100644 index 00000000..70070786 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -0,0 +1,431 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSplitTmuxCmd(t *testing.T) { + tests := []struct { + name string + args []string + wantCmd string + wantN int // expected number of remaining args + }{ + {"simple", []string{"list-panes", "-t", "%abc"}, "list-panes", 2}, + {"version flag", []string{"-V"}, "-V", 0}, + {"with global flags", []string{"-L", "foo", "split-window", "-h"}, "split-window", 1}, + {"case insensitive", []string{"Display-Message", "-p"}, "display-message", 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, args, err := splitTmuxCmd(tt.args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd != tt.wantCmd { + t.Errorf("command = %q, want %q", cmd, tt.wantCmd) + } + if len(args) != tt.wantN { + t.Errorf("args count = %d, want %d", len(args), tt.wantN) + } + }) + } +} + +func TestParseTmuxArgs(t *testing.T) { + p := parseTmuxArgs( + []string{"-dP", "-t", "%abc", "-F", "#{pane_id}", "shell", "cmd"}, + []string{"-t", "-F"}, + []string{"-d", "-P"}, + ) + if !p.hasFlag("-d") { + t.Error("expected -d flag") + } + if !p.hasFlag("-P") { + t.Error("expected -P flag") + } + if p.value("-t") != "%abc" { + t.Errorf("target = %q, want %%abc", p.value("-t")) + } + if p.value("-F") != "#{pane_id}" { + t.Errorf("format = %q, want #{pane_id}", p.value("-F")) + } + if len(p.positional) != 2 || p.positional[0] != "shell" { + t.Errorf("positional = %v, want [shell cmd]", p.positional) + } +} + +func TestParseTmuxArgsClusteredValueFlag(t *testing.T) { + // -t%abc should parse -t with value "%abc" + p := parseTmuxArgs([]string{"-t%abc"}, []string{"-t"}, nil) + if p.value("-t") != "%abc" { + t.Errorf("target = %q, want %%abc", p.value("-t")) + } +} + +func TestTmuxRenderFormat(t *testing.T) { + ctx := map[string]string{ + "pane_id": "%abc123", + "pane_width": "80", + "window_id": "@ws1", + } + + tests := []struct { + format string + fallback string + want string + }{ + {"#{pane_id}", "fallback", "%abc123"}, + {"#{pane_id}:#{pane_width}", "", "%abc123:80"}, + {"#{unknown_var}", "fallback", "fallback"}, + {"", "fallback", "fallback"}, + {"#{pane_id} #{pane_width} #{window_id}", "", "%abc123 80 @ws1"}, + } + for _, tt := range tests { + got := tmuxRenderFormat(tt.format, ctx, tt.fallback) + if got != tt.want { + t.Errorf("tmuxRenderFormat(%q) = %q, want %q", tt.format, got, tt.want) + } + } +} + +func TestTmuxSendKeysText(t *testing.T) { + tests := []struct { + name string + tokens []string + literal bool + want string + }{ + {"literal", []string{"hello", "world"}, true, "hello world"}, + {"special enter", []string{"echo", "hello", "Enter"}, false, "echo hello\r"}, + {"special ctrl-c", []string{"C-c"}, false, "\x03"}, + {"mixed", []string{"ls", "-la", "Enter"}, false, "ls -la\r"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tmuxSendKeysText(tt.tokens, tt.literal) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestTmuxShellCommandText(t *testing.T) { + tests := []struct { + positional []string + cwd string + want string + }{ + {[]string{"echo hi"}, "", "echo hi\r"}, + {nil, "/tmp", "cd -- '/tmp'\r"}, + {[]string{"make"}, "/home/user", "cd -- '/home/user' && make\r"}, + {nil, "", ""}, + } + for _, tt := range tests { + got := tmuxShellCommandText(tt.positional, tt.cwd) + if got != tt.want { + t.Errorf("tmuxShellCommandText(%v, %q) = %q, want %q", tt.positional, tt.cwd, got, tt.want) + } + } +} + +func TestTmuxWaitForSignalPath(t *testing.T) { + path := tmuxWaitForSignalPath("test-signal") + if !strings.HasPrefix(path, "/tmp/cmux-wait-for-") { + t.Errorf("unexpected path prefix: %s", path) + } + if !strings.HasSuffix(path, ".sig") { + t.Errorf("unexpected path suffix: %s", path) + } +} + +func TestTmuxCompatStoreRoundTrip(t *testing.T) { + // Use a temp dir for the store + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + store := loadTmuxCompatStore() + store.Buffers["test"] = "captured text" + store.MainVerticalLayouts["ws1"] = mainVerticalState{ + MainSurfaceId: "surface-main", + LastColumnSurfaceId: "surface-col", + } + if err := saveTmuxCompatStore(store); err != nil { + t.Fatalf("save: %v", err) + } + + loaded := loadTmuxCompatStore() + if loaded.Buffers["test"] != "captured text" { + t.Errorf("buffer = %q, want %q", loaded.Buffers["test"], "captured text") + } + if mvs, ok := loaded.MainVerticalLayouts["ws1"]; !ok { + t.Error("missing main vertical layout for ws1") + } else if mvs.LastColumnSurfaceId != "surface-col" { + t.Errorf("lastColumnSurfaceId = %q, want %q", mvs.LastColumnSurfaceId, "surface-col") + } +} + +func TestTmuxVersion(t *testing.T) { + output := captureStdout(t, func() { + dispatchTmuxCommand(nil, "-v", nil) + }) + if strings.TrimSpace(output) != "tmux 3.4" { + t.Errorf("version = %q, want %q", strings.TrimSpace(output), "tmux 3.4") + } +} + +func TestTmuxNoOps(t *testing.T) { + noOps := []string{ + "set-option", "set", "set-window-option", "setw", + "source-file", "refresh-client", "attach-session", "detach-client", + "last-window", "next-window", "previous-window", + "set-hook", "set-buffer", "list-buffers", + } + for _, cmd := range noOps { + t.Run(cmd, func(t *testing.T) { + if err := dispatchTmuxCommand(nil, cmd, nil); err != nil { + t.Errorf("no-op %q returned error: %v", cmd, err) + } + }) + } +} + +func TestTmuxUnsupportedCommand(t *testing.T) { + err := dispatchTmuxCommand(nil, "some-unknown-cmd", nil) + if err == nil { + t.Error("expected error for unknown command") + } + if !strings.Contains(err.Error(), "unsupported") { + t.Errorf("error = %q, want to contain 'unsupported'", err.Error()) + } +} + +func TestIsUUIDish(t *testing.T) { + if !isUUIDish("D88CE676-0A95-4DDA-AD94-E535B0D966DF") { + t.Error("expected UUID to be detected") + } + if !isUUIDish("d88ce676-0a95-4dda-ad94-e535b0d966df") { + t.Error("expected lowercase UUID to be detected") + } + if isUUIDish("not-a-uuid") { + t.Error("expected non-UUID to be rejected") + } +} + +func TestTmuxPaneSelector(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"%abc123", "abc123"}, + {"pane:test", "pane:test"}, + {"@ws1.%pane2", "%pane2"}, + {"@ws1", ""}, + {"", ""}, + } + for _, tt := range tests { + got := tmuxPaneSelector(tt.input) + if got != tt.want { + t.Errorf("tmuxPaneSelector(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestTmuxWindowSelector(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"%abc123", ""}, + {"pane:test", ""}, + {"@ws1.%pane2", "@ws1"}, + {"@ws1", "@ws1"}, + {"", ""}, + } + for _, tt := range tests { + got := tmuxWindowSelector(tt.input) + if got != tt.want { + t.Errorf("tmuxWindowSelector(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestCreateTmuxShimDir(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + dir, err := createTmuxShimDir("test-shim-bin", claudeTeamsShimScript) + if err != nil { + t.Fatalf("createTmuxShimDir: %v", err) + } + tmuxPath := filepath.Join(dir, "tmux") + info, err := os.Stat(tmuxPath) + if err != nil { + t.Fatalf("tmux shim not found: %v", err) + } + if info.Mode()&0111 == 0 { + t.Error("tmux shim is not executable") + } + content, _ := os.ReadFile(tmuxPath) + if !strings.Contains(string(content), "__tmux-compat") { + t.Error("shim script should reference __tmux-compat") + } +} + +func TestCreateOMOShimDir(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + dir, err := createOMOShimDir() + if err != nil { + t.Fatalf("createOMOShimDir: %v", err) + } + // Check tmux shim exists + tmuxPath := filepath.Join(dir, "tmux") + if _, err := os.Stat(tmuxPath); err != nil { + t.Fatalf("tmux shim not found: %v", err) + } + // Check terminal-notifier shim exists + notifierPath := filepath.Join(dir, "terminal-notifier") + if _, err := os.Stat(notifierPath); err != nil { + t.Fatalf("terminal-notifier shim not found: %v", err) + } +} + +func TestConfigureAgentEnvironment(t *testing.T) { + // Save and restore env vars + envKeys := []string{ + "CMUX_CLAUDE_TEAMS_CMUX_BIN", "PATH", "TMUX", "TMUX_PANE", + "TERM", "CMUX_SOCKET_PATH", "CMUX_SOCKET", "TERM_PROGRAM", + "CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", + } + saved := make(map[string]string) + for _, k := range envKeys { + saved[k] = os.Getenv(k) + } + defer func() { + for k, v := range saved { + if v != "" { + os.Setenv(k, v) + } else { + os.Unsetenv(k) + } + } + }() + + os.Setenv("TERM_PROGRAM", "should-be-removed") + + configureAgentEnvironment(agentConfig{ + shimDir: "/tmp/test-shim", + socketPath: "127.0.0.1:54321", + focused: &focusedContext{ + workspaceId: "ws-abc", + windowId: "win-123", + paneHandle: "pane-456", + surfaceId: "surf-789", + }, + tmuxPathPrefix: "cmux-claude-teams", + cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", + termEnvVar: "CMUX_CLAUDE_TEAMS_TERM", + extraEnv: map[string]string{ + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", + }, + }) + + // Verify PATH was prepended + if !strings.HasPrefix(os.Getenv("PATH"), "/tmp/test-shim:") { + t.Error("PATH should start with shim dir") + } + // Verify TMUX is set with focused context + tmux := os.Getenv("TMUX") + if !strings.Contains(tmux, "ws-abc") { + t.Errorf("TMUX = %q, should contain workspace ID", tmux) + } + // Verify TMUX_PANE + if os.Getenv("TMUX_PANE") != "%pane-456" { + t.Errorf("TMUX_PANE = %q, want %%pane-456", os.Getenv("TMUX_PANE")) + } + // Verify socket path + if os.Getenv("CMUX_SOCKET_PATH") != "127.0.0.1:54321" { + t.Errorf("CMUX_SOCKET_PATH = %q", os.Getenv("CMUX_SOCKET_PATH")) + } + // Verify COLORTERM is set for truecolor support + if os.Getenv("COLORTERM") != "truecolor" { + t.Errorf("COLORTERM = %q, want truecolor", os.Getenv("COLORTERM")) + } + // Verify workspace/surface IDs + if os.Getenv("CMUX_WORKSPACE_ID") != "ws-abc" { + t.Errorf("CMUX_WORKSPACE_ID = %q", os.Getenv("CMUX_WORKSPACE_ID")) + } + if os.Getenv("CMUX_SURFACE_ID") != "surf-789" { + t.Errorf("CMUX_SURFACE_ID = %q", os.Getenv("CMUX_SURFACE_ID")) + } + // Verify extra env + if os.Getenv("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS") != "1" { + t.Error("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS should be 1") + } +} + +func TestClaudeTeamsLaunchArgs(t *testing.T) { + // Should prepend --teammate-mode auto + args := claudeTeamsLaunchArgs([]string{"--verbose"}) + if args[0] != "--teammate-mode" || args[1] != "auto" || args[2] != "--verbose" { + t.Errorf("args = %v, want [--teammate-mode auto --verbose]", args) + } + + // Should not duplicate if already present + args = claudeTeamsLaunchArgs([]string{"--teammate-mode", "off"}) + if args[0] != "--teammate-mode" || args[1] != "off" { + t.Errorf("args = %v, should not prepend when already present", args) + } +} + +func TestTmuxWaitForSignalRoundTrip(t *testing.T) { + name := "test-roundtrip-" + randomHex(4) + path := tmuxWaitForSignalPath(name) + defer os.Remove(path) + + // Signal creates the file + dispatchTmuxCommand(nil, "wait-for", []string{"-S", name}) + if _, err := os.Stat(path); err != nil { + t.Fatalf("signal file not created: %v", err) + } + + // Wait consumes the file + err := dispatchTmuxCommand(nil, "wait-for", []string{name}) + if err != nil { + t.Fatalf("wait-for should succeed: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("signal file should be removed after wait") + } +} + +func TestTmuxShowBuffer(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + store := loadTmuxCompatStore() + store.Buffers["default"] = "hello world" + saveTmuxCompatStore(store) + + output := captureStdout(t, func() { + tmuxShowBuffer(nil) + }) + if strings.TrimSpace(output) != "hello world" { + t.Errorf("output = %q, want %q", output, "hello world") + } +}