From e984a55fe52c13bdd1c75a335eafca933229b0be Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 14:19:15 -0800 Subject: [PATCH] feat: add mail system and CLI commands - internal/mail: Message types with priority support - internal/mail: Mailbox JSONL operations (list, get, append, delete) - internal/mail: Router for address resolution and delivery - gt mail send: Send messages to agents - gt mail inbox: List messages (--unread, --json) - gt mail read: Read and mark messages as read - Address formats: mayor/, rig/, rig/polecat, rig/refinery - High priority messages trigger tmux notification - Auto-detect sender from GT_RIG/GT_POLECAT env vars Closes gt-u1j.6, gt-u1j.12 Generated with Claude Code Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mail.go | 285 +++++++++++++++++++++++++++++++++++++++ internal/mail/mailbox.go | 222 ++++++++++++++++++++++++++++++ internal/mail/router.go | 136 +++++++++++++++++++ internal/mail/types.go | 67 +++++++++ 4 files changed, 710 insertions(+) create mode 100644 internal/cmd/mail.go create mode 100644 internal/mail/mailbox.go create mode 100644 internal/mail/router.go create mode 100644 internal/mail/types.go diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go new file mode 100644 index 00000000..1fc6568d --- /dev/null +++ b/internal/cmd/mail.go @@ -0,0 +1,285 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// Mail command flags +var ( + mailSubject string + mailBody string + mailPriority string + mailNotify bool + mailInboxJSON bool + mailReadJSON bool + mailInboxUnread bool +) + +var mailCmd = &cobra.Command{ + Use: "mail", + Short: "Agent messaging system", + Long: `Send and receive messages between agents. + +The mail system allows Mayor, polecats, and the Refinery to communicate.`, +} + +var mailSendCmd = &cobra.Command{ + Use: "send
", + Short: "Send a message", + Long: `Send a message to an agent. + +Addresses: + mayor/ - Send to Mayor + /refinery - Send to a rig's Refinery + / - Send to a specific polecat + / - Broadcast to a rig + +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`, + Args: cobra.ExactArgs(1), + RunE: runMailSend, +} + +var mailInboxCmd = &cobra.Command{ + Use: "inbox [address]", + Short: "Check inbox", + Long: `Check messages in an inbox. + +If no address is specified, shows the current context's inbox. + +Examples: + gt mail inbox # Current context + gt mail inbox mayor/ # Mayor's inbox + gt mail inbox gastown/Toast # Polecat's inbox`, + Args: cobra.MaximumNArgs(1), + RunE: runMailInbox, +} + +var mailReadCmd = &cobra.Command{ + Use: "read ", + Short: "Read a message", + Long: `Read a specific message and mark it as read. + +The message ID can be found from 'gt mail inbox'.`, + Args: cobra.ExactArgs(1), + RunE: runMailRead, +} + +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().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient") + mailSendCmd.MarkFlagRequired("subject") + + // Inbox flags + mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON") + mailInboxCmd.Flags().BoolVarP(&mailInboxUnread, "unread", "u", false, "Show only unread messages") + + // Read flags + mailReadCmd.Flags().BoolVar(&mailReadJSON, "json", false, "Output as JSON") + + // Add subcommands + mailCmd.AddCommand(mailSendCmd) + mailCmd.AddCommand(mailInboxCmd) + mailCmd.AddCommand(mailReadCmd) + + rootCmd.AddCommand(mailCmd) +} + +func runMailSend(cmd *cobra.Command, args []string) error { + to := args[0] + + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Determine sender + from := detectSender(townRoot) + + // Create message + msg := mail.NewMessage(from, to, mailSubject, mailBody) + + // Set priority + if mailPriority == "high" || mailNotify { + msg.Priority = mail.PriorityHigh + } + + // Send + router := mail.NewRouter(townRoot) + if err := router.Send(msg); err != nil { + return fmt.Errorf("sending message: %w", err) + } + + fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to) + fmt.Printf(" ID: %s\n", style.Dim.Render(msg.ID)) + fmt.Printf(" Subject: %s\n", mailSubject) + + return nil +} + +func runMailInbox(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Determine which inbox to check + address := "" + if len(args) > 0 { + address = args[0] + } else { + address = detectSender(townRoot) + } + + // Get mailbox + router := mail.NewRouter(townRoot) + mailbox, err := router.GetMailbox(address) + if err != nil { + return fmt.Errorf("getting mailbox: %w", err) + } + + // Get messages + var messages []*mail.Message + if mailInboxUnread { + messages, err = mailbox.ListUnread() + } else { + messages, err = mailbox.List() + } + if err != nil { + return fmt.Errorf("listing messages: %w", err) + } + + // JSON output + if mailInboxJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(messages) + } + + // Human-readable output + total, unread, _ := mailbox.Count() + fmt.Printf("%s Inbox: %s (%d messages, %d unread)\n\n", + style.Bold.Render("📬"), address, total, unread) + + if len(messages) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no messages)")) + return nil + } + + for _, msg := range messages { + readMarker := "●" + if msg.Read { + readMarker = "○" + } + priorityMarker := "" + if msg.Priority == mail.PriorityHigh { + priorityMarker = " " + style.Bold.Render("!") + } + + fmt.Printf(" %s %s%s\n", readMarker, msg.Subject, priorityMarker) + fmt.Printf(" %s from %s\n", + style.Dim.Render(msg.ID), + msg.From) + fmt.Printf(" %s\n", + style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04"))) + } + + return nil +} + +func runMailRead(cmd *cobra.Command, args []string) error { + msgID := args[0] + + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Determine which inbox + address := detectSender(townRoot) + + // Get mailbox and message + router := mail.NewRouter(townRoot) + mailbox, err := router.GetMailbox(address) + if err != nil { + return fmt.Errorf("getting mailbox: %w", err) + } + + msg, err := mailbox.Get(msgID) + if err != nil { + return fmt.Errorf("getting message: %w", err) + } + + // Mark as read + mailbox.MarkRead(msgID) + + // JSON output + if mailReadJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(msg) + } + + // Human-readable output + priorityStr := "" + 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) + 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.Body != "" { + fmt.Printf("\n%s\n", msg.Body) + } + + return nil +} + +// detectSender determines the current context's address. +func detectSender(townRoot string) string { + // Check environment variables (set by session start) + rig := os.Getenv("GT_RIG") + polecat := os.Getenv("GT_POLECAT") + + if rig != "" && polecat != "" { + return fmt.Sprintf("%s/%s", rig, polecat) + } + + // Check current directory + cwd, err := os.Getwd() + if err != nil { + return "mayor/" + } + + // If in a rig's polecats directory, extract address + if strings.Contains(cwd, "/polecats/") { + parts := strings.Split(cwd, "/polecats/") + if len(parts) >= 2 { + rigPath := parts[0] + polecatPath := strings.Split(parts[1], "/")[0] + rigName := filepath.Base(rigPath) + return fmt.Sprintf("%s/%s", rigName, polecatPath) + } + } + + // Default to mayor + return "mayor/" +} diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go new file mode 100644 index 00000000..ec4f91ac --- /dev/null +++ b/internal/mail/mailbox.go @@ -0,0 +1,222 @@ +package mail + +import ( + "bufio" + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" +) + +// Common errors +var ( + ErrMessageNotFound = errors.New("message not found") + ErrEmptyInbox = errors.New("inbox is empty") +) + +// Mailbox manages a JSONL-based inbox. +type Mailbox struct { + path string +} + +// NewMailbox creates a mailbox at the given path. +func NewMailbox(path string) *Mailbox { + return &Mailbox{path: path} +} + +// Path returns the mailbox file path. +func (m *Mailbox) Path() string { + return m.path +} + +// List returns all messages in the mailbox. +func (m *Mailbox) List() ([]*Message, error) { + file, err := os.Open(m.path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer file.Close() + + var messages []*Message + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var msg Message + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue // Skip malformed lines + } + messages = append(messages, &msg) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + // Sort by timestamp (newest first) + sort.Slice(messages, func(i, j int) bool { + return messages[i].Timestamp.After(messages[j].Timestamp) + }) + + return messages, nil +} + +// ListUnread returns unread messages. +func (m *Mailbox) ListUnread() ([]*Message, error) { + all, err := m.List() + if err != nil { + return nil, err + } + + var unread []*Message + for _, msg := range all { + if !msg.Read { + unread = append(unread, msg) + } + } + + return unread, nil +} + +// Get returns a message by ID. +func (m *Mailbox) Get(id string) (*Message, error) { + messages, err := m.List() + if err != nil { + return nil, err + } + + for _, msg := range messages { + if msg.ID == id { + return msg, nil + } + } + + return nil, ErrMessageNotFound +} + +// Append adds a message to the mailbox. +func (m *Mailbox) Append(msg *Message) error { + // Ensure directory exists + dir := filepath.Dir(m.path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + // Open for append + file, err := os.OpenFile(m.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer file.Close() + + data, err := json.Marshal(msg) + if err != nil { + return err + } + + _, err = file.WriteString(string(data) + "\n") + return err +} + +// MarkRead marks a message as read. +func (m *Mailbox) MarkRead(id string) error { + messages, err := m.List() + if err != nil { + return err + } + + found := false + for _, msg := range messages { + if msg.ID == id { + msg.Read = true + found = true + } + } + + if !found { + return ErrMessageNotFound + } + + return m.rewrite(messages) +} + +// Delete removes a message from the mailbox. +func (m *Mailbox) Delete(id string) error { + messages, err := m.List() + if err != nil { + return err + } + + var filtered []*Message + found := false + for _, msg := range messages { + if msg.ID == id { + found = true + } else { + filtered = append(filtered, msg) + } + } + + if !found { + return ErrMessageNotFound + } + + return m.rewrite(filtered) +} + +// Count returns the total and unread message counts. +func (m *Mailbox) Count() (total, unread int, err error) { + messages, err := m.List() + if err != nil { + return 0, 0, err + } + + total = len(messages) + for _, msg := range messages { + if !msg.Read { + unread++ + } + } + + return total, unread, nil +} + +// rewrite rewrites the mailbox with the given messages. +func (m *Mailbox) rewrite(messages []*Message) error { + // Sort by timestamp (oldest first for JSONL) + sort.Slice(messages, func(i, j int) bool { + return messages[i].Timestamp.Before(messages[j].Timestamp) + }) + + // Write to temp file + tmpPath := m.path + ".tmp" + file, err := os.Create(tmpPath) + if err != nil { + return err + } + + for _, msg := range messages { + data, err := json.Marshal(msg) + if err != nil { + file.Close() + os.Remove(tmpPath) + return err + } + file.WriteString(string(data) + "\n") + } + + if err := file.Close(); err != nil { + os.Remove(tmpPath) + return err + } + + // Atomic rename + return os.Rename(tmpPath, m.path) +} diff --git a/internal/mail/router.go b/internal/mail/router.go new file mode 100644 index 00000000..b6c9ccf8 --- /dev/null +++ b/internal/mail/router.go @@ -0,0 +1,136 @@ +package mail + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/steveyegge/gastown/internal/tmux" +) + +// Router handles message delivery and address resolution. +type Router struct { + townRoot string + tmux *tmux.Tmux +} + +// NewRouter creates a new mail router. +func NewRouter(townRoot string) *Router { + return &Router{ + townRoot: townRoot, + tmux: tmux.NewTmux(), + } +} + +// Send delivers a message to its recipient. +func (r *Router) Send(msg *Message) error { + // Resolve recipient mailbox path + mailboxPath, err := r.ResolveMailbox(msg.To) + if err != nil { + return fmt.Errorf("resolving address '%s': %w", msg.To, err) + } + + // Append to mailbox + mailbox := NewMailbox(mailboxPath) + if err := mailbox.Append(msg); err != nil { + return fmt.Errorf("delivering message: %w", err) + } + + // Optionally notify if recipient is a polecat with active session + if isPolecat(msg.To) && msg.Priority == PriorityHigh { + r.notifyPolecat(msg) + } + + return nil +} + +// ResolveMailbox converts an address to a mailbox file path. +// +// Address formats: +// - mayor/ → /mayor/mail/inbox.jsonl +// - /refinery → //refinery/mail/inbox.jsonl +// - ///polecats//mail/inbox.jsonl +// - / → //mail/inbox.jsonl (rig broadcast) +func (r *Router) ResolveMailbox(address string) (string, error) { + address = strings.TrimSpace(address) + if address == "" { + return "", fmt.Errorf("empty address") + } + + // Mayor + if address == "mayor/" || address == "mayor" { + return filepath.Join(r.townRoot, "mayor", "mail", "inbox.jsonl"), nil + } + + // Parse rig/target + parts := strings.SplitN(address, "/", 2) + if len(parts) < 2 { + return "", fmt.Errorf("invalid address format: %s", address) + } + + rig := parts[0] + target := parts[1] + + // Rig broadcast (empty target or just /) + if target == "" { + return filepath.Join(r.townRoot, rig, "mail", "inbox.jsonl"), nil + } + + // Refinery + if target == "refinery" { + return filepath.Join(r.townRoot, rig, "refinery", "mail", "inbox.jsonl"), nil + } + + // Polecat + return filepath.Join(r.townRoot, rig, "polecats", target, "mail", "inbox.jsonl"), nil +} + +// GetMailbox returns a Mailbox for the given address. +func (r *Router) GetMailbox(address string) (*Mailbox, error) { + path, err := r.ResolveMailbox(address) + if err != nil { + return nil, err + } + return NewMailbox(path), nil +} + +// notifyPolecat sends a notification to a polecat's tmux session. +func (r *Router) notifyPolecat(msg *Message) error { + // Parse rig/polecat from address + parts := strings.SplitN(msg.To, "/", 2) + if len(parts) != 2 { + return nil + } + + rig := parts[0] + polecat := parts[1] + + // Generate session name (matches session.Manager) + sessionID := fmt.Sprintf("gt-%s-%s", rig, polecat) + + // Check if session exists + hasSession, err := r.tmux.HasSession(sessionID) + if err != nil || !hasSession { + return nil // No active session, skip notification + } + + // Inject notification + notification := fmt.Sprintf("[MAIL] %s", msg.Subject) + return r.tmux.SendKeys(sessionID, notification) +} + +// isPolecat checks if an address points to a polecat. +func isPolecat(address string) bool { + // Not mayor, not refinery, has rig/name format + if strings.HasPrefix(address, "mayor") { + return false + } + + parts := strings.SplitN(address, "/", 2) + if len(parts) != 2 { + return false + } + + target := parts[1] + return target != "" && target != "refinery" +} diff --git a/internal/mail/types.go b/internal/mail/types.go new file mode 100644 index 00000000..af15fa20 --- /dev/null +++ b/internal/mail/types.go @@ -0,0 +1,67 @@ +// Package mail provides JSONL-based messaging for agent communication. +package mail + +import ( + "crypto/rand" + "encoding/hex" + "time" +) + +// Priority levels for messages. +type Priority string + +const ( + // PriorityNormal is the default priority. + PriorityNormal Priority = "normal" + + // PriorityHigh indicates an urgent message. + PriorityHigh Priority = "high" +) + +// Message represents a mail message between agents. +type Message struct { + // ID is a unique message identifier. + ID string `json:"id"` + + // From is the sender address (e.g., "gastown/Toast" or "mayor/"). + From string `json:"from"` + + // To is the recipient address. + To string `json:"to"` + + // Subject is a brief summary. + Subject string `json:"subject"` + + // Body is the full message content. + Body string `json:"body"` + + // Timestamp is when the message was sent. + Timestamp time.Time `json:"timestamp"` + + // Read indicates if the message has been read. + Read bool `json:"read"` + + // Priority is the message priority. + Priority Priority `json:"priority"` +} + +// NewMessage creates a new message with a generated ID. +func NewMessage(from, to, subject, body string) *Message { + return &Message{ + ID: generateID(), + From: from, + To: to, + Subject: subject, + Body: body, + Timestamp: time.Now(), + Read: false, + Priority: PriorityNormal, + } +} + +// generateID creates a random message ID. +func generateID() string { + b := make([]byte, 8) + rand.Read(b) + return "msg-" + hex.EncodeToString(b) +}