package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/spf13/cobra" ) var messageCmd = &cobra.Command{ Use: "message", Short: "Send and receive messages via Agent Mail", Long: `Send and receive messages between agents using Agent Mail server. Requires Agent Mail server running and these environment variables: BEADS_AGENT_MAIL_URL - Server URL (e.g., http://127.0.0.1:8765) BEADS_AGENT_NAME - Your agent name (e.g., fred-beads-stevey-macbook) BEADS_PROJECT_ID - Project identifier (defaults to repo path) Example: bd message send dave-beads-stevey-macbook "Need review on bd-z0yn" bd message inbox --unread-only bd message read msg-abc123 bd message ack msg-abc123`, } var messageSendCmd = &cobra.Command{ Use: "send ", Short: "Send a message to another agent", Long: `Send a message to another agent via Agent Mail. The message can be plain text or GitHub-flavored Markdown. Examples: bd message send dave-beads-stevey-macbook "Working on bd-z0yn" bd message send cino-beads-stevey-macbook "Please review PR #42" --subject "Review Request" bd message send emma-beads-stevey-macbook "Found bug in auth" --thread-id bd-123`, Args: cobra.ExactArgs(2), RunE: runMessageSend, } var messageInboxCmd = &cobra.Command{ Use: "inbox", Short: "List inbox messages", Long: `List messages in your inbox. Examples: bd message inbox bd message inbox --unread-only --limit 10 bd message inbox --urgent-only`, RunE: runMessageInbox, } var messageReadCmd = &cobra.Command{ Use: "read ", Short: "Read a specific message", Long: `Read and display a specific message by ID. Marks the message as read automatically. Example: bd message read msg-abc123`, Args: cobra.ExactArgs(1), RunE: runMessageRead, } var messageAckCmd = &cobra.Command{ Use: "ack ", Short: "Acknowledge a message", Long: `Acknowledge a message that requires acknowledgement. Also marks the message as read if not already. Example: bd message ack msg-abc123`, Args: cobra.ExactArgs(1), RunE: runMessageAck, } // Message send flags var ( messageSubject string messageThreadID string messageImportance string messageAckRequired bool ) // Message inbox flags var ( messageLimit int messageUnreadOnly bool messageUrgentOnly bool ) func init() { // Register message commands rootCmd.AddCommand(messageCmd) messageCmd.AddCommand(messageSendCmd) messageCmd.AddCommand(messageInboxCmd) messageCmd.AddCommand(messageReadCmd) messageCmd.AddCommand(messageAckCmd) // Send command flags messageSendCmd.Flags().StringVarP(&messageSubject, "subject", "s", "", "Message subject") messageSendCmd.Flags().StringVar(&messageThreadID, "thread-id", "", "Thread ID to group related messages") messageSendCmd.Flags().StringVar(&messageImportance, "importance", "normal", "Message importance (low, normal, high, urgent)") messageSendCmd.Flags().BoolVar(&messageAckRequired, "ack-required", false, "Require acknowledgement from recipient") // Inbox command flags messageInboxCmd.Flags().IntVar(&messageLimit, "limit", 20, "Maximum number of messages to show") messageInboxCmd.Flags().BoolVar(&messageUnreadOnly, "unread-only", false, "Show only unread messages") messageInboxCmd.Flags().BoolVar(&messageUrgentOnly, "urgent-only", false, "Show only urgent messages") } // AgentMailConfig holds configuration for Agent Mail server type AgentMailConfig struct { URL string AgentName string ProjectID string } const agentMailConfigHelp = `Agent Mail not configured. Configure with: export BEADS_AGENT_MAIL_URL=http://127.0.0.1:8765 export BEADS_AGENT_NAME=your-agent-name export BEADS_PROJECT_ID=your-project` // Message represents an Agent Mail message type Message struct { ID int `json:"id"` Subject string `json:"subject"` Body string `json:"body,omitempty"` FromAgent string `json:"from_agent"` CreatedAt time.Time `json:"created_at"` Importance string `json:"importance"` AckRequired bool `json:"ack_required"` ThreadID string `json:"thread_id,omitempty"` Read bool `json:"read"` Acknowledged bool `json:"acknowledged"` } // getAgentMailConfig retrieves Agent Mail configuration from environment func getAgentMailConfig() (*AgentMailConfig, error) { url := os.Getenv("BEADS_AGENT_MAIL_URL") if url == "" { return nil, fmt.Errorf("BEADS_AGENT_MAIL_URL not set") } agentName := os.Getenv("BEADS_AGENT_NAME") if agentName == "" { return nil, fmt.Errorf("BEADS_AGENT_NAME not set") } projectID := os.Getenv("BEADS_PROJECT_ID") if projectID == "" { // Default to workspace root path (directory containing .beads/) if dbPath != "" { beadsDir := filepath.Dir(dbPath) projectID = filepath.Dir(beadsDir) } else { // Fallback to current directory cwd, err := os.Getwd() if err == nil { projectID = cwd } } } return &AgentMailConfig{ URL: url, AgentName: agentName, ProjectID: projectID, }, nil } // sendAgentMailRequest sends a JSON-RPC request to Agent Mail server func sendAgentMailRequest(config *AgentMailConfig, method string, params interface{}) (json.RawMessage, error) { request := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]interface{}{ "name": method, "arguments": params, }, } reqBody, err := json.Marshal(request) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } url := strings.TrimRight(config.URL, "/") + "/mcp" client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Post(url, "application/json", bytes.NewReader(reqBody)) if err != nil { return nil, fmt.Errorf("failed to connect to Agent Mail server: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("Agent Mail server returned error: %s (status %d)", string(body), resp.StatusCode) } var response struct { Result struct { Content []struct { Type string `json:"type"` Text json.RawMessage `json:"text"` } `json:"content"` } `json:"result"` Error *struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if response.Error != nil { return nil, fmt.Errorf("Agent Mail error: %s (code %d)", response.Error.Message, response.Error.Code) } if len(response.Result.Content) == 0 { return nil, fmt.Errorf("no content in response") } return response.Result.Content[0].Text, nil } func runMessageSend(cmd *cobra.Command, args []string) error { // Validate importance flag validImportance := map[string]bool{ "low": true, "normal": true, "high": true, "urgent": true, } if !validImportance[messageImportance] { return fmt.Errorf("invalid importance: %s (must be: low, normal, high, urgent)", messageImportance) } config, err := getAgentMailConfig() if err != nil { return fmt.Errorf("%w\n\n%s", err, agentMailConfigHelp) } toAgent := args[0] message := args[1] // Prepare request parameters params := map[string]interface{}{ "project_key": config.ProjectID, "sender_name": config.AgentName, "to": []string{toAgent}, "body_md": message, } if messageSubject != "" { params["subject"] = messageSubject } else { // Generate subject from first line of message firstLine := strings.Split(message, "\n")[0] if len(firstLine) > 50 { firstLine = firstLine[:50] + "..." } params["subject"] = firstLine } if messageThreadID != "" { params["thread_id"] = messageThreadID } if messageImportance != "normal" { params["importance"] = messageImportance } if messageAckRequired { params["ack_required"] = true } // Send message via Agent Mail result, err := sendAgentMailRequest(config, "send_message", params) if err != nil { return err } // Parse result var sendResult struct { Deliveries []struct { Recipient string `json:"recipient"` MessageID int `json:"message_id"` } `json:"deliveries"` Count int `json:"count"` } if err := json.Unmarshal(result, &sendResult); err != nil { return fmt.Errorf("failed to parse send result: %w", err) } if jsonOutput { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(sendResult) } fmt.Printf("Message sent to %s\n", toAgent) if sendResult.Count > 0 && len(sendResult.Deliveries) > 0 { fmt.Printf("Message ID: %d\n", sendResult.Deliveries[0].MessageID) } if messageThreadID != "" { fmt.Printf("Thread: %s\n", messageThreadID) } return nil } func runMessageInbox(cmd *cobra.Command, args []string) error { config, err := getAgentMailConfig() if err != nil { return fmt.Errorf("%w\n\n%s", err, agentMailConfigHelp) } // Prepare request parameters params := map[string]interface{}{ "project_key": config.ProjectID, "agent_name": config.AgentName, "limit": messageLimit, "include_bodies": false, } if messageUnreadOnly { params["unread_only"] = true } if messageUrgentOnly { params["urgent_only"] = true } // Fetch inbox via Agent Mail result, err := sendAgentMailRequest(config, "fetch_inbox", params) if err != nil { return err } // Parse result var messages []Message if err := json.Unmarshal(result, &messages); err != nil { return fmt.Errorf("failed to parse inbox: %w", err) } if jsonOutput { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(messages) } if len(messages) == 0 { fmt.Println("No messages in inbox") return nil } fmt.Printf("Inbox for %s (%d messages):\n\n", config.AgentName, len(messages)) for _, msg := range messages { // Format timestamp age := time.Since(msg.CreatedAt) var timeStr string if age < time.Hour { timeStr = fmt.Sprintf("%dm ago", int(age.Minutes())) } else if age < 24*time.Hour { timeStr = fmt.Sprintf("%dh ago", int(age.Hours())) } else { timeStr = fmt.Sprintf("%dd ago", int(age.Hours()/24)) } // Status indicators status := "" if !msg.Read { status += " [UNREAD]" } if msg.AckRequired && !msg.Acknowledged { status += " [ACK REQUIRED]" } if msg.Importance == "high" || msg.Importance == "urgent" { status += fmt.Sprintf(" [%s]", strings.ToUpper(msg.Importance)) } fmt.Printf(" %d: %s%s\n", msg.ID, msg.Subject, status) fmt.Printf(" From: %s (%s)\n", msg.FromAgent, timeStr) if msg.ThreadID != "" { fmt.Printf(" Thread: %s\n", msg.ThreadID) } fmt.Println() } return nil } func runMessageRead(cmd *cobra.Command, args []string) error { config, err := getAgentMailConfig() if err != nil { return fmt.Errorf("%w\n\n%s", err, agentMailConfigHelp) } messageID := args[0] // Fetch full message with body fetchParams := map[string]interface{}{ "project_key": config.ProjectID, "agent_name": config.AgentName, "message_id": messageID, "include_bodies": true, } result, err := sendAgentMailRequest(config, "fetch_inbox", fetchParams) if err != nil { return fmt.Errorf("failed to fetch message: %w", err) } // Parse message var messages []Message if err := json.Unmarshal(result, &messages); err != nil { return fmt.Errorf("failed to parse message: %w", err) } if len(messages) == 0 { return fmt.Errorf("message not found: %s", messageID) } msg := messages[0] // Mark as read if not already if !msg.Read { markParams := map[string]interface{}{ "project_key": config.ProjectID, "agent_name": config.AgentName, "message_id": messageID, } _, _ = sendAgentMailRequest(config, "mark_message_read", markParams) } if jsonOutput { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(msg) } // Display message fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") fmt.Printf("From: %s\n", msg.FromAgent) fmt.Printf("Subject: %s\n", msg.Subject) fmt.Printf("Time: %s\n", msg.CreatedAt.Format("2006-01-02 15:04:05 MST")) if msg.ThreadID != "" { fmt.Printf("Thread: %s\n", msg.ThreadID) } if msg.Importance != "" && msg.Importance != "normal" { fmt.Printf("Priority: %s\n", strings.ToUpper(msg.Importance)) } if msg.AckRequired { status := "Required" if msg.Acknowledged { status = "Acknowledged" } fmt.Printf("Acknowledgement: %s\n", status) } fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") fmt.Println(msg.Body) fmt.Println() return nil } func runMessageAck(cmd *cobra.Command, args []string) error { config, err := getAgentMailConfig() if err != nil { return fmt.Errorf("%w\n\n%s", err, agentMailConfigHelp) } messageID := args[0] // Acknowledge message params := map[string]interface{}{ "project_key": config.ProjectID, "agent_name": config.AgentName, "message_id": messageID, } result, err := sendAgentMailRequest(config, "acknowledge_message", params) if err != nil { return err } if jsonOutput { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") var ackResult map[string]interface{} if err := json.Unmarshal(result, &ackResult); err != nil { return fmt.Errorf("failed to parse ack result: %w", err) } return encoder.Encode(ackResult) } fmt.Printf("Message %s acknowledged\n", messageID) return nil }