Files
gastown/internal/cmd/start.go
gastown/crew/gus b7c26d52e1 Migrate startup paths from SessionBeacon to StartupNudge (gt-7pp3l)
All 12 startup paths now use session.StartupNudge instead of the
older SessionBeacon pattern. The key difference is that StartupNudge
includes sender information, enabling better predecessor discovery
via gt seance.

Files updated:
- crew_lifecycle.go: 3 locations (refresh, restart)
- start.go: 2 locations (crew start)
- deacon.go: 1 location (deacon start)
- witness.go: 1 location (witness start)
- up.go: 4 locations (mayor, witness, crew, polecat)
- mayor.go: 1 location (mayor start)
- session/manager.go: 1 location (polecat spawn)

Format change:
Old: [GAS TOWN] address • molID • timestamp
New: [GAS TOWN] recipient <- sender • timestamp • topic[:mol-id]

This completes the Agent Startup Consolidation epic (gt-85whr).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:52:46 -08:00

957 lines
30 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/constants"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
startAll bool
startCrewRig string
startCrewAccount string
shutdownGraceful bool
shutdownWait int
shutdownAll bool
shutdownForce bool
shutdownYes bool
shutdownPolecatsOnly bool
shutdownNuclear bool
)
var startCmd = &cobra.Command{
Use: "start [path]",
GroupID: GroupServices,
Short: "Start Gas Town or a crew workspace",
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.
Crew shortcut:
If a path like "rig/crew/name" is provided, starts that crew workspace.
This is equivalent to 'gt start crew rig/name'.
To stop Gas Town, use 'gt shutdown'.`,
Args: cobra.MaximumNArgs(1),
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,
}
var startCrewCmd = &cobra.Command{
Use: "crew <name>",
Short: "Start a crew workspace (creates if needed)",
Long: `Start a crew workspace, creating it if it doesn't exist.
This is a convenience command that combines 'gt crew add' and 'gt crew at --detached'.
The crew session starts in the background with Claude running and ready.
The name can include the rig in slash format (e.g., greenplace/joe).
If not specified, the rig is inferred from the current directory.
Examples:
gt start crew joe # Start joe in current rig
gt start crew greenplace/joe # Start joe in gastown rig
gt start crew joe --rig beads # Start joe in beads rig`,
Args: cobra.ExactArgs(1),
RunE: runStartCrew,
}
func init() {
startCmd.Flags().BoolVarP(&startAll, "all", "a", false,
"Also start Witnesses and Refineries for all rigs")
startCrewCmd.Flags().StringVar(&startCrewRig, "rig", "", "Rig to use")
startCrewCmd.Flags().StringVar(&startCrewAccount, "account", "", "Claude Code account handle to use")
startCmd.AddCommand(startCrewCmd)
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 {
// Check if arg looks like a crew path (rig/crew/name)
if len(args) == 1 && strings.Contains(args[0], "/crew/") {
// Parse rig/crew/name format
parts := strings.SplitN(args[0], "/crew/", 2)
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
// Route to crew start with rig/name format
crewArg := parts[0] + "/" + parts[1]
return runStartCrew(cmd, []string{crewArg})
}
}
// 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 core agents (Mayor and Deacon)
if err := startCoreAgents(t); err != nil {
return err
}
// If --all, start witnesses and refineries for all rigs
if startAll {
fmt.Println()
fmt.Println("Starting rig agents...")
startRigAgents(t, townRoot)
}
// Auto-start configured crew for each rig
fmt.Println()
fmt.Println("Starting configured crew...")
startConfiguredCrew(t, townRoot)
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
}
// startCoreAgents starts Mayor and Deacon sessions.
func startCoreAgents(t *tmux.Tmux) error {
// 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("✓"))
}
return nil
}
// startRigAgents starts witness and refinery for all rigs.
// Called when --all flag is passed to gt start.
func startRigAgents(t *tmux.Tmux, townRoot string) {
rigs, err := discoverAllRigs(townRoot)
if err != nil {
fmt.Printf(" %s Could not discover rigs: %v\n", style.Dim.Render("○"), err)
return
}
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)
}
}
}
}
// startConfiguredCrew starts crew members configured in rig settings.
func startConfiguredCrew(t *tmux.Tmux, townRoot string) {
rigs, err := discoverAllRigs(townRoot)
if err != nil {
fmt.Printf(" %s Could not discover rigs: %v\n", style.Dim.Render("○"), err)
return
}
startedAny := false
for _, r := range rigs {
crewToStart := getCrewToStart(r)
for _, crewName := range crewToStart {
sessionID := crewSessionName(r.Name, crewName)
if running, _ := t.HasSession(sessionID); running {
fmt.Printf(" %s %s/%s already running\n", style.Dim.Render("○"), r.Name, crewName)
} else {
if err := startCrewMember(r.Name, crewName, townRoot); err != nil {
fmt.Printf(" %s %s/%s failed: %v\n", style.Dim.Render("○"), r.Name, crewName, err)
} else {
fmt.Printf(" %s %s/%s started\n", style.Bold.Render("✓"), r.Name, crewName)
startedAny = true
}
}
}
}
if !startedAny {
fmt.Printf(" %s No crew configured or all already running\n", style.Dim.Render("○"))
}
}
// 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
bdActor := fmt.Sprintf("%s/refinery", rigName)
t.SetEnvironment(sessionName, "GT_ROLE", "refinery")
t.SetEnvironment(sessionName, "GT_RIG", rigName)
t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// 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 (non-fatal: theming failure doesn't affect operation)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "refinery", "refinery")
// Launch Claude in a respawn loop
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
runtimeCmd := config.GetRuntimeCommand(r.Path)
loopCmd := `export GT_ROLE=refinery BD_ACTOR=` + bdActor + ` GIT_AUTHOR_NAME=` + bdActor + ` && while true; do echo "🛢️ Starting Refinery for ` + rigName + `..."; ` + runtimeCmd + `; 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") // best-effort interrupt
}
// 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(constants.ShutdownNotifyDelay)
_ = t.SendKeys(sess, shutdownMsg) // best-effort notification
}
// 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("○"))
}
}
// runStartCrew starts a crew workspace, creating it if it doesn't exist.
// This combines the functionality of 'gt crew add' and 'gt crew at --detached'.
func runStartCrew(cmd *cobra.Command, args []string) error {
name := args[0]
// Parse rig/name format (e.g., "greenplace/joe" -> rig=gastown, name=joe)
rigName := startCrewRig
if parsedRig, crewName, ok := parseRigSlashName(name); ok {
if rigName == "" {
rigName = parsedRig
}
name = crewName
}
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// If rig still not specified, try to infer from cwd
if rigName == "" {
rigName, err = inferRigFromCwd(townRoot)
if err != nil {
return fmt.Errorf("could not determine rig (use --rig flag or rig/name format): %w", err)
}
}
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
}
// Get rig
g := git.NewGit(townRoot)
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
r, err := rigMgr.GetRig(rigName)
if err != nil {
return fmt.Errorf("rig '%s' not found", rigName)
}
// Create crew manager
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
// Check if crew exists, create if not
worker, err := crewMgr.Get(name)
if err == crew.ErrCrewNotFound {
fmt.Printf("Creating crew workspace %s in %s...\n", name, rigName)
worker, err = crewMgr.Add(name, false) // No feature branch for crew
if err != nil {
return fmt.Errorf("creating crew workspace: %w", err)
}
fmt.Printf("%s Created crew workspace: %s/%s\n",
style.Bold.Render("✓"), rigName, name)
} else if err != nil {
return fmt.Errorf("getting crew worker: %w", err)
} else {
fmt.Printf("Crew workspace %s/%s exists\n", rigName, name)
}
// Ensure crew workspace is on main branch
ensureMainBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, name))
// Resolve account for Claude config
accountsPath := constants.MayorAccountsPath(townRoot)
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, startCrewAccount)
if err != nil {
return fmt.Errorf("resolving account: %w", err)
}
if accountHandle != "" {
fmt.Printf("Using account: %s\n", accountHandle)
}
// Check if session exists
t := tmux.NewTmux()
sessionID := crewSessionName(rigName, name)
hasSession, err := t.HasSession(sessionID)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if hasSession {
// Session exists - check if Claude is still running
if !t.IsClaudeRunning(sessionID) {
// Claude has exited, restart it
fmt.Printf("Session exists, restarting Claude...\n")
if err := t.SendKeys(sessionID, config.GetRuntimeCommand(r.Path)); err != nil {
return fmt.Errorf("restarting claude: %w", err)
}
// Wait for Claude to start, then prime
shells := constants.SupportedShells
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
style.PrintWarning("Timeout waiting for Claude to start: %v", err)
}
time.Sleep(constants.ShutdownNotifyDelay)
if err := t.NudgeSession(sessionID, "gt prime"); err != nil {
style.PrintWarning("Could not send prime command: %v", err)
}
} else {
fmt.Printf("%s Session already running: %s\n", style.Dim.Render("○"), sessionID)
}
} else {
// Create new session
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
if claudeConfigDir != "" {
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", claudeConfigDir)
}
// 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")
// Wait for shell to be ready after session creation
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
return fmt.Errorf("waiting for shell: %w", err)
}
// Start claude with skip permissions
if err := t.SendKeys(sessionID, config.GetRuntimeCommand(r.Path)); err != nil {
return fmt.Errorf("starting claude: %w", err)
}
// Wait for Claude to start
shells := constants.SupportedShells
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
style.PrintWarning("Timeout waiting for Claude to start: %v", err)
}
// Give Claude time to initialize after process starts
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/crew/%s", rigName, name)
_ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{
Recipient: address,
Sender: "human",
Topic: "cold-start",
}) // Non-fatal: session works without nudge
// Send gt prime to initialize context
if err := t.NudgeSession(sessionID, "gt prime"); err != nil {
style.PrintWarning("Could not send prime command: %v", err)
}
fmt.Printf("%s Started crew workspace: %s/%s\n",
style.Bold.Render("✓"), rigName, name)
}
fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name)))
return nil
}
// getCrewToStart reads rig settings and parses the crew.startup field.
// Returns a list of crew names to start.
func getCrewToStart(r *rig.Rig) []string {
// Load rig settings
settingsPath := filepath.Join(r.Path, "settings", "config.json")
settings, err := config.LoadRigSettings(settingsPath)
if err != nil {
return nil
}
if settings.Crew == nil || settings.Crew.Startup == "" || settings.Crew.Startup == "none" {
return nil
}
startup := settings.Crew.Startup
// Handle "all" - list all existing crew
if startup == "all" {
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
workers, err := crewMgr.List()
if err != nil {
return nil
}
var names []string
for _, w := range workers {
names = append(names, w.Name)
}
return names
}
// Parse names: "max", "max and joe", "max, joe", "max, joe, emma"
// Replace "and" with comma for uniform parsing
startup = strings.ReplaceAll(startup, " and ", ", ")
parts := strings.Split(startup, ",")
var names []string
for _, part := range parts {
name := strings.TrimSpace(part)
if name != "" {
names = append(names, name)
}
}
return names
}
// startCrewMember starts a single crew member, creating if needed.
// This is a simplified version of runStartCrew that doesn't print output.
func startCrewMember(rigName, crewName, townRoot string) error {
// Load rigs config
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
}
// Get rig
g := git.NewGit(townRoot)
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
r, err := rigMgr.GetRig(rigName)
if err != nil {
return fmt.Errorf("rig '%s' not found", rigName)
}
// Create crew manager
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
// Check if crew exists, create if not
worker, err := crewMgr.Get(crewName)
if err == crew.ErrCrewNotFound {
worker, err = crewMgr.Add(crewName, false)
if err != nil {
return fmt.Errorf("creating crew workspace: %w", err)
}
} else if err != nil {
return fmt.Errorf("getting crew worker: %w", err)
}
// Ensure crew workspace is on main branch
ensureMainBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, crewName))
// Create tmux session
t := tmux.NewTmux()
sessionID := crewSessionName(rigName, crewName)
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionID, "GT_CREW", crewName)
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
theme := getThemeForRig(rigName)
_ = t.ConfigureGasTownSession(sessionID, theme, rigName, crewName, "crew")
// Set up C-b n/p keybindings for crew session cycling (non-fatal)
_ = t.SetCrewCycleBindings(sessionID)
// Wait for shell to be ready
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
return fmt.Errorf("waiting for shell: %w", err)
}
// Start claude
if err := t.SendKeys(sessionID, config.GetRuntimeCommand(r.Path)); err != nil {
return fmt.Errorf("starting claude: %w", err)
}
// Wait for Claude to start
shells := constants.SupportedShells
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal: Claude might still be starting
}
// Give Claude time to initialize
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/crew/%s", rigName, crewName)
_ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{
Recipient: address,
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
// Send gt prime to initialize context (non-fatal: session works without priming)
_ = t.NudgeSession(sessionID, "gt prime")
return nil
}