diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index bfab1dda..4ebfb83f 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -84,8 +84,14 @@ Compare: gt sling # Attach + start now (keep context) gt handoff # Attach + restart (fresh context) -The propulsion principle: if it's on your hook, YOU RUN IT.`, - Args: cobra.RangeArgs(1, 2), +The propulsion principle: if it's on your hook, YOU RUN IT. + +Batch Slinging: + gt sling gt-abc gt-def gt-ghi gastown # Sling multiple beads to a rig + + When multiple beads are provided with a rig target, each bead gets its own + polecat. This parallelizes work dispatch without running gt sling N times.`, + Args: cobra.MinimumNArgs(1), RunE: runSling, } @@ -146,6 +152,16 @@ func runSling(cmd *cobra.Command, args []string) error { return fmt.Errorf("--var cannot be used with --on (formula-on-bead mode doesn't support variables)") } + // Batch mode detection: multiple beads with rig target + // Pattern: gt sling gt-abc gt-def gt-ghi gastown + // When len(args) > 2 and last arg is a rig, sling each bead to its own polecat + if len(args) > 2 { + lastArg := args[len(args)-1] + if rigName, isRig := IsRigName(lastArg); isRig { + return runBatchSling(args[:len(args)-1], rigName, townBeadsDir) + } + } + // --quality is shorthand for formula-on-bead with polecat workflow // Convert: gt sling gp-abc greenplace --quality=shiny // To: gt sling mol-polecat-shiny --on gt-abc gastown @@ -1233,3 +1249,150 @@ func createAutoConvoy(beadID, beadTitle string) (string, error) { return convoyID, nil } + +// runBatchSling handles slinging multiple beads to a rig. +// Each bead gets its own freshly spawned polecat. +func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error { + // Validate all beads exist before spawning any polecats + for _, beadID := range beadIDs { + if err := verifyBeadExists(beadID); err != nil { + return fmt.Errorf("bead '%s' not found", beadID) + } + } + + if slingDryRun { + fmt.Printf("%s Batch slinging %d beads to rig '%s':\n", style.Bold.Render("🎯"), len(beadIDs), rigName) + for _, beadID := range beadIDs { + fmt.Printf(" Would spawn polecat for: %s\n", beadID) + } + if slingNaked { + fmt.Printf(" --naked: would skip tmux sessions\n") + } + return nil + } + + fmt.Printf("%s Batch slinging %d beads to rig '%s'...\n", style.Bold.Render("🎯"), len(beadIDs), rigName) + + // Track results for summary + type slingResult struct { + beadID string + polecat string + success bool + errMsg string + } + results := make([]slingResult, 0, len(beadIDs)) + + // Spawn a polecat for each bead and sling it + for i, beadID := range beadIDs { + fmt.Printf("\n[%d/%d] Slinging %s...\n", i+1, len(beadIDs), beadID) + + // Check bead status + info, err := getBeadInfo(beadID) + if err != nil { + results = append(results, slingResult{beadID: beadID, success: false, errMsg: err.Error()}) + fmt.Printf(" %s Could not get bead info: %v\n", style.Dim.Render("✗"), err) + continue + } + + if info.Status == "pinned" && !slingForce { + results = append(results, slingResult{beadID: beadID, success: false, errMsg: "already pinned"}) + fmt.Printf(" %s Already pinned (use --force to re-sling)\n", style.Dim.Render("✗")) + continue + } + + // Spawn a fresh polecat + spawnOpts := SlingSpawnOptions{ + Force: slingForce, + Naked: slingNaked, + Account: slingAccount, + Create: slingCreate, + HookBead: beadID, // Set atomically at spawn time + } + spawnInfo, err := SpawnPolecatForSling(rigName, spawnOpts) + if err != nil { + results = append(results, slingResult{beadID: beadID, success: false, errMsg: err.Error()}) + fmt.Printf(" %s Failed to spawn polecat: %v\n", style.Dim.Render("✗"), err) + continue + } + + targetAgent := spawnInfo.AgentID() + hookWorkDir := spawnInfo.ClonePath + + // Auto-convoy: check if issue is already tracked + if !slingNoConvoy { + existingConvoy := isTrackedByConvoy(beadID) + if existingConvoy == "" { + convoyID, err := createAutoConvoy(beadID, info.Title) + if err != nil { + fmt.Printf(" %s Could not create auto-convoy: %v\n", style.Dim.Render("Warning:"), err) + } else { + fmt.Printf(" %s Created convoy 🚚 %s\n", style.Bold.Render("→"), convoyID) + } + } else { + fmt.Printf(" %s Already tracked by convoy %s\n", style.Dim.Render("○"), existingConvoy) + } + } + + // Hook the bead + hookCmd := exec.Command("bd", "update", beadID, "--status=hooked", "--assignee="+targetAgent) + hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir) + if hookWorkDir != "" { + hookCmd.Dir = hookWorkDir + } + hookCmd.Stderr = os.Stderr + if err := hookCmd.Run(); err != nil { + results = append(results, slingResult{beadID: beadID, polecat: spawnInfo.PolecatName, success: false, errMsg: "hook failed"}) + fmt.Printf(" %s Failed to hook bead: %v\n", style.Dim.Render("✗"), err) + continue + } + + fmt.Printf(" %s Work attached to %s\n", style.Bold.Render("✓"), spawnInfo.PolecatName) + + // Log sling event + actor := detectActor() + _ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadID, targetAgent)) + + // Update agent bead state + updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir) + + // Store args if provided + if slingArgs != "" { + if err := storeArgsInBead(beadID, slingArgs); err != nil { + fmt.Printf(" %s Could not store args: %v\n", style.Dim.Render("Warning:"), err) + } + } + + // Nudge the polecat + if spawnInfo.Pane != "" { + if err := injectStartPrompt(spawnInfo.Pane, beadID, slingSubject, slingArgs); err != nil { + fmt.Printf(" %s Could not nudge (agent will discover via gt prime)\n", style.Dim.Render("○")) + } else { + fmt.Printf(" %s Start prompt sent\n", style.Bold.Render("▶")) + } + } + + results = append(results, slingResult{beadID: beadID, polecat: spawnInfo.PolecatName, success: true}) + } + + // Wake witness and refinery once at the end + wakeRigAgents(rigName) + + // Print summary + successCount := 0 + for _, r := range results { + if r.success { + successCount++ + } + } + + fmt.Printf("\n%s Batch sling complete: %d/%d succeeded\n", style.Bold.Render("📊"), successCount, len(beadIDs)) + if successCount < len(beadIDs) { + for _, r := range results { + if !r.success { + fmt.Printf(" %s %s: %s\n", style.Dim.Render("✗"), r.beadID, r.errMsg) + } + } + } + + return nil +}