package cmd import ( "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/crew" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/townlog" "github.com/steveyegge/gastown/internal/workspace" ) func runCrewRemove(cmd *cobra.Command, args []string) error { var lastErr error // --purge implies --force forceRemove := crewForce || crewPurge for _, arg := range args { name := arg rigOverride := crewRig // Parse rig/name format (e.g., "beads/emma" -> rig=beads, name=emma) if rig, crewName, ok := parseRigSlashName(name); ok { if rigOverride == "" { rigOverride = rig } name = crewName } crewMgr, r, err := getCrewManager(rigOverride) if err != nil { fmt.Printf("Error removing %s: %v\n", arg, err) lastErr = err continue } // Check for running session (unless forced) if !forceRemove { t := tmux.NewTmux() sessionID := crewSessionName(r.Name, name) hasSession, _ := t.HasSession(sessionID) if hasSession { fmt.Printf("Error removing %s: session '%s' is running (use --force to kill and remove)\n", arg, sessionID) lastErr = fmt.Errorf("session running") continue } } // Kill session if it exists t := tmux.NewTmux() sessionID := crewSessionName(r.Name, name) if hasSession, _ := t.HasSession(sessionID); hasSession { if err := t.KillSession(sessionID); err != nil { fmt.Printf("Error killing session for %s: %v\n", arg, err) lastErr = err continue } fmt.Printf("Killed session %s\n", sessionID) } // Determine workspace path crewPath := filepath.Join(r.Path, "crew", name) // Check if this is a worktree (has .git file) vs regular clone (has .git directory) isWorktree := false gitPath := filepath.Join(crewPath, ".git") if info, err := os.Stat(gitPath); err == nil && !info.IsDir() { isWorktree = true } // Remove the workspace if isWorktree { // For worktrees, use git worktree remove mayorRigPath := constants.RigMayorPath(r.Path) removeArgs := []string{"worktree", "remove", crewPath} if forceRemove { removeArgs = []string{"worktree", "remove", "--force", crewPath} } removeCmd := exec.Command("git", removeArgs...) removeCmd.Dir = mayorRigPath if output, err := removeCmd.CombinedOutput(); err != nil { fmt.Printf("Error removing worktree %s: %v\n%s", arg, err, string(output)) lastErr = err continue } fmt.Printf("%s Removed crew worktree: %s/%s\n", style.Bold.Render("✓"), r.Name, name) } else { // For regular clones, use the crew manager if err := crewMgr.Remove(name, forceRemove); err != nil { if err == crew.ErrCrewNotFound { fmt.Printf("Error removing %s: crew workspace not found\n", arg) } else if err == crew.ErrHasChanges { fmt.Printf("Error removing %s: uncommitted changes (use --force)\n", arg) } else { fmt.Printf("Error removing %s: %v\n", arg, err) } lastErr = err continue } fmt.Printf("%s Removed crew workspace: %s/%s\n", style.Bold.Render("✓"), r.Name, name) } // Handle agent bead townRoot, _ := workspace.Find(r.Path) if townRoot == "" { townRoot = r.Path } prefix := beads.GetPrefixForRig(townRoot, r.Name) agentBeadID := beads.CrewBeadIDWithPrefix(prefix, r.Name, name) if crewPurge { // --purge: DELETE the agent bead entirely (obliterate) deleteArgs := []string{"delete", agentBeadID, "--force"} deleteCmd := exec.Command("bd", deleteArgs...) deleteCmd.Dir = r.Path if output, err := deleteCmd.CombinedOutput(); err != nil { // Non-fatal: bead might not exist if !strings.Contains(string(output), "no issue found") && !strings.Contains(string(output), "not found") { style.PrintWarning("could not delete agent bead %s: %v", agentBeadID, err) } } else { fmt.Printf("Deleted agent bead: %s\n", agentBeadID) } // Unassign any beads assigned to this crew member agentAddr := fmt.Sprintf("%s/crew/%s", r.Name, name) unassignArgs := []string{"list", "--assignee=" + agentAddr, "--format=id"} unassignCmd := exec.Command("bd", unassignArgs...) unassignCmd.Dir = r.Path if output, err := unassignCmd.CombinedOutput(); err == nil { ids := strings.Fields(strings.TrimSpace(string(output))) for _, id := range ids { if id == "" { continue } updateCmd := exec.Command("bd", "update", id, "--unassign") updateCmd.Dir = r.Path if _, err := updateCmd.CombinedOutput(); err == nil { fmt.Printf("Unassigned: %s\n", id) } } } // Clear mail directory if it exists mailDir := filepath.Join(crewPath, "mail") if _, err := os.Stat(mailDir); err == nil { // Mail dir was removed with the workspace, so nothing to do // But if we want to be extra thorough, we could look in town beads } } else { // Default: CLOSE the agent bead (preserves CV history) closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"} if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" { closeArgs = append(closeArgs, "--session="+sessionID) } closeCmd := exec.Command("bd", closeArgs...) closeCmd.Dir = r.Path if output, err := closeCmd.CombinedOutput(); err != nil { // Non-fatal: bead might not exist or already be closed if !strings.Contains(string(output), "no issue found") && !strings.Contains(string(output), "already closed") { style.PrintWarning("could not close agent bead %s: %v", agentBeadID, err) } } else { fmt.Printf("Closed agent bead: %s\n", agentBeadID) } } } return lastErr } func runCrewRefresh(cmd *cobra.Command, args []string) error { name := args[0] // Parse rig/name format (e.g., "beads/emma" -> rig=beads, name=emma) if rig, crewName, ok := parseRigSlashName(name); ok { if crewRig == "" { crewRig = rig } name = crewName } crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } // Get the crew worker (must exist for refresh) worker, err := crewMgr.Get(name) if err != nil { if err == crew.ErrCrewNotFound { return fmt.Errorf("crew workspace '%s' not found", name) } return fmt.Errorf("getting crew worker: %w", err) } // Create handoff message handoffMsg := crewMessage if handoffMsg == "" { handoffMsg = fmt.Sprintf("Context refresh for %s. Check mail and beads for current work state.", name) } // Send handoff mail to self mailDir := filepath.Join(worker.ClonePath, "mail") if _, err := os.Stat(mailDir); os.IsNotExist(err) { if err := os.MkdirAll(mailDir, 0755); err != nil { return fmt.Errorf("creating mail dir: %w", err) } } // Create and send mail mailbox := mail.NewMailbox(mailDir) msg := &mail.Message{ From: fmt.Sprintf("%s/%s", r.Name, name), To: fmt.Sprintf("%s/%s", r.Name, name), Subject: "🤝 HANDOFF: Context Refresh", Body: handoffMsg, } if err := mailbox.Append(msg); err != nil { return fmt.Errorf("sending handoff mail: %w", err) } fmt.Printf("Sent handoff mail to %s/%s\n", r.Name, name) // Use manager's Start() with refresh options err = crewMgr.Start(name, crew.StartOptions{ KillExisting: true, // Kill old session if running Topic: "refresh", // Startup nudge topic Interactive: true, // No --dangerously-skip-permissions AgentOverride: crewAgentOverride, }) if err != nil { return fmt.Errorf("starting crew session: %w", err) } fmt.Printf("%s Refreshed crew workspace: %s/%s\n", style.Bold.Render("✓"), r.Name, name) fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name))) return nil } // runCrewStart starts crew workers in a rig. // If first arg is a valid rig name, it's used as the rig; otherwise rig is inferred from cwd. // Remaining args (or all args if rig is inferred) are crew member names. // Defaults to all crew members if no names specified. func runCrewStart(cmd *cobra.Command, args []string) error { var rigName string var crewNames []string if len(args) == 0 { // No args - infer rig from cwd rigName = "" // getCrewManager will infer from cwd } else { // Check if first arg is a valid rig name if _, _, err := getRig(args[0]); err == nil { // First arg is a rig name rigName = args[0] crewNames = args[1:] } else { // First arg is not a rig - infer rig from cwd and treat all args as crew names rigName = "" // getCrewManager will infer from cwd crewNames = args } } // Get the rig manager and rig (infers from cwd if rigName is empty) crewMgr, r, err := getCrewManager(rigName) if err != nil { return err } // Update rigName in case it was inferred rigName = r.Name // If --all flag OR no crew names specified, get all crew members if crewAll || len(crewNames) == 0 { workers, err := crewMgr.List() if err != nil { return fmt.Errorf("listing crew: %w", err) } if len(workers) == 0 { fmt.Printf("No crew members in rig %s\n", rigName) return nil } for _, w := range workers { crewNames = append(crewNames, w.Name) } } // Start each crew member var lastErr error startedCount := 0 for _, name := range crewNames { // Set the start.go flags before calling runStartCrew startCrewRig = rigName startCrewAccount = crewAccount startCrewAgentOverride = crewAgentOverride // Use rig/name format for runStartCrew fullName := rigName + "/" + name if err := runStartCrew(cmd, []string{fullName}); err != nil { fmt.Printf("Error starting %s/%s: %v\n", rigName, name, err) lastErr = err } else { startedCount++ } } if startedCount > 0 { fmt.Printf("\n%s Started %d crew member(s) in %s\n", style.Bold.Render("✓"), startedCount, r.Name) } return lastErr } func runCrewRestart(cmd *cobra.Command, args []string) error { // Handle --all flag if crewAll { return runCrewRestartAll() } var lastErr error for _, arg := range args { name := arg rigOverride := crewRig // Parse rig/name format (e.g., "beads/emma" -> rig=beads, name=emma) if rig, crewName, ok := parseRigSlashName(name); ok { if rigOverride == "" { rigOverride = rig } name = crewName } crewMgr, r, err := getCrewManager(rigOverride) if err != nil { fmt.Printf("Error restarting %s: %v\n", arg, err) lastErr = err continue } // Use manager's Start() with restart options // Start() will create workspace if needed (idempotent) err = crewMgr.Start(name, crew.StartOptions{ KillExisting: true, // Kill old session if running Topic: "restart", // Startup nudge topic AgentOverride: crewAgentOverride, }) if err != nil { fmt.Printf("Error restarting %s: %v\n", arg, err) lastErr = err continue } fmt.Printf("%s Restarted crew workspace: %s/%s\n", style.Bold.Render("✓"), r.Name, name) fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name))) } return lastErr } // runCrewRestartAll restarts all running crew sessions. // If crewRig is set, only restarts crew in that rig. func runCrewRestartAll() error { // Get all agent sessions (including polecats to find crew) agents, err := getAgentSessions(true) if err != nil { return fmt.Errorf("listing sessions: %w", err) } // Filter to crew agents only var targets []*AgentSession for _, agent := range agents { if agent.Type != AgentCrew { continue } // Filter by rig if specified if crewRig != "" && agent.Rig != crewRig { continue } targets = append(targets, agent) } if len(targets) == 0 { fmt.Println("No running crew sessions to restart.") if crewRig != "" { fmt.Printf(" (filtered by rig: %s)\n", crewRig) } return nil } // Dry run - just show what would be restarted if crewDryRun { fmt.Printf("Would restart %d crew session(s):\n\n", len(targets)) for _, agent := range targets { fmt.Printf(" %s %s/crew/%s\n", AgentTypeIcons[AgentCrew], agent.Rig, agent.AgentName) } return nil } fmt.Printf("Restarting %d crew session(s)...\n\n", len(targets)) var succeeded, failed int var failures []string for _, agent := range targets { agentName := fmt.Sprintf("%s/crew/%s", agent.Rig, agent.AgentName) // Use crewRig temporarily to get the right crew manager savedRig := crewRig crewRig = agent.Rig crewMgr, _, err := getCrewManager(crewRig) if err != nil { failed++ failures = append(failures, fmt.Sprintf("%s: %v", agentName, err)) fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName) crewRig = savedRig continue } // Use manager's Start() with restart options err = crewMgr.Start(agent.AgentName, crew.StartOptions{ KillExisting: true, // Kill old session if running Topic: "restart", // Startup nudge topic AgentOverride: crewAgentOverride, }) if err != nil { failed++ failures = append(failures, fmt.Sprintf("%s: %v", agentName, err)) fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName) } else { succeeded++ fmt.Printf(" %s %s\n", style.SuccessPrefix, agentName) } crewRig = savedRig // Small delay between restarts to avoid overwhelming the system time.Sleep(constants.ShutdownNotifyDelay) } fmt.Println() if failed > 0 { fmt.Printf("%s Restart complete: %d succeeded, %d failed\n", style.WarningPrefix, succeeded, failed) for _, f := range failures { fmt.Printf(" %s\n", style.Dim.Render(f)) } return fmt.Errorf("%d restart(s) failed", failed) } fmt.Printf("%s Restart complete: %d crew session(s) restarted\n", style.SuccessPrefix, succeeded) return nil } // runCrewStop stops one or more crew workers. // Supports: "name", "rig/name" formats, "rig" (to stop all in rig), or --all. func runCrewStop(cmd *cobra.Command, args []string) error { // Handle --all flag if crewAll { return runCrewStopAll() } // Handle 0 args: default to all in inferred rig if len(args) == 0 { return runCrewStopAll() } // Handle 1 arg without "/": check if it's a rig name // If so, stop all crew in that rig if len(args) == 1 && !strings.Contains(args[0], "/") { // Try to interpret as rig name if _, _, err := getRig(args[0]); err == nil { // It's a valid rig name - stop all crew in that rig crewRig = args[0] return runCrewStopAll() } // Not a rig name - fall through to treat as crew name } var lastErr error t := tmux.NewTmux() for _, arg := range args { name := arg rigOverride := crewRig // Parse rig/name format (e.g., "beads/emma" -> rig=beads, name=emma) if rig, crewName, ok := parseRigSlashName(name); ok { if rigOverride == "" { rigOverride = rig } name = crewName } _, r, err := getCrewManager(rigOverride) if err != nil { fmt.Printf("Error stopping %s: %v\n", arg, err) lastErr = err continue } sessionID := crewSessionName(r.Name, name) // Check if session exists hasSession, err := t.HasSession(sessionID) if err != nil { fmt.Printf("Error checking session %s: %v\n", sessionID, err) lastErr = err continue } if !hasSession { fmt.Printf("No session found for %s/%s\n", r.Name, name) continue } // Dry run - just show what would be stopped if crewDryRun { fmt.Printf("Would stop %s/%s (session: %s)\n", r.Name, name, sessionID) continue } // Capture output before stopping (best effort) var output string if !crewForce { output, _ = t.CapturePane(sessionID, 50) } // Kill the session if err := t.KillSession(sessionID); err != nil { fmt.Printf(" %s [%s] %s: %s\n", style.ErrorPrefix, r.Name, name, style.Dim.Render(err.Error())) lastErr = err continue } fmt.Printf(" %s [%s] %s: stopped\n", style.SuccessPrefix, r.Name, name) // Log kill event to town log townRoot, _ := workspace.Find(r.Path) if townRoot != "" { agent := fmt.Sprintf("%s/crew/%s", r.Name, name) logger := townlog.NewLogger(townRoot) _ = logger.Log(townlog.EventKill, agent, "gt crew stop") } // Log captured output (truncated) if len(output) > 200 { output = output[len(output)-200:] } if output != "" { fmt.Printf(" %s\n", style.Dim.Render("(output captured)")) } } return lastErr } // runCrewStopAll stops all running crew sessions. // If crewRig is set, only stops crew in that rig. func runCrewStopAll() error { // Get all agent sessions (including polecats to find crew) agents, err := getAgentSessions(true) if err != nil { return fmt.Errorf("listing sessions: %w", err) } // Filter to crew agents only var targets []*AgentSession for _, agent := range agents { if agent.Type != AgentCrew { continue } // Filter by rig if specified if crewRig != "" && agent.Rig != crewRig { continue } targets = append(targets, agent) } if len(targets) == 0 { fmt.Println("No running crew sessions to stop.") if crewRig != "" { fmt.Printf(" (filtered by rig: %s)\n", crewRig) } return nil } // Dry run - just show what would be stopped if crewDryRun { fmt.Printf("Would stop %d crew session(s):\n\n", len(targets)) for _, agent := range targets { fmt.Printf(" %s %s/crew/%s\n", AgentTypeIcons[AgentCrew], agent.Rig, agent.AgentName) } return nil } fmt.Printf("%s Stopping %d crew session(s)...\n\n", style.Bold.Render("🛑"), len(targets)) t := tmux.NewTmux() var succeeded, failed int var failures []string for _, agent := range targets { agentName := fmt.Sprintf("%s/crew/%s", agent.Rig, agent.AgentName) sessionID := agent.Name // agent.Name IS the tmux session name // Capture output before stopping (best effort) var output string if !crewForce { output, _ = t.CapturePane(sessionID, 50) } // Kill the session if err := t.KillSession(sessionID); err != nil { failed++ failures = append(failures, fmt.Sprintf("%s: %v", agentName, err)) fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName) continue } succeeded++ fmt.Printf(" %s %s\n", style.SuccessPrefix, agentName) // Log kill event to town log townRoot, _ := workspace.FindFromCwd() if townRoot != "" { logger := townlog.NewLogger(townRoot) _ = logger.Log(townlog.EventKill, agentName, "gt crew stop --all") } // Log captured output (truncated) if len(output) > 200 { output = output[len(output)-200:] } if output != "" { fmt.Printf(" %s\n", style.Dim.Render("(output captured)")) } } fmt.Println() if failed > 0 { fmt.Printf("%s Stop complete: %d succeeded, %d failed\n", style.WarningPrefix, succeeded, failed) for _, f := range failures { fmt.Printf(" %s\n", style.Dim.Render(f)) } return fmt.Errorf("%d stop(s) failed", failed) } fmt.Printf("%s Stop complete: %d crew session(s) stopped\n", style.SuccessPrefix, succeeded) return nil }