Files
gastown/internal/tmux/tmux.go
diesel 8b2dc39e88 fix(handoff): prevent race condition when killing pane processes
KillPaneProcesses was killing ALL processes in the pane, including the
gt handoff process itself. This created a race condition where the
process could be killed before RespawnPane executes, causing the pane
to close prematurely and requiring manual reattach.

Added KillPaneProcessesExcluding() function that excludes specified PIDs
from being killed. The handoff command now passes its own PID to avoid
the race condition.

Fixes: gt-85qd

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:15:29 -08:00

1599 lines
53 KiB
Go

// Package tmux provides a wrapper for tmux session operations via subprocess.
package tmux
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
)
// sessionNudgeLocks serializes nudges to the same session.
// This prevents interleaving when multiple nudges arrive concurrently,
// which can cause garbled input and missed Enter keys.
var sessionNudgeLocks sync.Map // map[string]*sync.Mutex
// versionPattern matches Claude Code version numbers like "2.0.76"
var versionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
// validSessionNameRe validates session names to prevent shell injection
var validSessionNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// 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") ||
strings.Contains(stderr, "no current target") {
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
}
// NewSessionWithCommand creates a new detached tmux session that immediately runs a command.
// Unlike NewSession + SendKeys, this avoids race conditions where the shell isn't ready
// or the command arrives before the shell prompt. The command runs directly as the
// initial process of the pane.
// See: https://github.com/anthropics/gastown/issues/280
func (t *Tmux) NewSessionWithCommand(name, workDir, command string) error {
args := []string{"new-session", "-d", "-s", name}
if workDir != "" {
args = append(args, "-c", workDir)
}
// Add the command as the last argument - tmux runs it as the pane's initial process
args = append(args, command)
_, err := t.run(args...)
return err
}
// EnsureSessionFresh ensures a session is available and healthy.
// If the session exists but is a zombie (Claude not running), it kills the session first.
// This prevents "session already exists" errors when trying to restart dead agents.
//
// A session is considered a zombie if:
// - The tmux session exists
// - But Claude (node process) is not running in it
//
// Returns nil if session was created successfully.
func (t *Tmux) EnsureSessionFresh(name, workDir string) error {
// Check if session already exists
exists, err := t.HasSession(name)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if exists {
// Session exists - check if it's a zombie
if !t.IsAgentRunning(name) {
// Zombie session: tmux alive but Claude dead
// Kill it so we can create a fresh one
// Use KillSessionWithProcesses to ensure all descendant processes are killed
if err := t.KillSessionWithProcesses(name); err != nil {
return fmt.Errorf("killing zombie session: %w", err)
}
} else {
// Session is healthy (Claude running) - nothing to do
return nil
}
}
// Create fresh session
return t.NewSession(name, workDir)
}
// KillSession terminates a tmux session.
func (t *Tmux) KillSession(name string) error {
_, err := t.run("kill-session", "-t", name)
return err
}
// processKillGracePeriod is how long to wait after SIGTERM before sending SIGKILL.
// 2 seconds gives processes time to clean up gracefully. The previous 100ms was too short
// and caused Claude processes to become orphans when they couldn't shut down in time.
const processKillGracePeriod = 2 * time.Second
// KillSessionWithProcesses explicitly kills all processes in a session before terminating it.
// This prevents orphan processes that survive tmux kill-session due to SIGHUP being ignored.
//
// Process:
// 1. Get the pane's main process PID and its process group ID (PGID)
// 2. Kill the entire process group (catches reparented processes that stayed in the group)
// 3. Find all descendant processes recursively (catches any stragglers)
// 4. Send SIGTERM/SIGKILL to descendants
// 5. Kill the pane process itself
// 6. Kill the tmux session
//
// The process group kill is critical because:
// - pgrep -P only finds direct children (PPID matching)
// - Processes that reparent to init (PID 1) are missed by pgrep
// - But they typically stay in the same process group unless they call setsid()
//
// This ensures Claude processes and all their children are properly terminated.
func (t *Tmux) KillSessionWithProcesses(name string) error {
// Get the pane PID
pid, err := t.GetPanePID(name)
if err != nil {
// Session might not exist or be in bad state, try direct kill
return t.KillSession(name)
}
if pid != "" {
// First, kill the entire process group. This catches processes that:
// - Reparented to init (PID 1) when their parent died
// - Are not direct children but stayed in the same process group
// Note: Processes that called setsid() will have a new PGID and won't be killed here
pgid := getProcessGroupID(pid)
if pgid != "" && pgid != "0" && pgid != "1" {
// Kill process group with negative PGID (POSIX convention)
// Use SIGTERM first for graceful shutdown
_ = exec.Command("kill", "-TERM", "-"+pgid).Run()
time.Sleep(100 * time.Millisecond)
// Force kill any remaining processes in the group
_ = exec.Command("kill", "-KILL", "-"+pgid).Run()
}
// Also walk the process tree for any descendants that might have called setsid()
// and created their own process groups (rare but possible)
descendants := getAllDescendants(pid)
// Send SIGTERM to all descendants (deepest first to avoid orphaning)
for _, dpid := range descendants {
_ = exec.Command("kill", "-TERM", dpid).Run()
}
// Wait for graceful shutdown (2s gives processes time to clean up)
time.Sleep(processKillGracePeriod)
// Send SIGKILL to any remaining descendants
for _, dpid := range descendants {
_ = exec.Command("kill", "-KILL", dpid).Run()
}
// Kill the pane process itself (may have called setsid() and detached)
_ = exec.Command("kill", "-TERM", pid).Run()
time.Sleep(processKillGracePeriod)
_ = exec.Command("kill", "-KILL", pid).Run()
}
// Kill the tmux session
// Ignore "session not found" - killing the pane process may have already
// caused tmux to destroy the session automatically
err = t.KillSession(name)
if err == ErrSessionNotFound {
return nil
}
return err
}
// KillSessionWithProcessesExcluding is like KillSessionWithProcesses but excludes
// specified PIDs from being killed. This is essential for self-kill scenarios where
// the calling process (e.g., gt done) is running inside the session it's terminating.
// Without exclusion, the caller would be killed before completing the cleanup.
func (t *Tmux) KillSessionWithProcessesExcluding(name string, excludePIDs []string) error {
// Build exclusion set for O(1) lookup
exclude := make(map[string]bool)
for _, pid := range excludePIDs {
exclude[pid] = true
}
// Get the pane PID
pid, err := t.GetPanePID(name)
if err != nil {
// Session might not exist or be in bad state, try direct kill
return t.KillSession(name)
}
if pid != "" {
// Get the process group ID
pgid := getProcessGroupID(pid)
// Collect all PIDs to kill (from multiple sources)
toKill := make(map[string]bool)
// 1. Get all process group members (catches reparented processes)
if pgid != "" && pgid != "0" && pgid != "1" {
for _, member := range getProcessGroupMembers(pgid) {
if !exclude[member] {
toKill[member] = true
}
}
}
// 2. Get all descendant PIDs recursively (catches processes that called setsid())
descendants := getAllDescendants(pid)
for _, dpid := range descendants {
if !exclude[dpid] {
toKill[dpid] = true
}
}
// Convert to slice for iteration
var killList []string
for p := range toKill {
killList = append(killList, p)
}
// Send SIGTERM to all non-excluded processes
for _, dpid := range killList {
_ = exec.Command("kill", "-TERM", dpid).Run()
}
// Wait for graceful shutdown (2s gives processes time to clean up)
time.Sleep(processKillGracePeriod)
// Send SIGKILL to any remaining non-excluded processes
for _, dpid := range killList {
_ = exec.Command("kill", "-KILL", dpid).Run()
}
// Kill the pane process itself (may have called setsid() and detached)
// Only if not excluded
if !exclude[pid] {
_ = exec.Command("kill", "-TERM", pid).Run()
time.Sleep(processKillGracePeriod)
_ = exec.Command("kill", "-KILL", pid).Run()
}
}
// Kill the tmux session - this will terminate the excluded process too
// Ignore "session not found" - if we killed all non-excluded processes,
// tmux may have already destroyed the session automatically
err = t.KillSession(name)
if err == ErrSessionNotFound {
return nil
}
return err
}
// getAllDescendants recursively finds all descendant PIDs of a process.
// Returns PIDs in deepest-first order so killing them doesn't orphan grandchildren.
func getAllDescendants(pid string) []string {
var result []string
// Get direct children using pgrep
out, err := exec.Command("pgrep", "-P", pid).Output()
if err != nil {
return result
}
children := strings.Fields(strings.TrimSpace(string(out)))
for _, child := range children {
// First add grandchildren (recursively) - deepest first
result = append(result, getAllDescendants(child)...)
// Then add this child
result = append(result, child)
}
return result
}
// getProcessGroupID returns the process group ID (PGID) for a given PID.
// Returns empty string if the process doesn't exist or PGID can't be determined.
func getProcessGroupID(pid string) string {
out, err := exec.Command("ps", "-o", "pgid=", "-p", pid).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// getProcessGroupMembers returns all PIDs in a process group.
// This finds processes that share the same PGID, including those that reparented to init.
func getProcessGroupMembers(pgid string) []string {
// Use ps to find all processes with this PGID
// On macOS: ps -axo pid,pgid
// On Linux: ps -eo pid,pgid
out, err := exec.Command("ps", "-axo", "pid,pgid").Output()
if err != nil {
return nil
}
var members []string
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && strings.TrimSpace(fields[1]) == pgid {
members = append(members, strings.TrimSpace(fields[0]))
}
}
return members
}
// KillPaneProcesses explicitly kills all processes associated with a tmux pane.
// This prevents orphan processes that survive pane respawn due to SIGHUP being ignored.
//
// Process:
// 1. Get the pane's main process PID and its process group ID (PGID)
// 2. Kill the entire process group (catches reparented processes)
// 3. Find all descendant processes recursively (catches any stragglers)
// 4. Send SIGTERM/SIGKILL to descendants
// 5. Kill the pane process itself
//
// This ensures Claude processes and all their children are properly terminated
// before respawning the pane.
func (t *Tmux) KillPaneProcesses(pane string) error {
// Get the pane PID
pid, err := t.GetPanePID(pane)
if err != nil {
return fmt.Errorf("getting pane PID: %w", err)
}
if pid == "" {
return fmt.Errorf("pane PID is empty")
}
// First, kill the entire process group. This catches processes that:
// - Reparented to init (PID 1) when their parent died
// - Are not direct children but stayed in the same process group
pgid := getProcessGroupID(pid)
if pgid != "" && pgid != "0" && pgid != "1" {
// Kill process group with negative PGID (POSIX convention)
_ = exec.Command("kill", "-TERM", "-"+pgid).Run()
time.Sleep(100 * time.Millisecond)
_ = exec.Command("kill", "-KILL", "-"+pgid).Run()
}
// Also walk the process tree for any descendants that might have called setsid()
descendants := getAllDescendants(pid)
// Send SIGTERM to all descendants (deepest first to avoid orphaning)
for _, dpid := range descendants {
_ = exec.Command("kill", "-TERM", dpid).Run()
}
// Wait for graceful shutdown (2s gives processes time to clean up)
time.Sleep(processKillGracePeriod)
// Send SIGKILL to any remaining descendants
for _, dpid := range descendants {
_ = exec.Command("kill", "-KILL", dpid).Run()
}
// Kill the pane process itself (may have called setsid() and detached,
// or may have no children like Claude Code)
_ = exec.Command("kill", "-TERM", pid).Run()
time.Sleep(processKillGracePeriod)
_ = exec.Command("kill", "-KILL", pid).Run()
return nil
}
// KillPaneProcessesExcluding is like KillPaneProcesses but excludes specified PIDs.
// This is essential for self-handoff scenarios where the calling process (e.g., gt handoff)
// is running inside the pane it's about to respawn. Without exclusion, the caller would
// be killed before completing the respawn operation, potentially leaving the pane in a
// broken state.
func (t *Tmux) KillPaneProcessesExcluding(pane string, excludePIDs []string) error {
// Build exclusion set for O(1) lookup
exclude := make(map[string]bool)
for _, pid := range excludePIDs {
exclude[pid] = true
}
// Get the pane PID
pid, err := t.GetPanePID(pane)
if err != nil {
return fmt.Errorf("getting pane PID: %w", err)
}
if pid == "" {
return fmt.Errorf("pane PID is empty")
}
// Collect PIDs to kill (excluding specified ones)
toKill := make(map[string]bool)
// First, collect process group members (catches reparented processes)
pgid := getProcessGroupID(pid)
if pgid != "" && pgid != "0" && pgid != "1" {
for _, member := range getProcessGroupMembers(pgid) {
if !exclude[member] {
toKill[member] = true
}
}
}
// Also walk the process tree for any descendants that might have called setsid()
descendants := getAllDescendants(pid)
for _, dpid := range descendants {
if !exclude[dpid] {
toKill[dpid] = true
}
}
// Convert to slice for iteration
var killList []string
for dpid := range toKill {
killList = append(killList, dpid)
}
// Send SIGTERM to all non-excluded processes
for _, dpid := range killList {
_ = exec.Command("kill", "-TERM", dpid).Run()
}
// Wait for graceful shutdown (2s gives processes time to clean up)
time.Sleep(processKillGracePeriod)
// Send SIGKILL to any remaining non-excluded processes
for _, dpid := range killList {
_ = exec.Command("kill", "-KILL", dpid).Run()
}
// Kill the pane process itself only if not excluded
if !exclude[pid] {
_ = exec.Command("kill", "-TERM", pid).Run()
time.Sleep(processKillGracePeriod)
_ = exec.Command("kill", "-KILL", pid).Run()
}
return nil
}
// 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
}
// SetExitEmpty controls the tmux exit-empty server option.
// When on (default), the server exits when there are no sessions.
// When off, the server stays running even with no sessions.
// This is useful during shutdown to prevent the server from exiting
// when all Gas Town sessions are killed but the user has no other sessions.
func (t *Tmux) SetExitEmpty(on bool) error {
value := "on"
if !on {
value = "off"
}
_, err := t.run("set-option", "-g", "exit-empty", value)
if errors.Is(err, ErrNoServer) {
return nil // No server to configure
}
return err
}
// IsAvailable checks if tmux is installed and can be invoked.
func (t *Tmux) IsAvailable() bool {
cmd := exec.Command("tmux", "-V")
return cmd.Run() == nil
}
// HasSession checks if a session exists (exact match).
// Uses "=" prefix for exact matching, preventing prefix matches
// (e.g., "gt-deacon-boot" won't match when checking for "gt-deacon").
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
}
// SessionSet provides O(1) session existence checks by caching session names.
// Use this when you need to check multiple sessions to avoid N+1 subprocess calls.
type SessionSet struct {
sessions map[string]struct{}
}
// GetSessionSet returns a SessionSet containing all current sessions.
// Call this once at the start of an operation, then use Has() for O(1) checks.
// This replaces multiple HasSession() calls with a single ListSessions() call.
//
// Builds the map directly from tmux output to avoid intermediate slice allocation.
func (t *Tmux) GetSessionSet() (*SessionSet, error) {
out, err := t.run("list-sessions", "-F", "#{session_name}")
if err != nil {
if errors.Is(err, ErrNoServer) {
return &SessionSet{sessions: make(map[string]struct{})}, nil
}
return nil, err
}
// Count newlines to pre-size map (avoids rehashing during insertion)
count := strings.Count(out, "\n") + 1
set := &SessionSet{
sessions: make(map[string]struct{}, count),
}
// Parse directly without intermediate slice allocation
for len(out) > 0 {
idx := strings.IndexByte(out, '\n')
var line string
if idx >= 0 {
line = out[:idx]
out = out[idx+1:]
} else {
line = out
out = ""
}
if line != "" {
set.sessions[line] = struct{}{}
}
}
return set, nil
}
// Has returns true if the session exists in the set.
// This is an O(1) lookup - no subprocess is spawned.
func (s *SessionSet) Has(name string) bool {
if s == nil {
return false
}
_, ok := s.sessions[name]
return ok
}
// Names returns all session names in the set.
func (s *SessionSet) Names() []string {
if s == nil || len(s.sessions) == 0 {
return nil
}
names := make([]string, 0, len(s.sessions))
for name := range s.sessions {
names = append(names, name)
}
return names
}
// ListSessionIDs returns a map of session name to session ID.
// Session IDs are in the format "$N" where N is a number.
func (t *Tmux) ListSessionIDs() (map[string]string, error) {
out, err := t.run("list-sessions", "-F", "#{session_name}:#{session_id}")
if err != nil {
if errors.Is(err, ErrNoServer) {
return nil, nil // No server = no sessions
}
return nil, err
}
if out == "" {
return nil, nil
}
result := make(map[string]string)
skipped := 0
for _, line := range strings.Split(out, "\n") {
if line == "" {
continue
}
// Parse "name:$id" format
idx := strings.Index(line, ":")
if idx > 0 && idx < len(line)-1 {
name := line[:idx]
id := line[idx+1:]
result[name] = id
} else {
skipped++
}
}
// Note: skipped lines are silently ignored for backward compatibility
_ = skipped
return result, 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, constants.DefaultDebounceMs) // 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)
}
// getSessionNudgeLock returns the mutex for serializing nudges to a session.
// Creates a new mutex if one doesn't exist for this session.
func getSessionNudgeLock(session string) *sync.Mutex {
actual, _ := sessionNudgeLocks.LoadOrStore(session, &sync.Mutex{})
return actual.(*sync.Mutex)
}
// 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 + ESC (for vim mode) + separate Enter.
// Verification is the Witness's job (AI), not this function.
//
// IMPORTANT: Nudges to the same session are serialized to prevent interleaving.
// If multiple goroutines try to nudge the same session concurrently, they will
// queue up and execute one at a time. This prevents garbled input when
// SessionStart hooks and nudges arrive simultaneously.
func (t *Tmux) NudgeSession(session, message string) error {
// Serialize nudges to this session to prevent interleaving
lock := getSessionNudgeLock(session)
lock.Lock()
defer lock.Unlock()
// 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 Escape to exit vim INSERT mode if enabled (harmless in normal mode)
// See: https://github.com/anthropics/gastown/issues/307
_, _ = t.run("send-keys", "-t", session, "Escape")
time.Sleep(100 * time.Millisecond)
// 4. Send Enter with retry (critical for message submission)
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(200 * time.Millisecond)
}
if _, err := t.run("send-keys", "-t", session, "Enter"); err != nil {
lastErr = err
continue
}
return nil
}
return fmt.Errorf("failed to send Enter after 3 attempts: %w", lastErr)
}
// 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.
// Nudges to the same pane are serialized to prevent interleaving.
func (t *Tmux) NudgePane(pane, message string) error {
// Serialize nudges to this pane to prevent interleaving
lock := getSessionNudgeLock(pane)
lock.Lock()
defer lock.Unlock()
// 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 Escape to exit vim INSERT mode if enabled (harmless in normal mode)
// See: https://github.com/anthropics/gastown/issues/307
_, _ = t.run("send-keys", "-t", pane, "Escape")
time.Sleep(100 * time.Millisecond)
// 4. Send Enter with retry (critical for message submission)
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(200 * time.Millisecond)
}
if _, err := t.run("send-keys", "-t", pane, "Enter"); err != nil {
lastErr = err
continue
}
return nil
}
return fmt.Errorf("failed to send Enter after 3 attempts: %w", lastErr)
}
// AcceptBypassPermissionsWarning dismisses the Claude Code bypass permissions warning dialog.
// When Claude starts with --dangerously-skip-permissions, it shows a warning dialog that
// requires pressing Down arrow to select "Yes, I accept" and then Enter to confirm.
// This function checks if the warning is present before sending keys to avoid interfering
// with sessions that don't show the warning (e.g., already accepted or different config).
//
// Call this after starting Claude and waiting for it to initialize (WaitForCommand),
// but before sending any prompts.
func (t *Tmux) AcceptBypassPermissionsWarning(session string) error {
// Wait for the dialog to potentially render
time.Sleep(1 * time.Second)
// Check if the bypass permissions warning is present
content, err := t.CapturePane(session, 30)
if err != nil {
return err
}
// Look for the characteristic warning text
if !strings.Contains(content, "Bypass Permissions mode") {
// Warning not present, nothing to do
return nil
}
// Press Down to select "Yes, I accept" (option 2)
if _, err := t.run("send-keys", "-t", session, "Down"); err != nil {
return err
}
// Small delay to let selection update
time.Sleep(200 * time.Millisecond)
// Press Enter to confirm
if _, err := t.run("send-keys", "-t", session, "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
}
// GetPaneID returns the pane identifier for a session's first pane.
// Returns a pane ID like "%0" that can be used with RespawnPane.
func (t *Tmux) GetPaneID(session string) (string, error) {
out, err := t.run("list-panes", "-t", session, "-F", "#{pane_id}")
if err != nil {
return "", err
}
lines := strings.Split(out, "\n")
if len(lines) == 0 || lines[0] == "" {
return "", fmt.Errorf("no panes found in session %s", session)
}
return lines[0], 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
}
// GetPanePID returns the PID of the pane's main process.
func (t *Tmux) GetPanePID(session string) (string, error) {
out, err := t.run("list-panes", "-t", session, "-F", "#{pane_pid}")
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
// hasClaudeChild checks if a process has a child running claude/node.
// Used when the pane command is a shell (bash, zsh) that launched claude.
func hasClaudeChild(pid string) bool {
// Use pgrep to find child processes
cmd := exec.Command("pgrep", "-P", pid, "-l")
out, err := cmd.Output()
if err != nil {
return false
}
// Check if any child is node or claude
lines := strings.Split(string(out), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Format: "PID name" e.g., "29677 node"
parts := strings.Fields(line)
if len(parts) >= 2 {
name := parts[1]
if name == "node" || name == "claude" {
return true
}
}
}
return false
}
// FindSessionByWorkDir finds tmux sessions where the pane's current working directory
// matches or is under the target directory. Returns session names that match.
// If processNames is provided, only returns sessions that match those processes.
// If processNames is nil or empty, returns all sessions matching the directory.
func (t *Tmux) FindSessionByWorkDir(targetDir string, processNames []string) ([]string, error) {
sessions, err := t.ListSessions()
if err != nil {
return nil, err
}
var matches []string
for _, session := range sessions {
if session == "" {
continue
}
workDir, err := t.GetPaneWorkDir(session)
if err != nil {
continue // Skip sessions we can't query
}
// Check if workdir matches target (exact match or subdir)
if workDir == targetDir || strings.HasPrefix(workDir, targetDir+"/") {
if len(processNames) > 0 {
if t.IsRuntimeRunning(session, processNames) {
matches = append(matches, session)
}
continue
}
matches = append(matches, session)
}
}
return matches, 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 "", fmt.Errorf("unexpected environment format for %s: %q", key, out)
}
return parts[1], nil
}
// GetAllEnvironment returns all environment variables for a session.
func (t *Tmux) GetAllEnvironment(session string) (map[string]string, error) {
out, err := t.run("show-environment", "-t", session)
if err != nil {
return nil, err
}
env := make(map[string]string)
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "-") {
// Skip empty lines and unset markers (lines starting with -)
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
env[parts[0]] = parts[1]
}
}
return env, 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, constants.DefaultDisplayMs)
}
// 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 {
// Sanitize inputs to prevent output manipulation
from = strings.ReplaceAll(from, "\n", " ")
from = strings.ReplaceAll(from, "\r", " ")
subject = strings.ReplaceAll(subject, "\n", " ")
subject = strings.ReplaceAll(subject, "\r", " ")
// 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)
}
// IsAgentRunning checks if an agent appears to be running in the session.
//
// If expectedPaneCommands is non-empty, the pane's current command must match one of them.
// If expectedPaneCommands is empty, any non-shell command counts as "agent running".
func (t *Tmux) IsAgentRunning(session string, expectedPaneCommands ...string) bool {
cmd, err := t.GetPaneCommand(session)
if err != nil {
return false
}
if len(expectedPaneCommands) > 0 {
for _, expected := range expectedPaneCommands {
if expected != "" && cmd == expected {
return true
}
}
return false
}
// Fallback: any non-shell command counts as running.
for _, shell := range constants.SupportedShells {
if cmd == shell {
return false
}
}
return cmd != ""
}
// IsClaudeRunning checks if Claude appears to be running in the session.
// Only trusts the pane command - UI markers in scrollback cause false positives.
// Claude can report as "node", "claude", or a version number like "2.0.76".
// Also checks for child processes when the pane is a shell running claude via "bash -c".
func (t *Tmux) IsClaudeRunning(session string) bool {
// Check for known command names first
if t.IsAgentRunning(session, "node", "claude") {
return true
}
// Check for version pattern (e.g., "2.0.76") - Claude Code shows version as pane command
cmd, err := t.GetPaneCommand(session)
if err != nil {
return false
}
if versionPattern.MatchString(cmd) {
return true
}
// If pane command is a shell, check for claude/node child processes.
// This handles the case where sessions are started with "bash -c 'export ... && claude ...'"
for _, shell := range constants.SupportedShells {
if cmd == shell {
pid, err := t.GetPanePID(session)
if err == nil && pid != "" {
return hasClaudeChild(pid)
}
break
}
}
return false
}
// IsRuntimeRunning checks if a runtime appears to be running in the session.
// Only trusts the pane command - UI markers in scrollback cause false positives.
// This is the runtime-config-aware version of IsAgentRunning.
func (t *Tmux) IsRuntimeRunning(session string, processNames []string) bool {
if len(processNames) == 0 {
return false
}
cmd, err := t.GetPaneCommand(session)
if err != nil {
return false
}
for _, name := range processNames {
if cmd == name {
return true
}
}
return false
}
// 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(constants.PollInterval)
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(constants.PollInterval)
}
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 := constants.SupportedShells
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
cmd, err := t.GetPaneCommand(session)
if err != nil {
time.Sleep(constants.PollInterval)
continue
}
for _, shell := range shells {
if cmd == shell {
return nil
}
}
time.Sleep(constants.PollInterval)
}
return fmt.Errorf("timeout waiting for shell")
}
// WaitForRuntimeReady polls until the runtime's prompt indicator appears in the pane.
// Runtime is ready when we see the configured prompt prefix at the start of a line.
//
// IMPORTANT: Bootstrap vs Steady-State Observation
//
// This function uses regex to detect runtime prompts - a ZFC violation.
// ZFC (Zero False Commands) principle: AI should observe AI, not regex.
//
// Bootstrap (acceptable):
//
// During cold startup when no AI agent is running, the daemon uses this
// function to get the Deacon online. Regex is acceptable here.
//
// Steady-State (use AI observation instead):
//
// Once any AI agent is running, observation should be AI-to-AI:
// - Deacon starting polecats → use 'gt deacon pending' + AI analysis
// - Deacon restarting → Mayor watches via 'gt peek'
// - Mayor restarting → Deacon watches via 'gt peek'
//
// See: gt deacon pending (ZFC-compliant AI observation)
// See: gt deacon trigger-pending (bootstrap mode, regex-based)
func (t *Tmux) WaitForRuntimeReady(session string, rc *config.RuntimeConfig, timeout time.Duration) error {
if rc == nil || rc.Tmux == nil {
return nil
}
if rc.Tmux.ReadyPromptPrefix == "" {
if rc.Tmux.ReadyDelayMs <= 0 {
return nil
}
// Fallback to fixed delay when prompt detection is unavailable.
delay := time.Duration(rc.Tmux.ReadyDelayMs) * time.Millisecond
if delay > timeout {
delay = timeout
}
time.Sleep(delay)
return nil
}
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 runtime prompt indicator at start of line
for _, line := range lines {
trimmed := strings.TrimSpace(line)
prefix := strings.TrimSpace(rc.Tmux.ReadyPromptPrefix)
if strings.HasPrefix(trimmed, rc.Tmux.ReadyPromptPrefix) || (prefix != "" && trimmed == prefix) {
return nil
}
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for runtime 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) // non-fatal: defaults to 0 on parse error
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.
// Uses centralized emojis from constants package.
// Includes legacy keys ("coordinator", "health-check") for backwards compatibility.
var roleIcons = map[string]string{
// Standard role names (from constants)
constants.RoleMayor: constants.EmojiMayor,
constants.RoleDeacon: constants.EmojiDeacon,
constants.RoleWitness: constants.EmojiWitness,
constants.RoleRefinery: constants.EmojiRefinery,
constants.RoleCrew: constants.EmojiCrew,
constants.RolePolecat: constants.EmojiPolecat,
// Legacy names (for backwards compatibility)
"coordinator": constants.EmojiMayor,
"health-check": constants.EmojiDeacon,
}
// 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 {
// Validate session name to prevent shell injection
if !validSessionNameRe.MatchString(session) {
return fmt.Errorf("invalid session name %q: must match %s", session, validSessionNameRe.String())
}
// 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)
}
if err := t.SetFeedBinding(session); err != nil {
return fmt.Errorf("setting feed binding: %w", err)
}
if err := t.SetCycleBindings(session); err != nil {
return fmt.Errorf("setting cycle bindings: %w", err)
}
if err := t.EnableMouseMode(session); err != nil {
return fmt.Errorf("enabling mouse mode: %w", err)
}
return nil
}
// EnableMouseMode enables mouse support and clipboard integration for a tmux session.
// This allows clicking to select panes/windows, scrolling with mouse wheel,
// and dragging to resize panes. Hold Shift for native terminal text selection.
// Also enables clipboard integration so copied text goes to system clipboard.
func (t *Tmux) EnableMouseMode(session string) error {
if _, err := t.run("set-option", "-t", session, "mouse", "on"); err != nil {
return err
}
// Enable clipboard integration with terminal (OSC 52)
// This allows copying text to system clipboard when selecting with mouse
_, err := t.run("set-option", "-t", session, "set-clipboard", "on")
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
}
// SetRemainOnExit controls whether a pane stays around after its process exits.
// When on, the pane remains with "[Exited]" status, allowing respawn-pane to restart it.
// When off (default), the pane is destroyed when its process exits.
// This is essential for handoff: set on before killing processes, so respawn-pane works.
func (t *Tmux) SetRemainOnExit(pane string, on bool) error {
value := "on"
if !on {
value = "off"
}
_, err := t.run("set-option", "-t", pane, "remain-on-exit", value)
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 sessions.
// This is now an alias for SetCycleBindings - the unified command detects
// session type automatically.
//
// IMPORTANT: We pass #{session_name} to the command because run-shell doesn't
// reliably preserve the session context. tmux expands #{session_name} at binding
// resolution time (when the key is pressed), giving us the correct session.
func (t *Tmux) SetCrewCycleBindings(session string) error {
return t.SetCycleBindings(session)
}
// SetTownCycleBindings sets up C-b n/p to cycle through sessions.
// This is now an alias for SetCycleBindings - the unified command detects
// session type automatically.
func (t *Tmux) SetTownCycleBindings(session string) error {
return t.SetCycleBindings(session)
}
// SetCycleBindings sets up C-b n/p to cycle through related sessions.
// The gt cycle command automatically detects the session type and cycles
// within the appropriate group:
// - Town sessions: Mayor ↔ Deacon
// - Crew sessions: All crew members in the same rig
//
// IMPORTANT: These bindings are conditional - they only run gt cycle for
// Gas Town sessions (those starting with "gt-" or "hq-"). For non-GT sessions,
// the default tmux behavior (next-window/previous-window) is preserved.
// See: https://github.com/steveyegge/gastown/issues/13
//
// IMPORTANT: We pass #{session_name} to the command because run-shell doesn't
// reliably preserve the session context. tmux expands #{session_name} at binding
// resolution time (when the key is pressed), giving us the correct session.
func (t *Tmux) SetCycleBindings(session string) error {
// C-b n → gt cycle next for GT sessions, next-window otherwise
// The if-shell checks if session name starts with "gt-" or "hq-"
if _, err := t.run("bind-key", "-T", "prefix", "n",
"if-shell", "echo '#{session_name}' | grep -Eq '^(gt|hq)-'",
"run-shell 'gt cycle next --session #{session_name}'",
"next-window"); err != nil {
return err
}
// C-b p → gt cycle prev for GT sessions, previous-window otherwise
if _, err := t.run("bind-key", "-T", "prefix", "p",
"if-shell", "echo '#{session_name}' | grep -Eq '^(gt|hq)-'",
"run-shell 'gt cycle prev --session #{session_name}'",
"previous-window"); err != nil {
return err
}
return nil
}
// SetFeedBinding configures C-b a to jump to the activity feed window.
// This creates the feed window if it doesn't exist, or switches to it if it does.
// Uses `gt feed --window` which handles both creation and switching.
//
// IMPORTANT: This binding is conditional - it only runs for Gas Town sessions
// (those starting with "gt-" or "hq-"). For non-GT sessions, a help message is shown.
// See: https://github.com/steveyegge/gastown/issues/13
func (t *Tmux) SetFeedBinding(session string) error {
// C-b a → gt feed --window for GT sessions, help message otherwise
_, err := t.run("bind-key", "-T", "prefix", "a",
"if-shell", "echo '#{session_name}' | grep -Eq '^(gt|hq)-'",
"run-shell 'gt feed --window'",
"display-message 'C-b a is for Gas Town sessions only'")
return err
}
// CleanupOrphanedSessions scans for zombie Gas Town sessions and kills them.
// A zombie session is one where tmux is alive but the Claude process has died.
// This runs at `gt start` time to prevent session name conflicts and resource accumulation.
//
// Returns:
// - cleaned: number of zombie sessions that were killed
// - err: error if session listing failed (individual kill errors are logged but not returned)
func (t *Tmux) CleanupOrphanedSessions() (cleaned int, err error) {
sessions, err := t.ListSessions()
if err != nil {
return 0, fmt.Errorf("listing sessions: %w", err)
}
for _, sess := range sessions {
// Only process Gas Town sessions (gt-* for rigs, hq-* for town-level)
if !strings.HasPrefix(sess, "gt-") && !strings.HasPrefix(sess, "hq-") {
continue
}
// Check if the session is a zombie (tmux alive, Claude dead)
if !t.IsClaudeRunning(sess) {
// Kill the zombie session
if killErr := t.KillSessionWithProcesses(sess); killErr != nil {
// Log but continue - other sessions may still need cleanup
fmt.Printf(" warning: failed to kill orphaned session %s: %v\n", sess, killErr)
continue
}
cleaned++
}
}
return cleaned, nil
}
// SetPaneDiedHook sets a pane-died hook on a session to detect crashes.
// When the pane exits, tmux runs the hook command with exit status info.
// The agentID is used to identify the agent in crash logs (e.g., "gastown/Toast").
func (t *Tmux) SetPaneDiedHook(session, agentID string) error {
// Sanitize inputs to prevent shell injection
session = strings.ReplaceAll(session, "'", "'\\''")
agentID = strings.ReplaceAll(agentID, "'", "'\\''")
// Hook command logs the crash with exit status
// #{pane_dead_status} is the exit code of the process that died
// We run gt log crash which records to the town log
hookCmd := fmt.Sprintf(`run-shell "gt log crash --agent '%s' --session '%s' --exit-code #{pane_dead_status}"`,
agentID, session)
// Set the hook on this specific session
_, err := t.run("set-hook", "-t", session, "pane-died", hookCmd)
return err
}