diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index ac2d13b4..02b95c7d 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -17,18 +17,20 @@ import ( // Mail command flags var ( - mailSubject string - mailBody string - mailPriority string - mailType string - mailReplyTo string - mailNotify bool - mailInboxJSON bool - mailReadJSON bool - mailInboxUnread bool - mailCheckInject bool - mailCheckJSON bool - mailThreadJSON bool + mailSubject string + mailBody string + mailPriority string + mailType string + mailReplyTo string + mailNotify bool + mailInboxJSON bool + mailReadJSON bool + mailInboxUnread bool + mailCheckInject bool + mailCheckJSON bool + mailThreadJSON bool + mailReplySubject string + mailReplyMessage string ) var mailCmd = &cobra.Command{ @@ -137,6 +139,23 @@ Examples: RunE: runMailThread, } +var mailReplyCmd = &cobra.Command{ + Use: "reply ", + Short: "Reply to a message", + Long: `Reply to a specific message. + +This is a convenience command that automatically: +- Sets the reply-to field to the original message +- Prefixes the subject with "Re: " (if not already present) +- Sends to the original sender + +Examples: + gt mail reply msg-abc123 -m "Thanks, working on it now" + gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`, + Args: cobra.ExactArgs(1), + RunE: runMailReply, +} + func init() { // Send flags mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") @@ -161,6 +180,11 @@ func init() { // Thread flags mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON") + // Reply flags + mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: )") + mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)") + mailReplyCmd.MarkFlagRequired("message") + // Add subcommands mailCmd.AddCommand(mailSendCmd) mailCmd.AddCommand(mailInboxCmd) @@ -168,6 +192,7 @@ func init() { mailCmd.AddCommand(mailDeleteCmd) mailCmd.AddCommand(mailCheckCmd) mailCmd.AddCommand(mailThreadCmd) + mailCmd.AddCommand(mailReplyCmd) rootCmd.AddCommand(mailCmd) } @@ -629,6 +654,71 @@ func runMailThread(cmd *cobra.Command, args []string) error { return nil } +func runMailReply(cmd *cobra.Command, args []string) error { + msgID := args[0] + + // Find workspace + workDir, err := findBeadsWorkDir() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Determine current address + from := detectSender() + + // Get the original message + router := mail.NewRouter(workDir) + mailbox, err := router.GetMailbox(from) + if err != nil { + return fmt.Errorf("getting mailbox: %w", err) + } + + original, err := mailbox.Get(msgID) + if err != nil { + return fmt.Errorf("getting message: %w", err) + } + + // Build reply subject + subject := mailReplySubject + if subject == "" { + if strings.HasPrefix(original.Subject, "Re: ") { + subject = original.Subject + } else { + subject = "Re: " + original.Subject + } + } + + // Create reply message + reply := &mail.Message{ + From: from, + To: original.From, // Reply to sender + Subject: subject, + Body: mailReplyMessage, + Type: mail.TypeReply, + Priority: mail.PriorityNormal, + ReplyTo: msgID, + ThreadID: original.ThreadID, + } + + // If original has no thread ID, create one + if reply.ThreadID == "" { + reply.ThreadID = generateThreadID() + } + + // Send the reply + if err := router.Send(reply); err != nil { + return fmt.Errorf("sending reply: %w", err) + } + + fmt.Printf("%s Reply sent to %s\n", style.Bold.Render("✓"), original.From) + fmt.Printf(" Subject: %s\n", subject) + if original.ThreadID != "" { + fmt.Printf(" Thread: %s\n", style.Dim.Render(original.ThreadID)) + } + + return nil +} + // generateThreadID creates a random thread ID for new message threads. func generateThreadID() string { b := make([]byte, 6) diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 8ad74992..ebcfbcaf 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -11,8 +11,12 @@ import ( "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/refinery" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/witness" "github.com/steveyegge/gastown/internal/workspace" ) @@ -77,12 +81,32 @@ Examples: RunE: runRigReset, } +var rigShutdownCmd = &cobra.Command{ + Use: "shutdown ", + Short: "Gracefully stop all rig agents", + Long: `Stop all agents in a rig. + +This command gracefully shuts down: +- All polecat sessions +- The refinery (if running) +- The witness (if running) + +Use --force to skip graceful shutdown and kill immediately. + +Examples: + gt rig shutdown gastown + gt rig shutdown gastown --force`, + Args: cobra.ExactArgs(1), + RunE: runRigShutdown, +} + // Flags var ( - rigAddPrefix string - rigAddCrew string - rigResetHandoff bool - rigResetRole string + rigAddPrefix string + rigAddCrew string + rigResetHandoff bool + rigResetRole string + rigShutdownForce bool ) func init() { @@ -91,12 +115,15 @@ func init() { rigCmd.AddCommand(rigListCmd) rigCmd.AddCommand(rigRemoveCmd) rigCmd.AddCommand(rigResetCmd) + rigCmd.AddCommand(rigShutdownCmd) rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)") rigAddCmd.Flags().StringVar(&rigAddCrew, "crew", "main", "Default crew workspace name") rigResetCmd.Flags().BoolVar(&rigResetHandoff, "handoff", false, "Clear handoff content") rigResetCmd.Flags().StringVar(&rigResetRole, "role", "", "Role to reset (default: auto-detect from cwd)") + + rigShutdownCmd.Flags().BoolVarP(&rigShutdownForce, "force", "f", false, "Force immediate shutdown") } func runRigAdd(cmd *cobra.Command, args []string) error { @@ -302,3 +329,73 @@ func pathExists(path string) bool { _, err := os.Stat(path) return err == nil } + +func runRigShutdown(cmd *cobra.Command, args []string) error { + rigName := 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 and get rig + rigsPath := filepath.Join(townRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsPath) + 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 fmt.Errorf("rig '%s' not found", rigName) + } + + fmt.Printf("Shutting down rig %s...\n", style.Bold.Render(rigName)) + + var errors []string + + // 1. Stop all polecat sessions + t := tmux.NewTmux() + sessMgr := session.NewManager(t, r) + infos, err := sessMgr.List() + if err == nil && len(infos) > 0 { + fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos)) + if err := sessMgr.StopAll(rigShutdownForce); err != nil { + errors = append(errors, fmt.Sprintf("polecat sessions: %v", err)) + } + } + + // 2. Stop the refinery + refMgr := refinery.NewManager(r) + refStatus, err := refMgr.Status() + if err == nil && refStatus.State == refinery.StateRunning { + fmt.Printf(" Stopping refinery...\n") + if err := refMgr.Stop(); err != nil { + errors = append(errors, fmt.Sprintf("refinery: %v", err)) + } + } + + // 3. Stop the witness + witMgr := witness.NewManager(r) + witStatus, err := witMgr.Status() + if err == nil && witStatus.State == witness.StateRunning { + fmt.Printf(" Stopping witness...\n") + if err := witMgr.Stop(); err != nil { + errors = append(errors, fmt.Sprintf("witness: %v", err)) + } + } + + if len(errors) > 0 { + fmt.Printf("\n%s Some agents failed to stop:\n", style.Warning.Render("⚠")) + for _, e := range errors { + fmt.Printf(" - %s\n", e) + } + return fmt.Errorf("shutdown incomplete") + } + + fmt.Printf("%s Rig %s shut down successfully\n", style.Success.Render("✓"), rigName) + return nil +} diff --git a/internal/cmd/session.go b/internal/cmd/session.go index e9a15ac3..7dec6ca4 100644 --- a/internal/cmd/session.go +++ b/internal/cmd/session.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/config" @@ -114,6 +115,27 @@ Examples: RunE: runSessionInject, } +var sessionRestartCmd = &cobra.Command{ + Use: "restart /", + Short: "Restart a polecat session", + Long: `Restart a polecat session (stop + start). + +Gracefully stops the current session and starts a fresh one. +Use --force to skip graceful shutdown.`, + Args: cobra.ExactArgs(1), + RunE: runSessionRestart, +} + +var sessionStatusCmd = &cobra.Command{ + Use: "status /", + Short: "Show session status details", + Long: `Show detailed status for a polecat session. + +Displays running state, uptime, session info, and activity.`, + Args: cobra.ExactArgs(1), + RunE: runSessionStatus, +} + func init() { // Start flags sessionStartCmd.Flags().StringVar(&sessionIssue, "issue", "", "Issue ID to work on") @@ -132,6 +154,9 @@ func init() { sessionInjectCmd.Flags().StringVarP(&sessionMessage, "message", "m", "", "Message to inject") sessionInjectCmd.Flags().StringVarP(&sessionFile, "file", "f", "", "File to read message from") + // Restart flags + sessionRestartCmd.Flags().BoolVarP(&sessionForce, "force", "f", false, "Force immediate shutdown") + // Add subcommands sessionCmd.AddCommand(sessionStartCmd) sessionCmd.AddCommand(sessionStopCmd) @@ -139,6 +164,8 @@ func init() { sessionCmd.AddCommand(sessionListCmd) sessionCmd.AddCommand(sessionCaptureCmd) sessionCmd.AddCommand(sessionInjectCmd) + sessionCmd.AddCommand(sessionRestartCmd) + sessionCmd.AddCommand(sessionStatusCmd) rootCmd.AddCommand(sessionCmd) } @@ -413,3 +440,108 @@ func runSessionInject(cmd *cobra.Command, args []string) error { style.Bold.Render("✓"), rigName, polecatName) return nil } + +func runSessionRestart(cmd *cobra.Command, args []string) error { + rigName, polecatName, err := parseAddress(args[0]) + if err != nil { + return err + } + + mgr, _, err := getSessionManager(rigName) + if err != nil { + return err + } + + // Check if running + running, err := mgr.IsRunning(polecatName) + if err != nil { + return fmt.Errorf("checking session: %w", err) + } + + if running { + // Stop first + if sessionForce { + fmt.Printf("Force stopping session for %s/%s...\n", rigName, polecatName) + } else { + fmt.Printf("Stopping session for %s/%s...\n", rigName, polecatName) + } + if err := mgr.Stop(polecatName, sessionForce); err != nil { + return fmt.Errorf("stopping session: %w", err) + } + } + + // Start fresh session + fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName) + opts := session.StartOptions{} + if err := mgr.Start(polecatName, opts); err != nil { + return fmt.Errorf("starting session: %w", err) + } + + fmt.Printf("%s Session restarted. Attach with: %s\n", + style.Bold.Render("✓"), + style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName))) + return nil +} + +func runSessionStatus(cmd *cobra.Command, args []string) error { + rigName, polecatName, err := parseAddress(args[0]) + if err != nil { + return err + } + + mgr, _, err := getSessionManager(rigName) + if err != nil { + return err + } + + // Get session info + info, err := mgr.Status(polecatName) + if err != nil { + return fmt.Errorf("getting status: %w", err) + } + + // Format output + fmt.Printf("%s Session: %s/%s\n\n", style.Bold.Render("📺"), rigName, polecatName) + + if info.Running { + fmt.Printf(" State: %s\n", style.Bold.Render("● running")) + } else { + fmt.Printf(" State: %s\n", style.Dim.Render("○ stopped")) + return nil + } + + fmt.Printf(" Session ID: %s\n", info.SessionID) + + if info.Attached { + fmt.Printf(" Attached: yes\n") + } else { + fmt.Printf(" Attached: no\n") + } + + if !info.Created.IsZero() { + uptime := time.Since(info.Created) + fmt.Printf(" Created: %s\n", info.Created.Format("2006-01-02 15:04:05")) + fmt.Printf(" Uptime: %s\n", formatDuration(uptime)) + } + + fmt.Printf("\nAttach with: %s\n", style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName))) + return nil +} + +// formatDuration formats a duration for human display. +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) + } + hours := int(d.Hours()) + mins := int(d.Minutes()) % 60 + if hours >= 24 { + days := hours / 24 + hours = hours % 24 + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + } + return fmt.Sprintf("%dh %dm", hours, mins) +} diff --git a/internal/cmd/witness.go b/internal/cmd/witness.go index ed466cd8..b5dbc9e4 100644 --- a/internal/cmd/witness.go +++ b/internal/cmd/witness.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "github.com/spf13/cobra" @@ -11,6 +12,7 @@ import ( "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/witness" "github.com/steveyegge/gastown/internal/workspace" ) @@ -65,6 +67,20 @@ Displays running state, monitored polecats, and statistics.`, RunE: runWitnessStatus, } +var witnessAttachCmd = &cobra.Command{ + Use: "attach ", + Aliases: []string{"at"}, + Short: "Attach to witness session", + Long: `Attach to the Witness tmux session for a rig. + +Attaches the current terminal to the witness's tmux session. +Detach with Ctrl-B D. + +If the witness is not running, this will start it first.`, + Args: cobra.ExactArgs(1), + RunE: runWitnessAttach, +} + func init() { // Start flags witnessStartCmd.Flags().BoolVar(&witnessForeground, "foreground", false, "Run in foreground (default: background)") @@ -76,6 +92,7 @@ func init() { witnessCmd.AddCommand(witnessStartCmd) witnessCmd.AddCommand(witnessStopCmd) witnessCmd.AddCommand(witnessStatusCmd) + witnessCmd.AddCommand(witnessAttachCmd) rootCmd.AddCommand(witnessCmd) } @@ -213,3 +230,58 @@ func runWitnessStatus(cmd *cobra.Command, args []string) error { return nil } + +// witnessSessionName returns the tmux session name for a rig's witness. +func witnessSessionName(rigName string) string { + return fmt.Sprintf("gt-witness-%s", rigName) +} + +func runWitnessAttach(cmd *cobra.Command, args []string) error { + rigName := args[0] + + // Verify rig exists + _, r, err := getWitnessManager(rigName) + if err != nil { + return err + } + + t := tmux.NewTmux() + sessionName := witnessSessionName(rigName) + + // Check if session exists + running, err := t.HasSession(sessionName) + if err != nil { + return fmt.Errorf("checking session: %w", err) + } + + if !running { + // Start witness session (like Mayor) + fmt.Printf("Starting witness session for %s...\n", rigName) + + if err := t.NewSession(sessionName, r.Path); err != nil { + return fmt.Errorf("creating session: %w", err) + } + + // Set environment + t.SetEnvironment(sessionName, "GT_ROLE", "witness") + t.SetEnvironment(sessionName, "GT_RIG", rigName) + + // Launch Claude in a respawn loop + loopCmd := `while true; do echo "👁️ Starting Witness for ` + rigName + `..."; claude --dangerously-skip-permissions; echo ""; echo "Witness exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done` + if err := t.SendKeysDelayed(sessionName, loopCmd, 200); err != nil { + return fmt.Errorf("sending command: %w", err) + } + } + + // Attach to the session + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + return fmt.Errorf("tmux not found: %w", err) + } + + attachCmd := exec.Command(tmuxPath, "attach-session", "-t", sessionName) + attachCmd.Stdin = os.Stdin + attachCmd.Stdout = os.Stdout + attachCmd.Stderr = os.Stderr + return attachCmd.Run() +} diff --git a/internal/session/manager.go b/internal/session/manager.go index f557f6a8..e866d5b0 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -59,6 +59,15 @@ type Info struct { // RigName is the rig this session belongs to. RigName string `json:"rig_name"` + + // Attached indicates if someone is attached to the session. + Attached bool `json:"attached,omitempty"` + + // Created is when the session was created. + Created time.Time `json:"created,omitempty"` + + // Windows is the number of tmux windows. + Windows int `json:"windows,omitempty"` } // sessionName generates the tmux session name for a polecat. @@ -170,6 +179,56 @@ func (m *Manager) IsRunning(polecat string) (bool, error) { return m.tmux.HasSession(sessionID) } +// Status returns detailed status for a polecat session. +func (m *Manager) Status(polecat string) (*Info, error) { + sessionID := m.sessionName(polecat) + + running, err := m.tmux.HasSession(sessionID) + if err != nil { + return nil, fmt.Errorf("checking session: %w", err) + } + + info := &Info{ + Polecat: polecat, + SessionID: sessionID, + Running: running, + RigName: m.rig.Name, + } + + if !running { + return info, nil + } + + // Get detailed session info + tmuxInfo, err := m.tmux.GetSessionInfo(sessionID) + if err != nil { + // Non-fatal - return basic info + return info, nil + } + + info.Attached = tmuxInfo.Attached + info.Windows = tmuxInfo.Windows + + // Parse created time from tmux format (e.g., "Thu Dec 19 10:30:00 2025") + if tmuxInfo.Created != "" { + // Try common tmux date formats + formats := []string{ + "Mon Jan 2 15:04:05 2006", + "Mon Jan _2 15:04:05 2006", + time.ANSIC, + time.UnixDate, + } + for _, format := range formats { + if t, err := time.Parse(format, tmuxInfo.Created); err == nil { + info.Created = t + break + } + } + } + + return info, nil +} + // List returns information about all sessions for this rig. func (m *Manager) List() ([]Info, error) { sessions, err := m.tmux.ListSessions()