package cmd import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/crew" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) // Crew command flags var ( crewRig string crewBranch bool crewJSON bool crewForce bool crewNoTmux bool crewMessage string ) var crewCmd = &cobra.Command{ Use: "crew", Short: "Manage crew workspaces (user-managed persistent workspaces)", Long: `Crew workers are user-managed persistent workspaces within a rig. Unlike polecats which are witness-managed and ephemeral, crew workers are: - Persistent: Not auto-garbage-collected - User-managed: Overseer controls lifecycle - Long-lived identities: recognizable names like dave, emma, fred - Gas Town integrated: Mail, handoff mechanics work - Tmux optional: Can work in terminal directly Commands: gt crew add Create a new crew workspace gt crew list List crew workspaces with status gt crew at Attach to crew workspace session gt crew remove Remove a crew workspace gt crew refresh Context cycling with mail-to-self handoff gt crew restart Kill and restart session fresh (alias: rs) gt crew status [] Show detailed workspace status`, } var crewAddCmd = &cobra.Command{ Use: "add ", Short: "Create a new crew workspace", Long: `Create a new crew workspace with a clone of the rig repository. The workspace is created at /crew// with: - A full git clone of the project repository - Mail directory for message delivery - CLAUDE.md with crew worker prompting - Optional feature branch (crew/) Examples: gt crew add dave # Create in current rig gt crew add emma --rig gastown # Create in specific rig gt crew add fred --branch # Create with feature branch`, Args: cobra.ExactArgs(1), RunE: runCrewAdd, } var crewListCmd = &cobra.Command{ Use: "list", Short: "List crew workspaces with status", Long: `List all crew workspaces in a rig with their status. Shows git branch, session state, and git status for each workspace. Examples: gt crew list # List in current rig gt crew list --rig gastown # List in specific rig gt crew list --json # JSON output`, RunE: runCrewList, } var crewAtCmd = &cobra.Command{ Use: "at [name]", Aliases: []string{"attach"}, Short: "Attach to crew workspace session", Long: `Start or attach to a tmux session for a crew workspace. Creates a new tmux session if none exists, or attaches to existing. Use --no-tmux to just print the directory path instead. Role Discovery: If no name is provided, attempts to detect the crew workspace from the current directory. If you're in /crew//, it will attach to that workspace automatically. Examples: gt crew at dave # Attach to dave's session gt crew at # Auto-detect from cwd gt crew at dave --no-tmux # Just print path`, Args: cobra.MaximumNArgs(1), RunE: runCrewAt, } var crewRemoveCmd = &cobra.Command{ Use: "remove ", Short: "Remove a crew workspace", Long: `Remove a crew workspace from the rig. Checks for uncommitted changes and running sessions before removing. Use --force to skip checks and remove anyway. Examples: gt crew remove dave # Remove with safety checks gt crew remove dave --force # Force remove`, Args: cobra.ExactArgs(1), RunE: runCrewRemove, } var crewRefreshCmd = &cobra.Command{ Use: "refresh ", Short: "Context cycling with mail-to-self handoff", Long: `Cycle a crew workspace session with handoff. Sends a handoff mail to the workspace's own inbox, then restarts the session. The new session reads the handoff mail and resumes work. Examples: gt crew refresh dave # Refresh with auto-generated handoff gt crew refresh dave -m "Working on gt-123" # Add custom message`, Args: cobra.ExactArgs(1), RunE: runCrewRefresh, } var crewStatusCmd = &cobra.Command{ Use: "status []", Short: "Show detailed workspace status", Long: `Show detailed status for crew workspace(s). Displays session state, git status, branch info, and mail inbox status. If no name given, shows status for all crew workers. Examples: gt crew status # Status of all crew workers gt crew status dave # Status of specific worker gt crew status --json # JSON output`, RunE: runCrewStatus, } var crewRestartCmd = &cobra.Command{ Use: "restart ", Aliases: []string{"rs"}, Short: "Kill and restart crew workspace session", Long: `Kill the tmux session and restart fresh with Claude. Useful when a crew member gets confused or needs a clean slate. Unlike 'refresh', this does NOT send handoff mail - it's a clean start. The command will: 1. Kill existing tmux session if running 2. Start fresh session with Claude 3. Run gt prime to reinitialize context Examples: gt crew restart dave # Restart dave's session gt crew rs emma # Same, using alias`, Args: cobra.ExactArgs(1), RunE: runCrewRestart, } var crewRenameCmd = &cobra.Command{ Use: "rename ", Short: "Rename a crew workspace", Long: `Rename a crew workspace. Kills any running session, renames the directory, and updates state. The new session will use the new name (gt--crew-). Examples: gt crew rename dave david # Rename dave to david gt crew rename madmax max # Rename madmax to max`, Args: cobra.ExactArgs(2), RunE: runCrewRename, } var crewPristineCmd = &cobra.Command{ Use: "pristine []", Short: "Sync crew workspaces with remote", Long: `Ensure crew workspace(s) are up-to-date. Runs git pull and bd sync for the specified crew, or all crew workers. Reports any uncommitted changes that may need attention. Examples: gt crew pristine # Pristine all crew workers gt crew pristine dave # Pristine specific worker gt crew pristine --json # JSON output`, RunE: runCrewPristine, } func init() { // Add flags crewAddCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to create crew workspace in") crewAddCmd.Flags().BoolVar(&crewBranch, "branch", false, "Create a feature branch (crew/)") crewListCmd.Flags().StringVar(&crewRig, "rig", "", "Filter by rig name") crewListCmd.Flags().BoolVar(&crewJSON, "json", false, "Output as JSON") crewAtCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") crewAtCmd.Flags().BoolVar(&crewNoTmux, "no-tmux", false, "Just print directory path") crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)") crewRefreshCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") crewRefreshCmd.Flags().StringVarP(&crewMessage, "message", "m", "", "Custom handoff message") crewStatusCmd.Flags().StringVar(&crewRig, "rig", "", "Filter by rig name") crewStatusCmd.Flags().BoolVar(&crewJSON, "json", false, "Output as JSON") crewRenameCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") crewPristineCmd.Flags().StringVar(&crewRig, "rig", "", "Filter by rig name") crewPristineCmd.Flags().BoolVar(&crewJSON, "json", false, "Output as JSON") crewRestartCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") // Add subcommands crewCmd.AddCommand(crewAddCmd) crewCmd.AddCommand(crewListCmd) crewCmd.AddCommand(crewAtCmd) crewCmd.AddCommand(crewRemoveCmd) crewCmd.AddCommand(crewRefreshCmd) crewCmd.AddCommand(crewStatusCmd) crewCmd.AddCommand(crewRenameCmd) crewCmd.AddCommand(crewPristineCmd) crewCmd.AddCommand(crewRestartCmd) rootCmd.AddCommand(crewCmd) } func runCrewAdd(cmd *cobra.Command, args []string) error { name := args[0] // Find workspace townRoot, err := workspace.FindFromCwdOrError() if err != nil { return fmt.Errorf("not in a Gas Town workspace: %w", err) } // Load rigs config rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} } // Determine rig rigName := crewRig if rigName == "" { // Try to infer from cwd rigName, err = inferRigFromCwd(townRoot) if err != nil { return fmt.Errorf("could not determine rig (use --rig flag): %w", err) } } // Get rig g := git.NewGit(townRoot) rigMgr := rig.NewManager(townRoot, rigsConfig, g) r, err := rigMgr.GetRig(rigName) if err != nil { return fmt.Errorf("rig '%s' not found", rigName) } // Create crew manager crewGit := git.NewGit(r.Path) crewMgr := crew.NewManager(r, crewGit) // Create crew workspace fmt.Printf("Creating crew workspace %s in %s...\n", name, rigName) worker, err := crewMgr.Add(name, crewBranch) if err != nil { if err == crew.ErrCrewExists { return fmt.Errorf("crew workspace '%s' already exists", name) } return fmt.Errorf("creating crew workspace: %w", err) } fmt.Printf("%s Created crew workspace: %s/%s\n", style.Bold.Render("✓"), rigName, name) fmt.Printf(" Path: %s\n", worker.ClonePath) fmt.Printf(" Branch: %s\n", worker.Branch) fmt.Printf(" Mail: %s/mail/\n", worker.ClonePath) fmt.Printf("\n%s\n", style.Dim.Render("Start working with: cd "+worker.ClonePath)) return nil } // inferRigFromCwd tries to determine the rig from the current directory. func inferRigFromCwd(townRoot string) (string, error) { cwd, err := filepath.Abs(".") if err != nil { return "", err } // Check if cwd is within a rig rel, err := filepath.Rel(townRoot, cwd) if err != nil { return "", fmt.Errorf("not in workspace") } // Normalize and split path - first component is the rig name rel = filepath.ToSlash(rel) parts := strings.Split(rel, "/") if len(parts) > 0 && parts[0] != "" && parts[0] != "." { return parts[0], nil } return "", fmt.Errorf("could not infer rig from current directory") } // getCrewManager returns a crew manager for the specified or inferred rig. func getCrewManager(rigName string) (*crew.Manager, *rig.Rig, error) { // Find town root townRoot, err := workspace.FindFromCwdOrError() if err != nil { return nil, nil, fmt.Errorf("not in a Gas Town workspace: %w", err) } // Load rigs config rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} } // Determine rig if rigName == "" { rigName, err = inferRigFromCwd(townRoot) if err != nil { return nil, nil, fmt.Errorf("could not determine rig (use --rig flag): %w", err) } } // Get rig g := git.NewGit(townRoot) rigMgr := rig.NewManager(townRoot, rigsConfig, g) r, err := rigMgr.GetRig(rigName) if err != nil { return nil, nil, fmt.Errorf("rig '%s' not found", rigName) } // Create crew manager crewGit := git.NewGit(r.Path) crewMgr := crew.NewManager(r, crewGit) return crewMgr, r, nil } // crewSessionName generates the tmux session name for a crew worker. func crewSessionName(rigName, crewName string) string { return fmt.Sprintf("gt-%s-crew-%s", rigName, crewName) } // CrewListItem represents a crew worker in list output. type CrewListItem struct { Name string `json:"name"` Rig string `json:"rig"` Branch string `json:"branch"` Path string `json:"path"` HasSession bool `json:"has_session"` GitClean bool `json:"git_clean"` } func runCrewList(cmd *cobra.Command, args []string) error { crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } workers, err := crewMgr.List() if err != nil { return fmt.Errorf("listing crew workers: %w", err) } if len(workers) == 0 { fmt.Println("No crew workspaces found.") return nil } // Check session and git status for each worker t := tmux.NewTmux() var items []CrewListItem for _, w := range workers { sessionID := crewSessionName(r.Name, w.Name) hasSession, _ := t.HasSession(sessionID) crewGit := git.NewGit(w.ClonePath) gitClean := true if status, err := crewGit.Status(); err == nil { gitClean = status.Clean } items = append(items, CrewListItem{ Name: w.Name, Rig: r.Name, Branch: w.Branch, Path: w.ClonePath, HasSession: hasSession, GitClean: gitClean, }) } if crewJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(items) } // Text output fmt.Printf("%s\n\n", style.Bold.Render("Crew Workspaces")) for _, item := range items { status := style.Dim.Render("○") if item.HasSession { status = style.Bold.Render("●") } gitStatus := style.Dim.Render("clean") if !item.GitClean { gitStatus = style.Bold.Render("dirty") } fmt.Printf(" %s %s/%s\n", status, item.Rig, item.Name) fmt.Printf(" Branch: %s Git: %s\n", item.Branch, gitStatus) fmt.Printf(" %s\n", style.Dim.Render(item.Path)) } return nil } func runCrewAt(cmd *cobra.Command, args []string) error { var name string // Determine crew name: from arg, or auto-detect from cwd if len(args) > 0 { name = args[0] } else { // Try to detect from current directory detected, err := detectCrewFromCwd() if err != nil { return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at ", err) } name = detected.crewName if crewRig == "" { crewRig = detected.rigName } fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name) } crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } // Get the crew worker 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) } // If --no-tmux, just print the path if crewNoTmux { fmt.Println(worker.ClonePath) return nil } // Check if session exists t := tmux.NewTmux() sessionID := crewSessionName(r.Name, name) hasSession, err := t.HasSession(sessionID) if err != nil { return fmt.Errorf("checking session: %w", err) } if !hasSession { // Create new session if err := t.NewSession(sessionID, worker.ClonePath); err != nil { return fmt.Errorf("creating session: %w", err) } // Set environment _ = t.SetEnvironment(sessionID, "GT_RIG", r.Name) _ = t.SetEnvironment(sessionID, "GT_CREW", name) // Apply rig-based theming (uses config if set, falls back to hash) theme := getThemeForRig(r.Name) _ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew") // Wait for shell to be ready after session creation if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { return fmt.Errorf("waiting for shell: %w", err) } // Start claude with skip permissions (crew workers are trusted like Mayor) if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { return fmt.Errorf("starting claude: %w", err) } // Wait for Claude to start (pane command changes from shell to node) shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) } // Give Claude time to initialize after process starts time.Sleep(500 * time.Millisecond) // Send gt prime to initialize context if err := t.SendKeys(sessionID, "gt prime"); err != nil { // Non-fatal: Claude started but priming failed fmt.Printf("Warning: Could not send prime command: %v\n", err) } fmt.Printf("%s Created session for %s/%s\n", style.Bold.Render("✓"), r.Name, name) } else { // Session exists - check if Claude is still running // Uses both pane command check and UI marker detection to avoid // restarting when user is in a subshell spawned from Claude if !t.IsClaudeRunning(sessionID) { // Claude has exited, restart it fmt.Printf("Claude exited, restarting...\n") if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { return fmt.Errorf("restarting claude: %w", err) } // Wait for Claude to start, then prime shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) } // Give Claude time to initialize after process starts time.Sleep(500 * time.Millisecond) if err := t.SendKeys(sessionID, "gt prime"); err != nil { fmt.Printf("Warning: Could not send prime command: %v\n", err) } // Send crew resume prompt after prime completes // Use longer debounce (300ms) to ensure paste completes before Enter crewPrompt := "Run gt prime. Check your mail and in-progress issues. Act on anything urgent, else await instructions." if err := t.SendKeysDelayedDebounced(sessionID, crewPrompt, 3000, 300); err != nil { fmt.Printf("Warning: Could not send resume prompt: %v\n", err) } } } // Check if we're already in the target session if isInTmuxSession(sessionID) { // We're in the session at a shell prompt - just start Claude directly fmt.Printf("Starting Claude in current session...\n") return execClaude() } // Attach to session using exec to properly forward TTY return attachToTmuxSession(sessionID) } // isShellCommand checks if the command is a shell (meaning Claude has exited). func isShellCommand(cmd string) bool { shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} for _, shell := range shells { if cmd == shell { return true } } return false } // execClaude execs claude, replacing the current process. // Used when we're already in the target session and just need to start Claude. func execClaude() error { claudePath, err := exec.LookPath("claude") if err != nil { return fmt.Errorf("claude not found: %w", err) } // exec replaces current process with claude args := []string{"claude", "--dangerously-skip-permissions"} return syscall.Exec(claudePath, args, os.Environ()) } // isInTmuxSession checks if we're currently inside the target tmux session. func isInTmuxSession(targetSession string) bool { // TMUX env var format: /tmp/tmux-501/default,12345,0 // We need to get the current session name via tmux display-message tmuxEnv := os.Getenv("TMUX") if tmuxEnv == "" { return false // Not in tmux at all } // Get current session name cmd := exec.Command("tmux", "display-message", "-p", "#{session_name}") out, err := cmd.Output() if err != nil { return false } currentSession := strings.TrimSpace(string(out)) return currentSession == targetSession } // attachToTmuxSession attaches to a tmux session with proper TTY forwarding. func attachToTmuxSession(sessionID string) error { tmuxPath, err := exec.LookPath("tmux") if err != nil { return fmt.Errorf("tmux not found: %w", err) } cmd := exec.Command(tmuxPath, "attach-session", "-t", sessionID) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // crewDetection holds the result of detecting crew workspace from cwd. type crewDetection struct { rigName string crewName string } // detectCrewFromCwd attempts to detect the crew workspace from the current directory. // It looks for the pattern //crew// in the current path. func detectCrewFromCwd() (*crewDetection, error) { cwd, err := os.Getwd() if err != nil { return nil, fmt.Errorf("getting cwd: %w", err) } // Find town root townRoot, err := workspace.FindFromCwd() if err != nil { return nil, fmt.Errorf("not in Gas Town workspace: %w", err) } if townRoot == "" { return nil, fmt.Errorf("not in Gas Town workspace") } // Get relative path from town root relPath, err := filepath.Rel(townRoot, cwd) if err != nil { return nil, fmt.Errorf("getting relative path: %w", err) } // Normalize and split path relPath = filepath.ToSlash(relPath) parts := strings.Split(relPath, "/") // Look for pattern: /crew//... // Minimum: rig, crew, name = 3 parts if len(parts) < 3 { return nil, fmt.Errorf("not in a crew workspace (path too short)") } rigName := parts[0] if parts[1] != "crew" { return nil, fmt.Errorf("not in a crew workspace (not in crew/ directory)") } crewName := parts[2] return &crewDetection{ rigName: rigName, crewName: crewName, }, nil } func runCrewRemove(cmd *cobra.Command, args []string) error { name := args[0] crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } // Check for running session (unless forced) if !crewForce { t := tmux.NewTmux() sessionID := crewSessionName(r.Name, name) hasSession, _ := t.HasSession(sessionID) if hasSession { return fmt.Errorf("session '%s' is running (use --force to kill and remove)", sessionID) } } // 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 { return fmt.Errorf("killing session: %w", err) } fmt.Printf("Killed session %s\n", sessionID) } // Remove the crew workspace if err := crewMgr.Remove(name, crewForce); err != nil { if err == crew.ErrCrewNotFound { return fmt.Errorf("crew workspace '%s' not found", name) } if err == crew.ErrHasChanges { return fmt.Errorf("crew workspace has uncommitted changes (use --force to remove anyway)") } return fmt.Errorf("removing crew workspace: %w", err) } fmt.Printf("%s Removed crew workspace: %s/%s\n", style.Bold.Render("✓"), r.Name, name) return nil } func runCrewRefresh(cmd *cobra.Command, args []string) error { name := args[0] crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } // Get the crew worker 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) } t := tmux.NewTmux() sessionID := crewSessionName(r.Name, name) // Check if session exists hasSession, _ := t.HasSession(sessionID) // 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) // Kill existing session if running if hasSession { if err := t.KillSession(sessionID); err != nil { return fmt.Errorf("killing old session: %w", err) } fmt.Printf("Killed old session %s\n", sessionID) } // Start new session if err := t.NewSession(sessionID, worker.ClonePath); err != nil { return fmt.Errorf("creating session: %w", err) } // Set environment _ = t.SetEnvironment(sessionID, "GT_RIG", r.Name) _ = t.SetEnvironment(sessionID, "GT_CREW", name) // Wait for shell to be ready if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { return fmt.Errorf("waiting for shell: %w", err) } // Start claude (refresh uses regular permissions, reads handoff mail) if err := t.SendKeys(sessionID, "claude"); err != nil { return fmt.Errorf("starting claude: %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 } func runCrewRestart(cmd *cobra.Command, args []string) error { name := args[0] crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } // Get the crew worker 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) } t := tmux.NewTmux() sessionID := crewSessionName(r.Name, name) // Kill existing session if running if hasSession, _ := t.HasSession(sessionID); hasSession { if err := t.KillSession(sessionID); err != nil { return fmt.Errorf("killing old session: %w", err) } fmt.Printf("Killed session %s\n", sessionID) } // Start new session if err := t.NewSession(sessionID, worker.ClonePath); err != nil { return fmt.Errorf("creating session: %w", err) } // Set environment t.SetEnvironment(sessionID, "GT_RIG", r.Name) t.SetEnvironment(sessionID, "GT_CREW", name) // Apply rig-based theming (uses config if set, falls back to hash) theme := getThemeForRig(r.Name) _ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew") // Wait for shell to be ready if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil { return fmt.Errorf("waiting for shell: %w", err) } // Start claude with skip permissions (crew workers are trusted) if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { return fmt.Errorf("starting claude: %w", err) } // Wait for Claude to start, then prime it shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err) } // Give Claude time to initialize after process starts time.Sleep(500 * time.Millisecond) if err := t.SendKeys(sessionID, "gt prime"); err != nil { // Non-fatal: Claude started but priming failed fmt.Printf("Warning: Could not send prime command: %v\n", err) } // Send crew resume prompt after prime completes // Use longer debounce (300ms) to ensure paste completes before Enter crewPrompt := "Read your mail, act on anything urgent, else await instructions." if err := t.SendKeysDelayedDebounced(sessionID, crewPrompt, 3000, 300); err != nil { fmt.Printf("Warning: Could not send resume prompt: %v\n", err) } 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 nil } // CrewStatusItem represents detailed status for a crew worker. type CrewStatusItem struct { Name string `json:"name"` Rig string `json:"rig"` Path string `json:"path"` Branch string `json:"branch"` HasSession bool `json:"has_session"` SessionID string `json:"session_id,omitempty"` GitClean bool `json:"git_clean"` GitModified []string `json:"git_modified,omitempty"` GitUntracked []string `json:"git_untracked,omitempty"` MailTotal int `json:"mail_total"` MailUnread int `json:"mail_unread"` } func runCrewStatus(cmd *cobra.Command, args []string) error { crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } var workers []*crew.CrewWorker if len(args) > 0 { // Specific worker name := args[0] 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) } workers = []*crew.CrewWorker{worker} } else { // All workers workers, err = crewMgr.List() if err != nil { return fmt.Errorf("listing crew workers: %w", err) } } if len(workers) == 0 { fmt.Println("No crew workspaces found.") return nil } t := tmux.NewTmux() var items []CrewStatusItem for _, w := range workers { sessionID := crewSessionName(r.Name, w.Name) hasSession, _ := t.HasSession(sessionID) // Git status crewGit := git.NewGit(w.ClonePath) gitStatus, _ := crewGit.Status() branch, _ := crewGit.CurrentBranch() gitClean := true var modified, untracked []string if gitStatus != nil { gitClean = gitStatus.Clean modified = append(gitStatus.Modified, gitStatus.Added...) modified = append(modified, gitStatus.Deleted...) untracked = gitStatus.Untracked } // Mail status mailDir := filepath.Join(w.ClonePath, "mail") mailTotal, mailUnread := 0, 0 if _, err := os.Stat(mailDir); err == nil { mailbox := mail.NewMailbox(mailDir) mailTotal, mailUnread, _ = mailbox.Count() } item := CrewStatusItem{ Name: w.Name, Rig: r.Name, Path: w.ClonePath, Branch: branch, HasSession: hasSession, GitClean: gitClean, GitModified: modified, GitUntracked: untracked, MailTotal: mailTotal, MailUnread: mailUnread, } if hasSession { item.SessionID = sessionID } items = append(items, item) } if crewJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(items) } // Text output for i, item := range items { if i > 0 { fmt.Println() } sessionStatus := style.Dim.Render("○ stopped") if item.HasSession { sessionStatus = style.Bold.Render("● running") } fmt.Printf("%s %s/%s\n", sessionStatus, item.Rig, item.Name) fmt.Printf(" Path: %s\n", item.Path) fmt.Printf(" Branch: %s\n", item.Branch) if item.GitClean { fmt.Printf(" Git: %s\n", style.Dim.Render("clean")) } else { fmt.Printf(" Git: %s\n", style.Bold.Render("dirty")) if len(item.GitModified) > 0 { fmt.Printf(" Modified: %s\n", strings.Join(item.GitModified, ", ")) } if len(item.GitUntracked) > 0 { fmt.Printf(" Untracked: %s\n", strings.Join(item.GitUntracked, ", ")) } } if item.MailUnread > 0 { fmt.Printf(" Mail: %d unread / %d total\n", item.MailUnread, item.MailTotal) } else { fmt.Printf(" Mail: %s\n", style.Dim.Render(fmt.Sprintf("%d messages", item.MailTotal))) } } return nil } func runCrewRename(cmd *cobra.Command, args []string) error { oldName := args[0] newName := args[1] crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } // Kill any running session for the old name t := tmux.NewTmux() oldSessionID := crewSessionName(r.Name, oldName) if hasSession, _ := t.HasSession(oldSessionID); hasSession { if err := t.KillSession(oldSessionID); err != nil { return fmt.Errorf("killing old session: %w", err) } fmt.Printf("Killed session %s\n", oldSessionID) } // Perform the rename if err := crewMgr.Rename(oldName, newName); err != nil { if err == crew.ErrCrewNotFound { return fmt.Errorf("crew workspace '%s' not found", oldName) } if err == crew.ErrCrewExists { return fmt.Errorf("crew workspace '%s' already exists", newName) } return fmt.Errorf("renaming crew workspace: %w", err) } fmt.Printf("%s Renamed crew workspace: %s/%s → %s/%s\n", style.Bold.Render("✓"), r.Name, oldName, r.Name, newName) fmt.Printf("New session will be: %s\n", style.Dim.Render(crewSessionName(r.Name, newName))) return nil } func runCrewPristine(cmd *cobra.Command, args []string) error { crewMgr, r, err := getCrewManager(crewRig) if err != nil { return err } var workers []*crew.CrewWorker if len(args) > 0 { // Specific worker name := args[0] 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) } workers = []*crew.CrewWorker{worker} } else { // All workers workers, err = crewMgr.List() if err != nil { return fmt.Errorf("listing crew workers: %w", err) } } if len(workers) == 0 { fmt.Println("No crew workspaces found.") return nil } var results []*crew.PristineResult for _, w := range workers { result, err := crewMgr.Pristine(w.Name) if err != nil { return fmt.Errorf("pristine %s: %w", w.Name, err) } results = append(results, result) } if crewJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(results) } // Text output for _, result := range results { fmt.Printf("%s %s/%s\n", style.Bold.Render("→"), r.Name, result.Name) if result.HadChanges { fmt.Printf(" %s\n", style.Bold.Render("⚠ Has uncommitted changes")) } if result.Pulled { fmt.Printf(" %s git pull\n", style.Dim.Render("✓")) } else if result.PullError != "" { fmt.Printf(" %s git pull: %s\n", style.Bold.Render("✗"), result.PullError) } if result.Synced { fmt.Printf(" %s bd sync\n", style.Dim.Render("✓")) } else if result.SyncError != "" { fmt.Printf(" %s bd sync: %s\n", style.Bold.Render("✗"), result.SyncError) } } return nil }