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") + } +}