From 96902092fd17e1431ed06e519d0d238b20c8a5c2 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 20:14:13 -0800 Subject: [PATCH] feat(mail): add message types and threading support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MessageType enum (task, scavenge, notification, reply) - Expand Priority from 2 to 4 levels (low, normal, high, urgent) - Add ThreadID and ReplyTo fields to Message struct - Add --type and --reply-to flags to 'gt mail send' - Add 'gt mail thread ' command to view conversation threads - Update inbox/read display to show type and threading info - Auto-generate thread IDs for new messages - Reply messages inherit thread from original Closes: gt-hgk ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mail.go | 171 +++++++++++++++++++++++++++++++++++++-- internal/mail/mailbox.go | 68 ++++++++++++++++ internal/mail/router.go | 22 ++++- internal/mail/types.go | 124 ++++++++++++++++++++++++++-- 4 files changed, 369 insertions(+), 16 deletions(-) diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 858934ca..77d72b8b 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -1,6 +1,8 @@ package cmd import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "os" @@ -18,12 +20,15 @@ var ( mailSubject string mailBody string mailPriority string + mailType string + mailReplyTo string mailNotify bool mailInboxJSON bool mailReadJSON bool mailInboxUnread bool mailCheckInject bool mailCheckJSON bool + mailThreadJSON bool ) var mailCmd = &cobra.Command{ @@ -46,10 +51,21 @@ Addresses: / - Send to a specific polecat / - Broadcast to a rig +Message types: + task - Required processing + scavenge - Optional first-come work + notification - Informational (default) + reply - Response to message + +Priority levels: + low, normal (default), high, urgent + 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/ -s "All hands" -m "Swarm starting" --notify + gt mail send gastown/Toast -s "Task" -m "Fix bug" --type task --priority high + gt mail send mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123`, Args: cobra.ExactArgs(1), RunE: runMailSend, } @@ -108,11 +124,26 @@ Examples: RunE: runMailCheck, } +var mailThreadCmd = &cobra.Command{ + Use: "thread ", + Short: "View a message thread", + Long: `View all messages in a conversation thread. + +Shows messages in chronological order (oldest first). + +Examples: + gt mail thread thread-abc123`, + Args: cobra.ExactArgs(1), + RunE: runMailThread, +} + 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 (normal, high)") + mailSendCmd.Flags().StringVar(&mailPriority, "priority", "normal", "Message priority (low, normal, high, 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") mailSendCmd.MarkFlagRequired("subject") @@ -127,12 +158,16 @@ func init() { mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks") mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON") + // Thread flags + mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON") + // Add subcommands mailCmd.AddCommand(mailSendCmd) mailCmd.AddCommand(mailInboxCmd) mailCmd.AddCommand(mailReadCmd) mailCmd.AddCommand(mailDeleteCmd) mailCmd.AddCommand(mailCheckCmd) + mailCmd.AddCommand(mailThreadCmd) rootCmd.AddCommand(mailCmd) } @@ -158,10 +193,36 @@ func runMailSend(cmd *cobra.Command, args []string) error { } // Set priority - if mailPriority == "high" || mailNotify { + msg.Priority = mail.ParsePriority(mailPriority) + if mailNotify && msg.Priority == mail.PriorityNormal { msg.Priority = mail.PriorityHigh } + // Set message type + msg.Type = mail.ParseMessageType(mailType) + + // Handle reply-to: auto-set type to reply and look up thread + if mailReplyTo != "" { + msg.ReplyTo = mailReplyTo + if msg.Type == mail.TypeNotification { + msg.Type = mail.TypeReply + } + + // Look up original message to get thread ID + router := mail.NewRouter(workDir) + mailbox, err := router.GetMailbox(from) + if err == nil { + if original, err := mailbox.Get(mailReplyTo); err == nil { + msg.ThreadID = original.ThreadID + } + } + } + + // Generate thread ID for new threads + if msg.ThreadID == "" { + msg.ThreadID = generateThreadID() + } + // Send via router router := mail.NewRouter(workDir) if err := router.Send(msg); err != nil { @@ -170,6 +231,9 @@ func runMailSend(cmd *cobra.Command, args []string) error { fmt.Printf("%s Message sent to %s\n", style.Bold.Render("โœ“"), to) fmt.Printf(" Subject: %s\n", mailSubject) + if msg.Type != mail.TypeNotification { + fmt.Printf(" Type: %s\n", msg.Type) + } return nil } @@ -229,12 +293,16 @@ func runMailInbox(cmd *cobra.Command, args []string) error { if msg.Read { readMarker = "โ—‹" } + typeMarker := "" + if msg.Type != "" && msg.Type != mail.TypeNotification { + typeMarker = fmt.Sprintf(" [%s]", msg.Type) + } priorityMarker := "" - if msg.Priority == mail.PriorityHigh { + if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent { priorityMarker = " " + style.Bold.Render("!") } - fmt.Printf(" %s %s%s\n", readMarker, msg.Subject, priorityMarker) + fmt.Printf(" %s %s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker) fmt.Printf(" %s from %s\n", style.Dim.Render(msg.ID), msg.From) @@ -281,16 +349,30 @@ func runMailRead(cmd *cobra.Command, args []string) error { // Human-readable output priorityStr := "" - if msg.Priority == mail.PriorityHigh { + if msg.Priority == mail.PriorityUrgent { + priorityStr = " " + style.Bold.Render("[URGENT]") + } else if msg.Priority == mail.PriorityHigh { priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]") } - fmt.Printf("%s %s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, priorityStr) + typeStr := "" + if msg.Type != "" && msg.Type != mail.TypeNotification { + typeStr = fmt.Sprintf(" [%s]", msg.Type) + } + + fmt.Printf("%s %s%s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, typeStr, priorityStr) fmt.Printf("From: %s\n", msg.From) fmt.Printf("To: %s\n", msg.To) fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05")) fmt.Printf("ID: %s\n", style.Dim.Render(msg.ID)) + if msg.ThreadID != "" { + fmt.Printf("Thread: %s\n", style.Dim.Render(msg.ThreadID)) + } + if msg.ReplyTo != "" { + fmt.Printf("Reply-To: %s\n", style.Dim.Render(msg.ReplyTo)) + } + if msg.Body != "" { fmt.Printf("\n%s\n", msg.Body) } @@ -467,3 +549,78 @@ func runMailCheck(cmd *cobra.Command, args []string) error { } return nil } + +func runMailThread(cmd *cobra.Command, args []string) error { + threadID := args[0] + + // Find workspace + workDir, err := findBeadsWorkDir() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Determine which inbox + address := detectSender() + + // Get mailbox and thread messages + router := mail.NewRouter(workDir) + mailbox, err := router.GetMailbox(address) + if err != nil { + return fmt.Errorf("getting mailbox: %w", err) + } + + messages, err := mailbox.ListByThread(threadID) + if err != nil { + return fmt.Errorf("getting thread: %w", err) + } + + // JSON output + if mailThreadJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(messages) + } + + // Human-readable output + fmt.Printf("%s Thread: %s (%d messages)\n\n", + style.Bold.Render("๐Ÿงต"), threadID, len(messages)) + + if len(messages) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no messages in thread)")) + return nil + } + + for i, msg := range messages { + typeMarker := "" + if msg.Type != "" && msg.Type != mail.TypeNotification { + typeMarker = fmt.Sprintf(" [%s]", msg.Type) + } + priorityMarker := "" + if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent { + priorityMarker = " " + style.Bold.Render("!") + } + + if i > 0 { + fmt.Printf(" %s\n", style.Dim.Render("โ”‚")) + } + fmt.Printf(" %s %s%s%s\n", style.Bold.Render("โ—"), msg.Subject, typeMarker, priorityMarker) + fmt.Printf(" %s from %s to %s\n", + style.Dim.Render(msg.ID), + msg.From, msg.To) + fmt.Printf(" %s\n", + style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04"))) + + if msg.Body != "" { + fmt.Printf(" %s\n", msg.Body) + } + } + + return nil +} + +// generateThreadID creates a random thread ID for new message threads. +func generateThreadID() string { + b := make([]byte, 6) + rand.Read(b) + return "thread-" + hex.EncodeToString(b) +} diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index d9554824..c66a1119 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -379,3 +379,71 @@ func (m *Mailbox) rewriteLegacy(messages []*Message) error { // Atomic rename return os.Rename(tmpPath, m.path) } + +// ListByThread returns all messages in a given thread. +func (m *Mailbox) ListByThread(threadID string) ([]*Message, error) { + if m.legacy { + return m.listByThreadLegacy(threadID) + } + return m.listByThreadBeads(threadID) +} + +func (m *Mailbox) listByThreadBeads(threadID string) ([]*Message, error) { + // bd message thread --json + cmd := exec.Command("bd", "message", "thread", threadID, "--json") + cmd.Dir = m.workDir + cmd.Env = append(cmd.Environ(), "BD_IDENTITY="+m.identity) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return nil, errors.New(errMsg) + } + return nil, err + } + + var beadsMsgs []BeadsMessage + if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil { + if len(stdout.Bytes()) == 0 || string(stdout.Bytes()) == "null" { + return nil, nil + } + return nil, err + } + + var messages []*Message + for _, bm := range beadsMsgs { + messages = append(messages, bm.ToMessage()) + } + + // Sort by timestamp (oldest first for thread view) + sort.Slice(messages, func(i, j int) bool { + return messages[i].Timestamp.Before(messages[j].Timestamp) + }) + + return messages, nil +} + +func (m *Mailbox) listByThreadLegacy(threadID string) ([]*Message, error) { + messages, err := m.List() + if err != nil { + return nil, err + } + + var thread []*Message + for _, msg := range messages { + if msg.ThreadID == threadID { + thread = append(thread, msg) + } + } + + // Sort by timestamp (oldest first for thread view) + sort.Slice(thread, func(i, j int) bool { + return thread[i].Timestamp.Before(thread[j].Timestamp) + }) + + return thread, nil +} diff --git a/internal/mail/router.go b/internal/mail/router.go index 7f859362..b2e7da3f 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -36,9 +36,23 @@ func (r *Router) Send(msg *Message) error { "-s", msg.Subject, } - // Add importance flag for high priority - if msg.Priority == PriorityHigh { - args = append(args, "--importance", "high") + // Add priority flag + beadsPriority := PriorityToBeads(msg.Priority) + args = append(args, "--priority", fmt.Sprintf("%d", beadsPriority)) + + // Add message type if set + if msg.Type != "" && msg.Type != TypeNotification { + args = append(args, "--type", string(msg.Type)) + } + + // Add thread ID if set + if msg.ThreadID != "" { + args = append(args, "--thread-id", msg.ThreadID) + } + + // Add reply-to if set + if msg.ReplyTo != "" { + args = append(args, "--reply-to", msg.ReplyTo) } cmd := exec.Command("bd", args...) @@ -57,7 +71,7 @@ func (r *Router) Send(msg *Message) error { } // Optionally notify if recipient is a polecat with active session - if isPolecat(msg.To) && msg.Priority == PriorityHigh { + if isPolecat(msg.To) && (msg.Priority == PriorityHigh || msg.Priority == PriorityUrgent) { r.notifyPolecat(msg) } diff --git a/internal/mail/types.go b/internal/mail/types.go index e602d072..b89866a5 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -11,11 +11,34 @@ import ( type Priority string const ( + // PriorityLow is for non-urgent messages. + PriorityLow Priority = "low" + // PriorityNormal is the default priority. PriorityNormal Priority = "normal" - // PriorityHigh indicates an urgent message. + // PriorityHigh indicates an important message. PriorityHigh Priority = "high" + + // PriorityUrgent indicates an urgent message requiring immediate attention. + PriorityUrgent Priority = "urgent" +) + +// MessageType indicates the purpose of a message. +type MessageType string + +const ( + // TypeTask indicates a message requiring action from the recipient. + TypeTask MessageType = "task" + + // TypeScavenge indicates optional first-come-first-served work. + TypeScavenge MessageType = "scavenge" + + // TypeNotification is an informational message (default). + TypeNotification MessageType = "notification" + + // TypeReply is a response to another message. + TypeReply MessageType = "reply" ) // Message represents a mail message between agents. @@ -44,9 +67,18 @@ type Message struct { // Priority is the message priority. Priority Priority `json:"priority"` + + // Type indicates the message type (task, scavenge, notification, reply). + Type MessageType `json:"type"` + + // ThreadID groups related messages into a conversation thread. + ThreadID string `json:"thread_id,omitempty"` + + // ReplyTo is the ID of the message this is replying to. + ReplyTo string `json:"reply_to,omitempty"` } -// NewMessage creates a new message with a generated ID (for legacy JSONL mode). +// NewMessage creates a new message with a generated ID and thread ID. func NewMessage(from, to, subject, body string) *Message { return &Message{ ID: generateID(), @@ -57,6 +89,25 @@ func NewMessage(from, to, subject, body string) *Message { Timestamp: time.Now(), Read: false, Priority: PriorityNormal, + Type: TypeNotification, + ThreadID: generateThreadID(), + } +} + +// NewReplyMessage creates a reply message that inherits the thread from the original. +func NewReplyMessage(from, to, subject, body string, original *Message) *Message { + return &Message{ + ID: generateID(), + From: from, + To: to, + Subject: subject, + Body: body, + Timestamp: time.Now(), + Read: false, + Priority: PriorityNormal, + Type: TypeReply, + ThreadID: original.ThreadID, + ReplyTo: original.ID, } } @@ -67,6 +118,13 @@ func generateID() string { return "msg-" + hex.EncodeToString(b) } +// generateThreadID creates a random thread ID. +func generateThreadID() string { + b := make([]byte, 6) + rand.Read(b) + return "thread-" + hex.EncodeToString(b) +} + // BeadsMessage represents a message as returned by bd mail commands. type BeadsMessage struct { ID string `json:"id"` @@ -74,16 +132,34 @@ type BeadsMessage struct { Description string `json:"description"` // Body Sender string `json:"sender"` // From identity Assignee string `json:"assignee"` // To identity - Priority int `json:"priority"` // 0=urgent, 2=normal + Priority int `json:"priority"` // 0=urgent, 1=high, 2=normal, 3=low Status string `json:"status"` // open=unread, closed=read CreatedAt time.Time `json:"created_at"` + Type string `json:"type,omitempty"` // Message type + ThreadID string `json:"thread_id,omitempty"` // Thread identifier + ReplyTo string `json:"reply_to,omitempty"` // Original message ID } // ToMessage converts a BeadsMessage to a GGT Message. func (bm *BeadsMessage) ToMessage() *Message { - priority := PriorityNormal - if bm.Priority == 0 { + // Convert beads priority (0=urgent, 1=high, 2=normal, 3=low) to GGT Priority + var priority Priority + switch bm.Priority { + case 0: + priority = PriorityUrgent + case 1: priority = PriorityHigh + case 3: + priority = PriorityLow + default: + priority = PriorityNormal + } + + // Convert message type, default to notification + msgType := TypeNotification + switch MessageType(bm.Type) { + case TypeTask, TypeScavenge, TypeReply: + msgType = MessageType(bm.Type) } return &Message{ @@ -95,6 +171,44 @@ func (bm *BeadsMessage) ToMessage() *Message { Timestamp: bm.CreatedAt, Read: bm.Status == "closed", Priority: priority, + Type: msgType, + ThreadID: bm.ThreadID, + ReplyTo: bm.ReplyTo, + } +} + +// PriorityToBeads converts a GGT Priority to beads priority integer. +// Returns: 0=urgent, 1=high, 2=normal, 3=low +func PriorityToBeads(p Priority) int { + switch p { + case PriorityUrgent: + return 0 + case PriorityHigh: + return 1 + case PriorityLow: + return 3 + default: + return 2 // normal + } +} + +// ParsePriority parses a priority string, returning PriorityNormal for invalid values. +func ParsePriority(s string) Priority { + switch Priority(s) { + case PriorityLow, PriorityNormal, PriorityHigh, PriorityUrgent: + return Priority(s) + default: + return PriorityNormal + } +} + +// ParseMessageType parses a message type string, returning TypeNotification for invalid values. +func ParseMessageType(s string) MessageType { + switch MessageType(s) { + case TypeTask, TypeScavenge, TypeNotification, TypeReply: + return MessageType(s) + default: + return TypeNotification } }