feat(mail): add message types and threading support

- 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 <id>' 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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-18 20:14:13 -08:00
parent 915594c44c
commit 96902092fd
4 changed files with 369 additions and 16 deletions

View File

@@ -1,6 +1,8 @@
package cmd package cmd
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -18,12 +20,15 @@ var (
mailSubject string mailSubject string
mailBody string mailBody string
mailPriority string mailPriority string
mailType 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
) )
var mailCmd = &cobra.Command{ var mailCmd = &cobra.Command{
@@ -46,10 +51,21 @@ Addresses:
<rig>/<polecat> - Send to a specific polecat <rig>/<polecat> - Send to a specific polecat
<rig>/ - Broadcast to a rig <rig>/ - 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: Examples:
gt mail send gastown/Toast -s "Status check" -m "How's that bug fix going?" 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 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), Args: cobra.ExactArgs(1),
RunE: runMailSend, RunE: runMailSend,
} }
@@ -108,11 +124,26 @@ Examples:
RunE: runMailCheck, RunE: runMailCheck,
} }
var mailThreadCmd = &cobra.Command{
Use: "thread <thread-id>",
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() { func init() {
// Send flags // Send flags
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body") 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.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient")
mailSendCmd.MarkFlagRequired("subject") mailSendCmd.MarkFlagRequired("subject")
@@ -127,12 +158,16 @@ func init() {
mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks") mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks")
mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON") mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON")
// Thread flags
mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON")
// Add subcommands // Add subcommands
mailCmd.AddCommand(mailSendCmd) mailCmd.AddCommand(mailSendCmd)
mailCmd.AddCommand(mailInboxCmd) mailCmd.AddCommand(mailInboxCmd)
mailCmd.AddCommand(mailReadCmd) mailCmd.AddCommand(mailReadCmd)
mailCmd.AddCommand(mailDeleteCmd) mailCmd.AddCommand(mailDeleteCmd)
mailCmd.AddCommand(mailCheckCmd) mailCmd.AddCommand(mailCheckCmd)
mailCmd.AddCommand(mailThreadCmd)
rootCmd.AddCommand(mailCmd) rootCmd.AddCommand(mailCmd)
} }
@@ -158,10 +193,36 @@ func runMailSend(cmd *cobra.Command, args []string) error {
} }
// Set priority // Set priority
if mailPriority == "high" || mailNotify { msg.Priority = mail.ParsePriority(mailPriority)
if mailNotify && msg.Priority == mail.PriorityNormal {
msg.Priority = mail.PriorityHigh 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 // Send via router
router := mail.NewRouter(workDir) router := mail.NewRouter(workDir)
if err := router.Send(msg); err != nil { 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("%s Message sent to %s\n", style.Bold.Render("✓"), to)
fmt.Printf(" Subject: %s\n", mailSubject) fmt.Printf(" Subject: %s\n", mailSubject)
if msg.Type != mail.TypeNotification {
fmt.Printf(" Type: %s\n", msg.Type)
}
return nil return nil
} }
@@ -229,12 +293,16 @@ func runMailInbox(cmd *cobra.Command, args []string) error {
if msg.Read { if msg.Read {
readMarker = "○" readMarker = "○"
} }
typeMarker := ""
if msg.Type != "" && msg.Type != mail.TypeNotification {
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
}
priorityMarker := "" priorityMarker := ""
if msg.Priority == mail.PriorityHigh { if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
priorityMarker = " " + style.Bold.Render("!") 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", fmt.Printf(" %s from %s\n",
style.Dim.Render(msg.ID), style.Dim.Render(msg.ID),
msg.From) msg.From)
@@ -281,16 +349,30 @@ func runMailRead(cmd *cobra.Command, args []string) error {
// Human-readable output // Human-readable output
priorityStr := "" 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]") 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("From: %s\n", msg.From)
fmt.Printf("To: %s\n", msg.To) fmt.Printf("To: %s\n", msg.To)
fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05")) fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05"))
fmt.Printf("ID: %s\n", style.Dim.Render(msg.ID)) 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 != "" { if msg.Body != "" {
fmt.Printf("\n%s\n", msg.Body) fmt.Printf("\n%s\n", msg.Body)
} }
@@ -467,3 +549,78 @@ func runMailCheck(cmd *cobra.Command, args []string) error {
} }
return nil 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)
}

View File

@@ -379,3 +379,71 @@ func (m *Mailbox) rewriteLegacy(messages []*Message) error {
// Atomic rename // Atomic rename
return os.Rename(tmpPath, m.path) 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 <thread-id> --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
}

View File

@@ -36,9 +36,23 @@ func (r *Router) Send(msg *Message) error {
"-s", msg.Subject, "-s", msg.Subject,
} }
// Add importance flag for high priority // Add priority flag
if msg.Priority == PriorityHigh { beadsPriority := PriorityToBeads(msg.Priority)
args = append(args, "--importance", "high") 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...) 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 // 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) r.notifyPolecat(msg)
} }

View File

@@ -11,11 +11,34 @@ import (
type Priority string type Priority string
const ( const (
// PriorityLow is for non-urgent messages.
PriorityLow Priority = "low"
// PriorityNormal is the default priority. // PriorityNormal is the default priority.
PriorityNormal Priority = "normal" PriorityNormal Priority = "normal"
// PriorityHigh indicates an urgent message. // PriorityHigh indicates an important message.
PriorityHigh Priority = "high" 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. // Message represents a mail message between agents.
@@ -44,9 +67,18 @@ type Message struct {
// Priority is the message priority. // Priority is the message priority.
Priority Priority `json:"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 { func NewMessage(from, to, subject, body string) *Message {
return &Message{ return &Message{
ID: generateID(), ID: generateID(),
@@ -57,6 +89,25 @@ func NewMessage(from, to, subject, body string) *Message {
Timestamp: time.Now(), Timestamp: time.Now(),
Read: false, Read: false,
Priority: PriorityNormal, 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) 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. // BeadsMessage represents a message as returned by bd mail commands.
type BeadsMessage struct { type BeadsMessage struct {
ID string `json:"id"` ID string `json:"id"`
@@ -74,16 +132,34 @@ type BeadsMessage struct {
Description string `json:"description"` // Body Description string `json:"description"` // Body
Sender string `json:"sender"` // From identity Sender string `json:"sender"` // From identity
Assignee string `json:"assignee"` // To 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 Status string `json:"status"` // open=unread, closed=read
CreatedAt time.Time `json:"created_at"` 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. // ToMessage converts a BeadsMessage to a GGT Message.
func (bm *BeadsMessage) ToMessage() *Message { func (bm *BeadsMessage) ToMessage() *Message {
priority := PriorityNormal // Convert beads priority (0=urgent, 1=high, 2=normal, 3=low) to GGT Priority
if bm.Priority == 0 { var priority Priority
switch bm.Priority {
case 0:
priority = PriorityUrgent
case 1:
priority = PriorityHigh 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{ return &Message{
@@ -95,6 +171,44 @@ func (bm *BeadsMessage) ToMessage() *Message {
Timestamp: bm.CreatedAt, Timestamp: bm.CreatedAt,
Read: bm.Status == "closed", Read: bm.Status == "closed",
Priority: priority, 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
} }
} }