From f32143de6ff527daa28f8714dd2fac492c891464 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 17:15:48 -0800 Subject: [PATCH] feat(doctor): add linked-panes check for tmux session crosstalk (gt-uohw) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects when multiple gt-* tmux sessions share the same pane, which causes messages sent to one session to appear in another. This catches the bug where gt-deacon and gt-mayor shared a pane, causing daemon heartbeats to appear as user input in the Mayor. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/doctor.go | 1 + internal/doctor/tmux_check.go | 148 ++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 internal/doctor/tmux_check.go diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 798936aa..4b33f5c8 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -61,6 +61,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewBranchCheck()) d.Register(doctor.NewBeadsSyncOrphanCheck()) d.Register(doctor.NewIdentityCollisionCheck()) + d.Register(doctor.NewLinkedPaneCheck()) d.Register(doctor.NewThemeCheck()) // Wisp storage checks diff --git a/internal/doctor/tmux_check.go b/internal/doctor/tmux_check.go new file mode 100644 index 00000000..61f3f3d5 --- /dev/null +++ b/internal/doctor/tmux_check.go @@ -0,0 +1,148 @@ +package doctor + +import ( + "fmt" + "os/exec" + "strings" + + "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)", + }, + }, + } +} + +// 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 gt-mayor since we don't want to kill it) + c.linkedSessions = nil + for session := range linkedSessionSet { + if session != "gt-mayor" { + c.linkedSessions = append(c.linkedSessions, session) + } + } + + 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 gt-mayor). +// 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 +}