// Package tmux provides a wrapper for tmux session operations via subprocess. package tmux import ( "bytes" "errors" "fmt" "os" "os/exec" "strings" "time" ) // Common errors var ( ErrNoServer = errors.New("no tmux server running") ErrSessionExists = errors.New("session already exists") ErrSessionNotFound = errors.New("session not found") ) // Tmux wraps tmux operations. type Tmux struct{} // NewTmux creates a new Tmux wrapper. func NewTmux() *Tmux { return &Tmux{} } // run executes a tmux command and returns stdout. func (t *Tmux) run(args ...string) (string, error) { cmd := exec.Command("tmux", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { return "", t.wrapError(err, stderr.String(), args) } return strings.TrimSpace(stdout.String()), nil } // wrapError wraps tmux errors with context. func (t *Tmux) wrapError(err error, stderr string, args []string) error { stderr = strings.TrimSpace(stderr) // Detect specific error types if strings.Contains(stderr, "no server running") || strings.Contains(stderr, "error connecting to") { return ErrNoServer } if strings.Contains(stderr, "duplicate session") { return ErrSessionExists } if strings.Contains(stderr, "session not found") || strings.Contains(stderr, "can't find session") { return ErrSessionNotFound } if stderr != "" { return fmt.Errorf("tmux %s: %s", args[0], stderr) } return fmt.Errorf("tmux %s: %w", args[0], err) } // NewSession creates a new detached tmux session. func (t *Tmux) NewSession(name, workDir string) error { args := []string{"new-session", "-d", "-s", name} if workDir != "" { args = append(args, "-c", workDir) } _, err := t.run(args...) return err } // KillSession terminates a tmux session. func (t *Tmux) KillSession(name string) error { _, err := t.run("kill-session", "-t", name) return err } // KillServer terminates the entire tmux server and all sessions. func (t *Tmux) KillServer() error { _, err := t.run("kill-server") if errors.Is(err, ErrNoServer) { return nil // Already dead } return err } // HasSession checks if a session exists. func (t *Tmux) HasSession(name string) (bool, error) { _, err := t.run("has-session", "-t", name) if err != nil { if errors.Is(err, ErrSessionNotFound) || errors.Is(err, ErrNoServer) { return false, nil } return false, err } return true, nil } // ListSessions returns all session names. func (t *Tmux) ListSessions() ([]string, error) { out, err := t.run("list-sessions", "-F", "#{session_name}") if err != nil { if errors.Is(err, ErrNoServer) { return nil, nil // No server = no sessions } return nil, err } if out == "" { return nil, nil } return strings.Split(out, "\n"), nil } // SendKeys sends keystrokes to a session and presses Enter. // Always sends Enter as a separate command for reliability. // Uses a debounce delay between paste and Enter to ensure paste completes. func (t *Tmux) SendKeys(session, keys string) error { return t.SendKeysDebounced(session, keys, 100) // 100ms default debounce } // SendKeysDebounced sends keystrokes with a configurable delay before Enter. // The debounceMs parameter controls how long to wait after paste before sending Enter. // This prevents race conditions where Enter arrives before paste is processed. func (t *Tmux) SendKeysDebounced(session, keys string, debounceMs int) error { // Send text using literal mode (-l) to handle special chars if _, err := t.run("send-keys", "-t", session, "-l", keys); err != nil { return err } // Wait for paste to be processed if debounceMs > 0 { time.Sleep(time.Duration(debounceMs) * time.Millisecond) } // Send Enter separately - more reliable than appending to send-keys _, err := t.run("send-keys", "-t", session, "Enter") return err } // SendKeysRaw sends keystrokes without adding Enter. func (t *Tmux) SendKeysRaw(session, keys string) error { _, err := t.run("send-keys", "-t", session, keys) return err } // SendKeysReplace sends keystrokes, clearing any pending input first. // This is useful for "replaceable" notifications where only the latest matters. // Uses Ctrl-U to clear the input line before sending the new message. // The delay parameter controls how long to wait after clearing before sending (ms). func (t *Tmux) SendKeysReplace(session, keys string, clearDelayMs int) error { // Send Ctrl-U to clear any pending input on the line if _, err := t.run("send-keys", "-t", session, "C-u"); err != nil { return err } // Small delay to let the clear take effect if clearDelayMs > 0 { time.Sleep(time.Duration(clearDelayMs) * time.Millisecond) } // Now send the actual message return t.SendKeys(session, keys) } // SendKeysDelayed sends keystrokes after a delay (in milliseconds). // Useful for waiting for a process to be ready before sending input. func (t *Tmux) SendKeysDelayed(session, keys string, delayMs int) error { time.Sleep(time.Duration(delayMs) * time.Millisecond) return t.SendKeys(session, keys) } // SendKeysDelayedDebounced sends keystrokes after a pre-delay, with a custom debounce before Enter. // Use this when sending input to a process that needs time to initialize AND the message // needs extra time between paste and Enter (e.g., Claude prompt injection). // preDelayMs: time to wait before sending text (for process readiness) // debounceMs: time to wait between text paste and Enter key (for paste completion) func (t *Tmux) SendKeysDelayedDebounced(session, keys string, preDelayMs, debounceMs int) error { if preDelayMs > 0 { time.Sleep(time.Duration(preDelayMs) * time.Millisecond) } return t.SendKeysDebounced(session, keys, debounceMs) } // NudgeSession sends a message to a Claude Code session reliably. // This is the canonical way to send messages to Claude sessions. // Uses: literal mode + 500ms debounce + separate Enter. // Verification is the Witness's job (AI), not this function. func (t *Tmux) NudgeSession(session, message string) error { // 1. Send text in literal mode (handles special characters) if _, err := t.run("send-keys", "-t", session, "-l", message); err != nil { return err } // 2. Wait 500ms for paste to complete (tested, required) time.Sleep(500 * time.Millisecond) // 3. Send Enter as separate command (key to reliability) if _, err := t.run("send-keys", "-t", session, "Enter"); err != nil { return err } return nil } // NudgePane sends a message to a specific pane reliably. // Same pattern as NudgeSession but targets a pane ID (e.g., "%9") instead of session name. func (t *Tmux) NudgePane(pane, message string) error { // 1. Send text in literal mode (handles special characters) if _, err := t.run("send-keys", "-t", pane, "-l", message); err != nil { return err } // 2. Wait 500ms for paste to complete (tested, required) time.Sleep(500 * time.Millisecond) // 3. Send Enter as separate command (key to reliability) if _, err := t.run("send-keys", "-t", pane, "Enter"); err != nil { return err } return nil } // GetPaneCommand returns the current command running in a pane. // Returns "bash", "zsh", "claude", "node", etc. func (t *Tmux) GetPaneCommand(session string) (string, error) { out, err := t.run("list-panes", "-t", session, "-F", "#{pane_current_command}") if err != nil { return "", err } return strings.TrimSpace(out), nil } // GetPaneWorkDir returns the current working directory of a pane. func (t *Tmux) GetPaneWorkDir(session string) (string, error) { out, err := t.run("list-panes", "-t", session, "-F", "#{pane_current_path}") if err != nil { return "", err } return strings.TrimSpace(out), nil } // CapturePane captures the visible content of a pane. func (t *Tmux) CapturePane(session string, lines int) (string, error) { return t.run("capture-pane", "-p", "-t", session, "-S", fmt.Sprintf("-%d", lines)) } // CapturePaneAll captures all scrollback history. func (t *Tmux) CapturePaneAll(session string) (string, error) { return t.run("capture-pane", "-p", "-t", session, "-S", "-") } // CapturePaneLines captures the last N lines of a pane as a slice. func (t *Tmux) CapturePaneLines(session string, lines int) ([]string, error) { out, err := t.CapturePane(session, lines) if err != nil { return nil, err } if out == "" { return nil, nil } return strings.Split(out, "\n"), nil } // AttachSession attaches to an existing session. // Note: This replaces the current process with tmux attach. func (t *Tmux) AttachSession(session string) error { _, err := t.run("attach-session", "-t", session) return err } // SelectWindow selects a window by index. func (t *Tmux) SelectWindow(session string, index int) error { _, err := t.run("select-window", "-t", fmt.Sprintf("%s:%d", session, index)) return err } // SetEnvironment sets an environment variable in the session. func (t *Tmux) SetEnvironment(session, key, value string) error { _, err := t.run("set-environment", "-t", session, key, value) return err } // GetEnvironment gets an environment variable from the session. func (t *Tmux) GetEnvironment(session, key string) (string, error) { out, err := t.run("show-environment", "-t", session, key) if err != nil { return "", err } // Output format: KEY=value parts := strings.SplitN(out, "=", 2) if len(parts) != 2 { return "", nil } return parts[1], nil } // RenameSession renames a session. func (t *Tmux) RenameSession(oldName, newName string) error { _, err := t.run("rename-session", "-t", oldName, newName) return err } // SessionInfo contains information about a tmux session. type SessionInfo struct { Name string Windows int Created string Attached bool Activity string // Last activity time LastAttached string // Last time the session was attached } // DisplayMessage shows a message in the tmux status line. // This is non-disruptive - it doesn't interrupt the session's input. // Duration is specified in milliseconds. func (t *Tmux) DisplayMessage(session, message string, durationMs int) error { // Set display time temporarily, show message, then restore // Use -d flag for duration in tmux 2.9+ _, err := t.run("display-message", "-t", session, "-d", fmt.Sprintf("%d", durationMs), message) return err } // DisplayMessageDefault shows a message with default duration (5 seconds). func (t *Tmux) DisplayMessageDefault(session, message string) error { return t.DisplayMessage(session, message, 5000) } // SendNotificationBanner sends a visible notification banner to a tmux session. // This interrupts the terminal to ensure the notification is seen. // Uses echo to print a boxed banner with the notification details. func (t *Tmux) SendNotificationBanner(session, from, subject string) error { // Build the banner text banner := fmt.Sprintf(`echo ' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📬 NEW MAIL from %s Subject: %s Run: gt mail inbox ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ '`, from, subject) return t.SendKeys(session, banner) } // IsClaudeRunning checks if Claude appears to be running in the session. // Only trusts the pane command - UI markers in scrollback cause false positives. func (t *Tmux) IsClaudeRunning(session string) bool { // Check pane command - Claude runs as node cmd, err := t.GetPaneCommand(session) if err != nil { return false } return cmd == "node" } // WaitForCommand polls until the pane is NOT running one of the excluded commands. // Useful for waiting until a shell has started a new process (e.g., claude). // Returns nil when a non-excluded command is detected, or error on timeout. func (t *Tmux) WaitForCommand(session string, excludeCommands []string, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { cmd, err := t.GetPaneCommand(session) if err != nil { time.Sleep(100 * time.Millisecond) continue } // Check if current command is NOT in the exclude list excluded := false for _, exc := range excludeCommands { if cmd == exc { excluded = true break } } if !excluded { return nil } time.Sleep(100 * time.Millisecond) } return fmt.Errorf("timeout waiting for command (still running excluded command)") } // WaitForShellReady polls until the pane is running a shell command. // Useful for waiting until a process has exited and returned to shell. func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error { shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { cmd, err := t.GetPaneCommand(session) if err != nil { time.Sleep(100 * time.Millisecond) continue } for _, shell := range shells { if cmd == shell { return nil } } time.Sleep(100 * time.Millisecond) } return fmt.Errorf("timeout waiting for shell") } // WaitForClaudeReady polls until Claude's prompt indicator appears in the pane. // Claude is ready when we see "> " at the start of a line (the input prompt). // This is more reliable than just checking if node is running. func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { // Capture last few lines of the pane lines, err := t.CapturePaneLines(session, 10) if err != nil { time.Sleep(200 * time.Millisecond) continue } // Look for Claude's prompt indicator "> " at start of line for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "> ") || trimmed == ">" { return nil } } time.Sleep(200 * time.Millisecond) } return fmt.Errorf("timeout waiting for Claude prompt") } // GetSessionInfo returns detailed information about a session. func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) { format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}|#{session_activity}|#{session_last_attached}" out, err := t.run("list-sessions", "-F", format, "-f", fmt.Sprintf("#{==:#{session_name},%s}", name)) if err != nil { return nil, err } if out == "" { return nil, ErrSessionNotFound } parts := strings.Split(out, "|") if len(parts) < 4 { return nil, fmt.Errorf("unexpected session info format: %s", out) } windows := 0 _, _ = fmt.Sscanf(parts[1], "%d", &windows) info := &SessionInfo{ Name: parts[0], Windows: windows, Created: parts[2], Attached: parts[3] == "1", } // Activity and last attached are optional (may not be present in older tmux) if len(parts) > 4 { info.Activity = parts[4] } if len(parts) > 5 { info.LastAttached = parts[5] } return info, nil } // ApplyTheme sets the status bar style for a session. func (t *Tmux) ApplyTheme(session string, theme Theme) error { _, err := t.run("set-option", "-t", session, "status-style", theme.Style()) return err } // roleIcons maps role names to display icons for the status bar. var roleIcons = map[string]string{ "coordinator": "🎩", // Mayor "health-check": "🦉", // Deacon "witness": "👁", "refinery": "🏭", "crew": "👷", "polecat": "😺", } // SetStatusFormat configures the left side of the status bar. // Shows compact identity: icon + minimal context func (t *Tmux) SetStatusFormat(session, rig, worker, role string) error { // Get icon for role (empty string if not found) icon := roleIcons[role] // Compact format - icon already identifies role // Mayor: 🎩 Mayor // Crew: 👷 gastown/crew/max (full path) // Polecat: 😺 gastown/Toast var left string if rig == "" { // Town-level agent (Mayor, Deacon) left = fmt.Sprintf("%s %s ", icon, worker) } else if role == "crew" { // Crew member - show full path: rig/crew/name left = fmt.Sprintf("%s %s/crew/%s ", icon, rig, worker) } else { // Rig-level agent - show rig/worker left = fmt.Sprintf("%s %s/%s ", icon, rig, worker) } if _, err := t.run("set-option", "-t", session, "status-left-length", "25"); err != nil { return err } _, err := t.run("set-option", "-t", session, "status-left", left) return err } // SetDynamicStatus configures the right side with dynamic content. // Uses a shell command that tmux calls periodically to get current status. func (t *Tmux) SetDynamicStatus(session string) error { // tmux calls this command every status-interval seconds // gt status-line reads env vars and mail to build the status right := fmt.Sprintf(`#(gt status-line --session=%s 2>/dev/null) %%H:%%M`, session) if _, err := t.run("set-option", "-t", session, "status-right-length", "80"); err != nil { return err } // Set faster refresh for more responsive status if _, err := t.run("set-option", "-t", session, "status-interval", "5"); err != nil { return err } _, err := t.run("set-option", "-t", session, "status-right", right) return err } // ConfigureGasTownSession applies full Gas Town theming to a session. // This is a convenience method that applies theme, status format, and dynamic status. func (t *Tmux) ConfigureGasTownSession(session string, theme Theme, rig, worker, role string) error { if err := t.ApplyTheme(session, theme); err != nil { return fmt.Errorf("applying theme: %w", err) } if err := t.SetStatusFormat(session, rig, worker, role); err != nil { return fmt.Errorf("setting status format: %w", err) } if err := t.SetDynamicStatus(session); err != nil { return fmt.Errorf("setting dynamic status: %w", err) } if err := t.SetMailClickBinding(session); err != nil { return fmt.Errorf("setting mail click binding: %w", err) } return nil } // LinkWindow links a window from another session into the current session. // This allows viewing another session's window as a tab without switching sessions. // Useful when already inside tmux and want to see another session. // Uses -d flag to NOT auto-select the new window, keeping user in current window. func (t *Tmux) LinkWindow(sourceSession string, windowIndex int) error { source := fmt.Sprintf("%s:%d", sourceSession, windowIndex) _, err := t.run("link-window", "-s", source, "-d") return err } // IsInsideTmux checks if the current process is running inside a tmux session. // This is detected by the presence of the TMUX environment variable. func IsInsideTmux() bool { return os.Getenv("TMUX") != "" } // SetMailClickBinding configures left-click on status-right to show mail preview. // This creates a popup showing the first unread message when clicking the mail icon area. func (t *Tmux) SetMailClickBinding(session string) error { // Bind left-click on status-right to show mail popup // The popup runs gt mail peek and closes on any key _, err := t.run("bind-key", "-T", "root", "MouseDown1StatusRight", "display-popup", "-E", "-w", "60", "-h", "15", "gt mail peek || echo 'No unread mail'") return err } // RespawnPane kills all processes in a pane and starts a new command. // This is used for "hot reload" of agent sessions - instantly restart in place. // The pane parameter should be a pane ID (e.g., "%0") or session:window.pane format. func (t *Tmux) RespawnPane(pane, command string) error { _, err := t.run("respawn-pane", "-k", "-t", pane, command) return err } // ClearHistory clears the scrollback history buffer for a pane. // This resets copy-mode display from [0/N] to [0/0]. // The pane parameter should be a pane ID (e.g., "%0") or session:window.pane format. func (t *Tmux) ClearHistory(pane string) error { _, err := t.run("clear-history", "-t", pane) return err } // SwitchClient switches the current tmux client to a different session. // Used after remote recycle to move the user's view to the recycled session. func (t *Tmux) SwitchClient(targetSession string) error { _, err := t.run("switch-client", "-t", targetSession) return err } // SetCrewCycleBindings sets up C-b n/p to cycle through crew sessions in the same rig. // This allows quick switching between crew members without using the session picker. func (t *Tmux) SetCrewCycleBindings(session string) error { // C-b n → gt crew next (switch to next crew session) if _, err := t.run("bind-key", "-T", "prefix", "n", "run-shell", "gt crew next"); err != nil { return err } // C-b p → gt crew prev (switch to previous crew session) if _, err := t.run("bind-key", "-T", "prefix", "p", "run-shell", "gt crew prev"); err != nil { return err } return nil }