package doctor import ( "fmt" "os/exec" "strings" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/tmux" ) // LinkedPaneCheck detects tmux sessions that share panes, // which can cause crosstalk (messages sent to one session appearing in another). type LinkedPaneCheck struct { FixableCheck linkedSessions []string // Sessions with linked panes, cached for Fix } // NewLinkedPaneCheck creates a new linked pane check. func NewLinkedPaneCheck() *LinkedPaneCheck { return &LinkedPaneCheck{ FixableCheck: FixableCheck{ BaseCheck: BaseCheck{ CheckName: "linked-panes", CheckDescription: "Detect tmux sessions sharing panes (causes crosstalk)", CheckCategory: CategoryInfrastructure, }, }, } } // Run checks for linked panes across Gas Town tmux sessions. func (c *LinkedPaneCheck) Run(ctx *CheckContext) *CheckResult { t := tmux.NewTmux() sessions, err := t.ListSessions() if err != nil { return &CheckResult{ Name: c.Name(), Status: StatusWarning, Message: "Could not list tmux sessions", Details: []string{err.Error()}, } } // Filter to gt-* sessions only var gtSessions []string for _, session := range sessions { if strings.HasPrefix(session, "gt-") { gtSessions = append(gtSessions, session) } } if len(gtSessions) < 2 { return &CheckResult{ Name: c.Name(), Status: StatusOK, Message: "Not enough sessions to check for linking", } } // Map pane IDs to sessions that contain them paneToSessions := make(map[string][]string) for _, session := range gtSessions { panes, err := c.getSessionPanes(session) if err != nil { continue } for _, pane := range panes { paneToSessions[pane] = append(paneToSessions[pane], session) } } // Find panes shared by multiple sessions var conflicts []string linkedSessionSet := make(map[string]bool) for pane, sessions := range paneToSessions { if len(sessions) > 1 { conflicts = append(conflicts, fmt.Sprintf("Pane %s shared by: %s", pane, strings.Join(sessions, ", "))) for _, s := range sessions { linkedSessionSet[s] = true } } } // Cache for Fix (exclude mayor session since we don't want to kill it) mayorSession := session.MayorSessionName() c.linkedSessions = nil for sess := range linkedSessionSet { if mayorSession == "" || sess != mayorSession { c.linkedSessions = append(c.linkedSessions, sess) } } if len(conflicts) == 0 { return &CheckResult{ Name: c.Name(), Status: StatusOK, Message: fmt.Sprintf("All %d Gas Town sessions have independent panes", len(gtSessions)), } } return &CheckResult{ Name: c.Name(), Status: StatusError, Message: fmt.Sprintf("Found %d linked pane(s) causing crosstalk!", len(conflicts)), Details: conflicts, FixHint: "Run 'gt doctor --fix' to kill linked sessions (daemon will recreate)", } } // Fix kills sessions with linked panes (except mayor session). // The daemon will recreate them with independent panes. func (c *LinkedPaneCheck) Fix(ctx *CheckContext) error { if len(c.linkedSessions) == 0 { return nil } t := tmux.NewTmux() var lastErr error for _, session := range c.linkedSessions { if err := t.KillSession(session); err != nil { lastErr = err } } return lastErr } // getSessionPanes returns all pane IDs for a session. func (c *LinkedPaneCheck) getSessionPanes(session string) ([]string, error) { // Get pane IDs using tmux list-panes with format // Using #{pane_id} which gives us the unique pane identifier like %123 // Note: -s flag lists all panes in all windows of this session (not -a which is global) out, err := exec.Command("tmux", "list-panes", "-t", session, "-s", "-F", "#{pane_id}").Output() if err != nil { return nil, err } var panes []string for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line != "" { panes = append(panes, line) } } return panes, nil }