From bd8d1e0f8a80272c32eb7b7458d39f1f06ead290 Mon Sep 17 00:00:00 2001 From: Patrick Marsceill Date: Fri, 23 Jan 2026 16:08:49 -0500 Subject: [PATCH] feat(daemon): add Zellij as alternative terminal multiplexer Add support for Zellij as an alternative to tmux for managing agent sessions. Users can configure their preferred multiplexer via config file, environment variable (MAP_MULTIPLEXER), or it defaults to tmux. Changes: - Create Multiplexer interface abstracting session management operations - Implement TmuxMultiplexer with existing tmux functionality - Implement ZellijMultiplexer with Zellij CLI commands - Refactor ProcessManager to use Multiplexer interface - Add 'multiplexer' config option to Viper defaults - Update CLI commands (watch, clean, up) to support both multiplexers - Add multiplexer field to proto messages for status reporting Closes #14 Co-Authored-By: Claude Opus 4.5 --- README.md | 39 ++++- internal/cli/agent_watch.go | 88 +++++----- internal/cli/clean.go | 37 +++-- internal/cli/config.go | 1 + internal/cli/root.go | 6 + internal/cli/up.go | 16 +- internal/daemon/multiplexer.go | 73 +++++++++ internal/daemon/multiplexer_tmux.go | 159 ++++++++++++++++++ internal/daemon/multiplexer_zellij.go | 143 ++++++++++++++++ internal/daemon/process.go | 226 +++++++++++--------------- internal/daemon/process_test.go | 38 ++++- internal/daemon/server.go | 38 ++++- proto/map/v1/daemon.pb.go | 34 +++- proto/map/v1/daemon.proto | 4 + 14 files changed, 686 insertions(+), 216 deletions(-) create mode 100644 internal/daemon/multiplexer.go create mode 100644 internal/daemon/multiplexer_tmux.go create mode 100644 internal/daemon/multiplexer_zellij.go diff --git a/README.md b/README.md index 12238d1..1e894a2 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,15 @@ MAP requires the following tools to be installed and available in your PATH: | Dependency | Required For | Version | |------------|--------------|---------| | **git** | Worktree isolation | 2.15+ (worktree support) | -| **tmux** | Agent session management | Any recent version | +| **tmux** | Agent session management (default) | Any recent version | +| **zellij** | Agent session management (alternative) | Any recent version | | **claude** | Claude Code agents | Latest (optional if only using Codex) | | **codex** | OpenAI Codex agents | Latest (optional if only using Claude) | At least one of `claude` or `codex` must be installed depending on which agent type you want to use. +**Terminal Multiplexer:** MAP supports both tmux (default) and Zellij for managing agent sessions. Only one is required. See [Multiplexer Configuration](#multiplexer-configuration) for details. + **Installing Dependencies:**
@@ -158,7 +161,7 @@ This creates two binaries in `bin/`: |---------|-------------| | `map up [-f]` | Start the daemon (foreground with -f) | | `map down [-f]` | Stop the daemon (force immediate shutdown with -f) | -| `map clean` | Clean up orphaned processes, tmux sessions, and socket files | +| `map clean` | Clean up orphaned processes, multiplexer sessions, and socket files | | `map watch` | Stream real-time events from the daemon | | `map config list` | List all configuration values | | `map config get ` | Get a configuration value | @@ -173,8 +176,8 @@ This creates two binaries in `bin/`: | `map agent list` | List spawned agents (alias: `ls`, same as `map agents`) | | `map agent kill ` | Terminate a spawned agent | | `map agent kill --all` | Terminate all spawned agents | -| `map agent watch [id]` | Attach to agent's tmux session | -| `map agent watch -a` | Watch all agents in tiled tmux view | +| `map agent watch [id]` | Attach to agent's terminal session | +| `map agent watch -a` | Watch all agents in tiled view (tmux only) | | `map agent respawn ` | Restart agent in dead tmux pane | | `map agent merge ` | Merge agent's worktree changes into current branch | | `map agent merge -k` | Merge agent's changes and kill the agent | @@ -421,7 +424,7 @@ Events include task lifecycle changes (created, offered, accepted, started, comp ▼ ▼ ┌───────────────────┐ ┌─────────────────────┐ │ ~/.mapd/worktrees │ │ claude/codex CLI │ - │ claude-abc123/ │◄─────── cwd ─────────│ (tmux session) │ + │ claude-abc123/ │◄─────── cwd ─────────│ (tmux/zellij session)│ │ codex-def456/ │ └─────────────────────┘ └───────────────────┘ ``` @@ -466,6 +469,7 @@ MAP supports persistent configuration via a YAML file at `~/.mapd/config.yaml`. # ~/.mapd/config.yaml socket: /tmp/mapd.sock data-dir: ~/.mapd +multiplexer: tmux # tmux or zellij agent: default-type: claude # claude or codex @@ -481,18 +485,43 @@ agent: |-----|---------|-------------| | `socket` | `/tmp/mapd.sock` | Unix socket path for daemon communication | | `data-dir` | `~/.mapd` | Data directory for SQLite and worktrees | +| `multiplexer` | `tmux` | Terminal multiplexer for agent sessions (`tmux` or `zellij`) | | `agent.default-type` | `claude` | Default agent type (`claude` or `codex`) | | `agent.default-count` | `1` | Default number of agents to spawn | | `agent.default-branch` | `""` | Default git branch for worktrees (empty = current branch) | | `agent.use-worktree` | `true` | Use worktree isolation by default | | `agent.skip-permissions` | `true` | Skip permission prompts by default | +### Multiplexer Configuration + +MAP supports both **tmux** (default) and **Zellij** as terminal multiplexers for managing agent sessions. You can switch between them via: + +```bash +# Set via config command +map config set multiplexer zellij + +# Or via environment variable +export MAP_MULTIPLEXER=zellij +map up +``` + +**Keyboard shortcuts by multiplexer:** + +| Action | tmux | Zellij | +|--------|------|--------| +| Detach from session | `Ctrl+B d` | `Ctrl+O d` | +| Navigate panes | `Ctrl+B arrow` | `Alt+arrow` | +| Next/prev pane | `Ctrl+B n/p` | `Alt+n/p` | + +**Note:** The `--all` flag for `map agent watch` (tiled multi-agent view) is currently only supported with tmux. + ### Environment Variables All configuration options can be set via environment variables with the `MAP_` prefix. Nested keys use underscores: ```bash export MAP_SOCKET=/custom/path.sock +export MAP_MULTIPLEXER=zellij export MAP_AGENT_DEFAULT_TYPE=codex export MAP_AGENT_DEFAULT_COUNT=3 ``` diff --git a/internal/cli/agent_watch.go b/internal/cli/agent_watch.go index 9d86ae2..269ea97 100644 --- a/internal/cli/agent_watch.go +++ b/internal/cli/agent_watch.go @@ -10,25 +10,31 @@ import ( "time" "github.com/pmarsceill/mapcli/internal/client" + "github.com/pmarsceill/mapcli/internal/daemon" mapv1 "github.com/pmarsceill/mapcli/proto/map/v1" "github.com/spf13/cobra" ) var agentWatchCmd = &cobra.Command{ Use: "watch [agent-id]", - Short: "Attach to an agent's tmux session", - Long: `Attach to a spawned agent's tmux session for full interactivity. + Short: "Attach to an agent's terminal session", + Long: `Attach to a spawned agent's terminal multiplexer session for full interactivity. You can accept tools, approve changes, and interact with Claude directly. -Use standard tmux controls: + +For tmux (default): - Ctrl+B d Detach from session (keeps agent running) - Ctrl+B n Next session (if multiple agents) - Ctrl+B p Previous session - Ctrl+B s List all sessions +For Zellij (when multiplexer=zellij): + - Ctrl+O d Detach from session + - Alt+n/p Next/previous pane + If no agent-id is specified, attaches to the first available agent. -Use --all to view multiple agents in a tiled tmux layout (up to 6 agents, 3 per row).`, +Use --all to view multiple agents in a tiled layout (up to 6 agents, tmux only).`, RunE: runAgentWatch, } @@ -40,9 +46,14 @@ func init() { } func runAgentWatch(cmd *cobra.Command, args []string) error { - // Check if tmux is available - if _, err := exec.LookPath("tmux"); err != nil { - return fmt.Errorf("tmux not found in PATH - required for agent watch") + // Detect multiplexer type from config + muxType := daemon.MultiplexerType(getMultiplexer()) + + // Check if the multiplexer is available + muxBinary := string(muxType) + muxPath, err := exec.LookPath(muxBinary) + if err != nil { + return fmt.Errorf("%s not found in PATH - required for agent watch", muxBinary) } c, err := client.New(getSocketPath()) @@ -64,8 +75,11 @@ func runAgentWatch(cmd *cobra.Command, args []string) error { return fmt.Errorf("no spawned agents found - create one with 'map agent create'") } - // Handle --all flag for tiled view + // Handle --all flag for tiled view (tmux only for now) if watchAllFlag { + if muxType != daemon.MultiplexerTmux { + return fmt.Errorf("--all flag is only supported with tmux multiplexer") + } return runAgentWatchAll(agents) } @@ -79,7 +93,7 @@ func runAgentWatch(cmd *cobra.Command, args []string) error { for _, a := range agents { if a.GetAgentId() == targetID || strings.HasPrefix(a.GetAgentId(), targetID) { targetAgent = a.GetAgentId() - targetSession = a.GetLogFile() // LogFile field repurposed to hold tmux session name + targetSession = a.GetLogFile() // LogFile field repurposed to hold session name break } } @@ -92,17 +106,19 @@ func runAgentWatch(cmd *cobra.Command, args []string) error { targetSession = agents[0].GetLogFile() } - // Verify tmux session exists - checkCmd := exec.Command("tmux", "has-session", "-t", targetSession) - if err := checkCmd.Run(); err != nil { - return fmt.Errorf("tmux session %s not found - agent may have crashed", targetSession) + // Create multiplexer instance for session operations + mux, err := daemon.NewMultiplexer(muxType) + if err != nil { + return fmt.Errorf("init multiplexer: %w", err) } - // Enable mouse mode for scrolling - _ = exec.Command("tmux", "set-option", "-t", targetSession, "mouse", "on").Run() + // Verify session exists + if !mux.HasSession(targetSession) { + return fmt.Errorf("%s session %s not found - agent may have crashed", muxType, targetSession) + } - // Check if the pane is dead (claude exited but session preserved) - if isPaneDead(targetSession) { + // For tmux, check if the pane is dead (claude exited but session preserved) + if muxType == daemon.MultiplexerTmux && mux.IsPaneDead(targetSession) { fmt.Printf("Agent %s pane is dead (claude exited).\n", targetAgent) fmt.Print("Respawn claude? [Y/n] ") @@ -125,23 +141,25 @@ func runAgentWatch(cmd *cobra.Command, args []string) error { } } - fmt.Printf("Attaching to agent %s (tmux session: %s)\n", targetAgent, targetSession) + fmt.Printf("Attaching to agent %s (%s session: %s)\n", targetAgent, muxType, targetSession) fmt.Println() - fmt.Println(" Ctrl+B d Detach (keeps agent running)") - fmt.Println(" Ctrl+C Interrupts claude (session preserved)") - fmt.Println(" Ctrl+B n/p Switch agents") + if muxType == daemon.MultiplexerTmux { + fmt.Println(" Ctrl+B d Detach (keeps agent running)") + fmt.Println(" Ctrl+C Interrupts claude (session preserved)") + fmt.Println(" Ctrl+B n/p Switch agents") + } else { + fmt.Println(" Ctrl+O d Detach (keeps agent running)") + fmt.Println(" Ctrl+C Interrupts claude") + fmt.Println(" Alt+n/p Navigate panes") + } fmt.Println() - // Attach to the tmux session - // We need to use syscall.Exec to replace the current process - // so that tmux can properly take over the terminal - tmuxPath, err := exec.LookPath("tmux") - if err != nil { - return err + // Attach to the session using the multiplexer's attach command + attachCmd := mux.AttachCommand(targetSession) + if attachCmd == nil { + // Fallback to direct command + attachCmd = exec.Command(muxPath, "attach", "-t", targetSession) } - - // Use exec.Command but connect stdin/stdout/stderr - attachCmd := exec.Command(tmuxPath, "attach", "-t", targetSession) attachCmd.Stdin = os.Stdin attachCmd.Stdout = os.Stdout attachCmd.Stderr = os.Stderr @@ -241,13 +259,3 @@ func runAgentWatchAll(agents []*mapv1.SpawnedAgentInfo) error { return attachCmd.Run() } - -// isPaneDead checks if a tmux pane's process has exited -func isPaneDead(sessionName string) bool { - cmd := exec.Command("tmux", "display-message", "-t", sessionName, "-p", "#{pane_dead}") - output, err := cmd.Output() - if err != nil { - return false - } - return strings.TrimSpace(string(output)) == "1" -} diff --git a/internal/cli/clean.go b/internal/cli/clean.go index 78c3496..4802b68 100644 --- a/internal/cli/clean.go +++ b/internal/cli/clean.go @@ -14,7 +14,7 @@ import ( var cleanCmd = &cobra.Command{ Use: "clean", Short: "Clean up orphaned processes and resources", - Long: `Clean up orphaned mapd processes, tmux sessions, and socket files. + Long: `Clean up orphaned mapd processes, multiplexer sessions (tmux/zellij), and socket files. This is useful when the daemon didn't shut down cleanly and left behind stale processes or socket files that prevent starting a new daemon.`, @@ -38,13 +38,13 @@ func runClean(cmd *cobra.Command, args []string) error { cleaned = true } - // 2. Kill orphaned tmux sessions - killedSessions, err := killOrphanedTmuxSessions() + // 2. Kill orphaned multiplexer sessions (both tmux and zellij) + killedSessions, err := killOrphanedSessions() if err != nil { - fmt.Printf("warning: error killing tmux sessions: %v\n", err) + fmt.Printf("warning: error killing sessions: %v\n", err) } if killedSessions > 0 { - fmt.Printf("killed %d orphaned tmux session(s)\n", killedSessions) + fmt.Printf("killed %d orphaned multiplexer session(s)\n", killedSessions) cleaned = true } @@ -106,20 +106,33 @@ func killOrphanedProcesses() (int, error) { return killed, nil } -// killOrphanedTmuxSessions kills map-agent-* tmux sessions -func killOrphanedTmuxSessions() (int, error) { - sessions, err := daemon.ListTmuxSessions() +// killOrphanedSessions kills map-agent-* sessions for both tmux and zellij +func killOrphanedSessions() (int, error) { + var killed int + + // Kill orphaned tmux sessions + tmuxSessions, err := daemon.ListTmuxSessions() if err != nil { - return 0, err + return killed, err } - - var killed int - for _, session := range sessions { + for _, session := range tmuxSessions { cmd := exec.Command("tmux", "kill-session", "-t", session) if err := cmd.Run(); err == nil { killed++ } } + // Kill orphaned zellij sessions + zellijSessions, err := daemon.ListZellijSessions() + if err != nil { + return killed, err + } + for _, session := range zellijSessions { + cmd := exec.Command("zellij", "kill-session", session) + if err := cmd.Run(); err == nil { + killed++ + } + } + return killed, nil } diff --git a/internal/cli/config.go b/internal/cli/config.go index 6da8d9d..2dfe330 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -67,6 +67,7 @@ func initConfig() error { // Set defaults viper.SetDefault("socket", "/tmp/mapd.sock") viper.SetDefault("data-dir", filepath.Join(os.Getenv("HOME"), ".mapd")) + viper.SetDefault("multiplexer", "tmux") // terminal multiplexer: "tmux" or "zellij" viper.SetDefault("agent.default-type", "claude") viper.SetDefault("agent.default-count", 1) viper.SetDefault("agent.default-branch", "") diff --git a/internal/cli/root.go b/internal/cli/root.go index 71fc2de..fdb79a9 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -32,6 +32,12 @@ func getSocketPath() string { return viper.GetString("socket") } +// getMultiplexer returns the multiplexer type from Viper (env > config > default) +// Returns "tmux" or "zellij" +func getMultiplexer() string { + return viper.GetString("multiplexer") +} + func init() { rootCmd.PersistentFlags().StringP("socket", "s", "/tmp/mapd.sock", "daemon socket path") rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.mapd/config.yaml)") diff --git a/internal/cli/up.go b/internal/cli/up.go index 15d2af2..3ba4caf 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -20,8 +20,15 @@ var ( var upCmd = &cobra.Command{ Use: "up", Short: "Start the mapd daemon", - Long: `Start the mapd daemon process. By default runs in the background.`, - RunE: runUp, + Long: `Start the mapd daemon process. By default runs in the background. + +The terminal multiplexer can be configured via: + - 'multiplexer' in ~/.mapd/config.yaml + - MAP_MULTIPLEXER environment variable + - defaults to 'tmux' if not specified + +Supported multiplexers: tmux, zellij`, + RunE: runUp, } func init() { @@ -46,8 +53,9 @@ func runUp(cmd *cobra.Command, args []string) error { func runForeground() error { cfg := &daemon.Config{ - SocketPath: getSocketPath(), - DataDir: dataDir, + SocketPath: getSocketPath(), + DataDir: dataDir, + Multiplexer: getMultiplexer(), } srv, err := daemon.NewServer(cfg) diff --git a/internal/daemon/multiplexer.go b/internal/daemon/multiplexer.go new file mode 100644 index 0000000..1098ea9 --- /dev/null +++ b/internal/daemon/multiplexer.go @@ -0,0 +1,73 @@ +package daemon + +import ( + "os" + "os/exec" +) + +// Multiplexer interface abstracts terminal multiplexer operations (tmux, zellij) +type Multiplexer interface { + // Session lifecycle + CreateSession(name, workdir, command string) error + KillSession(name string) error + HasSession(name string) bool + ListSessions(prefix string) ([]string, error) + + // Session interaction + SendText(sessionName, text string) error + SendEnter(sessionName string) error + RespawnPane(sessionName, command string) error + + // Session info + GetPaneWorkdir(sessionName string) string + GetPaneTitle(sessionName string) string + IsPaneDead(sessionName string) bool + + // Attachment (returns command to exec) + AttachCommand(sessionName string) *exec.Cmd + + // Configuration + ConfigureSession(sessionName string, opts SessionOptions) error + + // Identification + Name() string // "tmux" or "zellij" +} + +// SessionOptions contains configuration options for multiplexer sessions +type SessionOptions struct { + AgentID string + MouseEnabled bool + StatusBarLabel string + CLICommand string // The CLI command used to respawn (e.g., "claude --dangerously-skip-permissions") +} + +// MultiplexerType represents supported multiplexer types +type MultiplexerType string + +const ( + MultiplexerTmux MultiplexerType = "tmux" + MultiplexerZellij MultiplexerType = "zellij" +) + +// NewMultiplexer creates a multiplexer instance based on the specified type +func NewMultiplexer(muxType MultiplexerType) (Multiplexer, error) { + switch muxType { + case MultiplexerZellij: + return NewZellijMultiplexer() + default: + return NewTmuxMultiplexer() + } +} + +// GetMultiplexerType determines multiplexer type from environment or returns default +func GetMultiplexerType() MultiplexerType { + if mux := os.Getenv("MAP_MULTIPLEXER"); mux != "" { + switch mux { + case "zellij": + return MultiplexerZellij + case "tmux": + return MultiplexerTmux + } + } + return MultiplexerTmux // default +} diff --git a/internal/daemon/multiplexer_tmux.go b/internal/daemon/multiplexer_tmux.go new file mode 100644 index 0000000..b0de8bf --- /dev/null +++ b/internal/daemon/multiplexer_tmux.go @@ -0,0 +1,159 @@ +package daemon + +import ( + "fmt" + "os/exec" + "strings" +) + +// TmuxMultiplexer implements the Multiplexer interface using tmux +type TmuxMultiplexer struct{} + +// NewTmuxMultiplexer creates a new tmux multiplexer +func NewTmuxMultiplexer() (*TmuxMultiplexer, error) { + if _, err := exec.LookPath("tmux"); err != nil { + return nil, fmt.Errorf("tmux not found in PATH: %w", err) + } + return &TmuxMultiplexer{}, nil +} + +// Name returns the multiplexer name +func (t *TmuxMultiplexer) Name() string { + return "tmux" +} + +// CreateSession creates a new tmux session +func (t *TmuxMultiplexer) CreateSession(name, workdir, command string) error { + cmd := exec.Command("tmux", "new-session", "-d", "-s", name, "-c", workdir, command) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create tmux session: %w", err) + } + return nil +} + +// KillSession terminates a tmux session +func (t *TmuxMultiplexer) KillSession(name string) error { + cmd := exec.Command("tmux", "kill-session", "-t", name) + return cmd.Run() +} + +// HasSession checks if a tmux session exists +func (t *TmuxMultiplexer) HasSession(name string) bool { + cmd := exec.Command("tmux", "has-session", "-t", name) + return cmd.Run() == nil +} + +// ListSessions returns all tmux sessions with the given prefix +func (t *TmuxMultiplexer) ListSessions(prefix string) ([]string, error) { + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}") + output, err := cmd.Output() + if err != nil { + // No sessions is not an error + return nil, nil + } + + var sessions []string + for line := range strings.SplitSeq(strings.TrimSpace(string(output)), "\n") { + if strings.HasPrefix(line, prefix) { + sessions = append(sessions, line) + } + } + return sessions, nil +} + +// SendText sends text to a tmux session using literal mode +func (t *TmuxMultiplexer) SendText(sessionName, text string) error { + cmd := exec.Command("tmux", "send-keys", "-t", sessionName, "-l", text) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to send text to tmux: %w", err) + } + return nil +} + +// SendEnter sends an Enter keypress to a tmux session +func (t *TmuxMultiplexer) SendEnter(sessionName string) error { + cmd := exec.Command("tmux", "send-keys", "-t", sessionName, "Enter") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to send Enter to tmux: %w", err) + } + return nil +} + +// RespawnPane respawns the pane with a new command +func (t *TmuxMultiplexer) RespawnPane(sessionName, command string) error { + cmd := exec.Command("tmux", "respawn-pane", "-t", sessionName, "-k", command) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to respawn pane: %w", err) + } + return nil +} + +// GetPaneWorkdir returns the current working directory of a tmux pane +func (t *TmuxMultiplexer) GetPaneWorkdir(sessionName string) string { + cmd := exec.Command("tmux", "display-message", "-t", sessionName, "-p", "#{pane_current_path}") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +// GetPaneTitle returns the pane title of a tmux session +func (t *TmuxMultiplexer) GetPaneTitle(sessionName string) string { + cmd := exec.Command("tmux", "display-message", "-t", sessionName, "-p", "#{pane_title}") + output, err := cmd.Output() + if err != nil { + return "unknown" + } + title := strings.TrimSpace(string(output)) + if title == "" { + return "idle" + } + return title +} + +// IsPaneDead checks if the pane's process has exited +func (t *TmuxMultiplexer) IsPaneDead(sessionName string) bool { + cmd := exec.Command("tmux", "display-message", "-t", sessionName, "-p", "#{pane_dead}") + output, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(output)) == "1" +} + +// AttachCommand returns an exec.Cmd that attaches to the session +func (t *TmuxMultiplexer) AttachCommand(sessionName string) *exec.Cmd { + return exec.Command("tmux", "attach", "-t", sessionName) +} + +// ConfigureSession applies configuration options to a tmux session +func (t *TmuxMultiplexer) ConfigureSession(sessionName string, opts SessionOptions) error { + // Enable mouse scrolling + if opts.MouseEnabled { + _ = exec.Command("tmux", "set-option", "-t", sessionName, "mouse", "on").Run() + } + + // Enable remain-on-exit to keep pane open if agent exits + _ = exec.Command("tmux", "set-option", "-t", sessionName, "remain-on-exit", "on").Run() + + // Store the CLI command for respawn keybinding + if opts.CLICommand != "" { + _ = exec.Command("tmux", "set-option", "-t", sessionName, "@map_cli_cmd", opts.CLICommand).Run() + _ = exec.Command("tmux", "bind-key", "-t", sessionName, "R", "respawn-pane", "-k", opts.CLICommand).Run() + } + + // Add agent ID to the status-right for easy identification + if opts.AgentID != "" { + statusRight := fmt.Sprintf(" [%s] %%H %%H:%%M %%d-%%b-%%y", opts.AgentID) + _ = exec.Command("tmux", "set-option", "-t", sessionName, "status-right", statusRight).Run() + } + + // Apply a subtle theme (neutral grays that work on both dark and light terminals) + _ = exec.Command("tmux", "set-option", "-t", sessionName, "status-style", "bg=colour240,fg=colour255").Run() + _ = exec.Command("tmux", "set-option", "-t", sessionName, "status-left-style", "bg=colour243,fg=colour255").Run() + _ = exec.Command("tmux", "set-option", "-t", sessionName, "status-right-style", "bg=colour243,fg=colour255").Run() + _ = exec.Command("tmux", "set-option", "-t", sessionName, "window-status-current-style", "bg=colour245,fg=colour232,bold").Run() + + return nil +} diff --git a/internal/daemon/multiplexer_zellij.go b/internal/daemon/multiplexer_zellij.go new file mode 100644 index 0000000..112dece --- /dev/null +++ b/internal/daemon/multiplexer_zellij.go @@ -0,0 +1,143 @@ +package daemon + +import ( + "fmt" + "os/exec" + "slices" + "strings" +) + +// ZellijMultiplexer implements the Multiplexer interface using Zellij +type ZellijMultiplexer struct{} + +// NewZellijMultiplexer creates a new Zellij multiplexer +func NewZellijMultiplexer() (*ZellijMultiplexer, error) { + if _, err := exec.LookPath("zellij"); err != nil { + return nil, fmt.Errorf("zellij not found in PATH: %w", err) + } + return &ZellijMultiplexer{}, nil +} + +// Name returns the multiplexer name +func (z *ZellijMultiplexer) Name() string { + return "zellij" +} + +// CreateSession creates a new Zellij session +// Zellij doesn't have a direct equivalent to tmux's new-session with a command, +// so we create a session and then run the command in it +func (z *ZellijMultiplexer) CreateSession(name, workdir, command string) error { + // Create a detached Zellij session with the specified working directory + // Using: zellij -s NAME options --default-cwd DIR -- CMD + cmd := exec.Command("zellij", "-s", name, "options", "--default-cwd", workdir, "--", command) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create zellij session: %w", err) + } + return nil +} + +// KillSession terminates a Zellij session +func (z *ZellijMultiplexer) KillSession(name string) error { + cmd := exec.Command("zellij", "kill-session", name) + return cmd.Run() +} + +// HasSession checks if a Zellij session exists +func (z *ZellijMultiplexer) HasSession(name string) bool { + sessions, err := z.ListSessions("") + if err != nil { + return false + } + return slices.Contains(sessions, name) +} + +// ListSessions returns all Zellij sessions with the given prefix +func (z *ZellijMultiplexer) ListSessions(prefix string) ([]string, error) { + cmd := exec.Command("zellij", "list-sessions", "--short") + output, err := cmd.Output() + if err != nil { + // No sessions is not an error + return nil, nil + } + + var sessions []string + for line := range strings.SplitSeq(strings.TrimSpace(string(output)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if prefix == "" || strings.HasPrefix(line, prefix) { + sessions = append(sessions, line) + } + } + return sessions, nil +} + +// SendText sends text to a Zellij session +func (z *ZellijMultiplexer) SendText(sessionName, text string) error { + // zellij -s NAME action write-chars TEXT + cmd := exec.Command("zellij", "-s", sessionName, "action", "write-chars", text) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to send text to zellij: %w", err) + } + return nil +} + +// SendEnter sends an Enter keypress to a Zellij session +func (z *ZellijMultiplexer) SendEnter(sessionName string) error { + // zellij -s NAME action write 10 (10 is the ASCII code for newline/Enter) + cmd := exec.Command("zellij", "-s", sessionName, "action", "write", "10") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to send Enter to zellij: %w", err) + } + return nil +} + +// RespawnPane respawns the pane with a new command +// Zellij doesn't have direct pane respawn like tmux, so we close and reopen +func (z *ZellijMultiplexer) RespawnPane(sessionName, command string) error { + // Zellij doesn't have a direct equivalent to tmux's respawn-pane + // We can try to run a new command in the session + // Using: zellij -s NAME run -- CMD + cmd := exec.Command("zellij", "-s", sessionName, "run", "--", command) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to respawn pane in zellij: %w", err) + } + return nil +} + +// GetPaneWorkdir returns the working directory +// Zellij doesn't expose this directly, so we return empty string +func (z *ZellijMultiplexer) GetPaneWorkdir(sessionName string) string { + // Zellij doesn't have a direct way to query pane working directory + return "" +} + +// GetPaneTitle returns the pane title +// Zellij handles this differently than tmux +func (z *ZellijMultiplexer) GetPaneTitle(sessionName string) string { + // Zellij doesn't have a direct equivalent to query pane title + return "zellij" +} + +// IsPaneDead checks if the pane's process has exited +// Zellij doesn't have a direct equivalent to tmux's pane_dead +func (z *ZellijMultiplexer) IsPaneDead(sessionName string) bool { + // Zellij doesn't expose pane dead status directly + // We can check if the session still exists + return !z.HasSession(sessionName) +} + +// AttachCommand returns an exec.Cmd that attaches to the session +func (z *ZellijMultiplexer) AttachCommand(sessionName string) *exec.Cmd { + return exec.Command("zellij", "attach", sessionName) +} + +// ConfigureSession applies configuration options to a Zellij session +// Zellij uses config files rather than runtime options, so this is limited +func (z *ZellijMultiplexer) ConfigureSession(sessionName string, opts SessionOptions) error { + // Zellij configuration is primarily done through config files + // Runtime configuration options are limited compared to tmux + // Most styling and behavior is set in the Zellij config file (~/.config/zellij/config.kdl) + return nil +} diff --git a/internal/daemon/process.go b/internal/daemon/process.go index 2d88a72..8a36bdb 100644 --- a/internal/daemon/process.go +++ b/internal/daemon/process.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "os" "os/exec" "sort" "strings" @@ -15,21 +14,22 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -// ProcessManager manages spawned Claude Code agent slots using tmux sessions +// ProcessManager manages spawned Claude Code agent slots using terminal multiplexers type ProcessManager struct { mu sync.RWMutex agents map[string]*AgentSlot eventCh chan *mapv1.Event logsDir string - lastAssigned string // ID of last agent assigned a task (for round-robin) - onAgentAvailable func() // callback when an agent becomes available + lastAssigned string // ID of last agent assigned a task (for round-robin) + onAgentAvailable func() // callback when an agent becomes available + multiplexer Multiplexer // terminal multiplexer (tmux or zellij) } -// AgentSlot represents an agent running in a tmux session +// AgentSlot represents an agent running in a terminal multiplexer session type AgentSlot struct { AgentID string WorktreePath string - TmuxSession string // tmux session name + SessionName string // multiplexer session name (tmux or zellij) CreatedAt time.Time Status string // "idle", "busy" CurrentTask string // current task ID if busy @@ -50,18 +50,24 @@ const ( AgentTypeCodex = "codex" ) -// tmux session prefix to avoid conflicts -const tmuxPrefix = "map-agent-" +// session prefix to avoid conflicts with other multiplexer sessions +const sessionPrefix = "map-agent-" -// NewProcessManager creates a new process manager -func NewProcessManager(logsDir string, eventCh chan *mapv1.Event) *ProcessManager { +// NewProcessManager creates a new process manager with the specified multiplexer +func NewProcessManager(logsDir string, eventCh chan *mapv1.Event, mux Multiplexer) *ProcessManager { return &ProcessManager{ - agents: make(map[string]*AgentSlot), - eventCh: eventCh, - logsDir: logsDir, + agents: make(map[string]*AgentSlot), + eventCh: eventCh, + logsDir: logsDir, + multiplexer: mux, } } +// GetMultiplexer returns the multiplexer being used +func (m *ProcessManager) GetMultiplexer() Multiplexer { + return m.multiplexer +} + // SetOnAgentAvailable sets a callback that is invoked when an agent becomes available. // This is used to trigger processing of pending tasks. func (m *ProcessManager) SetOnAgentAvailable(callback func()) { @@ -70,7 +76,7 @@ func (m *ProcessManager) SetOnAgentAvailable(callback func()) { m.onAgentAvailable = callback } -// CreateSlot creates a new agent with a tmux session running claude or codex +// CreateSlot creates a new agent with a multiplexer session running claude or codex // agentType should be "claude" (default) or "codex" // If skipPermissions is true, the agent is started with permission-bypassing flags func (m *ProcessManager) CreateSlot(agentID, workdir, agentType string, skipPermissions bool) (*AgentSlot, error) { @@ -87,11 +93,6 @@ func (m *ProcessManager) CreateSlot(agentID, workdir, agentType string, skipPerm return nil, fmt.Errorf("agent %s already exists", agentID) } - // Check if tmux is available - if _, err := exec.LookPath("tmux"); err != nil { - return nil, fmt.Errorf("tmux not found in PATH: %w", err) - } - // Determine CLI binary and flags based on agent type var cliBinary, cliCmd string switch agentType { @@ -117,39 +118,25 @@ func (m *ProcessManager) CreateSlot(agentID, workdir, agentType string, skipPerm } } - tmuxSession := tmuxPrefix + agentID + sessionName := sessionPrefix + agentID - // Create tmux session with the agent CLI running in it - cmd := exec.Command("tmux", "new-session", "-d", "-s", tmuxSession, "-c", workdir, cliCmd) - cmd.Env = os.Environ() - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("failed to create tmux session: %w", err) + // Create multiplexer session with the agent CLI running in it + if err := m.multiplexer.CreateSession(sessionName, workdir, cliCmd); err != nil { + return nil, err } - // Configure tmux session for better resilience - // - mouse: enable scrolling - // - remain-on-exit: keep pane open if agent exits (prevents accidental Ctrl+C from killing session) - // - @map_cli_cmd: store the CLI command for respawn keybinding - // - bind R: respawn the agent with Ctrl+b R - _ = exec.Command("tmux", "set-option", "-t", tmuxSession, "mouse", "on").Run() - _ = exec.Command("tmux", "set-option", "-t", tmuxSession, "remain-on-exit", "on").Run() - _ = exec.Command("tmux", "set-option", "-t", tmuxSession, "@map_cli_cmd", cliCmd).Run() - _ = exec.Command("tmux", "bind-key", "-t", tmuxSession, "R", "respawn-pane", "-k", cliCmd).Run() - - // Add agent ID to the status-right for easy identification - statusRight := fmt.Sprintf(" [%s] %%H %%H:%%M %%d-%%b-%%y", agentID) - _ = exec.Command("tmux", "set-option", "-t", tmuxSession, "status-right", statusRight).Run() - - // Apply a subtle theme (neutral grays that work on both dark and light terminals) - _ = exec.Command("tmux", "set-option", "-t", tmuxSession, "status-style", "bg=colour240,fg=colour255").Run() - _ = exec.Command("tmux", "set-option", "-t", tmuxSession, "status-left-style", "bg=colour243,fg=colour255").Run() - _ = exec.Command("tmux", "set-option", "-t", tmuxSession, "status-right-style", "bg=colour243,fg=colour255").Run() - _ = exec.Command("tmux", "set-option", "-t", tmuxSession, "window-status-current-style", "bg=colour245,fg=colour232,bold").Run() + // Configure session for better resilience + opts := SessionOptions{ + AgentID: agentID, + MouseEnabled: true, + CLICommand: cliCmd, + } + _ = m.multiplexer.ConfigureSession(sessionName, opts) slot := &AgentSlot{ AgentID: agentID, WorktreePath: workdir, - TmuxSession: tmuxSession, + SessionName: sessionName, CreatedAt: time.Now(), Status: AgentStatusIdle, AgentType: agentType, @@ -163,7 +150,8 @@ func (m *ProcessManager) CreateSlot(agentID, workdir, agentType string, skipPerm // Emit connected event m.emitAgentEvent(slot, true) - log.Printf("created %s agent %s with tmux session %s (workdir: %s)", cliBinary, agentID, tmuxSession, workdir) + muxName := m.multiplexer.Name() + log.Printf("created %s agent %s with %s session %s (workdir: %s)", cliBinary, agentID, muxName, sessionName, workdir) // Notify that an agent is available (for pending task processing) if callback != nil { @@ -173,7 +161,7 @@ func (m *ProcessManager) CreateSlot(agentID, workdir, agentType string, skipPerm return slot, nil } -// ExecuteTask sends a task to the agent's tmux session +// ExecuteTask sends a task to the agent's multiplexer session func (m *ProcessManager) ExecuteTask(ctx context.Context, agentID string, taskID string, description string, scopePaths []string) (string, error) { m.mu.RLock() slot, exists := m.agents[agentID] @@ -191,7 +179,7 @@ func (m *ProcessManager) ExecuteTask(ctx context.Context, agentID string, taskID } slot.Status = AgentStatusBusy slot.CurrentTask = taskID - tmuxSession := slot.TmuxSession + sessionName := slot.SessionName slot.mu.Unlock() // Ensure we release the slot when done and notify about availability @@ -210,7 +198,8 @@ func (m *ProcessManager) ExecuteTask(ctx context.Context, agentID string, taskID } }() - log.Printf("agent %s executing task %s via tmux", agentID, taskID) + muxName := m.multiplexer.Name() + log.Printf("agent %s executing task %s via %s", agentID, taskID, muxName) // Build the prompt prompt := description @@ -218,45 +207,42 @@ func (m *ProcessManager) ExecuteTask(ctx context.Context, agentID string, taskID prompt = fmt.Sprintf("%s\n\nScope/files: %s", prompt, strings.Join(scopePaths, ", ")) } - // Send the prompt to the tmux session + // Send the prompt to the multiplexer session // Replace newlines with spaces to keep as single-line input for the CLI singleLinePrompt := strings.ReplaceAll(prompt, "\n", " ") singleLinePrompt = strings.ReplaceAll(singleLinePrompt, " ", " ") // collapse double spaces - // Use tmux send-keys with -l (literal) flag to send text, then Enter separately - // This ensures the text is sent exactly as-is without tmux interpreting special chars - cmd := exec.CommandContext(ctx, "tmux", "send-keys", "-t", tmuxSession, "-l", singleLinePrompt) - if err := cmd.Run(); err != nil { + // Send text to the session + if err := m.multiplexer.SendText(sessionName, singleLinePrompt); err != nil { log.Printf("agent %s task %s failed to send text: %v", agentID, taskID, err) - return "", fmt.Errorf("failed to send task to tmux: %w", err) + return "", err } // Send Enter key to submit the prompt - cmd = exec.CommandContext(ctx, "tmux", "send-keys", "-t", tmuxSession, "Enter") - if err := cmd.Run(); err != nil { + if err := m.multiplexer.SendEnter(sessionName); err != nil { log.Printf("agent %s task %s failed to send Enter: %v", agentID, taskID, err) - return "", fmt.Errorf("failed to submit task to tmux: %w", err) + return "", err } - log.Printf("agent %s task %s sent to tmux session", agentID, taskID) + log.Printf("agent %s task %s sent to %s session", agentID, taskID, muxName) - // Note: With tmux, we don't wait for completion or capture output + // Note: We don't wait for completion or capture output // The user interacts directly with the session - return "Task sent to agent's tmux session. Use 'map agent watch' to interact.", nil + return "Task sent to agent's session. Use 'map agent watch' to interact.", nil } -// GetTmuxSession returns the tmux session name for an agent -func (m *ProcessManager) GetTmuxSession(agentID string) string { +// GetSessionName returns the multiplexer session name for an agent +func (m *ProcessManager) GetSessionName(agentID string) string { m.mu.RLock() defer m.mu.RUnlock() if slot, ok := m.agents[agentID]; ok { - return slot.TmuxSession + return slot.SessionName } return "" } -// HasTmuxSession checks if a tmux session exists for the agent -func (m *ProcessManager) HasTmuxSession(agentID string) bool { +// HasSession checks if a multiplexer session exists for the agent +func (m *ProcessManager) HasSession(agentID string) bool { m.mu.RLock() slot, exists := m.agents[agentID] m.mu.RUnlock() @@ -265,9 +251,8 @@ func (m *ProcessManager) HasTmuxSession(agentID string) bool { return false } - // Check if tmux session actually exists - cmd := exec.Command("tmux", "has-session", "-t", slot.TmuxSession) - return cmd.Run() == nil + // Check if session actually exists + return m.multiplexer.HasSession(slot.SessionName) } // FindAvailableAgent finds an idle agent slot using round-robin selection @@ -312,24 +297,24 @@ func (m *ProcessManager) FindAvailableAgent() *AgentSlot { return nil } -// Remove removes an agent slot and kills its tmux session +// Remove removes an agent slot and kills its multiplexer session func (m *ProcessManager) Remove(agentID string) { m.mu.Lock() slot, exists := m.agents[agentID] if exists { delete(m.agents, agentID) } + mux := m.multiplexer m.mu.Unlock() if exists { - // Kill the tmux session - cmd := exec.Command("tmux", "kill-session", "-t", slot.TmuxSession) - if err := cmd.Run(); err != nil { - log.Printf("warning: failed to kill tmux session %s: %v", slot.TmuxSession, err) + // Kill the multiplexer session + if err := mux.KillSession(slot.SessionName); err != nil { + log.Printf("warning: failed to kill %s session %s: %v", mux.Name(), slot.SessionName, err) } m.emitAgentEvent(slot, false) - log.Printf("removed agent %s and killed tmux session %s", agentID, slot.TmuxSession) + log.Printf("removed agent %s and killed %s session %s", agentID, mux.Name(), slot.SessionName) } } @@ -414,9 +399,9 @@ func (slot *AgentSlot) ToProto() *mapv1.SpawnedAgentInfo { AgentId: slot.AgentID, WorktreePath: slot.WorktreePath, Pid: 0, - Status: GetTmuxPaneTitle(slot.TmuxSession), + Status: slot.Status, CreatedAt: timestamppb.New(slot.CreatedAt), - LogFile: slot.TmuxSession, // Repurpose LogFile to show tmux session + LogFile: slot.SessionName, // Repurpose LogFile to show multiplexer session AgentType: slot.AgentType, } } @@ -427,9 +412,14 @@ func (m *ProcessManager) emitAgentEvent(slot *AgentSlot, connected bool) { return } + muxName := "session" + if m.multiplexer != nil { + muxName = m.multiplexer.Name() + } + message := fmt.Sprintf("agent %s disconnected", slot.AgentID) if connected { - message = fmt.Sprintf("agent %s connected (tmux: %s)", slot.AgentID, slot.TmuxSession) + message = fmt.Sprintf("agent %s connected (%s: %s)", slot.AgentID, muxName, slot.SessionName) } event := &mapv1.Event{ @@ -457,7 +447,7 @@ func (m *ProcessManager) Spawn(agentID, workdir, prompt, agentType string, skipP return nil, err } - // If a prompt was provided, send it to the tmux session + // If a prompt was provided, send it to the multiplexer session if prompt != "" { // Give the agent a moment to start up time.Sleep(500 * time.Millisecond) @@ -466,14 +456,12 @@ func (m *ProcessManager) Spawn(agentID, workdir, prompt, agentType string, skipP singleLinePrompt := strings.ReplaceAll(prompt, "\n", " ") singleLinePrompt = strings.ReplaceAll(singleLinePrompt, " ", " ") - // Send text with -l (literal) flag, then Enter separately - cmd := exec.Command("tmux", "send-keys", "-t", slot.TmuxSession, "-l", singleLinePrompt) - if err := cmd.Run(); err != nil { + // Send text to the session + if err := m.multiplexer.SendText(slot.SessionName, singleLinePrompt); err != nil { log.Printf("warning: failed to send initial prompt text to %s: %v", agentID, err) } else { // Send Enter to submit - cmd = exec.Command("tmux", "send-keys", "-t", slot.TmuxSession, "Enter") - if err := cmd.Run(); err != nil { + if err := m.multiplexer.SendEnter(slot.SessionName); err != nil { log.Printf("warning: failed to send Enter to %s: %v", agentID, err) } else { log.Printf("sent initial prompt to agent %s", agentID) @@ -484,62 +472,34 @@ func (m *ProcessManager) Spawn(agentID, workdir, prompt, agentType string, skipP return slot, nil } -// ListTmuxSessions returns all map agent tmux sessions (including orphaned ones) -func ListTmuxSessions() ([]string, error) { - cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}") - output, err := cmd.Output() - if err != nil { - // No sessions is not an error - return nil, nil - } - - var sessions []string - for line := range strings.SplitSeq(strings.TrimSpace(string(output)), "\n") { - if strings.HasPrefix(line, tmuxPrefix) { - sessions = append(sessions, line) - } - } - return sessions, nil -} - -// GetTmuxSessionDir returns the working directory of a tmux session -func GetTmuxSessionDir(sessionName string) string { - cmd := exec.Command("tmux", "display-message", "-t", sessionName, "-p", "#{pane_current_path}") - output, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(output)) +// ListSessions returns all map agent sessions for a given multiplexer (including orphaned ones) +func ListSessions(mux Multiplexer) ([]string, error) { + return mux.ListSessions(sessionPrefix) } -// GetTmuxPaneTitle returns the pane title of a tmux session (used as status display) -func GetTmuxPaneTitle(sessionName string) string { - cmd := exec.Command("tmux", "display-message", "-t", sessionName, "-p", "#{pane_title}") - output, err := cmd.Output() +// ListTmuxSessions returns all map agent tmux sessions (for backwards compatibility) +func ListTmuxSessions() ([]string, error) { + mux, err := NewTmuxMultiplexer() if err != nil { - return "unknown" - } - title := strings.TrimSpace(string(output)) - if title == "" { - return "idle" + return nil, nil // tmux not available, return empty } - return title + return mux.ListSessions(sessionPrefix) } -// IsTmuxPaneDead checks if the pane's process has exited (remain-on-exit keeps pane open) -func IsTmuxPaneDead(sessionName string) bool { - cmd := exec.Command("tmux", "display-message", "-t", sessionName, "-p", "#{pane_dead}") - output, err := cmd.Output() +// ListZellijSessions returns all map agent Zellij sessions +func ListZellijSessions() ([]string, error) { + mux, err := NewZellijMultiplexer() if err != nil { - return false + return nil, nil // zellij not available, return empty } - return strings.TrimSpace(string(output)) == "1" + return mux.ListSessions(sessionPrefix) } -// RespawnInPane respawns the agent process in a dead tmux pane +// RespawnInPane respawns the agent process in a dead pane func (m *ProcessManager) RespawnInPane(agentID string, skipPermissions bool) error { m.mu.RLock() slot, exists := m.agents[agentID] + mux := m.multiplexer m.mu.RUnlock() if !exists { @@ -547,13 +507,12 @@ func (m *ProcessManager) RespawnInPane(agentID string, skipPermissions bool) err } // Check if session exists - checkCmd := exec.Command("tmux", "has-session", "-t", slot.TmuxSession) - if err := checkCmd.Run(); err != nil { - return fmt.Errorf("tmux session %s not found", slot.TmuxSession) + if !mux.HasSession(slot.SessionName) { + return fmt.Errorf("%s session %s not found", mux.Name(), slot.SessionName) } - // Check if pane is dead - if !IsTmuxPaneDead(slot.TmuxSession) { + // Check if pane is dead (for multiplexers that support this) + if !mux.IsPaneDead(slot.SessionName) { return fmt.Errorf("agent %s pane is still running - cannot respawn", agentID) } @@ -579,8 +538,7 @@ func (m *ProcessManager) RespawnInPane(agentID string, skipPermissions bool) err } } - cmd := exec.Command("tmux", "respawn-pane", "-t", slot.TmuxSession, "-k", cliCmd) - if err := cmd.Run(); err != nil { + if err := mux.RespawnPane(slot.SessionName, cliCmd); err != nil { return fmt.Errorf("failed to respawn %s in pane: %w", agentType, err) } diff --git a/internal/daemon/process_test.go b/internal/daemon/process_test.go index 986e417..2605aa2 100644 --- a/internal/daemon/process_test.go +++ b/internal/daemon/process_test.go @@ -1,24 +1,44 @@ package daemon import ( + "os/exec" "slices" "testing" "time" ) +// mockMultiplexer implements Multiplexer interface for testing +type mockMultiplexer struct{} + +func (m *mockMultiplexer) CreateSession(name, workdir, command string) error { return nil } +func (m *mockMultiplexer) KillSession(name string) error { return nil } +func (m *mockMultiplexer) HasSession(name string) bool { return true } +func (m *mockMultiplexer) ListSessions(prefix string) ([]string, error) { return nil, nil } +func (m *mockMultiplexer) SendText(sessionName, text string) error { return nil } +func (m *mockMultiplexer) SendEnter(sessionName string) error { return nil } +func (m *mockMultiplexer) RespawnPane(sessionName, command string) error { return nil } +func (m *mockMultiplexer) GetPaneWorkdir(sessionName string) string { return "" } +func (m *mockMultiplexer) GetPaneTitle(sessionName string) string { return "mock" } +func (m *mockMultiplexer) IsPaneDead(sessionName string) bool { return false } +func (m *mockMultiplexer) AttachCommand(sessionName string) *exec.Cmd { return nil } +func (m *mockMultiplexer) ConfigureSession(sessionName string, opts SessionOptions) error { + return nil +} +func (m *mockMultiplexer) Name() string { return "mock" } + func TestProcessManager_AgentTracking(t *testing.T) { - manager := NewProcessManager("/tmp/logs", nil) + manager := NewProcessManager("/tmp/logs", nil, &mockMultiplexer{}) idleSlot := &AgentSlot{ AgentID: "agent-idle", - TmuxSession: "tmux-idle", + SessionName: "session-idle", CreatedAt: time.Now(), Status: AgentStatusIdle, AgentType: AgentTypeClaude, } busySlot := &AgentSlot{ AgentID: "agent-busy", - TmuxSession: "tmux-busy", + SessionName: "session-busy", CreatedAt: time.Now(), Status: AgentStatusBusy, AgentType: AgentTypeCodex, @@ -27,11 +47,11 @@ func TestProcessManager_AgentTracking(t *testing.T) { manager.agents[idleSlot.AgentID] = idleSlot manager.agents[busySlot.AgentID] = busySlot - if got := manager.GetTmuxSession("agent-idle"); got != "tmux-idle" { - t.Errorf("GetTmuxSession = %q, want %q", got, "tmux-idle") + if got := manager.GetSessionName("agent-idle"); got != "session-idle" { + t.Errorf("GetSessionName = %q, want %q", got, "session-idle") } - if got := manager.GetTmuxSession("missing"); got != "" { - t.Errorf("GetTmuxSession(missing) = %q, want empty", got) + if got := manager.GetSessionName("missing"); got != "" { + t.Errorf("GetSessionName(missing) = %q, want empty", got) } slot := manager.FindAvailableAgent() @@ -58,11 +78,11 @@ func TestProcessManager_AgentTracking(t *testing.T) { } func TestProcessManager_Remove(t *testing.T) { - manager := NewProcessManager("/tmp/logs", nil) + manager := NewProcessManager("/tmp/logs", nil, &mockMultiplexer{}) manager.agents["agent-1"] = &AgentSlot{ AgentID: "agent-1", - TmuxSession: "tmux-missing", + SessionName: "session-missing", CreatedAt: time.Now(), Status: AgentStatusIdle, } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 64a5866..c6cc92d 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -45,8 +45,9 @@ type Server struct { // Config holds daemon configuration type Config struct { - SocketPath string - DataDir string + SocketPath string + DataDir string + Multiplexer string // "tmux" (default) or "zellij" } // NewServer creates a new daemon server @@ -70,7 +71,18 @@ func NewServer(cfg *Config) (*Server, error) { return nil, fmt.Errorf("init worktree manager: %w", err) } - processes := NewProcessManager(cfg.DataDir, eventCh) + // Initialize multiplexer based on config or environment + muxType := GetMultiplexerType() + if cfg.Multiplexer != "" { + muxType = MultiplexerType(cfg.Multiplexer) + } + mux, err := NewMultiplexer(muxType) + if err != nil { + return nil, fmt.Errorf("init multiplexer (%s): %w", muxType, err) + } + log.Printf("using %s as terminal multiplexer", mux.Name()) + + processes := NewProcessManager(cfg.DataDir, eventCh, mux) tasks := NewTaskRouter(store, processes, eventCh) names := NewNameGenerator() @@ -214,12 +226,18 @@ func (s *Server) GetStatus(ctx context.Context, req *mapv1.GetStatusRequest) (*m pending, active, _ := s.store.GetStats() spawnedAgents := len(s.processes.List()) + muxName := "" + if mux := s.processes.GetMultiplexer(); mux != nil { + muxName = mux.Name() + } + return &mapv1.GetStatusResponse{ Running: true, StartedAt: timestamppb.New(s.startedAt), ConnectedAgents: int32(spawnedAgents), PendingTasks: int32(pending), ActiveTasks: int32(active), + Multiplexer: muxName, }, nil } @@ -350,7 +368,11 @@ func (s *Server) SpawnAgent(ctx context.Context, req *mapv1.SpawnAgentRequest) ( log.Printf("failed to store spawned agent %s: %v", agentID, err) } - agents = append(agents, slot.ToProto()) + info := slot.ToProto() + if mux := s.processes.GetMultiplexer(); mux != nil { + info.Multiplexer = mux.Name() + } + agents = append(agents, info) log.Printf("created %s agent %s in %s", agentType, agentID, workdir) } @@ -396,10 +418,16 @@ func (s *Server) KillAgent(ctx context.Context, req *mapv1.KillAgentRequest) (*m func (s *Server) ListSpawnedAgents(ctx context.Context, req *mapv1.ListSpawnedAgentsRequest) (*mapv1.ListSpawnedAgentsResponse, error) { processes := s.processes.List() + muxName := "" + if mux := s.processes.GetMultiplexer(); mux != nil { + muxName = mux.Name() + } agents := make([]*mapv1.SpawnedAgentInfo, 0, len(processes)) for _, sp := range processes { - agents = append(agents, sp.ToProto()) + info := sp.ToProto() + info.Multiplexer = muxName + agents = append(agents, info) } return &mapv1.ListSpawnedAgentsResponse{Agents: agents}, nil diff --git a/proto/map/v1/daemon.pb.go b/proto/map/v1/daemon.pb.go index c441d90..397fc27 100644 --- a/proto/map/v1/daemon.pb.go +++ b/proto/map/v1/daemon.pb.go @@ -554,8 +554,10 @@ type GetStatusResponse struct { ConnectedAgents int32 `protobuf:"varint,3,opt,name=connected_agents,json=connectedAgents,proto3" json:"connected_agents,omitempty"` PendingTasks int32 `protobuf:"varint,4,opt,name=pending_tasks,json=pendingTasks,proto3" json:"pending_tasks,omitempty"` ActiveTasks int32 `protobuf:"varint,5,opt,name=active_tasks,json=activeTasks,proto3" json:"active_tasks,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Terminal multiplexer being used: "tmux" or "zellij" + Multiplexer string `protobuf:"bytes,6,opt,name=multiplexer,proto3" json:"multiplexer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetStatusResponse) Reset() { @@ -623,6 +625,13 @@ func (x *GetStatusResponse) GetActiveTasks() int32 { return 0 } +func (x *GetStatusResponse) GetMultiplexer() string { + if x != nil { + return x.Multiplexer + } + return "" +} + // WatchEventsRequest configures event streaming type WatchEventsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -845,7 +854,9 @@ type SpawnedAgentInfo struct { CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` LogFile string `protobuf:"bytes,6,opt,name=log_file,json=logFile,proto3" json:"log_file,omitempty"` // Agent type: "claude" or "codex" - AgentType string `protobuf:"bytes,7,opt,name=agent_type,json=agentType,proto3" json:"agent_type,omitempty"` + AgentType string `protobuf:"bytes,7,opt,name=agent_type,json=agentType,proto3" json:"agent_type,omitempty"` + // Terminal multiplexer type: "tmux" or "zellij" + Multiplexer string `protobuf:"bytes,8,opt,name=multiplexer,proto3" json:"multiplexer,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -929,6 +940,13 @@ func (x *SpawnedAgentInfo) GetAgentType() string { return "" } +func (x *SpawnedAgentInfo) GetMultiplexer() string { + if x != nil { + return x.Multiplexer + } + return "" +} + // KillAgentRequest requests termination of a spawned agent type KillAgentRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1505,14 +1523,15 @@ const file_map_v1_daemon_proto_rawDesc = "" + "\x05force\x18\x01 \x01(\bR\x05force\",\n" + "\x10ShutdownResponse\x12\x18\n" + "\amessage\x18\x01 \x01(\tR\amessage\"\x12\n" + - "\x10GetStatusRequest\"\xdb\x01\n" + + "\x10GetStatusRequest\"\xfd\x01\n" + "\x11GetStatusResponse\x12\x18\n" + "\arunning\x18\x01 \x01(\bR\arunning\x129\n" + "\n" + "started_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12)\n" + "\x10connected_agents\x18\x03 \x01(\x05R\x0fconnectedAgents\x12#\n" + "\rpending_tasks\x18\x04 \x01(\x05R\fpendingTasks\x12!\n" + - "\factive_tasks\x18\x05 \x01(\x05R\vactiveTasks\"\x8c\x01\n" + + "\factive_tasks\x18\x05 \x01(\x05R\vactiveTasks\x12 \n" + + "\vmultiplexer\x18\x06 \x01(\tR\vmultiplexer\"\x8c\x01\n" + "\x12WatchEventsRequest\x122\n" + "\vtype_filter\x18\x01 \x03(\x0e2\x11.map.v1.EventTypeR\n" + "typeFilter\x12!\n" + @@ -1530,7 +1549,7 @@ const file_map_v1_daemon_proto_rawDesc = "" + "agent_type\x18\x06 \x01(\tR\tagentType\x12)\n" + "\x10skip_permissions\x18\a \x01(\bR\x0fskipPermissions\"F\n" + "\x12SpawnAgentResponse\x120\n" + - "\x06agents\x18\x01 \x03(\v2\x18.map.v1.SpawnedAgentInfoR\x06agents\"\xf1\x01\n" + + "\x06agents\x18\x01 \x03(\v2\x18.map.v1.SpawnedAgentInfoR\x06agents\"\x93\x02\n" + "\x10SpawnedAgentInfo\x12\x19\n" + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12#\n" + "\rworktree_path\x18\x02 \x01(\tR\fworktreePath\x12\x10\n" + @@ -1540,7 +1559,8 @@ const file_map_v1_daemon_proto_rawDesc = "" + "created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x19\n" + "\blog_file\x18\x06 \x01(\tR\alogFile\x12\x1d\n" + "\n" + - "agent_type\x18\a \x01(\tR\tagentType\"C\n" + + "agent_type\x18\a \x01(\tR\tagentType\x12 \n" + + "\vmultiplexer\x18\b \x01(\tR\vmultiplexer\"C\n" + "\x10KillAgentRequest\x12\x19\n" + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x14\n" + "\x05force\x18\x02 \x01(\bR\x05force\"G\n" + diff --git a/proto/map/v1/daemon.proto b/proto/map/v1/daemon.proto index ea6beda..86f1f9d 100644 --- a/proto/map/v1/daemon.proto +++ b/proto/map/v1/daemon.proto @@ -102,6 +102,8 @@ message GetStatusResponse { int32 connected_agents = 3; int32 pending_tasks = 4; int32 active_tasks = 5; + // Terminal multiplexer being used: "tmux" or "zellij" + string multiplexer = 6; } // WatchEventsRequest configures event streaming @@ -152,6 +154,8 @@ message SpawnedAgentInfo { string log_file = 6; // Agent type: "claude" or "codex" string agent_type = 7; + // Terminal multiplexer type: "tmux" or "zellij" + string multiplexer = 8; } // KillAgentRequest requests termination of a spawned agent