Files
gastown/internal/cmd/session.go
2026-01-08 22:10:40 +13:00

688 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/polecat"
"github.com/steveyegge/gastown/internal/rig"
"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) (*polecat.SessionManager, *rig.Rig, error) {
_, r, err := getRig(rigName)
if err != nil {
return nil, nil, err
}
t := tmux.NewTmux()
polecatMgr := polecat.NewSessionManager(t, r)
return polecatMgr, r, nil
}
func runSessionStart(cmd *cobra.Command, args []string) error {
rigName, polecatName, err := parseAddress(args[0])
if err != nil {
return err
}
polecatMgr, 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 := polecat.SessionStartOptions{
Issue: sessionIssue,
}
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
if err := polecatMgr.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
}
polecatMgr, _, 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 := polecatMgr.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
}
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
// Attach (this replaces the process)
return polecatMgr.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 {
polecatMgr := polecat.NewSessionManager(t, r)
infos, err := polecatMgr.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
}
polecatMgr, _, 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 := polecatMgr.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)")
}
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
if err := polecatMgr.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
}
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
// Check if running
running, err := polecatMgr.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 := polecatMgr.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 := polecat.SessionStartOptions{}
if err := polecatMgr.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
}
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
// Get session info
info, err := polecatMgr.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
}
if strings.HasPrefix(entry.Name(), ".") {
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
}