Refactor to return errors instead of calling os.Exit() directly: - Add SilentExitError type for commands that signal status via exit code - Update mail.go runMailPeek() and runMailCheck() to return errors - Change Execute() to return int exit code instead of calling os.Exit() - Move os.Exit() call to main() where it belongs This improves testability, enables graceful shutdown, and follows Go conventions for library code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1068 lines
30 KiB
Go
1068 lines
30 KiB
Go
package cmd
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/events"
|
|
"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 int
|
|
mailUrgent bool
|
|
mailPinned bool
|
|
mailWisp bool
|
|
mailPermanent bool
|
|
mailType string
|
|
mailReplyTo string
|
|
mailNotify bool
|
|
mailSendSelf bool
|
|
mailCC []string // CC recipients
|
|
mailInboxJSON bool
|
|
mailReadJSON bool
|
|
mailInboxUnread bool
|
|
mailInboxIdentity string
|
|
mailCheckInject bool
|
|
mailCheckJSON bool
|
|
mailCheckIdentity string
|
|
mailThreadJSON bool
|
|
mailReplySubject string
|
|
mailReplyMessage string
|
|
)
|
|
|
|
var mailCmd = &cobra.Command{
|
|
Use: "mail",
|
|
GroupID: GroupComm,
|
|
Short: "Agent messaging system",
|
|
Long: `Send and receive messages between agents.
|
|
|
|
The mail system allows Mayor, polecats, and the Refinery to communicate.
|
|
Messages are stored in beads as issues with type=message.
|
|
|
|
MAIL ROUTING:
|
|
┌─────────────────────────────────────────────────────┐
|
|
│ Town (.beads/) │
|
|
│ ┌─────────────────────────────────────────────┐ │
|
|
│ │ Mayor Inbox │ │
|
|
│ │ └── mayor/ │ │
|
|
│ └─────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ ┌─────────────────────────────────────────────┐ │
|
|
│ │ gastown/ (rig mailboxes) │ │
|
|
│ │ ├── witness ← gastown/witness │ │
|
|
│ │ ├── refinery ← gastown/refinery │ │
|
|
│ │ ├── Toast ← gastown/Toast │ │
|
|
│ │ └── crew/max ← gastown/crew/max │ │
|
|
│ └─────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────┘
|
|
|
|
ADDRESS FORMATS:
|
|
mayor/ → Mayor inbox
|
|
<rig>/witness → Rig's Witness
|
|
<rig>/refinery → Rig's Refinery
|
|
<rig>/<polecat> → Polecat (e.g., gastown/Toast)
|
|
<rig>/crew/<name> → Crew worker (e.g., gastown/crew/max)
|
|
--human → Special: human overseer
|
|
|
|
COMMANDS:
|
|
inbox View your inbox
|
|
send Send a message
|
|
read Read a specific message
|
|
mark Mark messages read/unread`,
|
|
}
|
|
|
|
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
|
|
list:<name> - Send to a mailing list (fans out to all members)
|
|
|
|
Mailing lists are defined in ~/gt/config/messaging.json and allow
|
|
sending to multiple recipients at once. Each recipient gets their
|
|
own copy of the message.
|
|
|
|
Message types:
|
|
task - Required processing
|
|
scavenge - Optional first-come work
|
|
notification - Informational (default)
|
|
reply - Response to message
|
|
|
|
Priority levels:
|
|
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 1
|
|
gt mail send gastown/Toast -s "Urgent" -m "Help!" --urgent
|
|
gt mail send mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123
|
|
gt mail send --self -s "Handoff" -m "Context for next session"
|
|
gt mail send gastown/Toast -s "Update" -m "Progress report" --cc overseer
|
|
gt mail send list:oncall -s "Alert" -m "System down"`,
|
|
Args: cobra.MaximumNArgs(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.
|
|
Use --identity for polecats to explicitly specify their identity.
|
|
|
|
Examples:
|
|
gt mail inbox # Current context (auto-detected)
|
|
gt mail inbox mayor/ # Mayor's inbox
|
|
gt mail inbox gastown/Toast # Polecat's inbox
|
|
gt mail inbox --identity gastown/Toast # Explicit polecat identity`,
|
|
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,
|
|
}
|
|
|
|
var mailPeekCmd = &cobra.Command{
|
|
Use: "peek",
|
|
Short: "Show preview of first unread message",
|
|
Long: `Display a compact preview of the first unread message.
|
|
|
|
Useful for status bar popups - shows subject, sender, and body preview.
|
|
Exits silently with code 1 if no unread messages.`,
|
|
RunE: runMailPeek,
|
|
}
|
|
|
|
var mailDeleteCmd = &cobra.Command{
|
|
Use: "delete <message-id>",
|
|
Short: "Delete a message",
|
|
Long: `Delete (acknowledge) a message.
|
|
|
|
This closes the message in beads.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMailDelete,
|
|
}
|
|
|
|
var mailArchiveCmd = &cobra.Command{
|
|
Use: "archive <message-id>",
|
|
Short: "Archive a message",
|
|
Long: `Archive a message (alias for delete).
|
|
|
|
Removes the message from your inbox by closing it in beads.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMailArchive,
|
|
}
|
|
|
|
var mailCheckCmd = &cobra.Command{
|
|
Use: "check",
|
|
Short: "Check for new mail (for hooks)",
|
|
Long: `Check for new mail - useful for Claude Code hooks.
|
|
|
|
Exit codes (normal mode):
|
|
0 - New mail available
|
|
1 - No new mail
|
|
|
|
Exit codes (--inject mode):
|
|
0 - Always (hooks should never block)
|
|
Output: system-reminder if mail exists, silent if no mail
|
|
|
|
Use --identity for polecats to explicitly specify their identity.
|
|
|
|
Examples:
|
|
gt mail check # Simple check (auto-detect identity)
|
|
gt mail check --inject # For hooks
|
|
gt mail check --identity gastown/Toast # Explicit polecat identity`,
|
|
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,
|
|
}
|
|
|
|
var mailReplyCmd = &cobra.Command{
|
|
Use: "reply <message-id>",
|
|
Short: "Reply to a message",
|
|
Long: `Reply to a specific message.
|
|
|
|
This is a convenience command that automatically:
|
|
- Sets the reply-to field to the original message
|
|
- Prefixes the subject with "Re: " (if not already present)
|
|
- Sends to the original sender
|
|
|
|
Examples:
|
|
gt mail reply msg-abc123 -m "Thanks, working on it now"
|
|
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runMailReply,
|
|
}
|
|
|
|
func init() {
|
|
// Send flags
|
|
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
|
mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body")
|
|
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")
|
|
mailSendCmd.Flags().BoolVar(&mailPinned, "pinned", false, "Pin message (for handoff context that persists)")
|
|
mailSendCmd.Flags().BoolVar(&mailWisp, "wisp", true, "Send as wisp (ephemeral, default)")
|
|
mailSendCmd.Flags().BoolVar(&mailPermanent, "permanent", false, "Send as permanent (not ephemeral, synced to remote)")
|
|
mailSendCmd.Flags().BoolVar(&mailSendSelf, "self", false, "Send to self (auto-detect from cwd)")
|
|
mailSendCmd.Flags().StringArrayVar(&mailCC, "cc", nil, "CC recipients (can be used multiple times)")
|
|
_ = mailSendCmd.MarkFlagRequired("subject") // cobra flags: error only at runtime if missing
|
|
|
|
// Inbox flags
|
|
mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON")
|
|
mailInboxCmd.Flags().BoolVarP(&mailInboxUnread, "unread", "u", false, "Show only unread messages")
|
|
mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "identity", "", "Explicit identity for inbox (e.g., gastown/Toast)")
|
|
mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "address", "", "Alias for --identity")
|
|
|
|
// Read flags
|
|
mailReadCmd.Flags().BoolVar(&mailReadJSON, "json", false, "Output as JSON")
|
|
|
|
// Check flags
|
|
mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks")
|
|
mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON")
|
|
mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "identity", "", "Explicit identity for inbox (e.g., gastown/Toast)")
|
|
mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "address", "", "Alias for --identity")
|
|
|
|
// Thread flags
|
|
mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON")
|
|
|
|
// Reply flags
|
|
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
|
|
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)")
|
|
mailReplyCmd.MarkFlagRequired("message")
|
|
|
|
// Add subcommands
|
|
mailCmd.AddCommand(mailSendCmd)
|
|
mailCmd.AddCommand(mailInboxCmd)
|
|
mailCmd.AddCommand(mailReadCmd)
|
|
mailCmd.AddCommand(mailPeekCmd)
|
|
mailCmd.AddCommand(mailDeleteCmd)
|
|
mailCmd.AddCommand(mailArchiveCmd)
|
|
mailCmd.AddCommand(mailCheckCmd)
|
|
mailCmd.AddCommand(mailThreadCmd)
|
|
mailCmd.AddCommand(mailReplyCmd)
|
|
|
|
rootCmd.AddCommand(mailCmd)
|
|
}
|
|
|
|
func runMailSend(cmd *cobra.Command, args []string) error {
|
|
var to string
|
|
|
|
if mailSendSelf {
|
|
// Auto-detect identity from cwd
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("getting current directory: %w", err)
|
|
}
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil || townRoot == "" {
|
|
return fmt.Errorf("not in a Gas Town workspace")
|
|
}
|
|
roleInfo, err := GetRoleWithContext(cwd, townRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("detecting role: %w", err)
|
|
}
|
|
ctx := RoleContext{
|
|
Role: roleInfo.Role,
|
|
Rig: roleInfo.Rig,
|
|
Polecat: roleInfo.Polecat,
|
|
TownRoot: townRoot,
|
|
WorkDir: cwd,
|
|
}
|
|
to = buildAgentIdentity(ctx)
|
|
if to == "" {
|
|
return fmt.Errorf("cannot determine identity (role: %s)", ctx.Role)
|
|
}
|
|
} else if len(args) > 0 {
|
|
to = args[0]
|
|
} else {
|
|
return fmt.Errorf("address required (or use --self)")
|
|
}
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Determine sender
|
|
from := detectSender()
|
|
|
|
// Create message
|
|
msg := &mail.Message{
|
|
From: from,
|
|
To: to,
|
|
Subject: mailSubject,
|
|
Body: mailBody,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Set message type
|
|
msg.Type = mail.ParseMessageType(mailType)
|
|
|
|
// Set pinned flag
|
|
msg.Pinned = mailPinned
|
|
|
|
// Set wisp flag (ephemeral message) - default true, --permanent overrides
|
|
msg.Wisp = mailWisp && !mailPermanent
|
|
|
|
// Set CC recipients
|
|
msg.CC = mailCC
|
|
|
|
// 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)
|
|
|
|
// Check if this is a list address to show fan-out details
|
|
var listRecipients []string
|
|
if strings.HasPrefix(to, "list:") {
|
|
var err error
|
|
listRecipients, err = router.ExpandListAddress(to)
|
|
if err != nil {
|
|
return fmt.Errorf("sending message: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := router.Send(msg); err != nil {
|
|
return fmt.Errorf("sending message: %w", err)
|
|
}
|
|
|
|
// Log mail event to activity feed
|
|
_ = events.LogFeed(events.TypeMail, from, events.MailPayload(to, mailSubject))
|
|
|
|
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
|
|
fmt.Printf(" Subject: %s\n", mailSubject)
|
|
|
|
// Show fan-out recipients for list addresses
|
|
if len(listRecipients) > 0 {
|
|
fmt.Printf(" Recipients: %s\n", strings.Join(listRecipients, ", "))
|
|
}
|
|
|
|
if len(msg.CC) > 0 {
|
|
fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", "))
|
|
}
|
|
if msg.Type != mail.TypeNotification {
|
|
fmt.Printf(" Type: %s\n", msg.Type)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMailInbox(cmd *cobra.Command, args []string) error {
|
|
// Determine which inbox to check (priority: --identity flag, positional arg, auto-detect)
|
|
address := ""
|
|
if mailInboxIdentity != "" {
|
|
address = mailInboxIdentity
|
|
} else if len(args) > 0 {
|
|
address = args[0]
|
|
} else {
|
|
address = detectSender()
|
|
}
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Get mailbox
|
|
router := mail.NewRouter(workDir)
|
|
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 = "○"
|
|
}
|
|
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("!")
|
|
}
|
|
wispMarker := ""
|
|
if msg.Wisp {
|
|
wispMarker = " " + style.Dim.Render("(wisp)")
|
|
}
|
|
|
|
fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker)
|
|
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]
|
|
|
|
// Determine which inbox
|
|
address := detectSender()
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Get mailbox and message
|
|
router := mail.NewRouter(workDir)
|
|
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)
|
|
}
|
|
|
|
// Note: We intentionally do NOT mark as read/ack on read.
|
|
// User must explicitly delete/ack the message.
|
|
// This preserves handoff messages for reference.
|
|
|
|
// JSON output
|
|
if mailReadJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(msg)
|
|
}
|
|
|
|
// Human-readable output
|
|
priorityStr := ""
|
|
if msg.Priority == mail.PriorityUrgent {
|
|
priorityStr = " " + style.Bold.Render("[URGENT]")
|
|
} else if msg.Priority == mail.PriorityHigh {
|
|
priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMailPeek(cmd *cobra.Command, args []string) error {
|
|
// Determine which inbox
|
|
address := detectSender()
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
if err != nil {
|
|
return NewSilentExit(1) // Silent exit - no workspace
|
|
}
|
|
|
|
// Get mailbox
|
|
router := mail.NewRouter(workDir)
|
|
mailbox, err := router.GetMailbox(address)
|
|
if err != nil {
|
|
return NewSilentExit(1) // Silent exit - can't access mailbox
|
|
}
|
|
|
|
// Get unread messages
|
|
messages, err := mailbox.ListUnread()
|
|
if err != nil || len(messages) == 0 {
|
|
return NewSilentExit(1) // Silent exit - no unread
|
|
}
|
|
|
|
// Show first unread message
|
|
msg := messages[0]
|
|
|
|
// Header with priority indicator
|
|
priorityStr := ""
|
|
if msg.Priority == mail.PriorityUrgent {
|
|
priorityStr = " [URGENT]"
|
|
} else if msg.Priority == mail.PriorityHigh {
|
|
priorityStr = " [!]"
|
|
}
|
|
|
|
fmt.Printf("📬 %s%s\n", msg.Subject, priorityStr)
|
|
fmt.Printf("From: %s\n", msg.From)
|
|
fmt.Printf("ID: %s\n\n", msg.ID)
|
|
|
|
// Body preview (truncate long bodies)
|
|
if msg.Body != "" {
|
|
body := msg.Body
|
|
// Truncate to ~500 chars for popup display
|
|
if len(body) > 500 {
|
|
body = body[:500] + "\n..."
|
|
}
|
|
fmt.Print(body)
|
|
if !strings.HasSuffix(body, "\n") {
|
|
fmt.Println()
|
|
}
|
|
}
|
|
|
|
// Show count if more messages
|
|
if len(messages) > 1 {
|
|
fmt.Printf("\n%s\n", style.Dim.Render(fmt.Sprintf("(+%d more unread)", len(messages)-1)))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runMailDelete(cmd *cobra.Command, args []string) error {
|
|
msgID := args[0]
|
|
|
|
// Determine which inbox
|
|
address := detectSender()
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Get mailbox
|
|
router := mail.NewRouter(workDir)
|
|
mailbox, err := router.GetMailbox(address)
|
|
if err != nil {
|
|
return fmt.Errorf("getting mailbox: %w", err)
|
|
}
|
|
|
|
if err := mailbox.Delete(msgID); err != nil {
|
|
return fmt.Errorf("deleting message: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Message deleted\n", style.Bold.Render("✓"))
|
|
return nil
|
|
}
|
|
|
|
func runMailArchive(cmd *cobra.Command, args []string) error {
|
|
msgID := args[0]
|
|
|
|
// Determine which inbox
|
|
address := detectSender()
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Get mailbox
|
|
router := mail.NewRouter(workDir)
|
|
mailbox, err := router.GetMailbox(address)
|
|
if err != nil {
|
|
return fmt.Errorf("getting mailbox: %w", err)
|
|
}
|
|
|
|
if err := mailbox.Delete(msgID); err != nil {
|
|
return fmt.Errorf("archiving message: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Message archived\n", style.Bold.Render("✓"))
|
|
return nil
|
|
}
|
|
|
|
// findMailWorkDir returns the town root for all mail operations.
|
|
//
|
|
// Two-level beads architecture:
|
|
// - Town beads (~/gt/.beads/): ALL mail and coordination
|
|
// - Clone beads (<rig>/crew/*/.beads/): Project issues only
|
|
//
|
|
// Mail ALWAYS uses town beads, regardless of sender or recipient address.
|
|
// This ensures messages are visible to all agents in the town.
|
|
func findMailWorkDir() (string, error) {
|
|
return workspace.FindFromCwdOrError()
|
|
}
|
|
|
|
// findLocalBeadsDir finds the nearest .beads directory by walking up from CWD.
|
|
// Used for project work (molecules, issue creation) that uses clone beads.
|
|
func findLocalBeadsDir() (string, error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
path := cwd
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(path, ".beads")); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
parent := filepath.Dir(path)
|
|
if parent == path {
|
|
break // Reached root
|
|
}
|
|
path = parent
|
|
}
|
|
|
|
return "", fmt.Errorf("no .beads directory found")
|
|
}
|
|
|
|
// detectSender determines the current context's address.
|
|
// Priority:
|
|
// 1. GT_ROLE env var → use the role-based identity (agent session)
|
|
// 2. No GT_ROLE → try cwd-based detection (witness/refinery/polecat/crew directories)
|
|
// 3. No match → return "overseer" (human at terminal)
|
|
//
|
|
// All Gas Town agents run in tmux sessions with GT_ROLE set at spawn.
|
|
// However, cwd-based detection is also tried to support running commands
|
|
// from agent directories without GT_ROLE set (e.g., debugging sessions).
|
|
func detectSender() string {
|
|
// Check GT_ROLE first (authoritative for agent sessions)
|
|
role := os.Getenv("GT_ROLE")
|
|
if role != "" {
|
|
// Agent session - build address from role and context
|
|
return detectSenderFromRole(role)
|
|
}
|
|
|
|
// No GT_ROLE - try cwd-based detection, defaults to overseer if not in agent directory
|
|
return detectSenderFromCwd()
|
|
}
|
|
|
|
// detectSenderFromRole builds an address from the GT_ROLE and related env vars.
|
|
// GT_ROLE can be either a simple role name ("crew", "polecat") or a full address
|
|
// ("gastown/crew/joe") depending on how the session was started.
|
|
//
|
|
// If GT_ROLE is a simple name but required env vars (GT_RIG, GT_POLECAT, etc.)
|
|
// are missing, falls back to cwd-based detection. This could return "overseer"
|
|
// if cwd doesn't match any known agent path - a misconfigured agent session.
|
|
func detectSenderFromRole(role string) string {
|
|
rig := os.Getenv("GT_RIG")
|
|
|
|
// Check if role is already a full address (contains /)
|
|
if strings.Contains(role, "/") {
|
|
// GT_ROLE is already a full address, use it directly
|
|
return role
|
|
}
|
|
|
|
// GT_ROLE is a simple role name, build the full address
|
|
switch role {
|
|
case "mayor":
|
|
return "mayor/"
|
|
case "deacon":
|
|
return "deacon/"
|
|
case "polecat":
|
|
polecat := os.Getenv("GT_POLECAT")
|
|
if rig != "" && polecat != "" {
|
|
return fmt.Sprintf("%s/%s", rig, polecat)
|
|
}
|
|
// Fallback to cwd detection for polecats
|
|
return detectSenderFromCwd()
|
|
case "crew":
|
|
crew := os.Getenv("GT_CREW")
|
|
if rig != "" && crew != "" {
|
|
return fmt.Sprintf("%s/crew/%s", rig, crew)
|
|
}
|
|
// Fallback to cwd detection for crew
|
|
return detectSenderFromCwd()
|
|
case "witness":
|
|
if rig != "" {
|
|
return fmt.Sprintf("%s/witness", rig)
|
|
}
|
|
return detectSenderFromCwd()
|
|
case "refinery":
|
|
if rig != "" {
|
|
return fmt.Sprintf("%s/refinery", rig)
|
|
}
|
|
return detectSenderFromCwd()
|
|
default:
|
|
// Unknown role, try cwd detection
|
|
return detectSenderFromCwd()
|
|
}
|
|
}
|
|
|
|
// detectSenderFromCwd is the legacy cwd-based detection for edge cases.
|
|
func detectSenderFromCwd() string {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "overseer"
|
|
}
|
|
|
|
// If in a rig's polecats directory, extract address (format: rig/polecats/name)
|
|
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/polecats/%s", rigName, polecatPath)
|
|
}
|
|
}
|
|
|
|
// If in a rig's crew directory, extract address (format: rig/crew/name)
|
|
if strings.Contains(cwd, "/crew/") {
|
|
parts := strings.Split(cwd, "/crew/")
|
|
if len(parts) >= 2 {
|
|
rigPath := parts[0]
|
|
crewName := strings.Split(parts[1], "/")[0]
|
|
rigName := filepath.Base(rigPath)
|
|
return fmt.Sprintf("%s/crew/%s", rigName, crewName)
|
|
}
|
|
}
|
|
|
|
// If in a rig's refinery directory, extract address (format: rig/refinery)
|
|
if strings.Contains(cwd, "/refinery") {
|
|
parts := strings.Split(cwd, "/refinery")
|
|
if len(parts) >= 1 {
|
|
rigName := filepath.Base(parts[0])
|
|
return fmt.Sprintf("%s/refinery", rigName)
|
|
}
|
|
}
|
|
|
|
// If in a rig's witness directory, extract address (format: rig/witness)
|
|
if strings.Contains(cwd, "/witness") {
|
|
parts := strings.Split(cwd, "/witness")
|
|
if len(parts) >= 1 {
|
|
rigName := filepath.Base(parts[0])
|
|
return fmt.Sprintf("%s/witness", rigName)
|
|
}
|
|
}
|
|
|
|
// Default to overseer (human)
|
|
return "overseer"
|
|
}
|
|
|
|
func runMailCheck(cmd *cobra.Command, args []string) error {
|
|
// Determine which inbox (priority: --identity flag, auto-detect)
|
|
address := ""
|
|
if mailCheckIdentity != "" {
|
|
address = mailCheckIdentity
|
|
} else {
|
|
address = detectSender()
|
|
}
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
if err != nil {
|
|
if mailCheckInject {
|
|
// Inject mode: always exit 0, silent on error
|
|
return nil
|
|
}
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Get mailbox
|
|
router := mail.NewRouter(workDir)
|
|
mailbox, err := router.GetMailbox(address)
|
|
if err != nil {
|
|
if mailCheckInject {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("getting mailbox: %w", err)
|
|
}
|
|
|
|
// Count unread
|
|
_, unread, err := mailbox.Count()
|
|
if err != nil {
|
|
if mailCheckInject {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("counting messages: %w", err)
|
|
}
|
|
|
|
// JSON output
|
|
if mailCheckJSON {
|
|
result := map[string]interface{}{
|
|
"address": address,
|
|
"unread": unread,
|
|
"has_new": unread > 0,
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(result)
|
|
}
|
|
|
|
// Inject mode: output system-reminder if mail exists
|
|
if mailCheckInject {
|
|
if unread > 0 {
|
|
// Get subjects for context
|
|
messages, _ := mailbox.ListUnread()
|
|
var subjects []string
|
|
for _, msg := range messages {
|
|
subjects = append(subjects, fmt.Sprintf("- %s from %s: %s", msg.ID, msg.From, msg.Subject))
|
|
}
|
|
|
|
fmt.Println("<system-reminder>")
|
|
fmt.Printf("You have %d unread message(s) in your inbox.\n\n", unread)
|
|
for _, s := range subjects {
|
|
fmt.Println(s)
|
|
}
|
|
fmt.Println()
|
|
fmt.Println("Run 'gt mail inbox' to see your messages, or 'gt mail read <id>' for a specific message.")
|
|
fmt.Println("</system-reminder>")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Normal mode
|
|
if unread > 0 {
|
|
fmt.Printf("%s %d unread message(s)\n", style.Bold.Render("📬"), unread)
|
|
return NewSilentExit(0)
|
|
}
|
|
fmt.Println("No new mail")
|
|
return NewSilentExit(1)
|
|
}
|
|
|
|
func runMailThread(cmd *cobra.Command, args []string) error {
|
|
threadID := args[0]
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
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
|
|
}
|
|
|
|
func runMailReply(cmd *cobra.Command, args []string) error {
|
|
msgID := args[0]
|
|
|
|
// All mail uses town beads (two-level architecture)
|
|
workDir, err := findMailWorkDir()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Determine current address
|
|
from := detectSender()
|
|
|
|
// Get the original message
|
|
router := mail.NewRouter(workDir)
|
|
mailbox, err := router.GetMailbox(from)
|
|
if err != nil {
|
|
return fmt.Errorf("getting mailbox: %w", err)
|
|
}
|
|
|
|
original, err := mailbox.Get(msgID)
|
|
if err != nil {
|
|
return fmt.Errorf("getting message: %w", err)
|
|
}
|
|
|
|
// Build reply subject
|
|
subject := mailReplySubject
|
|
if subject == "" {
|
|
if strings.HasPrefix(original.Subject, "Re: ") {
|
|
subject = original.Subject
|
|
} else {
|
|
subject = "Re: " + original.Subject
|
|
}
|
|
}
|
|
|
|
// Create reply message
|
|
reply := &mail.Message{
|
|
From: from,
|
|
To: original.From, // Reply to sender
|
|
Subject: subject,
|
|
Body: mailReplyMessage,
|
|
Type: mail.TypeReply,
|
|
Priority: mail.PriorityNormal,
|
|
ReplyTo: msgID,
|
|
ThreadID: original.ThreadID,
|
|
}
|
|
|
|
// If original has no thread ID, create one
|
|
if reply.ThreadID == "" {
|
|
reply.ThreadID = generateThreadID()
|
|
}
|
|
|
|
// Send the reply
|
|
if err := router.Send(reply); err != nil {
|
|
return fmt.Errorf("sending reply: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s Reply sent to %s\n", style.Bold.Render("✓"), original.From)
|
|
fmt.Printf(" Subject: %s\n", subject)
|
|
if original.ThreadID != "" {
|
|
fmt.Printf(" Thread: %s\n", style.Dim.Render(original.ThreadID))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateThreadID creates a random thread ID for new message threads.
|
|
func generateThreadID() string {
|
|
b := make([]byte, 6)
|
|
_, _ = rand.Read(b) // crypto/rand.Read only fails on broken system
|
|
return "thread-" + hex.EncodeToString(b)
|
|
}
|