Files
gastown/internal/tmux/tmux.go
Steve Yegge d8f01171c8 feat(deacon): design and beads for Deacon health orchestrator
- Update gt-5af with full Deacon design (AI agent, not just Go daemon)
- Create 8 implementation tasks as children of gt-5af
- Add AGENTS.md for agent onboarding
- Fix crew restart timing (add debounce, delay for Claude init)
- Add SendKeysDelayedDebounced to tmux for better prompt injection

Deacon will monitor Mayor/Witnesses proactively and handle lifecycle
requests from Mayor/Witnesses/Crew reactively.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:19:28 -08:00

402 lines
13 KiB
Go

// Package tmux provides a wrapper for tmux session operations via subprocess.
package tmux
import (
"bytes"
"errors"
"fmt"
"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
}
// 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
}
// 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)
}
// 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
}
// 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", "-")
}
// 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
}
// 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: bd 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")
}
// 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}"
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)
return &SessionInfo{
Name: parts[0],
Windows: windows,
Created: parts[2],
Attached: parts[3] == "1",
}, 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
}
// SetStatusFormat configures the left side of the status bar.
// Shows: [rig/worker] role
func (t *Tmux) SetStatusFormat(session, rig, worker, role string) error {
// Format: [gastown/Rictus] polecat
var left string
if rig == "" {
// Mayor or other top-level agent
left = fmt.Sprintf("[%s] %s ", worker, role)
} else {
left = fmt.Sprintf("[%s/%s] %s ", rig, worker, role)
}
// Allow enough room for the identity
if _, err := t.run("set-option", "-t", session, "status-left-length", "40"); 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", "50"); 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)
}
return nil
}