Parent commands (mol, mail, crew, polecat, etc.) previously showed help and exited 0 for unknown subcommands like "gt mol foobar". This masked errors in scripts and confused users. Added requireSubcommand() helper to root.go and applied it to all parent commands. Now unknown subcommands properly error with exit code 1. Example before: gt mol unhook → shows help, exits 0 Example after: gt mol unhook → "Error: unknown command "unhook"", exits 1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1069 lines
30 KiB
Go
1069 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",
|
|
RunE: requireSubcommand,
|
|
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 ← greenplace/witness │ │
|
|
│ │ ├── refinery ← greenplace/refinery │ │
|
|
│ │ ├── Toast ← greenplace/Toast │ │
|
|
│ │ └── crew/max ← greenplace/crew/max │ │
|
|
│ └─────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────┘
|
|
|
|
ADDRESS FORMATS:
|
|
mayor/ → Mayor inbox
|
|
<rig>/witness → Rig's Witness
|
|
<rig>/refinery → Rig's Refinery
|
|
<rig>/<polecat> → Polecat (e.g., greenplace/Toast)
|
|
<rig>/crew/<name> → Crew worker (e.g., greenplace/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 greenplace/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 greenplace/Toast -s "Task" -m "Fix bug" --type task --priority 1
|
|
gt mail send greenplace/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 greenplace/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 greenplace/Toast # Polecat's inbox
|
|
gt mail inbox --identity greenplace/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 greenplace/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., greenplace/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., greenplace/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
|
|
// ("greenplace/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)
|
|
}
|