diff --git a/internal/cmd/up.go b/internal/cmd/up.go index d0129353..003874f1 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -1,10 +1,12 @@ package cmd import ( + "encoding/json" "fmt" "os" "os/exec" "path/filepath" + "strings" "time" "github.com/spf13/cobra" @@ -13,6 +15,7 @@ import ( "github.com/steveyegge/gastown/internal/refinery" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/wisp" "github.com/steveyegge/gastown/internal/workspace" ) @@ -34,17 +37,23 @@ infrastructure agents are running: Polecats are NOT started by this command - they are transient workers spawned on demand by the Mayor or Witnesses. +Use --restore to also start: + • Crew - Per rig settings (settings/config.json crew.startup) + • Polecats - Those with hooks (work attached) + Running 'gt up' multiple times is safe - it only starts services that aren't already running.`, RunE: runUp, } var ( - upQuiet bool + upQuiet bool + upRestore bool ) func init() { upCmd.Flags().BoolVarP(&upQuiet, "quiet", "q", false, "Only show errors") + upCmd.Flags().BoolVar(&upRestore, "restore", false, "Also restore crew (from settings) and polecats (from hooks)") rootCmd.AddCommand(upCmd) } @@ -122,6 +131,32 @@ func runUp(cmd *cobra.Command, args []string) error { } } + // 6. Crew (if --restore) + if upRestore { + for _, rigName := range rigs { + crewStarted, crewErrors := startCrewFromSettings(t, townRoot, rigName) + for _, name := range crewStarted { + printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-crew-%s", rigName, name)) + } + for name, err := range crewErrors { + printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), false, err.Error()) + allOK = false + } + } + + // 7. Polecats with hooks (if --restore) + for _, rigName := range rigs { + polecatsStarted, polecatErrors := startPolecatsWithHooks(t, townRoot, rigName) + for _, name := range polecatsStarted { + printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-polecat-%s", rigName, name)) + } + for name, err := range polecatErrors { + printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), false, err.Error()) + allOK = false + } + } + } + fmt.Println() if allOK { fmt.Printf("%s All services running\n", style.Bold.Render("✓")) @@ -315,3 +350,260 @@ func discoverRigs(townRoot string) []string { return rigs } + +// startCrewFromSettings starts crew members based on rig settings. +// Returns list of started crew names and map of errors. +func startCrewFromSettings(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) { + started := []string{} + errors := map[string]error{} + + rigPath := filepath.Join(townRoot, rigName) + + // Load rig settings + settingsPath := filepath.Join(rigPath, "settings", "config.json") + settings, err := config.LoadRigSettings(settingsPath) + if err != nil { + // No settings file or error - skip crew startup + return started, errors + } + + if settings.Crew == nil || settings.Crew.Startup == "" { + // No crew startup preference + return started, errors + } + + // Get available crew members using helper + crewMgr, _, err := getCrewManager(rigName) + if err != nil { + return started, errors + } + + crewWorkers, err := crewMgr.List() + if err != nil { + return started, errors + } + + if len(crewWorkers) == 0 { + return started, errors + } + + // Extract crew names + crewNames := make([]string, len(crewWorkers)) + for i, w := range crewWorkers { + crewNames[i] = w.Name + } + + // Parse startup preference and determine which crew to start + toStart := parseCrewStartupPreference(settings.Crew.Startup, crewNames) + + // Start each crew member + for _, crewName := range toStart { + sessionName := fmt.Sprintf("gt-%s-crew-%s", rigName, crewName) + + running, err := t.HasSession(sessionName) + if err != nil { + errors[crewName] = err + continue + } + if running { + started = append(started, crewName) + continue + } + + // Start the crew member + crewPath := filepath.Join(rigPath, "crew", crewName) + if err := ensureCrewSession(t, sessionName, crewPath, rigName, crewName); err != nil { + errors[crewName] = err + } else { + started = append(started, crewName) + } + } + + return started, errors +} + +// parseCrewStartupPreference parses the natural language crew startup preference. +// Examples: "max", "joe and max", "all", "none", "pick one" +func parseCrewStartupPreference(pref string, available []string) []string { + pref = strings.ToLower(strings.TrimSpace(pref)) + + // Special keywords + switch pref { + case "none", "": + return []string{} + case "all": + return available + case "pick one", "any", "any one": + if len(available) > 0 { + return []string{available[0]} + } + return []string{} + } + + // Parse comma/and-separated list + // "joe and max" -> ["joe", "max"] + // "joe, max" -> ["joe", "max"] + // "max" -> ["max"] + pref = strings.ReplaceAll(pref, " and ", ",") + pref = strings.ReplaceAll(pref, ", but not ", ",-") + pref = strings.ReplaceAll(pref, " but not ", ",-") + + parts := strings.Split(pref, ",") + + include := []string{} + exclude := map[string]bool{} + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + if strings.HasPrefix(part, "-") { + // Exclusion + exclude[strings.TrimPrefix(part, "-")] = true + } else { + include = append(include, part) + } + } + + // Filter to only available crew members + result := []string{} + for _, name := range include { + if exclude[name] { + continue + } + // Check if this crew exists + for _, avail := range available { + if avail == name { + result = append(result, name) + break + } + } + } + + return result +} + +// ensureCrewSession starts a crew session. +func ensureCrewSession(t *tmux.Tmux, sessionName, crewPath, rigName, crewName string) error { + // Create session in crew directory + if err := t.NewSession(sessionName, crewPath); err != nil { + return err + } + + // Set environment + bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName) + _ = t.SetEnvironment(sessionName, "GT_ROLE", "crew") + _ = t.SetEnvironment(sessionName, "GT_RIG", rigName) + _ = t.SetEnvironment(sessionName, "GT_CREW", crewName) + _ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor) + + // Apply theme (use rig-based theme) + theme := tmux.AssignTheme(rigName) + _ = t.ConfigureGasTownSession(sessionName, theme, "", "Crew", crewName) + + // Launch Claude + claudeCmd := fmt.Sprintf(`export GT_ROLE=crew GT_RIG=%s GT_CREW=%s BD_ACTOR=%s && claude --dangerously-skip-permissions`, rigName, crewName, bdActor) + if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { + return err + } + + return nil +} + +// startPolecatsWithHooks starts polecats that have hook files (work attached). +// Returns list of started polecat names and map of errors. +func startPolecatsWithHooks(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) { + started := []string{} + errors := map[string]error{} + + rigPath := filepath.Join(townRoot, rigName) + polecatsDir := filepath.Join(rigPath, "polecats") + + // List polecat directories + entries, err := os.ReadDir(polecatsDir) + if err != nil { + // No polecats directory + return started, errors + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + polecatName := entry.Name() + polecatPath := filepath.Join(polecatsDir, polecatName) + + // Check if this polecat has a hook file + agentID := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) + hookPath := filepath.Join(polecatPath, ".beads", wisp.HookFilename(agentID)) + + hookData, err := os.ReadFile(hookPath) + if err != nil { + // No hook file - skip + continue + } + + // Verify hook has work + var hook wisp.SlungWork + if err := json.Unmarshal(hookData, &hook); err != nil { + continue + } + + if hook.BeadID == "" { + // Empty hook - skip + continue + } + + // This polecat has work - start it + sessionName := fmt.Sprintf("gt-%s-polecat-%s", rigName, polecatName) + + running, err := t.HasSession(sessionName) + if err != nil { + errors[polecatName] = err + continue + } + if running { + started = append(started, polecatName) + continue + } + + // Start the polecat + if err := ensurePolecatSession(t, sessionName, polecatPath, rigName, polecatName); err != nil { + errors[polecatName] = err + } else { + started = append(started, polecatName) + } + } + + return started, errors +} + +// ensurePolecatSession starts a polecat session. +func ensurePolecatSession(t *tmux.Tmux, sessionName, polecatPath, rigName, polecatName string) error { + // Create session in polecat directory + if err := t.NewSession(sessionName, polecatPath); err != nil { + return err + } + + // Set environment + bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) + _ = t.SetEnvironment(sessionName, "GT_ROLE", "polecat") + _ = t.SetEnvironment(sessionName, "GT_RIG", rigName) + _ = t.SetEnvironment(sessionName, "GT_POLECAT", polecatName) + _ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor) + + // Apply theme (use rig-based theme) + theme := tmux.AssignTheme(rigName) + _ = t.ConfigureGasTownSession(sessionName, theme, "", "Polecat", polecatName) + + // Launch Claude + claudeCmd := fmt.Sprintf(`export GT_ROLE=polecat GT_RIG=%s GT_POLECAT=%s BD_ACTOR=%s && claude --dangerously-skip-permissions`, rigName, polecatName, bdActor) + if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { + return err + } + + return nil +} diff --git a/internal/config/types.go b/internal/config/types.go index 05bb8732..b160f391 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -99,6 +99,21 @@ type RigSettings struct { MergeQueue *MergeQueueConfig `json:"merge_queue,omitempty"` // merge queue settings Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings Namepool *NamepoolConfig `json:"namepool,omitempty"` // polecat name pool settings + Crew *CrewConfig `json:"crew,omitempty"` // crew startup settings +} + +// CrewConfig represents crew workspace settings for a rig. +type CrewConfig struct { + // Startup is a natural language instruction for which crew to start on boot. + // Interpreted by AI during startup. Examples: + // "max" - start only max + // "joe and max" - start joe and max + // "all" - start all crew members + // "pick one" - start any one crew member + // "none" - don't auto-start any crew + // "max, but not emma" - start max, skip emma + // If empty, defaults to starting no crew automatically. + Startup string `json:"startup,omitempty"` } // ThemeConfig represents tmux theme settings for a rig.