diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 2f28c71e..73add89c 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -19,7 +19,8 @@ import ( var ( mailSubject string mailBody string - mailPriority string + mailPriority int + mailUrgent bool mailType string mailReplyTo string mailNotify bool @@ -61,14 +62,21 @@ Message types: notification - Informational (default) reply - Response to message -Priority levels: - low, normal (default), high, urgent +Priority levels (compatible with bd mail send): + 0 - urgent/critical + 1 - high + 2 - normal (default) + 3 - low + 4 - backlog + +Use --urgent as shortcut for --priority 0. Examples: gt mail send gastown/Toast -s "Status check" -m "How's that bug fix going?" gt mail send mayor/ -s "Work complete" -m "Finished gt-abc" gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify - gt mail send gastown/Toast -s "Task" -m "Fix bug" --type task --priority high + gt mail send gastown/Toast -s "Task" -m "Fix bug" --type task --priority 1 + gt mail send gastown/Toast -s "Urgent" -m "Help!" --urgent gt mail send mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123`, Args: cobra.ExactArgs(1), RunE: runMailSend, @@ -167,7 +175,8 @@ func init() { // Send flags mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body") - mailSendCmd.Flags().StringVar(&mailPriority, "priority", "normal", "Message priority (low, normal, high, urgent)") + mailSendCmd.Flags().IntVar(&mailPriority, "priority", 2, "Message priority (0=urgent, 1=high, 2=normal, 3=low, 4=backlog)") + mailSendCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)") mailSendCmd.Flags().StringVar(&mailType, "type", "notification", "Message type (task, scavenge, notification, reply)") mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to") mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient") @@ -226,8 +235,12 @@ func runMailSend(cmd *cobra.Command, args []string) error { Body: mailBody, } - // Set priority - msg.Priority = mail.ParsePriority(mailPriority) + // Set priority (--urgent overrides --priority) + if mailUrgent { + msg.Priority = mail.PriorityUrgent + } else { + msg.Priority = mail.PriorityFromInt(mailPriority) + } if mailNotify && msg.Priority == mail.PriorityNormal { msg.Priority = mail.PriorityHigh } diff --git a/internal/cmd/polecat.go b/internal/cmd/polecat.go index ac24a916..6c050032 100644 --- a/internal/cmd/polecat.go +++ b/internal/cmd/polecat.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/config" @@ -169,9 +170,29 @@ Examples: RunE: runPolecatSync, } +var polecatStatusCmd = &cobra.Command{ + Use: "status /", + Short: "Show detailed status for a polecat", + Long: `Show detailed status for a polecat. + +Displays comprehensive information including: + - Current lifecycle state (working, done, stuck, idle) + - Assigned issue (if any) + - Session status (running/stopped, attached/detached) + - Session creation time + - Last activity time + +Examples: + gt polecat status gastown/Toast + gt polecat status gastown/Toast --json`, + Args: cobra.ExactArgs(1), + RunE: runPolecatStatus, +} + var ( polecatSyncAll bool polecatSyncFromMain bool + polecatStatusJSON bool ) func init() { @@ -186,6 +207,9 @@ func init() { polecatSyncCmd.Flags().BoolVar(&polecatSyncAll, "all", false, "Sync all polecats in the rig") polecatSyncCmd.Flags().BoolVar(&polecatSyncFromMain, "from-main", false, "Pull only, no push") + // Status flags + polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON") + // Add subcommands polecatCmd.AddCommand(polecatListCmd) polecatCmd.AddCommand(polecatAddCmd) @@ -195,6 +219,7 @@ func init() { polecatCmd.AddCommand(polecatDoneCmd) polecatCmd.AddCommand(polecatResetCmd) polecatCmd.AddCommand(polecatSyncCmd) + polecatCmd.AddCommand(polecatStatusCmd) rootCmd.AddCommand(polecatCmd) } @@ -577,3 +602,152 @@ func runPolecatSync(cmd *cobra.Command, args []string) error { return nil } + +// PolecatStatus represents detailed polecat status for JSON output. +type PolecatStatus struct { + Rig string `json:"rig"` + Name string `json:"name"` + State polecat.State `json:"state"` + Issue string `json:"issue,omitempty"` + ClonePath string `json:"clone_path"` + Branch string `json:"branch"` + SessionRunning bool `json:"session_running"` + SessionID string `json:"session_id,omitempty"` + Attached bool `json:"attached,omitempty"` + Windows int `json:"windows,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + LastActivity string `json:"last_activity,omitempty"` +} + +func runPolecatStatus(cmd *cobra.Command, args []string) error { + rigName, polecatName, err := parseAddress(args[0]) + if err != nil { + return err + } + + mgr, r, err := getPolecatManager(rigName) + if err != nil { + return err + } + + // Get polecat info + p, err := mgr.Get(polecatName) + if err != nil { + return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) + } + + // Get session info + t := tmux.NewTmux() + sessMgr := session.NewManager(t, r) + sessInfo, err := sessMgr.Status(polecatName) + if err != nil { + // Non-fatal - continue without session info + sessInfo = &session.Info{ + Polecat: polecatName, + Running: false, + } + } + + // JSON output + if polecatStatusJSON { + status := PolecatStatus{ + Rig: rigName, + Name: polecatName, + State: p.State, + Issue: p.Issue, + ClonePath: p.ClonePath, + Branch: p.Branch, + SessionRunning: sessInfo.Running, + SessionID: sessInfo.SessionID, + Attached: sessInfo.Attached, + Windows: sessInfo.Windows, + } + if !sessInfo.Created.IsZero() { + status.CreatedAt = sessInfo.Created.Format("2006-01-02 15:04:05") + } + if !sessInfo.LastActivity.IsZero() { + status.LastActivity = sessInfo.LastActivity.Format("2006-01-02 15:04:05") + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(status) + } + + // Human-readable output + fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Polecat: %s/%s", rigName, polecatName))) + + // State with color + stateStr := string(p.State) + switch p.State { + case polecat.StateWorking: + stateStr = style.Info.Render(stateStr) + case polecat.StateStuck: + stateStr = style.Warning.Render(stateStr) + case polecat.StateDone: + stateStr = style.Success.Render(stateStr) + default: + stateStr = style.Dim.Render(stateStr) + } + fmt.Printf(" State: %s\n", stateStr) + + // Issue + if p.Issue != "" { + fmt.Printf(" Issue: %s\n", p.Issue) + } else { + fmt.Printf(" Issue: %s\n", style.Dim.Render("(none)")) + } + + // Clone path and branch + fmt.Printf(" Clone: %s\n", style.Dim.Render(p.ClonePath)) + fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch)) + + // Session info + fmt.Println() + fmt.Printf("%s\n", style.Bold.Render("Session")) + + if sessInfo.Running { + fmt.Printf(" Status: %s\n", style.Success.Render("running")) + fmt.Printf(" Session ID: %s\n", style.Dim.Render(sessInfo.SessionID)) + + if sessInfo.Attached { + fmt.Printf(" Attached: %s\n", style.Info.Render("yes")) + } else { + fmt.Printf(" Attached: %s\n", style.Dim.Render("no")) + } + + if sessInfo.Windows > 0 { + fmt.Printf(" Windows: %d\n", sessInfo.Windows) + } + + if !sessInfo.Created.IsZero() { + fmt.Printf(" Created: %s\n", sessInfo.Created.Format("2006-01-02 15:04:05")) + } + + if !sessInfo.LastActivity.IsZero() { + // Show relative time for activity + ago := formatActivityTime(sessInfo.LastActivity) + fmt.Printf(" Last Activity: %s (%s)\n", + sessInfo.LastActivity.Format("15:04:05"), + style.Dim.Render(ago)) + } + } else { + fmt.Printf(" Status: %s\n", style.Dim.Render("not running")) + } + + return nil +} + +// formatActivityTime returns a human-readable relative time string. +func formatActivityTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%d seconds ago", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%d minutes ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%d hours ago", int(d.Hours())) + default: + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) + } +} diff --git a/internal/mail/types.go b/internal/mail/types.go index ebfe1df7..a4cb5cf8 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -219,6 +219,24 @@ func ParsePriority(s string) Priority { } } +// PriorityFromInt converts a beads-style integer priority to a Priority. +// Accepts: 0=urgent, 1=high, 2=normal, 3=low, 4=backlog (treated as low). +// Invalid values default to PriorityNormal. +func PriorityFromInt(p int) Priority { + switch p { + case 0: + return PriorityUrgent + case 1: + return PriorityHigh + case 2: + return PriorityNormal + case 3, 4: + return PriorityLow + default: + return PriorityNormal + } +} + // ParseMessageType parses a message type string, returning TypeNotification for invalid values. func ParseMessageType(s string) MessageType { switch MessageType(s) { diff --git a/internal/session/manager.go b/internal/session/manager.go index 594116e8..6ce90c6d 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -69,6 +69,9 @@ type Info struct { // Windows is the number of tmux windows. Windows int `json:"windows,omitempty"` + + // LastActivity is when the session last had activity. + LastActivity time.Time `json:"last_activity,omitempty"` } // sessionName generates the tmux session name for a polecat. @@ -254,6 +257,14 @@ func (m *Manager) Status(polecat string) (*Info, error) { } } + // Parse activity time (unix timestamp from tmux) + if tmuxInfo.Activity != "" { + var activityUnix int64 + if _, err := fmt.Sscanf(tmuxInfo.Activity, "%d", &activityUnix); err == nil && activityUnix > 0 { + info.LastActivity = time.Unix(activityUnix, 0) + } + } + return info, nil } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index b332b41a..2445d04f 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -218,10 +218,12 @@ func (t *Tmux) RenameSession(oldName, newName string) error { // SessionInfo contains information about a tmux session. type SessionInfo struct { - Name string - Windows int - Created string - Attached bool + Name string + Windows int + Created string + Attached bool + Activity string // Last activity time + LastAttached string // Last time the session was attached } // DisplayMessage shows a message in the tmux status line. @@ -316,7 +318,7 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error { // GetSessionInfo returns detailed information about a session. func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) { - format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}" + format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}|#{session_activity}|#{session_last_attached}" out, err := t.run("list-sessions", "-F", format, "-f", fmt.Sprintf("#{==:#{session_name},%s}", name)) if err != nil { return nil, err @@ -326,19 +328,29 @@ func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) { } parts := strings.Split(out, "|") - if len(parts) != 4 { + if len(parts) < 4 { return nil, fmt.Errorf("unexpected session info format: %s", out) } windows := 0 _, _ = fmt.Sscanf(parts[1], "%d", &windows) - return &SessionInfo{ + info := &SessionInfo{ Name: parts[0], Windows: windows, Created: parts[2], Attached: parts[3] == "1", - }, nil + } + + // Activity and last attached are optional (may not be present in older tmux) + if len(parts) > 4 { + info.Activity = parts[4] + } + if len(parts) > 5 { + info.LastAttached = parts[5] + } + + return info, nil } // ApplyTheme sets the status bar style for a session.