Merge polecat/rictus: fix mail priority flag (gt-kspu)

This commit is contained in:
Steve Yegge
2025-12-20 08:02:18 -08:00
5 changed files with 243 additions and 15 deletions

View File

@@ -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
}

View File

@@ -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 <rig>/<polecat>",
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))
}
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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.