From b172fd4787535c32a1adbf2e68b0042d176a9c2d Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 16:36:25 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20rig=20infra=20cycling=20(witness?= =?UTF-8?q?=20=E2=86=94=20refinery)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extended the unified cycle system to include rig infrastructure sessions: - Witness ↔ Refinery (per rig) now cycle with C-b n/p Also moved SetCycleBindings into ConfigureGasTownSession so ALL Gas Town sessions automatically get the unified cycle bindings. Removed redundant individual calls from crew, mayor, and deacon startup code. Cycle groups are now: - Town: Mayor ↔ Deacon - Crew (per rig): All crew members in same rig - Infra (per rig): Witness ↔ Refinery 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew_at.go | 4 +- internal/cmd/cycle.go | 89 ++++++++++++++++++++++++++++++++++++++++- internal/cmd/deacon.go | 4 +- internal/cmd/mayor.go | 4 +- internal/cmd/start.go | 8 +--- internal/tmux/tmux.go | 3 ++ 6 files changed, 96 insertions(+), 16 deletions(-) diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go index e2373a59..e6c57f51 100644 --- a/internal/cmd/crew_at.go +++ b/internal/cmd/crew_at.go @@ -101,12 +101,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error { } // Apply rig-based theming (non-fatal: theming failure doesn't affect operation) + // Note: ConfigureGasTownSession includes cycle bindings theme := getThemeForRig(r.Name) _ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew") - // Set up C-b n/p keybindings for crew session cycling (non-fatal) - _ = t.SetCrewCycleBindings(sessionID) - // Wait for shell to be ready after session creation if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { return fmt.Errorf("waiting for shell: %w", err) diff --git a/internal/cmd/cycle.go b/internal/cmd/cycle.go index e3e36e3a..7312684c 100644 --- a/internal/cmd/cycle.go +++ b/internal/cmd/cycle.go @@ -1,6 +1,9 @@ package cmd import ( + "fmt" + "os/exec" + "sort" "strings" "github.com/spf13/cobra" @@ -28,6 +31,7 @@ var cycleCmd = &cobra.Command{ Session groups: - Town sessions: Mayor ↔ Deacon - Crew sessions: All crew members in the same rig (e.g., gastown/crew/max ↔ gastown/crew/joe) +- Rig infra sessions: Witness ↔ Refinery (per rig) The appropriate cycling is detected automatically from the session name.`, } @@ -83,6 +87,89 @@ func cycleToSession(direction int, sessionOverride string) error { return cycleCrewSession(direction, session) } - // Unknown session type (polecat, witness, refinery) - do nothing + // Check if it's a rig infra session (witness or refinery) + if rig := parseRigInfraSession(session); rig != "" { + return cycleRigInfraSession(direction, session, rig) + } + + // Unknown session type (polecat) - do nothing return nil } + +// parseRigInfraSession extracts rig name if this is a witness or refinery session. +// Returns empty string if not a rig infra session. +// Format: gt--witness or gt--refinery +func parseRigInfraSession(session string) string { + if !strings.HasPrefix(session, "gt-") { + return "" + } + rest := session[3:] // Remove "gt-" prefix + + // Check for -witness or -refinery suffix + if strings.HasSuffix(rest, "-witness") { + return strings.TrimSuffix(rest, "-witness") + } + if strings.HasSuffix(rest, "-refinery") { + return strings.TrimSuffix(rest, "-refinery") + } + return "" +} + +// cycleRigInfraSession cycles between witness and refinery sessions for a rig. +func cycleRigInfraSession(direction int, currentSession, rig string) error { + // Find running infra sessions for this rig + witnessSession := fmt.Sprintf("gt-%s-witness", rig) + refinerySession := fmt.Sprintf("gt-%s-refinery", rig) + + var sessions []string + allSessions, err := listTmuxSessions() + if err != nil { + return err + } + + for _, s := range allSessions { + if s == witnessSession || s == refinerySession { + sessions = append(sessions, s) + } + } + + if len(sessions) == 0 { + return nil // No infra sessions running + } + + // Sort for consistent ordering + sort.Strings(sessions) + + // Find current position + currentIdx := -1 + for i, s := range sessions { + if s == currentSession { + currentIdx = i + break + } + } + + if currentIdx == -1 { + return nil // Current session not in list + } + + // Calculate target index (with wrapping) + targetIdx := (currentIdx + direction + len(sessions)) % len(sessions) + + if targetIdx == currentIdx { + return nil // Only one session + } + + // Switch to target session + cmd := exec.Command("tmux", "switch-client", "-t", sessions[targetIdx]) + return cmd.Run() +} + +// listTmuxSessions returns all tmux session names. +func listTmuxSessions() ([]string, error) { + out, err := exec.Command("tmux", "list-sessions", "-F", "#{session_name}").Output() + if err != nil { + return nil, err + } + return splitLines(string(out)), nil +} diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 5904901f..14025420 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -185,12 +185,10 @@ func startDeaconSession(t *tmux.Tmux) error { _ = t.SetEnvironment(DeaconSessionName, "BD_ACTOR", "deacon") // Apply Deacon theme (non-fatal: theming failure doesn't affect operation) + // Note: ConfigureGasTownSession includes cycle bindings theme := tmux.DeaconTheme() _ = t.ConfigureGasTownSession(DeaconSessionName, theme, "", "Deacon", "health-check") - // Set up C-b n/p keybindings for town session cycling (non-fatal) - _ = t.SetTownCycleBindings(DeaconSessionName) - // Launch Claude directly (no shell respawn loop) // Restarts are handled by daemon via ensureDeaconRunning on each heartbeat // The startup hook handles context loading automatically diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index 76f37597..2ce3196b 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -123,12 +123,10 @@ func startMayorSession(t *tmux.Tmux) error { _ = t.SetEnvironment(MayorSessionName, "BD_ACTOR", "mayor") // Apply Mayor theme (non-fatal: theming failure doesn't affect operation) + // Note: ConfigureGasTownSession includes cycle bindings theme := tmux.MayorTheme() _ = t.ConfigureGasTownSession(MayorSessionName, theme, "", "Mayor", "coordinator") - // Set up C-b n/p keybindings for town session cycling (non-fatal) - _ = t.SetTownCycleBindings(MayorSessionName) - // Launch Claude - the startup hook handles 'gt prime' automatically // Use SendKeysDelayed to allow shell initialization after NewSession // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 4adf80f9..3bbe54b2 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -753,12 +753,10 @@ func runStartCrew(cmd *cobra.Command, args []string) error { } // Apply rig-based theming (non-fatal: theming failure doesn't affect operation) + // Note: ConfigureGasTownSession includes cycle bindings theme := getThemeForRig(rigName) _ = t.ConfigureGasTownSession(sessionID, theme, rigName, name, "crew") - // Set up C-b n/p keybindings for crew session cycling (non-fatal) - _ = t.SetCrewCycleBindings(sessionID) - // Wait for shell to be ready after session creation if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { return fmt.Errorf("waiting for shell: %w", err) @@ -887,12 +885,10 @@ func startCrewMember(rigName, crewName, townRoot string) error { _ = t.SetEnvironment(sessionID, "GT_CREW", crewName) // Apply rig-based theming + // Note: ConfigureGasTownSession includes cycle bindings theme := getThemeForRig(rigName) _ = t.ConfigureGasTownSession(sessionID, theme, rigName, crewName, "crew") - // Set up C-b n/p keybindings for crew session cycling - _ = t.SetCrewCycleBindings(sessionID) - // Wait for shell to be ready if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { return fmt.Errorf("waiting for shell: %w", err) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 1b7870d8..a199e481 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -611,6 +611,9 @@ func (t *Tmux) ConfigureGasTownSession(session string, theme Theme, rig, worker, 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) + } return nil }