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 <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-27 21:04:24 -07:00 committed by GitHub
parent 2d51c14ba1
commit 27fa3873be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2711 additions and 0 deletions

View file

@ -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...)
}

View file

@ -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 <sub> 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 <method> [json-params] Send arbitrary JSON-RPC")
}

File diff suppressed because it is too large Load diff

View file

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