From 22fe74559584cb33f352954e00688e9868379a43 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 23:12:19 -0800 Subject: [PATCH] Add gt recycle command for instant session hot-reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses tmux respawn-pane to kill and restart agent sessions in place, bypassing the handoff/manager flow for quick context cycling. Supports local recycle (current session) and remote recycle (other roles) with automatic client switching. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/recycle.go | 241 ++++++++++++++++++++++++++++++++++++++++ internal/tmux/tmux.go | 8 ++ 2 files changed, 249 insertions(+) create mode 100644 internal/cmd/recycle.go diff --git a/internal/cmd/recycle.go b/internal/cmd/recycle.go new file mode 100644 index 00000000..2a644961 --- /dev/null +++ b/internal/cmd/recycle.go @@ -0,0 +1,241 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" +) + +var recycleCmd = &cobra.Command{ + Use: "recycle [role]", + Short: "Hot-reload the current (or specified) agent session", + Long: `Instantly restart an agent session in place. + +This command uses tmux respawn-pane to kill the current session and restart it +with a fresh Claude instance, running the full startup/priming sequence. + +When run without arguments, recycles the current session. +When given a role name, recycles that role's session (and switches to it). + +Examples: + gt recycle # Recycle current session + gt recycle crew # Recycle crew session (auto-detect name) + gt recycle mayor # Recycle mayor session + gt recycle witness # Recycle witness session for current rig + +The command executes instantly - no handoff, no manager involved. +Use 'gt handoff' for graceful lifecycle transitions with context preservation.`, + RunE: runRecycle, +} + +var ( + recycleWatch bool + recycleDryRun bool +) + +func init() { + recycleCmd.Flags().BoolVarP(&recycleWatch, "watch", "w", true, "Switch to recycled session (for remote recycle)") + recycleCmd.Flags().BoolVarP(&recycleDryRun, "dry-run", "n", false, "Show what would be done without executing") + rootCmd.AddCommand(recycleCmd) +} + +func runRecycle(cmd *cobra.Command, args []string) error { + t := tmux.NewTmux() + + // Verify we're in tmux + if !tmux.IsInsideTmux() { + return fmt.Errorf("not running in tmux - cannot recycle") + } + + pane := os.Getenv("TMUX_PANE") + if pane == "" { + return fmt.Errorf("TMUX_PANE not set - cannot recycle") + } + + // Get current session name + currentSession, err := getCurrentTmuxSession() + if err != nil { + return fmt.Errorf("getting session name: %w", err) + } + + // Determine target session + targetSession := currentSession + if len(args) > 0 { + // User specified a role to recycle + targetSession, err = resolveRoleToSession(args[0]) + if err != nil { + return fmt.Errorf("resolving role: %w", err) + } + } + + // Build the restart command + restartCmd, err := buildRestartCommand(targetSession) + if err != nil { + return err + } + + // If recycling a different session, we need to find its pane and respawn there + if targetSession != currentSession { + return recycleRemoteSession(t, targetSession, restartCmd) + } + + // Recycling ourselves - print feedback then respawn + fmt.Printf("%s Recycling %s...\n", style.Bold.Render("♻️"), currentSession) + + // Dry run mode - show what would happen + if recycleDryRun { + fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", pane, restartCmd) + return nil + } + + // Use exec to respawn the pane - this kills us and restarts + return t.RespawnPane(pane, restartCmd) +} + +// getCurrentTmuxSession returns the current tmux session name. +func getCurrentTmuxSession() (string, error) { + out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// resolveRoleToSession converts a role name to a tmux session name. +// For roles that need context (crew, witness, refinery), it auto-detects from environment. +func resolveRoleToSession(role string) (string, error) { + switch strings.ToLower(role) { + case "mayor", "may": + return "gt-mayor", nil + + case "deacon", "dea": + return "gt-deacon", nil + + case "crew": + // Try to get rig and crew name from environment or cwd + rig := os.Getenv("GT_RIG") + crewName := os.Getenv("GT_CREW") + if rig == "" || crewName == "" { + // Try to detect from cwd + detected, err := detectCrewFromCwd() + if err == nil { + rig = detected.rigName + crewName = detected.crewName + } + } + if rig == "" || crewName == "" { + return "", fmt.Errorf("cannot determine crew identity - run from crew directory or specify GT_RIG/GT_CREW") + } + return fmt.Sprintf("gt-%s-crew-%s", rig, crewName), nil + + case "witness", "wit": + rig := os.Getenv("GT_RIG") + if rig == "" { + return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context") + } + return fmt.Sprintf("gt-%s-witness", rig), nil + + case "refinery", "ref": + rig := os.Getenv("GT_RIG") + if rig == "" { + return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context") + } + return fmt.Sprintf("gt-%s-refinery", rig), nil + + default: + // Assume it's a direct session name + return role, nil + } +} + +// buildRestartCommand creates the gt command to restart a session. +func buildRestartCommand(sessionName string) (string, error) { + switch { + case sessionName == "gt-mayor": + return "gt may at", nil + + case sessionName == "gt-deacon": + return "gt dea at", nil + + case strings.Contains(sessionName, "-crew-"): + // gt--crew- + // The attach command can auto-detect from cwd, so just use `gt crew at` + return "gt crew at", nil + + case strings.HasSuffix(sessionName, "-witness"): + // gt--witness + return "gt wit at", nil + + case strings.HasSuffix(sessionName, "-refinery"): + // gt--refinery + return "gt ref at", nil + + default: + return "", fmt.Errorf("unknown session type: %s (try specifying role explicitly)", sessionName) + } +} + +// recycleRemoteSession respawns a different session and optionally switches to it. +func recycleRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error { + // Check if target session exists + exists, err := t.HasSession(targetSession) + if err != nil { + return fmt.Errorf("checking session: %w", err) + } + if !exists { + return fmt.Errorf("session '%s' not found - is the agent running?", targetSession) + } + + // Get the pane ID for the target session + targetPane, err := getSessionPane(targetSession) + if err != nil { + return fmt.Errorf("getting target pane: %w", err) + } + + fmt.Printf("%s Recycling %s...\n", style.Bold.Render("♻️"), targetSession) + + // Dry run mode + if recycleDryRun { + fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", targetPane, restartCmd) + if recycleWatch { + fmt.Printf("Would execute: tmux switch-client -t %s\n", targetSession) + } + return nil + } + + // Respawn the remote session's pane + if err := t.RespawnPane(targetPane, restartCmd); err != nil { + return fmt.Errorf("respawning pane: %w", err) + } + + // If --watch, switch to that session + if recycleWatch { + fmt.Printf("Switching to %s...\n", targetSession) + // Use tmux switch-client to move our view to the target session + if err := exec.Command("tmux", "switch-client", "-t", targetSession).Run(); err != nil { + // Non-fatal - they can manually switch + fmt.Printf("Note: Could not auto-switch (use: tmux switch-client -t %s)\n", targetSession) + } + } + + return nil +} + +// getSessionPane returns the pane identifier for a session's main pane. +func getSessionPane(sessionName string) (string, error) { + // Get the pane ID for the first pane in the session + out, err := exec.Command("tmux", "list-panes", "-t", sessionName, "-F", "#{pane_id}").Output() + if err != nil { + return "", err + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 || lines[0] == "" { + return "", fmt.Errorf("no panes found in session") + } + return lines[0], nil +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index a8da307b..90cd73d4 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -544,3 +544,11 @@ func (t *Tmux) SetMailClickBinding(session string) error { "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 +}