Files
gastown/internal/cmd/session.go
gastown/crew/joe df46e75a51 Fix: Unknown subcommands now error instead of silently showing help
Parent commands (mol, mail, crew, polecat, etc.) previously showed help
and exited 0 for unknown subcommands like "gt mol foobar". This masked
errors in scripts and confused users.

Added requireSubcommand() helper to root.go and applied it to all parent
commands. Now unknown subcommands properly error with exit code 1.

Example before: gt mol unhook → shows help, exits 0
Example after:  gt mol unhook → "Error: unknown command "unhook"", exits 1

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 20:52:23 -08:00

685 lines
18 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/townlog"
"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"},
GroupID: GroupAgents,
Short: "Manage polecat sessions",
RunE: requireSubcommand,
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.
TIP: To send messages to a running session, use 'gt nudge' (not 'session inject').
The nudge command uses reliable delivery that works correctly with Claude Code.`,
}
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 (prefer 'gt nudge')",
Long: `Send a message to a polecat session.
NOTE: For sending messages to Claude sessions, use 'gt nudge' instead.
It uses reliable delivery (literal mode + timing) that works correctly
with Claude Code's input handling.
This command is a low-level primitive for file-based injection or
cases where you need raw tmux send-keys behavior.
Examples:
gt nudge greenplace/furiosa "Check your mail" # Preferred
gt session inject wyvern/Toast -f prompt.txt # For file injection`,
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,
}
var sessionCheckCmd = &cobra.Command{
Use: "check [rig]",
Short: "Check session health for polecats",
Long: `Check if polecat tmux sessions are alive and healthy.
This command validates that:
1. Polecats with work-on-hook have running tmux sessions
2. Sessions are responsive
Use this for manual health checks or debugging session issues.
Examples:
gt session check # Check all rigs
gt session check greenplace # Check specific rig`,
Args: cobra.MaximumNArgs(1),
RunE: runSessionCheck,
}
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)
sessionCmd.AddCommand(sessionCheckCmd)
rootCmd.AddCommand(sessionCmd)
}
// parseAddress parses "rig/polecat" format.
// If no "/" is present, attempts to infer rig from current directory.
func parseAddress(addr string) (rigName, polecatName string, err error) {
parts := strings.SplitN(addr, "/", 2)
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
return parts[0], parts[1], nil
}
// No slash - try to infer rig from cwd
if !strings.Contains(addr, "/") && addr != "" {
townRoot, err := workspace.FindFromCwd()
if err == nil && townRoot != "" {
inferredRig, err := inferRigFromCwd(townRoot)
if err == nil && inferredRig != "" {
return inferredRig, addr, nil
}
}
}
return "", "", fmt.Errorf("invalid address format: expected 'rig/polecat', got '%s'", addr)
}
// 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)))
// Log wake event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := fmt.Sprintf("%s/%s", rigName, polecatName)
logger := townlog.NewLogger(townRoot)
logger.Log(townlog.EventWake, agent, sessionIssue)
}
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("✓"))
// Log kill event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := fmt.Sprintf("%s/%s", rigName, polecatName)
reason := "gt session stop"
if sessionForce {
reason = "gt session stop --force"
}
logger := townlog.NewLogger(townRoot)
logger.Log(townlog.EventKill, agent, reason)
}
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)
}
func runSessionCheck(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 rigs to check
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 specific rig requested
if len(args) > 0 {
rigFilter := args[0]
var filtered []*rig.Rig
for _, r := range rigs {
if r.Name == rigFilter {
filtered = append(filtered, r)
}
}
if len(filtered) == 0 {
return fmt.Errorf("rig not found: %s", rigFilter)
}
rigs = filtered
}
fmt.Printf("%s Session Health Check\n\n", style.Bold.Render("🔍"))
t := tmux.NewTmux()
totalChecked := 0
totalHealthy := 0
totalCrashed := 0
for _, r := range rigs {
polecatsDir := filepath.Join(r.Path, "polecats")
entries, err := os.ReadDir(polecatsDir)
if err != nil {
continue // Rig might not have polecats
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
polecatName := entry.Name()
sessionName := fmt.Sprintf("gt-%s-%s", r.Name, polecatName)
totalChecked++
// Check if session exists
running, err := t.HasSession(sessionName)
if err != nil {
fmt.Printf(" %s %s/%s: %s\n", style.Bold.Render("⚠"), r.Name, polecatName, style.Dim.Render("error checking session"))
continue
}
if running {
fmt.Printf(" %s %s/%s: %s\n", style.Bold.Render("✓"), r.Name, polecatName, style.Dim.Render("session alive"))
totalHealthy++
} else {
// Check if polecat has work on hook (would need restart)
fmt.Printf(" %s %s/%s: %s\n", style.Bold.Render("✗"), r.Name, polecatName, style.Dim.Render("session not running"))
totalCrashed++
}
}
}
// Summary
fmt.Printf("\n%s Summary: %d checked, %d healthy, %d not running\n",
style.Bold.Render("📊"), totalChecked, totalHealthy, totalCrashed)
if totalCrashed > 0 {
fmt.Printf("\n%s To restart crashed polecats: gt session restart <rig>/<polecat>\n",
style.Dim.Render("Tip:"))
}
return nil
}