package herdr import ( "context" "crypto/sha256" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // TmuxBackend runs one Claude Code process per isolated tmux session. It is // intentionally Claude-only for now: Codex and OpenCode keep using the // verified herdr protocol until their terminal behavior has been exercised // against a live installation. type TmuxBackend struct { // Socket is a tmux socket name (-L) or an absolute socket path (-S). // An empty value uses the isolated socket name "orchestra". Socket string // Command is the Claude Code executable. An empty value resolves "claude" // through PATH. Command string // Binary is test/packaging override for tmux itself. Binary string } func NewTmuxBackend(socket, command string) *TmuxBackend { return &TmuxBackend{Socket: socket, Command: command} } func (b *TmuxBackend) Kind() string { return "tmux" } func (b *TmuxBackend) binary() string { if b.Binary != "" { return b.Binary } return "tmux" } func (b *TmuxBackend) socketArgs() []string { socket := b.Socket if socket == "" { socket = "orchestra" } if filepath.IsAbs(socket) { return []string{"-S", socket} } return []string{"-L", socket} } func (b *TmuxBackend) command(ctx context.Context, args ...string) ([]byte, error) { all := append(b.socketArgs(), args...) out, err := exec.CommandContext(ctx, b.binary(), all...).CombinedOutput() if err != nil { return out, fmt.Errorf("tmux %s: %s: %w", strings.Join(args, " "), strings.TrimSpace(string(out)), err) } return out, nil } func (b *TmuxBackend) Check(ctx context.Context) error { if _, err := exec.LookPath(b.binary()); err != nil { return fmt.Errorf("tmux backend: %w", err) } // tmux -V does not require a server to exist. An idle backend is healthy // and will create its isolated server with the first session. if out, err := exec.CommandContext(ctx, b.binary(), "-V").CombinedOutput(); err != nil { return fmt.Errorf("tmux backend: %s: %w", strings.TrimSpace(string(out)), err) } command := b.Command if command == "" { command = "claude" } if _, err := exec.LookPath(command); err != nil { return fmt.Errorf("tmux backend Claude command: %w", err) } return nil } func (b *TmuxBackend) Worktree(_ context.Context, _ string, path, _ string) (string, error) { info, err := os.Stat(path) if err != nil { return "", fmt.Errorf("tmux backend worktree: %w", err) } if !info.IsDir() { return "", fmt.Errorf("tmux backend worktree %s is not a directory", path) } return path, nil } func tmuxSessionName(taskID string) string { id := strings.Trim(invalidAgentName.ReplaceAllString(strings.ToLower(taskID), "-"), "-_") if id == "" { id = "session" } if len(id) > 36 { id = strings.TrimRight(id[:36], "-_") } sum := sha256.Sum256([]byte(taskID)) return fmt.Sprintf("orchestra-%s-%x", id, sum[:4]) } func tmuxSession(paneID string) string { if before, _, ok := strings.Cut(paneID, ":"); ok { return before } return paneID } func tmuxTarget(paneID string) string { return "=" + paneID } func (b *TmuxBackend) hasSession(ctx context.Context, session string) (bool, error) { out, err := b.command(ctx, "has-session", "-t", "="+session) if err == nil { return true, nil } message := strings.ToLower(string(out) + " " + err.Error()) if strings.Contains(message, "can't find session") || strings.Contains(message, "no server running") || strings.Contains(message, "no sessions") || (strings.Contains(message, "error connecting to") && strings.Contains(message, "no such file")) { return false, nil } return false, err } func (b *TmuxBackend) StartAgent(ctx context.Context, _, path, _, harness, taskID string) (Session, error) { if !strings.EqualFold(harness, "claude") { return Session{}, fmt.Errorf("tmux backend: harness %q is unsupported; only claude is enabled", harness) } if _, err := b.Worktree(ctx, "", path, ""); err != nil { return Session{}, err } command := b.Command if command == "" { command = "claude" } resolved, err := exec.LookPath(command) if err != nil { return Session{}, fmt.Errorf("tmux backend Claude command: %w", err) } session := tmuxSessionName(taskID) existing, err := b.hasSession(ctx, session) if err != nil { return Session{}, err } if !existing { if _, err := b.command(ctx, "new-session", "-d", "-s", session, "-c", path, resolved); err != nil { return Session{}, err } } // The exact pane id is resolved below. Deployments may configure tmux // base-index/base-pane-index, so neither index is assumed to be zero. paneOut, err := b.command(ctx, "list-panes", "-t", "="+session, "-F", "#{session_name}:#{window_index}.#{pane_index}") if err != nil { return Session{}, err } paneID := strings.TrimSpace(strings.SplitN(string(paneOut), "\n", 2)[0]) if paneID == "" { return Session{}, fmt.Errorf("tmux backend: session %s has no pane", session) } if existing { out, err := b.command(ctx, "display-message", "-p", "-t", tmuxTarget(paneID), "#{pane_current_path}") if err != nil { return Session{}, err } current, currentErr := filepath.Abs(strings.TrimSpace(string(out))) want, wantErr := filepath.Abs(path) if currentErr != nil || wantErr != nil || current != want { return Session{}, fmt.Errorf("tmux backend: existing session %s belongs to %q, not %q", session, current, want) } } else { if _, err := b.command(ctx, "set-window-option", "-t", tmuxTarget(paneID), "remain-on-exit", "on"); err != nil { return Session{}, err } } s := Session{PaneID: paneID, Worktree: path, Harness: "claude", AgentName: session} if err := b.confirmClaudeWorkspaceTrust(ctx, s); err != nil { return Session{}, err } status, err := b.AgentStatus(ctx, s) if err != nil { return Session{}, err } if status == "exited" || status == "dead" { return Session{}, fmt.Errorf("tmux backend: Claude exited while starting session %s", session) } return s, nil } func (b *TmuxBackend) confirmClaudeWorkspaceTrust(ctx context.Context, s Session) error { deadline := time.Now().Add(15 * time.Second) accepted := false for { text, err := b.PaneCapture(ctx, s, "recent") if err == nil && claudeWorkspaceTrustPrompt(text) { if !accepted { if err := b.SendText(ctx, s, "1"); err != nil { return fmt.Errorf("tmux backend: accept Claude workspace trust: %w", err) } if err := b.SendKeys(ctx, s, []string{"Enter"}); err != nil { return fmt.Errorf("tmux backend: accept Claude workspace trust: %w", err) } accepted = true } } // Claude's input prompt is the readiness boundary. A banner or partially // painted fullscreen UI is not enough: input sent there can be lost. if err == nil && !claudeWorkspaceTrustPrompt(text) && strings.Contains(text, "❯") { return nil } if time.Now().After(deadline) { return fmt.Errorf("tmux backend: Claude input prompt did not become ready in session %s", tmuxSession(s.PaneID)) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } } func (b *TmuxBackend) Prompt(ctx context.Context, pane, text string, _ time.Duration) error { s := Session{PaneID: pane} status, err := b.AgentStatus(ctx, s) if err != nil { return err } if status == "blocked" { return fmt.Errorf("tmux backend: refusing prompt while pane %s shows a permission dialog", pane) } if err := b.SendText(ctx, s, text); err != nil { return err } return b.SendKeys(ctx, s, []string{"Enter"}) } func (b *TmuxBackend) Kill(ctx context.Context, s Session) error { exists, err := b.hasSession(ctx, tmuxSession(s.PaneID)) if err != nil { return err } if !exists { return nil } _, err = b.command(ctx, "kill-session", "-t", "="+tmuxSession(s.PaneID)) return err } func (b *TmuxBackend) paneState(ctx context.Context, s Session) (dead bool, command string, err error) { out, err := b.command(ctx, "display-message", "-p", "-t", tmuxTarget(s.PaneID), "#{pane_dead}\t#{pane_current_command}") if err != nil { return false, "", err } parts := strings.SplitN(strings.TrimSpace(string(out)), "\t", 2) dead = len(parts) > 0 && parts[0] == "1" if len(parts) == 2 { command = parts[1] } return dead, command, nil } func (b *TmuxBackend) AgentStatus(ctx context.Context, s Session) (string, error) { exists, err := b.hasSession(ctx, tmuxSession(s.PaneID)) if err != nil { return "", err } if !exists { return "exited", nil } dead, command, err := b.paneState(ctx, s) if err != nil { return "", err } if dead || command == "" { return "exited", nil } text, err := b.PaneCapture(ctx, s, "recent") if err != nil { return "", err } if permissionPrompt(text) { return "blocked", nil } lower := strings.ToLower(text) for _, marker := range []string{"esc to interrupt", "ctrl+c to interrupt", "press esc to interrupt"} { if strings.Contains(lower, marker) { return "busy", nil } } return "idle", nil } func (b *TmuxBackend) PaneCapture(ctx context.Context, s Session, source string) (string, error) { start := "-200" if source != "" && source != "recent" { start = "-1000" } out, err := b.command(ctx, "capture-pane", "-p", "-J", "-S", start, "-t", tmuxTarget(s.PaneID)) return string(out), err } func (b *TmuxBackend) SendText(ctx context.Context, s Session, text string) error { _, err := b.command(ctx, "send-keys", "-t", tmuxTarget(s.PaneID), "-l", "--", text) return err } func (b *TmuxBackend) SendKeys(ctx context.Context, s Session, keys []string) error { if len(keys) == 0 { return nil } for _, key := range keys { if strings.TrimSpace(key) == "" { return errors.New("tmux backend: empty key name") } } args := []string{"send-keys", "-t", tmuxTarget(s.PaneID)} args = append(args, keys...) _, err := b.command(ctx, args...) return err } // tmux has no separate agent binding to release. Keeping the session alive is // the tmux equivalent of herdr's split-then-close protocol; the worker kills // it only after successor pickup has been validated. func (b *TmuxBackend) ReleaseAgent(ctx context.Context, s Session, _ string) error { exists, err := b.hasSession(ctx, tmuxSession(s.PaneID)) if err != nil { return err } if !exists { return fmt.Errorf("tmux backend: session %s is not running", tmuxSession(s.PaneID)) } return nil } var _ Backend = (*TmuxBackend)(nil)