Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

<details>
Expand Down Expand Up @@ -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 <key>` | Get a configuration value |
Expand All @@ -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 <id>` | 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 <id>` | Restart agent in dead tmux pane |
| `map agent merge <id>` | Merge agent's worktree changes into current branch |
| `map agent merge <id> -k` | Merge agent's changes and kill the agent |
Expand Down Expand Up @@ -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/ │ └─────────────────────┘
└───────────────────┘
```
Expand Down Expand Up @@ -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
Expand All @@ -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
```
Expand Down
88 changes: 48 additions & 40 deletions internal/cli/agent_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -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())
Expand All @@ -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)
}

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

Expand All @@ -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
Expand Down Expand Up @@ -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"
}
37 changes: 25 additions & 12 deletions internal/cli/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
16 changes: 12 additions & 4 deletions internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
Expand Down
Loading