package cmd import ( "bufio" "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/config" "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 mailCheckQuiet bool mailCheckIdentity string mailThreadJSON bool mailReplySubject string mailReplyMessage string mailMarkRead bool mailMarkUnread bool mailDeleteForce bool mailArchiveOlderThan int mailArchiveAllRead bool mailArchiveDryRun bool mailPurgeOlderThan int mailPurgeDryRun bool mailPurgeForce bool mailSearchFrom string mailSearchSubject bool mailSearchBody bool mailSearchJSON bool ) 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 /witness → Rig's Witness /refinery → Rig's Refinery / → Polecat (e.g., greenplace/Toast) /crew/ → 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
", 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 list: - 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 ", 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 ", 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 messages", Long: `Archive messages from your inbox. Archives a single message by ID, or archives messages in bulk using filters. Archived messages are stored in an archive file for later retrieval or purging. Examples: gt mail archive msg-abc123 # Archive a single message gt mail archive --all-read # Archive all read messages gt mail archive --older-than 7 # Archive messages older than 7 days gt mail archive --older-than 30 --dry-run # Preview what would be archived`, Args: cobra.MaximumNArgs(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 (--quiet mode): 0 - New mail available (outputs count) 1 - No new mail (silent) 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 --quiet # For scripts (only output if new mail) gt mail check --inject # For hooks gt mail check --identity greenplace/Toast # Explicit polecat identity`, RunE: runMailCheck, } var mailThreadCmd = &cobra.Command{ Use: "thread ", 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 ", 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 - Preserves the thread ID for conversation tracking If -m is not provided, opens $EDITOR to compose the reply. The editor template shows the original message for context. Examples: gt mail reply msg-abc123 -m "Thanks, working on it now" gt mail reply msg-abc123 -s "Custom subject" -m "Reply body" gt mail reply msg-abc123 # Opens editor`, Args: cobra.ExactArgs(1), RunE: runMailReply, } var mailClaimCmd = &cobra.Command{ Use: "claim ", Short: "Claim a message from a queue", Long: `Claim the oldest unclaimed message from a work queue. SYNTAX: gt mail claim BEHAVIOR: 1. List unclaimed messages in the queue 2. Pick the oldest unclaimed message 3. Set assignee to caller identity 4. Set status to in_progress 5. Print claimed message details ELIGIBILITY: The caller must match a pattern in the queue's workers list (defined in ~/gt/config/messaging.json). Examples: gt mail claim work/gastown # Claim from gastown work queue`, Args: cobra.ExactArgs(1), RunE: runMailClaim, } var mailReleaseCmd = &cobra.Command{ Use: "release ", Short: "Release a claimed queue message", Long: `Release a previously claimed message back to its queue. SYNTAX: gt mail release BEHAVIOR: 1. Find the message by ID 2. Verify caller is the one who claimed it (assignee matches) 3. Set assignee back to queue: (from message labels) 4. Set status back to open 5. Message returns to queue for others to claim ERROR CASES: - Message not found - Message not claimed (still assigned to queue) - Caller did not claim this message Examples: gt mail release hq-abc123 # Release a claimed message`, Args: cobra.ExactArgs(1), RunE: runMailRelease, } var mailClearCmd = &cobra.Command{ Use: "clear [target]", Short: "Clear all messages from an inbox", Long: `Clear (delete) all messages from an inbox. SYNTAX: gt mail clear # Clear your own inbox gt mail clear # Clear another agent's inbox BEHAVIOR: 1. List all messages in the target inbox 2. Delete each message 3. Print count of deleted messages Use case: Town quiescence - reset all inboxes across workers efficiently. Examples: gt mail clear # Clear your inbox gt mail clear gastown/polecats/joe # Clear joe's inbox gt mail clear mayor/ # Clear mayor's inbox`, Args: cobra.MaximumNArgs(1), RunE: runMailClear, } var mailMarkCmd = &cobra.Command{ Use: "mark ", Short: "Change message read status", Long: `Mark a message as read or unread. Examples: gt mail mark msg-abc123 --read gt mail mark msg-abc123 --unread`, Args: cobra.ExactArgs(1), RunE: runMailMark, } var mailPurgeCmd = &cobra.Command{ Use: "purge", Short: "Permanently delete archived messages", Long: `Permanently delete messages from the archive. This removes archived messages that are no longer needed. Use with caution - purged messages cannot be recovered. Examples: gt mail purge # Delete all archived messages (with confirmation) gt mail purge --older-than 30 # Delete archived messages older than 30 days gt mail purge --dry-run # Preview what would be deleted gt mail purge --force # Delete without confirmation`, RunE: runMailPurge, } var mailSearchCmd = &cobra.Command{ Use: "search ", Short: "Search messages by content", Long: `Search messages in inbox and archive by content. Supports regex patterns for flexible searching. Searches both subject and body by default. Examples: gt mail search "error" # Find messages containing "error" gt mail search "deploy.*prod" # Regex pattern gt mail search "urgent" --subject # Search only in subject gt mail search "stack trace" --body # Search only in body gt mail search "bug" --from mayor/ # Filter by sender`, Args: cobra.ExactArgs(1), RunE: runMailSearch, } 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().BoolVarP(&mailCheckQuiet, "quiet", "q", false, "Only output if new mail (for scripts)") 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: )") mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (opens editor if not provided)") // Delete flags mailDeleteCmd.Flags().BoolVarP(&mailDeleteForce, "force", "f", false, "Delete without confirmation") // Archive flags mailArchiveCmd.Flags().IntVar(&mailArchiveOlderThan, "older-than", 0, "Archive messages older than N days") mailArchiveCmd.Flags().BoolVar(&mailArchiveAllRead, "all-read", false, "Archive all read messages") mailArchiveCmd.Flags().BoolVar(&mailArchiveDryRun, "dry-run", false, "Show what would be archived without archiving") // Mark flags mailMarkCmd.Flags().BoolVar(&mailMarkRead, "read", false, "Mark message as read") mailMarkCmd.Flags().BoolVar(&mailMarkUnread, "unread", false, "Mark message as unread") // Purge flags mailPurgeCmd.Flags().IntVar(&mailPurgeOlderThan, "older-than", 0, "Only purge messages older than N days") mailPurgeCmd.Flags().BoolVar(&mailPurgeDryRun, "dry-run", false, "Show what would be purged without purging") mailPurgeCmd.Flags().BoolVarP(&mailPurgeForce, "force", "f", false, "Purge without confirmation") // Search flags mailSearchCmd.Flags().StringVar(&mailSearchFrom, "from", "", "Filter by sender (regex)") mailSearchCmd.Flags().BoolVar(&mailSearchSubject, "subject", false, "Search only in subject") mailSearchCmd.Flags().BoolVar(&mailSearchBody, "body", false, "Search only in body") mailSearchCmd.Flags().BoolVar(&mailSearchJSON, "json", false, "Output as JSON") // 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) mailCmd.AddCommand(mailClaimCmd) mailCmd.AddCommand(mailReleaseCmd) mailCmd.AddCommand(mailClearCmd) mailCmd.AddCommand(mailMarkCmd) mailCmd.AddCommand(mailPurgeCmd) mailCmd.AddCommand(mailSearchCmd) 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) } // Confirmation unless --force if !mailDeleteForce { // Get message for display msg, err := mailbox.Get(msgID) if err != nil { return fmt.Errorf("getting message: %w", err) } fmt.Printf("Delete message: %s\n", msg.Subject) fmt.Printf(" From: %s\n", msg.From) fmt.Printf(" ID: %s\n", style.Dim.Render(msg.ID)) fmt.Printf("\nDelete this message? [y/N] ") reader := bufio.NewReader(os.Stdin) response, _ := reader.ReadString('\n') response = strings.TrimSpace(strings.ToLower(response)) if response != "y" && response != "yes" { fmt.Println("Delete cancelled.") return nil } } 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 { // 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) } // Single message archive if len(args) == 1 { msgID := args[0] if mailArchiveDryRun { msg, err := mailbox.Get(msgID) if err != nil { return fmt.Errorf("getting message: %w", err) } fmt.Printf("Would archive: %s (%s)\n", msg.Subject, msg.ID) return nil } if err := mailbox.Archive(msgID); err != nil { return fmt.Errorf("archiving message: %w", err) } fmt.Printf("%s Message archived\n", style.Bold.Render("✓")) return nil } // Batch archive - need at least one filter if mailArchiveOlderThan == 0 && !mailArchiveAllRead { return fmt.Errorf("must specify a message ID or use --older-than or --all-read") } // Get all messages messages, err := mailbox.List() if err != nil { return fmt.Errorf("listing messages: %w", err) } // Filter messages var toArchive []*mail.Message cutoff := time.Now().AddDate(0, 0, -mailArchiveOlderThan) for _, msg := range messages { matches := true // Apply --older-than filter if mailArchiveOlderThan > 0 && !msg.Timestamp.Before(cutoff) { matches = false } // Apply --all-read filter (in beads, all inbox messages are "unread") // For legacy mode, only archive read messages if mailArchiveAllRead && !msg.Read { matches = false } if matches { toArchive = append(toArchive, msg) } } if len(toArchive) == 0 { fmt.Println("No messages match the criteria") return nil } // Dry run - just show what would be archived if mailArchiveDryRun { fmt.Printf("Would archive %d message(s):\n", len(toArchive)) for _, msg := range toArchive { fmt.Printf(" - %s: %s (%s)\n", style.Dim.Render(msg.Timestamp.Format("2006-01-02")), msg.Subject, style.Dim.Render(msg.ID)) } return nil } // Archive messages archived := 0 for _, msg := range toArchive { if err := mailbox.Archive(msg.ID); err != nil { fmt.Printf("Warning: failed to archive %s: %v\n", msg.ID, err) continue } archived++ } fmt.Printf("%s Archived %d message(s)\n", style.Bold.Render("✓"), archived) return nil } func runMailClear(cmd *cobra.Command, args []string) error { // Determine which inbox to clear (target arg or auto-detect) address := "" 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) } // List all messages messages, err := mailbox.List() if err != nil { return fmt.Errorf("listing messages: %w", err) } if len(messages) == 0 { fmt.Printf("%s Inbox %s is already empty\n", style.Dim.Render("○"), address) return nil } // Delete each message deleted := 0 var errors []string for _, msg := range messages { if err := mailbox.Delete(msg.ID); err != nil { errors = append(errors, fmt.Sprintf("%s: %v", msg.ID, err)) } else { deleted++ } } // Report results if len(errors) > 0 { fmt.Printf("%s Cleared %d/%d messages from %s\n", style.Bold.Render("⚠"), deleted, len(messages), address) for _, e := range errors { fmt.Printf(" Error: %s\n", e) } return fmt.Errorf("failed to clear %d messages", len(errors)) } fmt.Printf("%s Cleared %d messages from %s\n", style.Bold.Render("✓"), deleted, address) 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 (/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("") 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 ' for a specific message.") fmt.Println("") } return nil } // Normal mode (or quiet mode) if unread > 0 { if mailCheckQuiet { fmt.Printf("%d new message(s)\n", unread) } else { fmt.Printf("%s %d unread message(s)\n", style.Bold.Render("📬"), unread) } return nil // Success - exit 0 } // No mail case if mailCheckQuiet { // Quiet mode: silence usage output on non-zero exit cmd.SilenceUsage = true return NewSilentExit(1) } fmt.Println("No new mail") cmd.SilenceUsage = true 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 } } // Get message body - from flag or open editor body := mailReplyMessage if body == "" { // Open editor to compose reply var err error body, err = openEditorForReply(original, subject) if err != nil { return fmt.Errorf("opening editor: %w", err) } if body == "" { return fmt.Errorf("reply message is empty, aborting") } } // Create reply message reply := &mail.Message{ From: from, To: original.From, // Reply to sender Subject: subject, Body: body, 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 } // openEditorForReply opens the user's editor to compose a reply message. // It creates a temp file with context about the original message. func openEditorForReply(original *mail.Message, subject string) (string, error) { // Create temp file with reply template tmpFile, err := os.CreateTemp("", "gt-mail-reply-*.txt") if err != nil { return "", fmt.Errorf("creating temp file: %w", err) } defer os.Remove(tmpFile.Name()) // Write template with original message for context template := fmt.Sprintf(` # Reply to: %s # From: %s # Subject: %s # --- # Write your reply below. Lines starting with # are ignored. # Save and close the editor to send, or leave empty to abort. `, original.From, original.From, subject) // Add quoted original message if original.Body != "" { template += "# Original message:\n" for _, line := range strings.Split(original.Body, "\n") { template += "# > " + line + "\n" } template += "\n" } if _, err := tmpFile.WriteString(template); err != nil { tmpFile.Close() return "", fmt.Errorf("writing template: %w", err) } tmpFile.Close() // Determine editor editor := os.Getenv("EDITOR") if editor == "" { editor = os.Getenv("VISUAL") } if editor == "" { // Try common editors for _, e := range []string{"vim", "vi", "nano", "emacs"} { if _, err := exec.LookPath(e); err == nil { editor = e break } } } if editor == "" { return "", fmt.Errorf("no editor found (set $EDITOR)") } // Open editor editorCmd := exec.Command(editor, tmpFile.Name()) editorCmd.Stdin = os.Stdin editorCmd.Stdout = os.Stdout editorCmd.Stderr = os.Stderr if err := editorCmd.Run(); err != nil { return "", fmt.Errorf("running editor: %w", err) } // Read the edited content content, err := os.ReadFile(tmpFile.Name()) if err != nil { return "", fmt.Errorf("reading edited file: %w", err) } // Filter out comment lines and trim var lines []string for _, line := range strings.Split(string(content), "\n") { if !strings.HasPrefix(strings.TrimSpace(line), "#") { lines = append(lines, line) } } return strings.TrimSpace(strings.Join(lines, "\n")), 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) } // runMailClaim claims the oldest unclaimed message from a work queue. func runMailClaim(cmd *cobra.Command, args []string) error { queueName := args[0] // Find workspace townRoot, err := workspace.FindFromCwdOrError() if err != nil { return fmt.Errorf("not in a Gas Town workspace: %w", err) } // Load queue config from messaging.json configPath := config.MessagingConfigPath(townRoot) cfg, err := config.LoadMessagingConfig(configPath) if err != nil { return fmt.Errorf("loading messaging config: %w", err) } queueCfg, ok := cfg.Queues[queueName] if !ok { return fmt.Errorf("unknown queue: %s", queueName) } // Get caller identity caller := detectSender() // Check if caller is eligible (matches any pattern in workers list) if !isEligibleWorker(caller, queueCfg.Workers) { return fmt.Errorf("not eligible to claim from queue %s (caller: %s, workers: %v)", queueName, caller, queueCfg.Workers) } // List unclaimed messages in the queue // Queue messages have assignee=queue: and status=open queueAssignee := "queue:" + queueName messages, err := listQueueMessages(townRoot, queueAssignee) if err != nil { return fmt.Errorf("listing queue messages: %w", err) } if len(messages) == 0 { fmt.Printf("%s No messages to claim in queue %s\n", style.Dim.Render("○"), queueName) return nil } // Pick the oldest unclaimed message (first in list, sorted by created) oldest := messages[0] // Claim the message: set assignee to caller and status to in_progress if err := claimMessage(townRoot, oldest.ID, caller); err != nil { return fmt.Errorf("claiming message: %w", err) } // Print claimed message details fmt.Printf("%s Claimed message from queue %s\n", style.Bold.Render("✓"), queueName) fmt.Printf(" ID: %s\n", oldest.ID) fmt.Printf(" Subject: %s\n", oldest.Title) if oldest.Description != "" { // Show first line of description lines := strings.SplitN(oldest.Description, "\n", 2) preview := lines[0] if len(preview) > 80 { preview = preview[:77] + "..." } fmt.Printf(" Preview: %s\n", style.Dim.Render(preview)) } fmt.Printf(" From: %s\n", oldest.From) fmt.Printf(" Created: %s\n", oldest.Created.Format("2006-01-02 15:04")) return nil } // queueMessage represents a message in a queue. type queueMessage struct { ID string Title string Description string From string Created time.Time Priority int } // isEligibleWorker checks if the caller matches any pattern in the workers list. // Patterns support wildcards: "gastown/polecats/*" matches "gastown/polecats/capable". func isEligibleWorker(caller string, patterns []string) bool { for _, pattern := range patterns { if matchWorkerPattern(pattern, caller) { return true } } return false } // matchWorkerPattern checks if caller matches the pattern. // Supports simple wildcards: * matches a single path segment (no slashes). func matchWorkerPattern(pattern, caller string) bool { // Handle exact match if pattern == caller { return true } // Handle wildcard patterns if strings.Contains(pattern, "*") { // Convert to simple glob matching // "gastown/polecats/*" should match "gastown/polecats/capable" // but NOT "gastown/polecats/sub/capable" parts := strings.Split(pattern, "*") if len(parts) == 2 { prefix := parts[0] suffix := parts[1] if strings.HasPrefix(caller, prefix) && strings.HasSuffix(caller, suffix) { // Check that the middle part doesn't contain path separators middle := caller[len(prefix) : len(caller)-len(suffix)] if !strings.Contains(middle, "/") { return true } } } } return false } // listQueueMessages lists unclaimed messages in a queue. func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) { // Use bd list to find messages with assignee=queue: and status=open beadsDir := filepath.Join(townRoot, ".beads") args := []string{"list", "--assignee", queueAssignee, "--status", "open", "--type", "message", "--sort", "created", "--limit", "0", // No limit "--json", } cmd := exec.Command("bd", args...) cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return nil, fmt.Errorf("%s", errMsg) } return nil, err } // Parse JSON output var issues []struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` Labels []string `json:"labels"` CreatedAt time.Time `json:"created_at"` Priority int `json:"priority"` } if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { // If no messages, bd might output empty or error if strings.TrimSpace(stdout.String()) == "" || strings.TrimSpace(stdout.String()) == "[]" { return nil, nil } return nil, fmt.Errorf("parsing bd output: %w", err) } // Convert to queueMessage, extracting 'from' from labels var messages []queueMessage for _, issue := range issues { msg := queueMessage{ ID: issue.ID, Title: issue.Title, Description: issue.Description, Created: issue.CreatedAt, Priority: issue.Priority, } // Extract 'from' from labels (format: "from:address") for _, label := range issue.Labels { if strings.HasPrefix(label, "from:") { msg.From = strings.TrimPrefix(label, "from:") break } } messages = append(messages, msg) } // Sort by created time (oldest first) sort.Slice(messages, func(i, j int) bool { return messages[i].Created.Before(messages[j].Created) }) return messages, nil } // claimMessage claims a message by setting assignee and status. func claimMessage(townRoot, messageID, claimant string) error { beadsDir := filepath.Join(townRoot, ".beads") args := []string{"update", messageID, "--assignee", claimant, "--status", "in_progress", } cmd := exec.Command("bd", args...) cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir, "BD_ACTOR="+claimant, ) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return fmt.Errorf("%s", errMsg) } return err } return nil } // runMailRelease releases a claimed queue message back to its queue. func runMailRelease(cmd *cobra.Command, args []string) error { messageID := args[0] // Find workspace townRoot, err := workspace.FindFromCwdOrError() if err != nil { return fmt.Errorf("not in a Gas Town workspace: %w", err) } // Get caller identity caller := detectSender() // Get message details to verify ownership and find queue msgInfo, err := getMessageInfo(townRoot, messageID) if err != nil { return fmt.Errorf("getting message: %w", err) } // Verify message exists and is a queue message if msgInfo.QueueName == "" { return fmt.Errorf("message %s is not a queue message (no queue label)", messageID) } // Verify caller is the one who claimed it if msgInfo.Assignee != caller { if strings.HasPrefix(msgInfo.Assignee, "queue:") { return fmt.Errorf("message %s is not claimed (still in queue)", messageID) } return fmt.Errorf("message %s was claimed by %s, not %s", messageID, msgInfo.Assignee, caller) } // Release the message: set assignee back to queue and status to open queueAssignee := "queue:" + msgInfo.QueueName if err := releaseMessage(townRoot, messageID, queueAssignee, caller); err != nil { return fmt.Errorf("releasing message: %w", err) } fmt.Printf("%s Released message back to queue %s\n", style.Bold.Render("✓"), msgInfo.QueueName) fmt.Printf(" ID: %s\n", messageID) fmt.Printf(" Subject: %s\n", msgInfo.Title) return nil } // messageInfo holds details about a queue message. type messageInfo struct { ID string Title string Assignee string QueueName string Status string } // getMessageInfo retrieves information about a message. func getMessageInfo(townRoot, messageID string) (*messageInfo, error) { beadsDir := filepath.Join(townRoot, ".beads") args := []string{"show", messageID, "--json"} cmd := exec.Command("bd", args...) cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if strings.Contains(errMsg, "not found") { return nil, fmt.Errorf("message not found: %s", messageID) } if errMsg != "" { return nil, fmt.Errorf("%s", errMsg) } return nil, err } // Parse JSON output - bd show --json returns an array var issues []struct { ID string `json:"id"` Title string `json:"title"` Assignee string `json:"assignee"` Labels []string `json:"labels"` Status string `json:"status"` } if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { return nil, fmt.Errorf("parsing message: %w", err) } if len(issues) == 0 { return nil, fmt.Errorf("message not found: %s", messageID) } issue := issues[0] info := &messageInfo{ ID: issue.ID, Title: issue.Title, Assignee: issue.Assignee, Status: issue.Status, } // Extract queue name from labels (format: "queue:") for _, label := range issue.Labels { if strings.HasPrefix(label, "queue:") { info.QueueName = strings.TrimPrefix(label, "queue:") break } } return info, nil } // releaseMessage releases a claimed message back to its queue. func releaseMessage(townRoot, messageID, queueAssignee, actor string) error { beadsDir := filepath.Join(townRoot, ".beads") args := []string{"update", messageID, "--assignee", queueAssignee, "--status", "open", } cmd := exec.Command("bd", args...) cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir, "BD_ACTOR="+actor, ) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return fmt.Errorf("%s", errMsg) } return err } return nil } func runMailMark(cmd *cobra.Command, args []string) error { msgID := args[0] // Require exactly one flag if mailMarkRead == mailMarkUnread { return fmt.Errorf("must specify either --read or --unread") } // Determine which inbox address := detectSender() // All mail uses town beads 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 mailMarkRead { if err := mailbox.MarkRead(msgID); err != nil { return fmt.Errorf("marking as read: %w", err) } fmt.Printf("%s Message marked as read\n", style.Bold.Render("✓")) } else { if err := mailbox.MarkUnread(msgID); err != nil { return fmt.Errorf("marking as unread: %w", err) } fmt.Printf("%s Message marked as unread\n", style.Bold.Render("✓")) } return nil } func runMailPurge(cmd *cobra.Command, args []string) error { // Determine which inbox address := detectSender() // All mail uses town beads 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 archived messages archived, err := mailbox.ListArchived() if err != nil { return fmt.Errorf("listing archived messages: %w", err) } if len(archived) == 0 { fmt.Println("No archived messages to purge") return nil } // Filter by age if specified var toPurge []*mail.Message if mailPurgeOlderThan > 0 { cutoff := time.Now().AddDate(0, 0, -mailPurgeOlderThan) for _, msg := range archived { if msg.Timestamp.Before(cutoff) { toPurge = append(toPurge, msg) } } } else { toPurge = archived } if len(toPurge) == 0 { fmt.Println("No archived messages match the criteria") return nil } // Dry run if mailPurgeDryRun { fmt.Printf("Would purge %d archived message(s):\n", len(toPurge)) for _, msg := range toPurge { fmt.Printf(" - %s: %s (%s)\n", style.Dim.Render(msg.Timestamp.Format("2006-01-02")), msg.Subject, style.Dim.Render(msg.ID)) } return nil } // Confirmation unless --force if !mailPurgeForce { fmt.Printf("This will permanently delete %d archived message(s).\n", len(toPurge)) fmt.Print("Are you sure? [y/N] ") reader := bufio.NewReader(os.Stdin) response, _ := reader.ReadString('\n') response = strings.TrimSpace(strings.ToLower(response)) if response != "y" && response != "yes" { fmt.Println("Purge cancelled.") return nil } } // Perform purge purged, err := mailbox.PurgeArchive(mailPurgeOlderThan) if err != nil { return fmt.Errorf("purging archive: %w", err) } fmt.Printf("%s Purged %d archived message(s)\n", style.Bold.Render("✓"), purged) return nil } func runMailSearch(cmd *cobra.Command, args []string) error { query := args[0] // Validate flags if mailSearchSubject && mailSearchBody { return fmt.Errorf("cannot use both --subject and --body") } // Determine which inbox address := detectSender() // All mail uses town beads 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) } // Search opts := mail.SearchOptions{ Query: query, FromFilter: mailSearchFrom, SubjectOnly: mailSearchSubject, BodyOnly: mailSearchBody, } matches, err := mailbox.Search(opts) if err != nil { return fmt.Errorf("searching: %w", err) } // JSON output if mailSearchJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(matches) } // Human-readable output if len(matches) == 0 { fmt.Println("No messages found") return nil } fmt.Printf("%s Found %d message(s) matching \"%s\":\n\n", style.Bold.Render("🔍"), len(matches), query) for _, msg := range matches { statusMarker := "●" // inbox if msg.Read { statusMarker = "○" // archived } fmt.Printf(" %s %s\n", statusMarker, msg.Subject) 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 }