package cmd import ( "bytes" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/spf13/cobra" "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/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/swarm" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) // Swarm command flags var ( swarmEpic string swarmTasks []string swarmWorkers []string swarmStart bool swarmStatusJSON bool swarmListRig string swarmListStatus string swarmListJSON bool swarmTarget string ) var swarmCmd = &cobra.Command{ Use: "swarm", GroupID: GroupWork, Short: "[DEPRECATED] Use 'gt convoy' instead", Deprecated: "Use 'gt convoy' for work tracking. A 'swarm' is now just the ephemeral workers on a convoy.", RunE: requireSubcommand, Long: `DEPRECATED: Use 'gt convoy' instead. The term "swarm" now refers to the ephemeral set of workers on a convoy's issues, not a persistent tracking unit. Use 'gt convoy' for creating and tracking batched work. TERMINOLOGY: Convoy: Persistent tracking unit (what this command was trying to be) Swarm: Ephemeral workers on a convoy (no separate tracking needed) MIGRATION: gt swarm create → gt convoy create gt swarm status → gt convoy status gt swarm list → gt convoy list See 'gt convoy --help' for the new workflow.`, } var swarmCreateCmd = &cobra.Command{ Use: "create ", Short: "Create a new swarm", Long: `Create a new swarm in a rig. Creates a swarm that coordinates multiple polecats working on tasks from a beads epic. All workers branch from the same base commit. Examples: gt swarm create greenplace --epic gp-abc --worker Toast --worker Nux gt swarm create greenplace --epic gp-abc --worker Toast --start`, Args: cobra.ExactArgs(1), RunE: runSwarmCreate, } var swarmStatusCmd = &cobra.Command{ Use: "status ", Short: "Show swarm status", Long: `Show detailed status for a swarm. Displays swarm metadata, task progress, worker assignments, and integration branch status.`, Args: cobra.ExactArgs(1), RunE: runSwarmStatus, } var swarmListCmd = &cobra.Command{ Use: "list [rig]", Short: "List swarms", Long: `List swarms, optionally filtered by rig or status. Examples: gt swarm list gt swarm list greenplace gt swarm list --status=active gt swarm list greenplace --status=landed`, Args: cobra.MaximumNArgs(1), RunE: runSwarmList, } var swarmLandCmd = &cobra.Command{ Use: "land ", Short: "Land a swarm to main", Long: `Manually trigger landing for a completed swarm. Merges the integration branch to the target branch (usually main). Normally this is done automatically by the Refinery.`, Args: cobra.ExactArgs(1), RunE: runSwarmLand, } var swarmCancelCmd = &cobra.Command{ Use: "cancel ", Short: "Cancel a swarm", Long: `Cancel an active swarm. Marks the swarm as cancelled and optionally cleans up branches.`, Args: cobra.ExactArgs(1), RunE: runSwarmCancel, } var swarmStartCmd = &cobra.Command{ Use: "start ", Short: "Start a created swarm", Long: `Start a swarm that was created without --start. Transitions the swarm from 'created' to 'active' state.`, Args: cobra.ExactArgs(1), RunE: runSwarmStart, } var swarmDispatchCmd = &cobra.Command{ Use: "dispatch ", Short: "Assign next ready task to an idle worker", Long: `Dispatch the next ready task from an epic to an available worker. Finds the first unassigned task in the epic's ready front and slings it to an idle polecat in the rig. Examples: gt swarm dispatch gt-abc # Dispatch next task from epic gt-abc gt swarm dispatch gt-abc --rig greenplace # Dispatch in specific rig`, Args: cobra.ExactArgs(1), RunE: runSwarmDispatch, } var swarmDispatchRig string func init() { // Create flags swarmCreateCmd.Flags().StringVar(&swarmEpic, "epic", "", "Beads epic ID for this swarm (required)") swarmCreateCmd.Flags().StringSliceVar(&swarmWorkers, "worker", nil, "Polecat names to assign (repeatable)") swarmCreateCmd.Flags().BoolVar(&swarmStart, "start", false, "Start swarm immediately after creation") swarmCreateCmd.Flags().StringVar(&swarmTarget, "target", "main", "Target branch for landing") _ = swarmCreateCmd.MarkFlagRequired("epic") // cobra flags: error only at runtime if missing // Status flags swarmStatusCmd.Flags().BoolVar(&swarmStatusJSON, "json", false, "Output as JSON") // List flags swarmListCmd.Flags().StringVar(&swarmListStatus, "status", "", "Filter by status (active, landed, cancelled, failed)") swarmListCmd.Flags().BoolVar(&swarmListJSON, "json", false, "Output as JSON") // Dispatch flags swarmDispatchCmd.Flags().StringVar(&swarmDispatchRig, "rig", "", "Rig to dispatch in (auto-detected from epic if not specified)") // Add subcommands swarmCmd.AddCommand(swarmCreateCmd) swarmCmd.AddCommand(swarmStartCmd) swarmCmd.AddCommand(swarmStatusCmd) swarmCmd.AddCommand(swarmListCmd) swarmCmd.AddCommand(swarmLandCmd) swarmCmd.AddCommand(swarmCancelCmd) swarmCmd.AddCommand(swarmDispatchCmd) rootCmd.AddCommand(swarmCmd) } // getSwarmRig gets a rig by name. func getSwarmRig(rigName string) (*rig.Rig, string, error) { townRoot, err := workspace.FindFromCwdOrError() if err != nil { return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err) } rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} } g := git.NewGit(townRoot) rigMgr := rig.NewManager(townRoot, rigsConfig, g) r, err := rigMgr.GetRig(rigName) if err != nil { return nil, "", fmt.Errorf("rig '%s' not found", rigName) } return r, townRoot, nil } // getAllRigs returns all discovered rigs. func getAllRigs() ([]*rig.Rig, string, error) { townRoot, err := workspace.FindFromCwdOrError() if err != nil { return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err) } rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} } g := git.NewGit(townRoot) rigMgr := rig.NewManager(townRoot, rigsConfig, g) rigs, err := rigMgr.DiscoverRigs() if err != nil { return nil, "", err } return rigs, townRoot, nil } func runSwarmCreate(cmd *cobra.Command, args []string) error { rigName := args[0] r, townRoot, err := getSwarmRig(rigName) if err != nil { return err } // Use beads to create the swarm molecule // First check if the epic already exists (it may be pre-created) // Use BeadsPath() to ensure we read from git-synced beads location beadsPath := r.BeadsPath() checkCmd := exec.Command("bd", "show", swarmEpic, "--json") checkCmd.Dir = beadsPath if err := checkCmd.Run(); err != nil { // Epic doesn't exist, create it as a swarm molecule createArgs := []string{ "create", "--type=epic", "--mol-type=swarm", "--title", swarmEpic, "--silent", } createCmd := exec.Command("bd", createArgs...) createCmd.Dir = beadsPath var stdout bytes.Buffer createCmd.Stdout = &stdout if err := createCmd.Run(); err != nil { return fmt.Errorf("creating swarm epic: %w", err) } } // Get current git commit as base baseCommit := "unknown" gitCmd := exec.Command("git", "rev-parse", "HEAD") gitCmd.Dir = r.Path if out, err := gitCmd.Output(); err == nil { baseCommit = strings.TrimSpace(string(out)) } integration := fmt.Sprintf("swarm/%s", swarmEpic) // Output fmt.Printf("%s Created swarm %s\n\n", style.Bold.Render("✓"), swarmEpic) fmt.Printf(" Epic: %s\n", swarmEpic) fmt.Printf(" Rig: %s\n", rigName) fmt.Printf(" Base commit: %s\n", truncate(baseCommit, 8)) fmt.Printf(" Integration: %s\n", integration) fmt.Printf(" Target: %s\n", swarmTarget) fmt.Printf(" Workers: %s\n", strings.Join(swarmWorkers, ", ")) // If workers specified, assign them to tasks if len(swarmWorkers) > 0 { fmt.Printf("\nNote: Worker assignment to tasks is handled during swarm start\n") } // Start if requested if swarmStart { // Get swarm status to find ready tasks statusCmd := exec.Command("bd", "swarm", "status", swarmEpic, "--json") statusCmd.Dir = beadsPath var statusOut bytes.Buffer statusCmd.Stdout = &statusOut if err := statusCmd.Run(); err != nil { return fmt.Errorf("getting swarm status: %w", err) } // Parse status to dispatch workers var status struct { Ready []struct { ID string `json:"id"` Title string `json:"title"` } `json:"ready"` } if err := json.Unmarshal(statusOut.Bytes(), &status); err == nil && len(status.Ready) > 0 { fmt.Printf("\nReady front has %d tasks available\n", len(status.Ready)) if len(swarmWorkers) > 0 { // Spawn workers for ready tasks fmt.Printf("Spawning workers...\n") _ = spawnSwarmWorkersFromBeads(r, townRoot, swarmEpic, swarmWorkers, status.Ready) } } } else { fmt.Printf("\n %s\n", style.Dim.Render("Use --start or 'gt swarm start' to activate")) } return nil } func runSwarmStart(cmd *cobra.Command, args []string) error { swarmID := args[0] // Find the swarm's rig rigs, townRoot, err := getAllRigs() if err != nil { return err } var foundRig *rig.Rig for _, r := range rigs { // Check if swarm exists in this rig by querying beads // Use BeadsPath() to ensure we read from git-synced location checkCmd := exec.Command("bd", "show", swarmID, "--json") checkCmd.Dir = r.BeadsPath() if err := checkCmd.Run(); err == nil { foundRig = r break } } if foundRig == nil { return fmt.Errorf("swarm '%s' not found", swarmID) } // Get swarm status from beads statusCmd := exec.Command("bd", "swarm", "status", swarmID, "--json") statusCmd.Dir = foundRig.BeadsPath() var stdout bytes.Buffer statusCmd.Stdout = &stdout if err := statusCmd.Run(); err != nil { return fmt.Errorf("getting swarm status: %w", err) } var status struct { EpicID string `json:"epic_id"` Ready []struct { ID string `json:"id"` Title string `json:"title"` } `json:"ready"` Active []struct { ID string `json:"id"` Assignee string `json:"assignee"` } `json:"active"` } if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { return fmt.Errorf("parsing swarm status: %w", err) } if len(status.Active) > 0 { fmt.Printf("Swarm already has %d active tasks\n", len(status.Active)) } if len(status.Ready) == 0 { fmt.Println("No ready tasks to dispatch") return nil } fmt.Printf("%s Swarm %s starting with %d ready tasks\n", style.Bold.Render("✓"), swarmID, len(status.Ready)) // If workers were specified in create, use them; otherwise prompt user if len(swarmWorkers) > 0 { fmt.Printf("\nSpawning workers...\n") _ = spawnSwarmWorkersFromBeads(foundRig, townRoot, swarmID, swarmWorkers, status.Ready) } else { fmt.Printf("\nReady tasks:\n") for _, task := range status.Ready { fmt.Printf(" ○ %s: %s\n", task.ID, task.Title) } fmt.Printf("\nUse 'gt sling /' to assign tasks\n") } return nil } func runSwarmDispatch(cmd *cobra.Command, args []string) error { epicID := args[0] // Find the epic's rig by trying to show it in each rig rigs, townRoot, err := getAllRigs() if err != nil { return err } var foundRig *rig.Rig for _, r := range rigs { // If --rig specified, only check that rig if swarmDispatchRig != "" && r.Name != swarmDispatchRig { continue } // Use BeadsPath() to ensure we read from git-synced location checkCmd := exec.Command("bd", "show", epicID, "--json") checkCmd.Dir = r.BeadsPath() if err := checkCmd.Run(); err == nil { foundRig = r break } } if foundRig == nil { if swarmDispatchRig != "" { return fmt.Errorf("epic '%s' not found in rig '%s'", epicID, swarmDispatchRig) } return fmt.Errorf("epic '%s' not found in any rig", epicID) } // Get swarm/epic status to find ready tasks statusCmd := exec.Command("bd", "swarm", "status", epicID, "--json") statusCmd.Dir = foundRig.BeadsPath() var stdout bytes.Buffer statusCmd.Stdout = &stdout if err := statusCmd.Run(); err != nil { return fmt.Errorf("getting epic status: %w", err) } var status struct { Ready []struct { ID string `json:"id"` Title string `json:"title"` Assignee string `json:"assignee"` } `json:"ready"` } if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { return fmt.Errorf("parsing epic status: %w", err) } // Filter to unassigned ready tasks var unassigned []struct { ID string Title string } for _, task := range status.Ready { if task.Assignee == "" { unassigned = append(unassigned, struct { ID string Title string }{task.ID, task.Title}) } } if len(unassigned) == 0 { fmt.Println("No unassigned ready tasks to dispatch") return nil } // Find idle polecats (no hooked work) polecatGit := git.NewGit(foundRig.Path) polecatMgr := polecat.NewManager(foundRig, polecatGit) polecats, err := polecatMgr.List() if err != nil { return fmt.Errorf("listing polecats: %w", err) } // Check which polecats have no hooked work var idlePolecats []string for _, p := range polecats { // Check if polecat has hooked work by querying beads hookCheckCmd := exec.Command("bd", "list", "--status=hooked", "--assignee", fmt.Sprintf("%s/polecats/%s", foundRig.Name, p.Name), "--json") hookCheckCmd.Dir = foundRig.BeadsPath() var hookOut bytes.Buffer hookCheckCmd.Stdout = &hookOut if err := hookCheckCmd.Run(); err == nil { var hooked []interface{} if err := json.Unmarshal(hookOut.Bytes(), &hooked); err == nil && len(hooked) == 0 { idlePolecats = append(idlePolecats, p.Name) } } } if len(idlePolecats) == 0 { fmt.Println("No idle polecats available") fmt.Printf("\nUnassigned ready tasks:\n") for _, task := range unassigned { fmt.Printf(" ○ %s: %s\n", task.ID, task.Title) } fmt.Printf("\nCreate a new polecat or wait for one to become idle.\n") return nil } // Dispatch first unassigned task to first idle polecat task := unassigned[0] worker := idlePolecats[0] target := fmt.Sprintf("%s/%s", foundRig.Name, worker) fmt.Printf("Dispatching %s to %s...\n", task.ID, target) // Use gt sling to assign the task slingCmd := exec.Command("gt", "sling", task.ID, target) slingCmd.Dir = townRoot slingCmd.Stdout = os.Stdout slingCmd.Stderr = os.Stderr if err := slingCmd.Run(); err != nil { return fmt.Errorf("slinging task: %w", err) } fmt.Printf("%s Dispatched %s: %s → %s\n", style.Bold.Render("✓"), task.ID, task.Title, target) // Show remaining tasks and workers if len(unassigned) > 1 { fmt.Printf("\n%d more ready tasks available\n", len(unassigned)-1) } if len(idlePolecats) > 1 { fmt.Printf("%d more idle polecats available\n", len(idlePolecats)-1) } return nil } // spawnSwarmWorkersFromBeads spawns sessions for swarm workers using beads task list. func spawnSwarmWorkersFromBeads(r *rig.Rig, townRoot string, swarmID string, workers []string, tasks []struct { ID string `json:"id"` Title string `json:"title"` }) error { t := tmux.NewTmux() sessMgr := session.NewManager(t, r) polecatGit := git.NewGit(r.Path) polecatMgr := polecat.NewManager(r, polecatGit) // Pair workers with tasks (round-robin if more tasks than workers) workerIdx := 0 for _, task := range tasks { if workerIdx >= len(workers) { break // No more workers } worker := workers[workerIdx] workerIdx++ // Use gt sling to assign task to worker (this updates beads) slingCmd := exec.Command("gt", "sling", task.ID, fmt.Sprintf("%s/%s", r.Name, worker)) slingCmd.Dir = townRoot if err := slingCmd.Run(); err != nil { style.PrintWarning(" couldn't sling %s to %s: %v", task.ID, worker, err) // Fallback: update polecat state directly if err := polecatMgr.AssignIssue(worker, task.ID); err != nil { style.PrintWarning(" couldn't assign %s to %s: %v", task.ID, worker, err) continue } } // Check if already running running, _ := sessMgr.IsRunning(worker) if running { fmt.Printf(" %s already running, injecting task...\n", worker) } else { fmt.Printf(" Starting %s...\n", worker) if err := sessMgr.Start(worker, session.StartOptions{}); err != nil { style.PrintWarning(" couldn't start %s: %v", worker, err) continue } // Wait for Claude to initialize time.Sleep(5 * time.Second) } // Inject work assignment context := fmt.Sprintf("[SWARM] You are part of swarm %s.\n\nAssigned task: %s\nTitle: %s\n\nWork on this task. When complete, commit and signal DONE.", swarmID, task.ID, task.Title) if err := sessMgr.Inject(worker, context); err != nil { style.PrintWarning(" couldn't inject to %s: %v", worker, err) } else { fmt.Printf(" %s → %s ✓\n", worker, task.ID) } } return nil } func runSwarmStatus(cmd *cobra.Command, args []string) error { swarmID := args[0] // Find the swarm's rig by trying to show it in each rig rigs, _, err := getAllRigs() if err != nil { return err } if len(rigs) == 0 { return fmt.Errorf("no rigs found") } // Find which rig has this swarm var foundRig *rig.Rig for _, r := range rigs { // Use BeadsPath() to ensure we read from git-synced location checkCmd := exec.Command("bd", "show", swarmID, "--json") checkCmd.Dir = r.BeadsPath() if err := checkCmd.Run(); err == nil { foundRig = r break } } if foundRig == nil { return fmt.Errorf("swarm '%s' not found in any rig", swarmID) } // Use bd swarm status to get swarm info from beads bdArgs := []string{"swarm", "status", swarmID} if swarmStatusJSON { bdArgs = append(bdArgs, "--json") } bdCmd := exec.Command("bd", bdArgs...) bdCmd.Dir = foundRig.BeadsPath() bdCmd.Stdout = os.Stdout bdCmd.Stderr = os.Stderr return bdCmd.Run() } func runSwarmList(cmd *cobra.Command, args []string) error { rigs, _, err := getAllRigs() if err != nil { return err } // Filter by rig if specified if len(args) > 0 { rigName := args[0] var filtered []*rig.Rig for _, r := range rigs { if r.Name == rigName { filtered = append(filtered, r) } } if len(filtered) == 0 { return fmt.Errorf("rig '%s' not found", rigName) } rigs = filtered } if len(rigs) == 0 { fmt.Println("No rigs found.") return nil } // Use bd list --mol-type=swarm to find swarm molecules bdArgs := []string{"list", "--mol-type=swarm", "--type=epic"} if swarmListJSON { bdArgs = append(bdArgs, "--json") } // Collect swarms from all rigs type swarmListEntry struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Rig string `json:"rig"` } var allSwarms []swarmListEntry for _, r := range rigs { bdCmd := exec.Command("bd", bdArgs...) bdCmd.Dir = r.BeadsPath() // Use BeadsPath() for git-synced beads var stdout bytes.Buffer bdCmd.Stdout = &stdout if err := bdCmd.Run(); err != nil { continue } if swarmListJSON { // Parse JSON output var issues []struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` } if err := json.Unmarshal(stdout.Bytes(), &issues); err == nil { for _, issue := range issues { allSwarms = append(allSwarms, swarmListEntry{ ID: issue.ID, Title: issue.Title, Status: issue.Status, Rig: r.Name, }) } } } else { // Parse line output - each line is an issue lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") for _, line := range lines { if line == "" { continue } // Filter by status if specified if swarmListStatus != "" && !strings.Contains(strings.ToLower(line), swarmListStatus) { continue } allSwarms = append(allSwarms, swarmListEntry{ ID: line, Rig: r.Name, }) } } } // JSON output if swarmListJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(allSwarms) } // Human-readable output if len(allSwarms) == 0 { fmt.Println("No swarms found.") fmt.Println("Create a swarm with: gt swarm create --epic ") return nil } fmt.Printf("%s\n\n", style.Bold.Render("Swarms")) for _, entry := range allSwarms { fmt.Printf(" %s [%s]\n", entry.ID, entry.Rig) } fmt.Printf("\nUse 'gt swarm status ' for detailed status.\n") return nil } func runSwarmLand(cmd *cobra.Command, args []string) error { swarmID := args[0] // Find the swarm's rig rigs, townRoot, err := getAllRigs() if err != nil { return err } var foundRig *rig.Rig for _, r := range rigs { // Use BeadsPath() for git-synced beads checkCmd := exec.Command("bd", "show", swarmID, "--json") checkCmd.Dir = r.BeadsPath() if err := checkCmd.Run(); err == nil { foundRig = r break } } if foundRig == nil { return fmt.Errorf("swarm '%s' not found", swarmID) } // Check swarm status - all children should be closed statusCmd := exec.Command("bd", "swarm", "status", swarmID, "--json") statusCmd.Dir = foundRig.BeadsPath() var stdout bytes.Buffer statusCmd.Stdout = &stdout if err := statusCmd.Run(); err != nil { return fmt.Errorf("getting swarm status: %w", err) } var status struct { Ready []struct{ ID string } `json:"ready"` Active []struct{ ID string } `json:"active"` Blocked []struct{ ID string } `json:"blocked"` Completed []struct{ ID string } `json:"completed"` TotalIssues int `json:"total_issues"` } if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { return fmt.Errorf("parsing swarm status: %w", err) } // Check if all tasks are complete if len(status.Ready) > 0 || len(status.Active) > 0 || len(status.Blocked) > 0 { return fmt.Errorf("swarm has incomplete tasks: %d ready, %d active, %d blocked", len(status.Ready), len(status.Active), len(status.Blocked)) } fmt.Printf("Landing swarm %s to main...\n", swarmID) // Use swarm manager for the actual landing (git operations) mgr := swarm.NewManager(foundRig) sw, err := mgr.LoadSwarm(swarmID) if err != nil { return fmt.Errorf("loading swarm from beads: %w", err) } // Execute full landing protocol config := swarm.LandingConfig{ TownRoot: townRoot, } result, err := mgr.ExecuteLanding(swarmID, config) if err != nil { return fmt.Errorf("landing protocol: %w", err) } if !result.Success { return fmt.Errorf("landing failed: %s", result.Error) } // Close the swarm epic in beads closeArgs := []string{"close", swarmID, "--reason", "Swarm landed to main"} if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" { closeArgs = append(closeArgs, "--session="+sessionID) } closeCmd := exec.Command("bd", closeArgs...) closeCmd.Dir = foundRig.BeadsPath() if err := closeCmd.Run(); err != nil { style.PrintWarning("couldn't close swarm epic in beads: %v", err) } fmt.Printf("%s Swarm %s landed to main\n", style.Bold.Render("✓"), sw.ID) fmt.Printf(" Sessions stopped: %d\n", result.SessionsStopped) fmt.Printf(" Branches cleaned: %d\n", result.BranchesCleaned) return nil } func runSwarmCancel(cmd *cobra.Command, args []string) error { swarmID := args[0] // Find the swarm's rig rigs, _, err := getAllRigs() if err != nil { return err } var foundRig *rig.Rig for _, r := range rigs { // Use BeadsPath() for git-synced beads checkCmd := exec.Command("bd", "show", swarmID, "--json") checkCmd.Dir = r.BeadsPath() if err := checkCmd.Run(); err == nil { foundRig = r break } } if foundRig == nil { return fmt.Errorf("swarm '%s' not found", swarmID) } // Check if swarm is already closed checkCmd := exec.Command("bd", "show", swarmID, "--json") checkCmd.Dir = foundRig.BeadsPath() var stdout bytes.Buffer checkCmd.Stdout = &stdout if err := checkCmd.Run(); err != nil { return fmt.Errorf("checking swarm status: %w", err) } var issue struct { Status string `json:"status"` } if err := json.Unmarshal(stdout.Bytes(), &issue); err == nil { if issue.Status == "closed" { return fmt.Errorf("swarm already closed") } } // Close the swarm epic in beads with cancelled reason closeArgs := []string{"close", swarmID, "--reason", "Swarm cancelled"} if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" { closeArgs = append(closeArgs, "--session="+sessionID) } closeCmd := exec.Command("bd", closeArgs...) closeCmd.Dir = foundRig.BeadsPath() if err := closeCmd.Run(); err != nil { return fmt.Errorf("closing swarm: %w", err) } fmt.Printf("%s Swarm %s cancelled\n", style.Bold.Render("✓"), swarmID) return nil } // Helper functions func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] }