From bed0dacf1f8c25fa4bec67dbeba5d90dc8ee5838 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 14:15:24 -0800 Subject: [PATCH] feat: Add gt polecat recycle command (gt-j9ddg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements session-preserving polecat recycle: - Kills Claude session (tmux kill-session) - Preserves sandbox (worktree and branch intact) - Updates agent bead state to 'stopped' - Leaves polecat ready for respawn on next molecule step 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/polecat.go | 87 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/internal/cmd/polecat.go b/internal/cmd/polecat.go index 9494d1b3..82543e83 100644 --- a/internal/cmd/polecat.go +++ b/internal/cmd/polecat.go @@ -11,6 +11,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/rig" @@ -220,6 +221,26 @@ Examples: RunE: runPolecatGC, } +var polecatRecycleCmd = &cobra.Command{ + Use: "recycle /", + Short: "Kill session but preserve sandbox for respawn", + Long: `Recycle a polecat session: kill the Claude session but preserve the sandbox. + +This command: + - Kills the tmux session (stopping the Claude agent) + - Preserves the worktree and branch (sandbox intact) + - Updates agent bead state to 'stopped' + - Leaves everything ready for respawn on next step + +Use this between molecule steps to give polecats fresh context. +Use 'gt polecat nuke' for full cleanup after merge. + +Examples: + gt polecat recycle gastown/Toast`, + Args: cobra.ExactArgs(1), + RunE: runPolecatRecycle, +} + var polecatGitStateCmd = &cobra.Command{ Use: "git-state /", Short: "Show git state for pre-kill verification", @@ -274,6 +295,7 @@ func init() { polecatCmd.AddCommand(polecatStatusCmd) polecatCmd.AddCommand(polecatGitStateCmd) polecatCmd.AddCommand(polecatGCCmd) + polecatCmd.AddCommand(polecatRecycleCmd) rootCmd.AddCommand(polecatCmd) } @@ -1089,6 +1111,71 @@ func runPolecatGC(cmd *cobra.Command, args []string) error { return nil } +func runPolecatRecycle(cmd *cobra.Command, args []string) error { + rigName, polecatName, err := parseAddress(args[0]) + if err != nil { + return err + } + + mgr, r, err := getPolecatManager(rigName) + if err != nil { + return err + } + + // Verify polecat exists + p, err := mgr.Get(polecatName) + if err != nil { + return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) + } + + fmt.Printf("Recycling polecat %s/%s...\n", rigName, polecatName) + + // Check if session is running + t := tmux.NewTmux() + sessMgr := session.NewManager(t, r) + running, _ := sessMgr.IsRunning(polecatName) + + if running { + // Kill the session (preserves sandbox) + fmt.Printf(" Stopping session...\n") + if err := sessMgr.Stop(polecatName, false); err != nil { + // Try force stop + if err := sessMgr.Stop(polecatName, true); err != nil { + return fmt.Errorf("stopping session: %w", err) + } + } + fmt.Printf(" %s Session stopped\n", style.Success.Render("✓")) + } else { + fmt.Printf(" %s Session not running\n", style.Dim.Render("○")) + } + + // Update agent bead state to 'stopped' + // Agent bead ID format: -polecat-- + // We need to get the prefix from the rig's beads config + beadsDir := filepath.Join(r.Path, "mayor", "rig") + bd := beads.New(beadsDir) + + // Find the agent bead by searching for type=agent matching this polecat + // Agent bead ID pattern: gt-polecat-- + agentBeadID := fmt.Sprintf("gt-polecat-%s-%s", rigName, polecatName) + + // Try to update agent state + fmt.Printf(" Updating agent state...\n") + if err := bd.UpdateAgentState(agentBeadID, "stopped", nil); err != nil { + // Non-fatal - agent bead might not exist yet + fmt.Printf(" %s Agent bead not found (ok for new polecats)\n", style.Dim.Render("○")) + } else { + fmt.Printf(" %s Agent state: stopped\n", style.Success.Render("✓")) + } + + // Report sandbox preserved + fmt.Printf(" %s Sandbox preserved: %s\n", style.Success.Render("✓"), style.Dim.Render(p.ClonePath)) + fmt.Printf(" %s Branch: %s\n", style.Success.Render("✓"), style.Dim.Render(p.Branch)) + + fmt.Printf("\n%s Polecat recycled. Ready for respawn.\n", style.SuccessPrefix) + return nil +} + // splitLines splits a string into non-empty lines. func splitLines(s string) []string { var lines []string