feat(gt): Complete command parity for GGT

Add missing gt commands to match PGT functionality:
- gt session restart: Restart polecat session (stop + start)
- gt session status: Show detailed session status with uptime
- gt rig shutdown: Gracefully stop all agents in a rig
- gt mail reply: Convenience command for replying to messages
- gt witness attach: Attach to witness tmux session

Closes: gt-hw6, gt-99m, gt-6db, gt-e76, gt-hzr, gt-sqi

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 12:05:59 -08:00
parent 372d4d4828
commit 557d0c6745
5 changed files with 466 additions and 16 deletions
+102 -12
View File
@@ -17,18 +17,20 @@ import (
// Mail command flags // Mail command flags
var ( var (
mailSubject string mailSubject string
mailBody string mailBody string
mailPriority string mailPriority string
mailType string mailType string
mailReplyTo string mailReplyTo string
mailNotify bool mailNotify bool
mailInboxJSON bool mailInboxJSON bool
mailReadJSON bool mailReadJSON bool
mailInboxUnread bool mailInboxUnread bool
mailCheckInject bool mailCheckInject bool
mailCheckJSON bool mailCheckJSON bool
mailThreadJSON bool mailThreadJSON bool
mailReplySubject string
mailReplyMessage string
) )
var mailCmd = &cobra.Command{ var mailCmd = &cobra.Command{
@@ -137,6 +139,23 @@ Examples:
RunE: runMailThread, RunE: runMailThread,
} }
var mailReplyCmd = &cobra.Command{
Use: "reply <message-id>",
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() { func init() {
// Send flags // Send flags
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
@@ -161,6 +180,11 @@ func init() {
// Thread flags // Thread flags
mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON") mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON")
// Reply flags
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)")
mailReplyCmd.MarkFlagRequired("message")
// Add subcommands // Add subcommands
mailCmd.AddCommand(mailSendCmd) mailCmd.AddCommand(mailSendCmd)
mailCmd.AddCommand(mailInboxCmd) mailCmd.AddCommand(mailInboxCmd)
@@ -168,6 +192,7 @@ func init() {
mailCmd.AddCommand(mailDeleteCmd) mailCmd.AddCommand(mailDeleteCmd)
mailCmd.AddCommand(mailCheckCmd) mailCmd.AddCommand(mailCheckCmd)
mailCmd.AddCommand(mailThreadCmd) mailCmd.AddCommand(mailThreadCmd)
mailCmd.AddCommand(mailReplyCmd)
rootCmd.AddCommand(mailCmd) rootCmd.AddCommand(mailCmd)
} }
@@ -629,6 +654,71 @@ func runMailThread(cmd *cobra.Command, args []string) error {
return nil 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. // generateThreadID creates a random thread ID for new message threads.
func generateThreadID() string { func generateThreadID() string {
b := make([]byte, 6) b := make([]byte, 6)
+101 -4
View File
@@ -11,8 +11,12 @@ import (
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -77,12 +81,32 @@ Examples:
RunE: runRigReset, RunE: runRigReset,
} }
var rigShutdownCmd = &cobra.Command{
Use: "shutdown <rig>",
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 // Flags
var ( var (
rigAddPrefix string rigAddPrefix string
rigAddCrew string rigAddCrew string
rigResetHandoff bool rigResetHandoff bool
rigResetRole string rigResetRole string
rigShutdownForce bool
) )
func init() { func init() {
@@ -91,12 +115,15 @@ func init() {
rigCmd.AddCommand(rigListCmd) rigCmd.AddCommand(rigListCmd)
rigCmd.AddCommand(rigRemoveCmd) rigCmd.AddCommand(rigRemoveCmd)
rigCmd.AddCommand(rigResetCmd) rigCmd.AddCommand(rigResetCmd)
rigCmd.AddCommand(rigShutdownCmd)
rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)") rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)")
rigAddCmd.Flags().StringVar(&rigAddCrew, "crew", "main", "Default crew workspace name") rigAddCmd.Flags().StringVar(&rigAddCrew, "crew", "main", "Default crew workspace name")
rigResetCmd.Flags().BoolVar(&rigResetHandoff, "handoff", false, "Clear handoff content") rigResetCmd.Flags().BoolVar(&rigResetHandoff, "handoff", false, "Clear handoff content")
rigResetCmd.Flags().StringVar(&rigResetRole, "role", "", "Role to reset (default: auto-detect from cwd)") 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 { func runRigAdd(cmd *cobra.Command, args []string) error {
@@ -302,3 +329,73 @@ func pathExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)
return err == nil 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
}
+132
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
@@ -114,6 +115,27 @@ Examples:
RunE: runSessionInject, RunE: runSessionInject,
} }
var sessionRestartCmd = &cobra.Command{
Use: "restart <rig>/<polecat>",
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 <rig>/<polecat>",
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() { func init() {
// Start flags // Start flags
sessionStartCmd.Flags().StringVar(&sessionIssue, "issue", "", "Issue ID to work on") 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(&sessionMessage, "message", "m", "", "Message to inject")
sessionInjectCmd.Flags().StringVarP(&sessionFile, "file", "f", "", "File to read message from") 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 // Add subcommands
sessionCmd.AddCommand(sessionStartCmd) sessionCmd.AddCommand(sessionStartCmd)
sessionCmd.AddCommand(sessionStopCmd) sessionCmd.AddCommand(sessionStopCmd)
@@ -139,6 +164,8 @@ func init() {
sessionCmd.AddCommand(sessionListCmd) sessionCmd.AddCommand(sessionListCmd)
sessionCmd.AddCommand(sessionCaptureCmd) sessionCmd.AddCommand(sessionCaptureCmd)
sessionCmd.AddCommand(sessionInjectCmd) sessionCmd.AddCommand(sessionInjectCmd)
sessionCmd.AddCommand(sessionRestartCmd)
sessionCmd.AddCommand(sessionStatusCmd)
rootCmd.AddCommand(sessionCmd) rootCmd.AddCommand(sessionCmd)
} }
@@ -413,3 +440,108 @@ func runSessionInject(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"), rigName, polecatName) style.Bold.Render("✓"), rigName, polecatName)
return nil 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)
}
+72
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -11,6 +12,7 @@ import (
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness" "github.com/steveyegge/gastown/internal/witness"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -65,6 +67,20 @@ Displays running state, monitored polecats, and statistics.`,
RunE: runWitnessStatus, RunE: runWitnessStatus,
} }
var witnessAttachCmd = &cobra.Command{
Use: "attach <rig>",
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() { func init() {
// Start flags // Start flags
witnessStartCmd.Flags().BoolVar(&witnessForeground, "foreground", false, "Run in foreground (default: background)") witnessStartCmd.Flags().BoolVar(&witnessForeground, "foreground", false, "Run in foreground (default: background)")
@@ -76,6 +92,7 @@ func init() {
witnessCmd.AddCommand(witnessStartCmd) witnessCmd.AddCommand(witnessStartCmd)
witnessCmd.AddCommand(witnessStopCmd) witnessCmd.AddCommand(witnessStopCmd)
witnessCmd.AddCommand(witnessStatusCmd) witnessCmd.AddCommand(witnessStatusCmd)
witnessCmd.AddCommand(witnessAttachCmd)
rootCmd.AddCommand(witnessCmd) rootCmd.AddCommand(witnessCmd)
} }
@@ -213,3 +230,58 @@ func runWitnessStatus(cmd *cobra.Command, args []string) error {
return nil 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()
}
+59
View File
@@ -59,6 +59,15 @@ type Info struct {
// RigName is the rig this session belongs to. // RigName is the rig this session belongs to.
RigName string `json:"rig_name"` 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. // 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) 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. // List returns information about all sessions for this rig.
func (m *Manager) List() ([]Info, error) { func (m *Manager) List() ([]Info, error) {
sessions, err := m.tmux.ListSessions() sessions, err := m.tmux.ListSessions()