Files
gastown/internal/cmd/start.go
Steve Yegge f86a73c2f0 feat: Add gcloud-style command grouping to gt help output
Organize 43 commands into 7 logical groups using cobra's built-in
AddGroup/GroupID feature:

- Work Management: spawn, sling, hook, handoff, done, mol, mq, etc.
- Agent Management: mayor, witness, refinery, deacon, polecat, etc.
- Communication: mail, nudge, broadcast, peek
- Services: daemon, start, stop, up, down, shutdown
- Workspace: rig, crew, init, install, git-init, namepool
- Configuration: account, theme, hooks, issue, completion
- Diagnostics: status, doctor, prime, version, help

Also renamed molecule to mol as the primary command name
(molecule is now an alias).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 02:32:01 -08:00

559 lines
17 KiB
Go

package cmd
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
startAll bool
shutdownGraceful bool
shutdownWait int
shutdownAll bool
shutdownForce bool
shutdownYes bool
shutdownPolecatsOnly bool
shutdownNuclear bool
)
var startCmd = &cobra.Command{
Use: "start",
GroupID: GroupServices,
Short: "Start Gas Town",
Long: `Start Gas Town by launching the Deacon and Mayor.
The Deacon is the health-check orchestrator that monitors Mayor and Witnesses.
The Mayor is the global coordinator that dispatches work.
By default, other agents (Witnesses, Refineries) are started lazily as needed.
Use --all to start Witnesses and Refineries for all registered rigs immediately.
To stop Gas Town, use 'gt shutdown'.`,
RunE: runStart,
}
var shutdownCmd = &cobra.Command{
Use: "shutdown",
GroupID: GroupServices,
Short: "Shutdown Gas Town",
Long: `Shutdown Gas Town by stopping agents and cleaning up polecats.
By default, preserves crew sessions (your persistent workspaces).
Prompts for confirmation before stopping.
After killing sessions, polecats are cleaned up:
- Worktrees are removed
- Polecat branches are deleted
- Polecats with uncommitted work are SKIPPED (protected)
Shutdown levels (progressively more aggressive):
(default) - Stop infrastructure (Mayor, Deacon, Witnesses, Refineries, Polecats)
--all - Also stop crew sessions
--polecats-only - Only stop polecats (leaves everything else running)
Use --force or --yes to skip confirmation prompt.
Use --graceful to allow agents time to save state before killing.
Use --nuclear to force cleanup even if polecats have uncommitted work (DANGER).`,
RunE: runShutdown,
}
func init() {
startCmd.Flags().BoolVarP(&startAll, "all", "a", false,
"Also start Witnesses and Refineries for all rigs")
shutdownCmd.Flags().BoolVarP(&shutdownGraceful, "graceful", "g", false,
"Send ESC to agents and wait for them to handoff before killing")
shutdownCmd.Flags().IntVarP(&shutdownWait, "wait", "w", 30,
"Seconds to wait for graceful shutdown (default 30)")
shutdownCmd.Flags().BoolVarP(&shutdownAll, "all", "a", false,
"Also stop crew sessions (by default, crew is preserved)")
shutdownCmd.Flags().BoolVarP(&shutdownForce, "force", "f", false,
"Skip confirmation prompt (alias for --yes)")
shutdownCmd.Flags().BoolVarP(&shutdownYes, "yes", "y", false,
"Skip confirmation prompt")
shutdownCmd.Flags().BoolVar(&shutdownPolecatsOnly, "polecats-only", false,
"Only stop polecats (minimal shutdown)")
shutdownCmd.Flags().BoolVar(&shutdownNuclear, "nuclear", false,
"Force cleanup even if polecats have uncommitted work (DANGER: may lose work)")
rootCmd.AddCommand(startCmd)
rootCmd.AddCommand(shutdownCmd)
}
func runStart(cmd *cobra.Command, args []string) error {
// Verify we're in a Gas Town workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
t := tmux.NewTmux()
fmt.Printf("Starting Gas Town from %s\n\n", style.Dim.Render(townRoot))
// Start Mayor first (so Deacon sees it as up)
mayorRunning, _ := t.HasSession(MayorSessionName)
if mayorRunning {
fmt.Printf(" %s Mayor already running\n", style.Dim.Render("○"))
} else {
fmt.Printf(" %s Starting Mayor...\n", style.Bold.Render("→"))
if err := startMayorSession(t); err != nil {
return fmt.Errorf("starting Mayor: %w", err)
}
fmt.Printf(" %s Mayor started\n", style.Bold.Render("✓"))
}
// Start Deacon (health monitor)
deaconRunning, _ := t.HasSession(DeaconSessionName)
if deaconRunning {
fmt.Printf(" %s Deacon already running\n", style.Dim.Render("○"))
} else {
fmt.Printf(" %s Starting Deacon...\n", style.Bold.Render("→"))
if err := startDeaconSession(t); err != nil {
return fmt.Errorf("starting Deacon: %w", err)
}
fmt.Printf(" %s Deacon started\n", style.Bold.Render("✓"))
}
// If --all, start witnesses and refineries for all rigs
if startAll {
fmt.Println()
fmt.Println("Starting rig agents...")
rigs, err := discoverAllRigs(townRoot)
if err != nil {
fmt.Printf(" %s Could not discover rigs: %v\n", style.Dim.Render("○"), err)
} else {
for _, r := range rigs {
// Start Witness
witnessSession := fmt.Sprintf("gt-%s-witness", r.Name)
witnessRunning, _ := t.HasSession(witnessSession)
if witnessRunning {
fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name)
} else {
created, err := ensureWitnessSession(r.Name, r)
if err != nil {
fmt.Printf(" %s %s witness failed: %v\n", style.Dim.Render("○"), r.Name, err)
} else if created {
fmt.Printf(" %s %s witness started\n", style.Bold.Render("✓"), r.Name)
}
}
// Start Refinery
refinerySession := fmt.Sprintf("gt-%s-refinery", r.Name)
refineryRunning, _ := t.HasSession(refinerySession)
if refineryRunning {
fmt.Printf(" %s %s refinery already running\n", style.Dim.Render("○"), r.Name)
} else {
created, err := ensureRefinerySession(r.Name, r)
if err != nil {
fmt.Printf(" %s %s refinery failed: %v\n", style.Dim.Render("○"), r.Name, err)
} else if created {
fmt.Printf(" %s %s refinery started\n", style.Bold.Render("✓"), r.Name)
}
}
}
}
}
fmt.Println()
fmt.Printf("%s Gas Town is running\n", style.Bold.Render("✓"))
fmt.Println()
fmt.Printf(" Attach to Mayor: %s\n", style.Dim.Render("gt mayor attach"))
fmt.Printf(" Attach to Deacon: %s\n", style.Dim.Render("gt deacon attach"))
fmt.Printf(" Check status: %s\n", style.Dim.Render("gt status"))
return nil
}
// discoverAllRigs finds all rigs in the workspace.
func discoverAllRigs(townRoot string) ([]*rig.Rig, error) {
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
return nil, fmt.Errorf("loading rigs config: %w", err)
}
g := git.NewGit(townRoot)
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
return rigMgr.DiscoverRigs()
}
// ensureRefinerySession creates a refinery tmux session if it doesn't exist.
// Returns true if a new session was created, false if it already existed.
func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) {
t := tmux.NewTmux()
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
// Check if session already exists
running, err := t.HasSession(sessionName)
if err != nil {
return false, fmt.Errorf("checking session: %w", err)
}
if running {
return false, nil
}
// Working directory is the refinery's rig clone
refineryRigDir := filepath.Join(r.Path, "refinery", "rig")
if _, err := os.Stat(refineryRigDir); os.IsNotExist(err) {
// Fall back to rig path if refinery/rig doesn't exist
refineryRigDir = r.Path
}
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
if err := claude.EnsureSettingsForRole(refineryRigDir, "refinery"); err != nil {
return false, fmt.Errorf("ensuring Claude settings: %w", err)
}
// Create new tmux session
if err := t.NewSession(sessionName, refineryRigDir); err != nil {
return false, fmt.Errorf("creating session: %w", err)
}
// Set environment
t.SetEnvironment(sessionName, "GT_ROLE", "refinery")
t.SetEnvironment(sessionName, "GT_RIG", rigName)
// Set beads environment
beadsDir := filepath.Join(r.Path, "mayor", "rig", ".beads")
t.SetEnvironment(sessionName, "BEADS_DIR", beadsDir)
t.SetEnvironment(sessionName, "BEADS_NO_DAEMON", "1")
t.SetEnvironment(sessionName, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", rigName))
// Apply Gas Town theming
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "refinery", "refinery")
// Launch Claude in a respawn loop
// Export GT_ROLE in the command since tmux SetEnvironment only affects new panes
loopCmd := `export GT_ROLE=refinery && while true; do echo "🛢️ Starting Refinery for ` + rigName + `..."; claude --dangerously-skip-permissions; echo ""; echo "Refinery exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
if err := t.SendKeysDelayed(sessionName, loopCmd, 200); err != nil {
return false, fmt.Errorf("sending command: %w", err)
}
return true, nil
}
func runShutdown(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
// Find workspace root for polecat cleanup
townRoot, _ := workspace.FindFromCwd()
// Collect sessions to show what will be stopped
sessions, err := t.ListSessions()
if err != nil {
return fmt.Errorf("listing sessions: %w", err)
}
toStop, preserved := categorizeSessions(sessions)
if len(toStop) == 0 {
fmt.Printf("%s Gas Town was not running\n", style.Dim.Render("○"))
return nil
}
// Show what will happen
fmt.Println("Sessions to stop:")
for _, sess := range toStop {
fmt.Printf(" %s %s\n", style.Bold.Render("→"), sess)
}
if len(preserved) > 0 && !shutdownAll {
fmt.Println()
fmt.Println("Sessions preserved (crew):")
for _, sess := range preserved {
fmt.Printf(" %s %s\n", style.Dim.Render("○"), sess)
}
}
fmt.Println()
// Confirmation prompt
if !shutdownYes && !shutdownForce {
fmt.Printf("Proceed with shutdown? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Shutdown cancelled.")
return nil
}
}
if shutdownGraceful {
return runGracefulShutdown(t, toStop, townRoot)
}
return runImmediateShutdown(t, toStop, townRoot)
}
// categorizeSessions splits sessions into those to stop and those to preserve.
func categorizeSessions(sessions []string) (toStop, preserved []string) {
for _, sess := range sessions {
if !strings.HasPrefix(sess, "gt-") {
continue // Not a Gas Town session
}
// Check if it's a crew session (pattern: gt-<rig>-crew-<name>)
isCrew := strings.Contains(sess, "-crew-")
// Check if it's a polecat session (pattern: gt-<rig>-<name> where name is not crew/witness/refinery)
isPolecat := false
if !isCrew && sess != MayorSessionName && sess != DeaconSessionName {
parts := strings.Split(sess, "-")
if len(parts) >= 3 {
role := parts[2]
if role != "witness" && role != "refinery" && role != "crew" {
isPolecat = true
}
}
}
// Decide based on flags
if shutdownPolecatsOnly {
// Only stop polecats
if isPolecat {
toStop = append(toStop, sess)
} else {
preserved = append(preserved, sess)
}
} else if shutdownAll {
// Stop everything including crew
toStop = append(toStop, sess)
} else {
// Default: preserve crew
if isCrew {
preserved = append(preserved, sess)
} else {
toStop = append(toStop, sess)
}
}
}
return
}
func runGracefulShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) error {
fmt.Printf("Graceful shutdown of Gas Town (waiting up to %ds)...\n\n", shutdownWait)
// Phase 1: Send ESC to all agents to interrupt them
fmt.Printf("Phase 1: Sending ESC to %d agent(s)...\n", len(gtSessions))
for _, sess := range gtSessions {
fmt.Printf(" %s Interrupting %s\n", style.Bold.Render("→"), sess)
_ = t.SendKeysRaw(sess, "Escape")
}
// Phase 2: Send shutdown message asking agents to handoff
fmt.Printf("\nPhase 2: Requesting handoff from agents...\n")
shutdownMsg := "[SHUTDOWN] Gas Town is shutting down. Please save your state and update your handoff bead, then type /exit or wait to be terminated."
for _, sess := range gtSessions {
// Small delay then send the message
time.Sleep(500 * time.Millisecond)
_ = t.SendKeys(sess, shutdownMsg)
}
// Phase 3: Wait for agents to complete handoff
fmt.Printf("\nPhase 3: Waiting %ds for agents to complete handoff...\n", shutdownWait)
fmt.Printf(" %s\n", style.Dim.Render("(Press Ctrl-C to force immediate shutdown)"))
// Wait with countdown
for remaining := shutdownWait; remaining > 0; remaining -= 5 {
if remaining < shutdownWait {
fmt.Printf(" %s %ds remaining...\n", style.Dim.Render("⏳"), remaining)
}
sleepTime := 5
if remaining < 5 {
sleepTime = remaining
}
time.Sleep(time.Duration(sleepTime) * time.Second)
}
// Phase 4: Kill sessions in correct order
fmt.Printf("\nPhase 4: Terminating sessions...\n")
stopped := killSessionsInOrder(t, gtSessions)
// Phase 5: Cleanup polecat worktrees and branches
fmt.Printf("\nPhase 5: Cleaning up polecats...\n")
if townRoot != "" {
cleanupPolecats(townRoot)
}
fmt.Println()
fmt.Printf("%s Graceful shutdown complete (%d sessions stopped)\n", style.Bold.Render("✓"), stopped)
return nil
}
func runImmediateShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) error {
fmt.Println("Shutting down Gas Town...")
stopped := killSessionsInOrder(t, gtSessions)
// Cleanup polecat worktrees and branches
if townRoot != "" {
fmt.Println()
fmt.Println("Cleaning up polecats...")
cleanupPolecats(townRoot)
}
fmt.Println()
fmt.Printf("%s Gas Town shutdown complete (%d sessions stopped)\n", style.Bold.Render("✓"), stopped)
return nil
}
// killSessionsInOrder stops sessions in the correct order:
// 1. Deacon first (so it doesn't restart others)
// 2. Everything except Mayor
// 3. Mayor last
func killSessionsInOrder(t *tmux.Tmux, sessions []string) int {
stopped := 0
// Helper to check if session is in our list
inList := func(sess string) bool {
for _, s := range sessions {
if s == sess {
return true
}
}
return false
}
// 1. Stop Deacon first
if inList(DeaconSessionName) {
if err := t.KillSession(DeaconSessionName); err == nil {
fmt.Printf(" %s %s stopped\n", style.Bold.Render("✓"), DeaconSessionName)
stopped++
}
}
// 2. Stop others (except Mayor)
for _, sess := range sessions {
if sess == DeaconSessionName || sess == MayorSessionName {
continue
}
if err := t.KillSession(sess); err == nil {
fmt.Printf(" %s %s stopped\n", style.Bold.Render("✓"), sess)
stopped++
}
}
// 3. Stop Mayor last
if inList(MayorSessionName) {
if err := t.KillSession(MayorSessionName); err == nil {
fmt.Printf(" %s %s stopped\n", style.Bold.Render("✓"), MayorSessionName)
stopped++
}
}
return stopped
}
// cleanupPolecats removes polecat worktrees and branches for all rigs.
// It refuses to clean up polecats with uncommitted work unless --nuclear is set.
func cleanupPolecats(townRoot string) {
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
fmt.Printf(" %s Could not load rigs config: %v\n", style.Dim.Render("○"), err)
return
}
g := git.NewGit(townRoot)
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
// Discover all rigs
rigs, err := rigMgr.DiscoverRigs()
if err != nil {
fmt.Printf(" %s Could not discover rigs: %v\n", style.Dim.Render("○"), err)
return
}
totalCleaned := 0
totalSkipped := 0
var uncommittedPolecats []string
for _, r := range rigs {
polecatGit := git.NewGit(r.Path)
polecatMgr := polecat.NewManager(r, polecatGit)
polecats, err := polecatMgr.List()
if err != nil {
continue
}
for _, p := range polecats {
// Check for uncommitted work
pGit := git.NewGit(p.ClonePath)
status, err := pGit.CheckUncommittedWork()
if err != nil {
// Can't check, be safe and skip unless nuclear
if !shutdownNuclear {
fmt.Printf(" %s %s/%s: could not check status, skipping\n",
style.Dim.Render("○"), r.Name, p.Name)
totalSkipped++
continue
}
} else if !status.Clean() {
// Has uncommitted work
if !shutdownNuclear {
uncommittedPolecats = append(uncommittedPolecats,
fmt.Sprintf("%s/%s (%s)", r.Name, p.Name, status.String()))
totalSkipped++
continue
}
// Nuclear mode: warn but proceed
fmt.Printf(" %s %s/%s: NUCLEAR - removing despite %s\n",
style.Bold.Render("⚠"), r.Name, p.Name, status.String())
}
// Clean: remove worktree and branch
if err := polecatMgr.RemoveWithOptions(p.Name, true, shutdownNuclear); err != nil {
fmt.Printf(" %s %s/%s: cleanup failed: %v\n",
style.Dim.Render("○"), r.Name, p.Name, err)
totalSkipped++
continue
}
// Delete the polecat branch from mayor's clone
branchName := fmt.Sprintf("polecat/%s", p.Name)
mayorPath := filepath.Join(r.Path, "mayor", "rig")
mayorGit := git.NewGit(mayorPath)
_ = mayorGit.DeleteBranch(branchName, true) // Ignore errors
fmt.Printf(" %s %s/%s: cleaned up\n", style.Bold.Render("✓"), r.Name, p.Name)
totalCleaned++
}
}
// Summary
if len(uncommittedPolecats) > 0 {
fmt.Println()
fmt.Printf(" %s Polecats with uncommitted work (use --nuclear to force):\n",
style.Bold.Render("⚠"))
for _, pc := range uncommittedPolecats {
fmt.Printf(" • %s\n", pc)
}
}
if totalCleaned > 0 || totalSkipped > 0 {
fmt.Printf(" Cleaned: %d, Skipped: %d\n", totalCleaned, totalSkipped)
} else {
fmt.Printf(" %s No polecats to clean up\n", style.Dim.Render("○"))
}
}