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 <noreply@anthropic.com>
This commit is contained in:
285
internal/cmd/mail.go
Normal file
285
internal/cmd/mail.go
Normal file
@@ -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 <address>",
|
||||||
|
Short: "Send a message",
|
||||||
|
Long: `Send a message to an agent.
|
||||||
|
|
||||||
|
Addresses:
|
||||||
|
mayor/ - Send to Mayor
|
||||||
|
<rig>/refinery - Send to a rig's Refinery
|
||||||
|
<rig>/<polecat> - Send to a specific polecat
|
||||||
|
<rig>/ - 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 <message-id>",
|
||||||
|
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/"
|
||||||
|
}
|
||||||
222
internal/mail/mailbox.go
Normal file
222
internal/mail/mailbox.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
136
internal/mail/router.go
Normal file
136
internal/mail/router.go
Normal file
@@ -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/ → <town>/mayor/mail/inbox.jsonl
|
||||||
|
// - <rig>/refinery → <town>/<rig>/refinery/mail/inbox.jsonl
|
||||||
|
// - <rig>/<polecat> → <town>/<rig>/polecats/<polecat>/mail/inbox.jsonl
|
||||||
|
// - <rig>/ → <town>/<rig>/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"
|
||||||
|
}
|
||||||
67
internal/mail/types.go
Normal file
67
internal/mail/types.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user