diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 5f719fcc..dee4fd62 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -5,10 +5,13 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/dog" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" @@ -25,6 +28,7 @@ var slingCmd = &cobra.Command{ This is THE command for assigning work in Gas Town. It handles: - Existing agents (mayor, crew, witness, refinery) - Auto-spawning polecats when target is a rig + - Dispatching to dogs (Deacon's helper workers) - Formula instantiation and wisp creation - No-tmux mode for manual agent operation @@ -34,6 +38,8 @@ Target Resolution: gt sling gt-abc gastown # Auto-spawn polecat in rig gt sling gt-abc gastown/Toast # Specific polecat gt sling gt-abc mayor # Mayor + gt sling gt-abc deacon/dogs # Auto-dispatch to idle dog + gt sling gt-abc deacon/dogs/alpha # Specific dog Spawning Options (when target is a rig): gt sling gt-abc gastown --molecule mol-review # Use specific workflow @@ -177,8 +183,31 @@ func runSling(cmd *cobra.Command, args []string) error { if len(args) > 1 { target := args[1] - // Check if target is a rig name (auto-spawn polecat) - if rigName, isRig := IsRigName(target); isRig { + // Check if target is a dog target (deacon/dogs or deacon/dogs/) + if dogName, isDog := IsDogTarget(target); isDog { + if slingDryRun { + if dogName == "" { + fmt.Printf("Would dispatch to idle dog in kennel\n") + } else { + fmt.Printf("Would dispatch to dog '%s'\n", dogName) + } + targetAgent = fmt.Sprintf("deacon/dogs/%s", dogName) + if dogName == "" { + targetAgent = "deacon/dogs/" + } + targetPane = "" + } else { + // Dispatch to dog + dispatchInfo, dispatchErr := DispatchToDog(dogName, slingCreate) + if dispatchErr != nil { + return fmt.Errorf("dispatching to dog: %w", dispatchErr) + } + targetAgent = dispatchInfo.AgentID + targetPane = dispatchInfo.Pane + fmt.Printf("Dispatched to dog %s\n", dispatchInfo.DogName) + } + } else if rigName, isRig := IsRigName(target); isRig { + // Check if target is a rig name (auto-spawn polecat) if slingDryRun { // Dry run - just indicate what would happen fmt.Printf("Would spawn fresh polecat in rig '%s'\n", rigName) @@ -593,8 +622,31 @@ func runSlingFormula(args []string) error { var err error if target != "" { - // Check if target is a rig name (auto-spawn polecat) - if rigName, isRig := IsRigName(target); isRig { + // Check if target is a dog target (deacon/dogs or deacon/dogs/) + if dogName, isDog := IsDogTarget(target); isDog { + if slingDryRun { + if dogName == "" { + fmt.Printf("Would dispatch to idle dog in kennel\n") + } else { + fmt.Printf("Would dispatch to dog '%s'\n", dogName) + } + targetAgent = fmt.Sprintf("deacon/dogs/%s", dogName) + if dogName == "" { + targetAgent = "deacon/dogs/" + } + targetPane = "" + } else { + // Dispatch to dog + dispatchInfo, dispatchErr := DispatchToDog(dogName, slingCreate) + if dispatchErr != nil { + return fmt.Errorf("dispatching to dog: %w", dispatchErr) + } + targetAgent = dispatchInfo.AgentID + targetPane = dispatchInfo.Pane + fmt.Printf("Dispatched to dog %s\n", dispatchInfo.DogName) + } + } else if rigName, isRig := IsRigName(target); isRig { + // Check if target is a rig name (auto-spawn polecat) if slingDryRun { // Dry run - just indicate what would happen fmt.Printf("Would spawn fresh polecat in rig '%s'\n", rigName) @@ -842,3 +894,144 @@ func qualityToFormula(quality string) (string, error) { return "", fmt.Errorf("invalid quality level '%s' (use: basic, shiny, or chrome)", quality) } } + +// IsDogTarget checks if target is a dog target pattern. +// Returns the dog name (or empty for pool dispatch) and true if it's a dog target. +// Patterns: +// - "deacon/dogs" -> ("", true) - dispatch to any idle dog +// - "deacon/dogs/alpha" -> ("alpha", true) - dispatch to specific dog +func IsDogTarget(target string) (dogName string, isDog bool) { + target = strings.ToLower(target) + + // Check for exact "deacon/dogs" (pool dispatch) + if target == "deacon/dogs" { + return "", true + } + + // Check for "deacon/dogs/" (specific dog) + if strings.HasPrefix(target, "deacon/dogs/") { + name := strings.TrimPrefix(target, "deacon/dogs/") + if name != "" && !strings.Contains(name, "/") { + return name, true + } + } + + return "", false +} + +// DogDispatchInfo contains information about a dog dispatch. +type DogDispatchInfo struct { + DogName string // Name of the dog + AgentID string // Agent ID format (deacon/dogs/) + Pane string // Tmux pane (empty if no session) + Spawned bool // True if dog was spawned (new) +} + +// DispatchToDog finds or spawns a dog for work dispatch. +// If dogName is empty, finds an idle dog from the pool. +// If create is true and no dogs exist, creates one. +func DispatchToDog(dogName string, create bool) (*DogDispatchInfo, error) { + townRoot, err := workspace.FindFromCwd() + if err != nil { + return nil, fmt.Errorf("finding town root: %w", err) + } + + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err != nil { + return nil, fmt.Errorf("loading rigs config: %w", err) + } + + mgr := dog.NewManager(townRoot, rigsConfig) + + var targetDog *dog.Dog + + if dogName != "" { + // Specific dog requested + targetDog, err = mgr.Get(dogName) + if err != nil { + if create { + // Create the dog if it doesn't exist + targetDog, err = mgr.Add(dogName) + if err != nil { + return nil, fmt.Errorf("creating dog %s: %w", dogName, err) + } + fmt.Printf("✓ Created dog %s\n", dogName) + } else { + return nil, fmt.Errorf("dog %s not found (use --create to add)", dogName) + } + } + } else { + // Pool dispatch - find an idle dog + targetDog, err = mgr.GetIdleDog() + if err != nil { + return nil, fmt.Errorf("finding idle dog: %w", err) + } + + if targetDog == nil { + if create { + // No idle dogs - create one + newName := generateDogName(mgr) + targetDog, err = mgr.Add(newName) + if err != nil { + return nil, fmt.Errorf("creating dog %s: %w", newName, err) + } + fmt.Printf("✓ Created dog %s (pool was empty)\n", newName) + } else { + return nil, fmt.Errorf("no idle dogs available (use --create to add)") + } + } + } + + // Mark dog as working + if err := mgr.SetState(targetDog.Name, dog.StateWorking); err != nil { + return nil, fmt.Errorf("setting dog state: %w", err) + } + + // Build agent ID + agentID := fmt.Sprintf("deacon/dogs/%s", targetDog.Name) + + // Try to find tmux session for the dog (dogs may run in tmux like polecats) + sessionName := fmt.Sprintf("gt-deacon-%s", targetDog.Name) + t := tmux.NewTmux() + var pane string + if has, _ := t.HasSession(sessionName); has { + // Get the pane from the session + pane, _ = getSessionPane(sessionName) + } + + return &DogDispatchInfo{ + DogName: targetDog.Name, + AgentID: agentID, + Pane: pane, + Spawned: false, // TODO: track if we spawned a new session + }, nil +} + +// generateDogName creates a unique dog name for pool expansion. +func generateDogName(mgr *dog.Manager) string { + // Use Greek alphabet for dog names + names := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"} + + dogs, _ := mgr.List() + existing := make(map[string]bool) + for _, d := range dogs { + existing[d.Name] = true + } + + for _, name := range names { + if !existing[name] { + return name + } + } + + // Fallback: numbered dogs + for i := 1; i <= 100; i++ { + name := fmt.Sprintf("dog%d", i) + if !existing[name] { + return name + } + } + + return fmt.Sprintf("dog%d", len(dogs)+1) +}