TUI improvements for Christmas launch: - Add phase transition table and lifecycle diagram to `gt molecule --help` - Add swarm lifecycle diagram to `gt swarm --help` - Add mail routing diagram to `gt mail --help` - Add sling mechanics diagram to `gt sling --help` - Create Lipgloss table helper (internal/style/table.go) - Migrate mq_list to use styled tables with color-coded priorities - Migrate molecule list to use styled tables - Add fuzzy matching "did you mean" suggestions for polecat not found errors - Add suggest package with Levenshtein distance implementation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
534 lines
14 KiB
Go
534 lines
14 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/git"
|
|
"github.com/steveyegge/gastown/internal/rig"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/suggest"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// Session command flags
|
|
var (
|
|
sessionIssue string
|
|
sessionForce bool
|
|
sessionLines int
|
|
sessionMessage string
|
|
sessionFile string
|
|
sessionRigFilter string
|
|
sessionListJSON bool
|
|
)
|
|
|
|
var sessionCmd = &cobra.Command{
|
|
Use: "session",
|
|
Aliases: []string{"sess"},
|
|
Short: "Manage polecat sessions",
|
|
Long: `Manage tmux sessions for polecats.
|
|
|
|
Sessions are tmux sessions running Claude for each polecat.
|
|
Use the subcommands to start, stop, attach, and monitor sessions.`,
|
|
}
|
|
|
|
var sessionStartCmd = &cobra.Command{
|
|
Use: "start <rig>/<polecat>",
|
|
Short: "Start a polecat session",
|
|
Long: `Start a new tmux session for a polecat.
|
|
|
|
Creates a tmux session, navigates to the polecat's working directory,
|
|
and launches claude. Optionally inject an initial issue to work on.
|
|
|
|
Examples:
|
|
gt session start wyvern/Toast
|
|
gt session start wyvern/Toast --issue gt-123`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runSessionStart,
|
|
}
|
|
|
|
var sessionStopCmd = &cobra.Command{
|
|
Use: "stop <rig>/<polecat>",
|
|
Short: "Stop a polecat session",
|
|
Long: `Stop a running polecat session.
|
|
|
|
Attempts graceful shutdown first (Ctrl-C), then kills the tmux session.
|
|
Use --force to skip graceful shutdown.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runSessionStop,
|
|
}
|
|
|
|
var sessionAtCmd = &cobra.Command{
|
|
Use: "at <rig>/<polecat>",
|
|
Aliases: []string{"attach"},
|
|
Short: "Attach to a running session",
|
|
Long: `Attach to a running polecat session.
|
|
|
|
Attaches the current terminal to the tmux session. Detach with Ctrl-B D.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runSessionAttach,
|
|
}
|
|
|
|
var sessionListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all sessions",
|
|
Long: `List all running polecat sessions.
|
|
|
|
Shows session status, rig, and polecat name. Use --rig to filter by rig.`,
|
|
RunE: runSessionList,
|
|
}
|
|
|
|
var sessionCaptureCmd = &cobra.Command{
|
|
Use: "capture <rig>/<polecat> [count]",
|
|
Short: "Capture recent session output",
|
|
Long: `Capture recent output from a polecat session.
|
|
|
|
Returns the last N lines of terminal output. Useful for checking progress.
|
|
|
|
Examples:
|
|
gt session capture wyvern/Toast # Last 100 lines (default)
|
|
gt session capture wyvern/Toast 50 # Last 50 lines
|
|
gt session capture wyvern/Toast -n 50 # Same as above`,
|
|
Args: cobra.RangeArgs(1, 2),
|
|
RunE: runSessionCapture,
|
|
}
|
|
|
|
var sessionInjectCmd = &cobra.Command{
|
|
Use: "inject <rig>/<polecat>",
|
|
Short: "Send message to session",
|
|
Long: `Send a message to a polecat session.
|
|
|
|
Injects text into the session via tmux send-keys. Useful for nudges or notifications.
|
|
|
|
Examples:
|
|
gt session inject wyvern/Toast -m "Check your mail"
|
|
gt session inject wyvern/Toast -f prompt.txt`,
|
|
Args: cobra.ExactArgs(1),
|
|
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() {
|
|
// Start flags
|
|
sessionStartCmd.Flags().StringVar(&sessionIssue, "issue", "", "Issue ID to work on")
|
|
|
|
// Stop flags
|
|
sessionStopCmd.Flags().BoolVarP(&sessionForce, "force", "f", false, "Force immediate shutdown")
|
|
|
|
// List flags
|
|
sessionListCmd.Flags().StringVar(&sessionRigFilter, "rig", "", "Filter by rig name")
|
|
sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "Output as JSON")
|
|
|
|
// Capture flags
|
|
sessionCaptureCmd.Flags().IntVarP(&sessionLines, "lines", "n", 100, "Number of lines to capture")
|
|
|
|
// Inject flags
|
|
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)
|
|
sessionCmd.AddCommand(sessionAtCmd)
|
|
sessionCmd.AddCommand(sessionListCmd)
|
|
sessionCmd.AddCommand(sessionCaptureCmd)
|
|
sessionCmd.AddCommand(sessionInjectCmd)
|
|
sessionCmd.AddCommand(sessionRestartCmd)
|
|
sessionCmd.AddCommand(sessionStatusCmd)
|
|
|
|
rootCmd.AddCommand(sessionCmd)
|
|
}
|
|
|
|
// parseAddress parses "rig/polecat" format.
|
|
func parseAddress(addr string) (rigName, polecatName string, err error) {
|
|
parts := strings.SplitN(addr, "/", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", "", fmt.Errorf("invalid address format: expected 'rig/polecat', got '%s'", addr)
|
|
}
|
|
return parts[0], parts[1], nil
|
|
}
|
|
|
|
// getSessionManager creates a session manager for the given rig.
|
|
func getSessionManager(rigName string) (*session.Manager, *rig.Rig, error) {
|
|
_, r, err := getRig(rigName)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
t := tmux.NewTmux()
|
|
mgr := session.NewManager(t, r)
|
|
|
|
return mgr, r, nil
|
|
}
|
|
|
|
func runSessionStart(cmd *cobra.Command, args []string) error {
|
|
rigName, polecatName, err := parseAddress(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
mgr, r, err := getSessionManager(rigName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check polecat exists
|
|
found := false
|
|
for _, p := range r.Polecats {
|
|
if p == polecatName {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
suggestions := suggest.FindSimilar(polecatName, r.Polecats, 3)
|
|
hint := fmt.Sprintf("Create with: gt polecat add %s/%s", rigName, polecatName)
|
|
return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint))
|
|
}
|
|
|
|
opts := session.StartOptions{
|
|
Issue: sessionIssue,
|
|
}
|
|
|
|
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
|
|
if err := mgr.Start(polecatName, opts); err != nil {
|
|
return fmt.Errorf("starting session: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Session started. Attach with: %s\n",
|
|
style.Bold.Render("✓"),
|
|
style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
|
|
|
|
return nil
|
|
}
|
|
|
|
func runSessionStop(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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fmt.Printf("%s Session stopped.\n", style.Bold.Render("✓"))
|
|
return nil
|
|
}
|
|
|
|
func runSessionAttach(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
|
|
}
|
|
|
|
// Attach (this replaces the process)
|
|
return mgr.Attach(polecatName)
|
|
}
|
|
|
|
// SessionListItem represents a session in list output.
|
|
type SessionListItem struct {
|
|
Rig string `json:"rig"`
|
|
Polecat string `json:"polecat"`
|
|
SessionID string `json:"session_id"`
|
|
Running bool `json:"running"`
|
|
}
|
|
|
|
func runSessionList(cmd *cobra.Command, args []string) error {
|
|
// Find town root
|
|
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)}
|
|
}
|
|
|
|
// Get all rigs
|
|
g := git.NewGit(townRoot)
|
|
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
|
|
rigs, err := rigMgr.DiscoverRigs()
|
|
if err != nil {
|
|
return fmt.Errorf("discovering rigs: %w", err)
|
|
}
|
|
|
|
// Filter if requested
|
|
if sessionRigFilter != "" {
|
|
var filtered []*rig.Rig
|
|
for _, r := range rigs {
|
|
if r.Name == sessionRigFilter {
|
|
filtered = append(filtered, r)
|
|
}
|
|
}
|
|
rigs = filtered
|
|
}
|
|
|
|
// Collect sessions from all rigs
|
|
t := tmux.NewTmux()
|
|
var allSessions []SessionListItem
|
|
|
|
for _, r := range rigs {
|
|
mgr := session.NewManager(t, r)
|
|
infos, err := mgr.List()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, info := range infos {
|
|
allSessions = append(allSessions, SessionListItem{
|
|
Rig: r.Name,
|
|
Polecat: info.Polecat,
|
|
SessionID: info.SessionID,
|
|
Running: info.Running,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Output
|
|
if sessionListJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(allSessions)
|
|
}
|
|
|
|
if len(allSessions) == 0 {
|
|
fmt.Println("No active sessions.")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("%s\n\n", style.Bold.Render("Active Sessions"))
|
|
for _, s := range allSessions {
|
|
status := style.Bold.Render("●")
|
|
if !s.Running {
|
|
status = style.Dim.Render("○")
|
|
}
|
|
fmt.Printf(" %s %s/%s\n", status, s.Rig, s.Polecat)
|
|
fmt.Printf(" %s\n", style.Dim.Render(s.SessionID))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runSessionCapture(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
|
|
}
|
|
|
|
// Use positional count if provided, otherwise use flag value
|
|
lines := sessionLines
|
|
if len(args) > 1 {
|
|
n, err := strconv.Atoi(args[1])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid line count '%s': must be a number", args[1])
|
|
}
|
|
if n <= 0 {
|
|
return fmt.Errorf("line count must be positive, got %d", n)
|
|
}
|
|
lines = n
|
|
}
|
|
|
|
output, err := mgr.Capture(polecatName, lines)
|
|
if err != nil {
|
|
return fmt.Errorf("capturing output: %w", err)
|
|
}
|
|
|
|
fmt.Print(output)
|
|
return nil
|
|
}
|
|
|
|
func runSessionInject(cmd *cobra.Command, args []string) error {
|
|
rigName, polecatName, err := parseAddress(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get message
|
|
message := sessionMessage
|
|
if sessionFile != "" {
|
|
data, err := os.ReadFile(sessionFile)
|
|
if err != nil {
|
|
return fmt.Errorf("reading file: %w", err)
|
|
}
|
|
message = string(data)
|
|
}
|
|
|
|
if message == "" {
|
|
return fmt.Errorf("no message provided (use -m or -f)")
|
|
}
|
|
|
|
mgr, _, err := getSessionManager(rigName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := mgr.Inject(polecatName, message); err != nil {
|
|
return fmt.Errorf("injecting message: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Message sent to %s/%s\n",
|
|
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)
|
|
}
|