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