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:
+102
-12
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
Reference in New Issue
Block a user