feat: refactor mail system to use bd mail backend

- Mail commands (send/inbox/read/delete) now wrap bd mail CLI
- Address translation: mayor/ → mayor, rig/polecat → rig-polecat
- Beads stores messages as type=message issues
- Legacy JSONL mode retained for crew workers (local mail)
- Refinery notifications use new mail interface
- Swarm landing notifications use new mail interface

Closes gt-u1j.6, gt-u1j.12

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-16 21:45:42 -08:00
parent 9e4cdba5d0
commit 59921d52c8
6 changed files with 503 additions and 213 deletions

View File

@@ -2,11 +2,14 @@ package mail
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// Common errors
@@ -15,23 +18,97 @@ var (
ErrEmptyInbox = errors.New("inbox is empty")
)
// Mailbox manages a JSONL-based inbox.
// Mailbox manages messages for an identity via beads.
type Mailbox struct {
path string
identity string // beads identity (e.g., "gastown-Toast")
workDir string // directory to run bd commands in
path string // for legacy JSONL mode (crew workers)
legacy bool // true = use JSONL files, false = use beads
}
// NewMailbox creates a mailbox at the given path.
// NewMailbox creates a mailbox for the given JSONL path (legacy mode).
// Used by crew workers that have local JSONL inboxes.
func NewMailbox(path string) *Mailbox {
return &Mailbox{path: path}
return &Mailbox{
path: filepath.Join(path, "inbox.jsonl"),
legacy: true,
}
}
// Path returns the mailbox file path.
// NewMailboxBeads creates a mailbox backed by beads.
func NewMailboxBeads(identity, workDir string) *Mailbox {
return &Mailbox{
identity: identity,
workDir: workDir,
legacy: false,
}
}
// NewMailboxFromAddress creates a beads-backed mailbox from a GGT address.
func NewMailboxFromAddress(address, workDir string) *Mailbox {
return &Mailbox{
identity: addressToIdentity(address),
workDir: workDir,
legacy: false,
}
}
// Identity returns the beads identity for this mailbox.
func (m *Mailbox) Identity() string {
return m.identity
}
// Path returns the JSONL path for legacy mailboxes.
func (m *Mailbox) Path() string {
return m.path
}
// List returns all messages in the mailbox.
// List returns all open messages in the mailbox.
func (m *Mailbox) List() ([]*Message, error) {
if m.legacy {
return m.listLegacy()
}
return m.listBeads()
}
func (m *Mailbox) listBeads() ([]*Message, error) {
// bd mail inbox --json
cmd := exec.Command("bd", "mail", "inbox", "--json")
cmd.Dir = m.workDir
cmd.Env = append(cmd.Environ(), "BD_IDENTITY="+m.identity)
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, errors.New(errMsg)
}
return nil, err
}
// Parse JSON output
var beadsMsgs []BeadsMessage
if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil {
// Empty inbox returns empty array or nothing
if len(stdout.Bytes()) == 0 || string(stdout.Bytes()) == "null" {
return nil, nil
}
return nil, err
}
// Convert to GGT messages
var messages []*Message
for _, bm := range beadsMsgs {
messages = append(messages, bm.ToMessage())
}
return messages, nil
}
func (m *Mailbox) listLegacy() ([]*Message, error) {
file, err := os.Open(m.path)
if err != nil {
if os.IsNotExist(err) {
@@ -68,41 +145,186 @@ func (m *Mailbox) List() ([]*Message, error) {
return messages, nil
}
// ListUnread returns unread messages.
// ListUnread returns unread (open) messages.
func (m *Mailbox) ListUnread() ([]*Message, error) {
all, err := m.List()
if err != nil {
return nil, err
}
var unread []*Message
for _, msg := range all {
if !msg.Read {
unread = append(unread, msg)
if m.legacy {
all, err := m.List()
if err != nil {
return nil, err
}
var unread []*Message
for _, msg := range all {
if !msg.Read {
unread = append(unread, msg)
}
}
return unread, nil
}
return unread, nil
// For beads, inbox only returns open (unread) messages
return m.List()
}
// Get returns a message by ID.
func (m *Mailbox) Get(id string) (*Message, error) {
if m.legacy {
return m.getLegacy(id)
}
return m.getBeads(id)
}
func (m *Mailbox) getBeads(id string) (*Message, error) {
cmd := exec.Command("bd", "mail", "read", id, "--json")
cmd.Dir = m.workDir
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, ErrMessageNotFound
}
if errMsg != "" {
return nil, errors.New(errMsg)
}
return nil, err
}
var bm BeadsMessage
if err := json.Unmarshal(stdout.Bytes(), &bm); err != nil {
return nil, err
}
return bm.ToMessage(), nil
}
func (m *Mailbox) getLegacy(id string) (*Message, error) {
messages, err := m.List()
if err != nil {
return nil, err
}
for _, msg := range messages {
if msg.ID == id {
return msg, nil
}
}
return nil, ErrMessageNotFound
}
// Append adds a message to the mailbox.
// MarkRead marks a message as read.
func (m *Mailbox) MarkRead(id string) error {
if m.legacy {
return m.markReadLegacy(id)
}
return m.markReadBeads(id)
}
func (m *Mailbox) markReadBeads(id string) error {
cmd := exec.Command("bd", "mail", "ack", id)
cmd.Dir = m.workDir
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if strings.Contains(errMsg, "not found") {
return ErrMessageNotFound
}
if errMsg != "" {
return errors.New(errMsg)
}
return err
}
return nil
}
func (m *Mailbox) markReadLegacy(id string) error {
messages, err := m.List()
if err != nil {
return err
}
found := false
for _, msg := range messages {
if msg.ID == id {
msg.Read = true
found = true
}
}
if !found {
return ErrMessageNotFound
}
return m.rewriteLegacy(messages)
}
// Delete removes a message.
func (m *Mailbox) Delete(id string) error {
if m.legacy {
return m.deleteLegacy(id)
}
return m.MarkRead(id) // beads: just acknowledge/close
}
func (m *Mailbox) deleteLegacy(id string) error {
messages, err := m.List()
if err != nil {
return err
}
var filtered []*Message
found := false
for _, msg := range messages {
if msg.ID == id {
found = true
} else {
filtered = append(filtered, msg)
}
}
if !found {
return ErrMessageNotFound
}
return m.rewriteLegacy(filtered)
}
// Count returns the total and unread message counts.
func (m *Mailbox) Count() (total, unread int, err error) {
messages, err := m.List()
if err != nil {
return 0, 0, err
}
total = len(messages)
if m.legacy {
for _, msg := range messages {
if !msg.Read {
unread++
}
}
} else {
// For beads, inbox only returns unread
unread = total
}
return total, unread, nil
}
// Append adds a message to the mailbox (legacy mode only).
// For beads mode, use Router.Send() instead.
func (m *Mailbox) Append(msg *Message) error {
if !m.legacy {
return errors.New("use Router.Send() to send messages via beads")
}
return m.appendLegacy(msg)
}
func (m *Mailbox) appendLegacy(msg *Message) error {
// Ensure directory exists
dir := filepath.Dir(m.path)
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -125,71 +347,8 @@ func (m *Mailbox) Append(msg *Message) error {
return err
}
// MarkRead marks a message as read.
func (m *Mailbox) MarkRead(id string) error {
messages, err := m.List()
if err != nil {
return err
}
found := false
for _, msg := range messages {
if msg.ID == id {
msg.Read = true
found = true
}
}
if !found {
return ErrMessageNotFound
}
return m.rewrite(messages)
}
// Delete removes a message from the mailbox.
func (m *Mailbox) Delete(id string) error {
messages, err := m.List()
if err != nil {
return err
}
var filtered []*Message
found := false
for _, msg := range messages {
if msg.ID == id {
found = true
} else {
filtered = append(filtered, msg)
}
}
if !found {
return ErrMessageNotFound
}
return m.rewrite(filtered)
}
// Count returns the total and unread message counts.
func (m *Mailbox) Count() (total, unread int, err error) {
messages, err := m.List()
if err != nil {
return 0, 0, err
}
total = len(messages)
for _, msg := range messages {
if !msg.Read {
unread++
}
}
return total, unread, nil
}
// rewrite rewrites the mailbox with the given messages.
func (m *Mailbox) rewrite(messages []*Message) error {
// rewriteLegacy rewrites the mailbox with the given messages.
func (m *Mailbox) rewriteLegacy(messages []*Message) error {
// Sort by timestamp (oldest first for JSONL)
sort.Slice(messages, func(i, j int) bool {
return messages[i].Timestamp.Before(messages[j].Timestamp)

View File

@@ -1,39 +1,60 @@
package mail
import (
"bytes"
"errors"
"fmt"
"path/filepath"
"os/exec"
"strings"
"github.com/steveyegge/gastown/internal/tmux"
)
// Router handles message delivery and address resolution.
// Router handles message delivery via beads.
type Router struct {
townRoot string
tmux *tmux.Tmux
workDir string // directory to run bd commands in
tmux *tmux.Tmux
}
// NewRouter creates a new mail router.
func NewRouter(townRoot string) *Router {
// workDir should be a directory containing a .beads database.
func NewRouter(workDir string) *Router {
return &Router{
townRoot: townRoot,
tmux: tmux.NewTmux(),
workDir: workDir,
tmux: tmux.NewTmux(),
}
}
// Send delivers a message to its recipient.
// Send delivers a message via beads mail.
func (r *Router) Send(msg *Message) error {
// Resolve recipient mailbox path
mailboxPath, err := r.ResolveMailbox(msg.To)
if err != nil {
return fmt.Errorf("resolving address '%s': %w", msg.To, err)
// Convert addresses to beads identities
toIdentity := addressToIdentity(msg.To)
fromIdentity := addressToIdentity(msg.From)
// Build command: bd mail send <recipient> -s <subject> -m <body> --identity <sender>
args := []string{"mail", "send", toIdentity,
"-s", msg.Subject,
"-m", msg.Body,
"--identity", fromIdentity,
}
// Append to mailbox
mailbox := NewMailbox(mailboxPath)
if err := mailbox.Append(msg); err != nil {
return fmt.Errorf("delivering message: %w", err)
// Add --urgent flag for high priority
if msg.Priority == PriorityHigh {
args = append(args, "--urgent")
}
cmd := exec.Command("bd", args...)
cmd.Dir = r.workDir
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return errors.New(errMsg)
}
return fmt.Errorf("sending message: %w", err)
}
// Optionally notify if recipient is a polecat with active session
@@ -44,54 +65,9 @@ func (r *Router) Send(msg *Message) error {
return nil
}
// ResolveMailbox converts an address to a mailbox file path.
//
// Address formats:
// - mayor/ → <town>/mayor/mail/inbox.jsonl
// - <rig>/refinery → <town>/<rig>/refinery/mail/inbox.jsonl
// - <rig>/<polecat> → <town>/<rig>/polecats/<polecat>/mail/inbox.jsonl
// - <rig>/ → <town>/<rig>/mail/inbox.jsonl (rig broadcast)
func (r *Router) ResolveMailbox(address string) (string, error) {
address = strings.TrimSpace(address)
if address == "" {
return "", fmt.Errorf("empty address")
}
// Mayor
if address == "mayor/" || address == "mayor" {
return filepath.Join(r.townRoot, "mayor", "mail", "inbox.jsonl"), nil
}
// Parse rig/target
parts := strings.SplitN(address, "/", 2)
if len(parts) < 2 {
return "", fmt.Errorf("invalid address format: %s", address)
}
rig := parts[0]
target := parts[1]
// Rig broadcast (empty target or just /)
if target == "" {
return filepath.Join(r.townRoot, rig, "mail", "inbox.jsonl"), nil
}
// Refinery
if target == "refinery" {
return filepath.Join(r.townRoot, rig, "refinery", "mail", "inbox.jsonl"), nil
}
// Polecat
return filepath.Join(r.townRoot, rig, "polecats", target, "mail", "inbox.jsonl"), nil
}
// GetMailbox returns a Mailbox for the given address.
func (r *Router) GetMailbox(address string) (*Mailbox, error) {
path, err := r.ResolveMailbox(address)
if err != nil {
return nil, err
}
return NewMailbox(path), nil
return NewMailboxFromAddress(address, r.workDir), nil
}
// notifyPolecat sends a notification to a polecat's tmux session.

View File

@@ -1,4 +1,4 @@
// Package mail provides JSONL-based messaging for agent communication.
// Package mail provides messaging for agent communication via beads.
package mail
import (
@@ -19,8 +19,9 @@ const (
)
// Message represents a mail message between agents.
// This is the GGT-side representation; it gets translated to/from beads messages.
type Message struct {
// ID is a unique message identifier.
// ID is a unique message identifier (beads issue ID like "bd-abc123").
ID string `json:"id"`
// From is the sender address (e.g., "gastown/Toast" or "mayor/").
@@ -38,14 +39,14 @@ type Message struct {
// Timestamp is when the message was sent.
Timestamp time.Time `json:"timestamp"`
// Read indicates if the message has been read.
// Read indicates if the message has been read (closed in beads).
Read bool `json:"read"`
// Priority is the message priority.
Priority Priority `json:"priority"`
}
// NewMessage creates a new message with a generated ID.
// NewMessage creates a new message with a generated ID (for legacy JSONL mode).
func NewMessage(from, to, subject, body string) *Message {
return &Message{
ID: generateID(),
@@ -65,3 +66,88 @@ func generateID() string {
rand.Read(b)
return "msg-" + hex.EncodeToString(b)
}
// BeadsMessage represents a message as returned by bd mail commands.
type BeadsMessage struct {
ID string `json:"id"`
Title string `json:"title"` // Subject
Description string `json:"description"` // Body
Sender string `json:"sender"` // From identity
Assignee string `json:"assignee"` // To identity
Priority int `json:"priority"` // 0=urgent, 2=normal
Status string `json:"status"` // open=unread, closed=read
CreatedAt time.Time `json:"created_at"`
}
// ToMessage converts a BeadsMessage to a GGT Message.
func (bm *BeadsMessage) ToMessage() *Message {
priority := PriorityNormal
if bm.Priority == 0 {
priority = PriorityHigh
}
return &Message{
ID: bm.ID,
From: identityToAddress(bm.Sender),
To: identityToAddress(bm.Assignee),
Subject: bm.Title,
Body: bm.Description,
Timestamp: bm.CreatedAt,
Read: bm.Status == "closed",
Priority: priority,
}
}
// addressToIdentity converts a GGT address to a beads identity.
//
// Examples:
// - "mayor/" → "mayor"
// - "gastown/Toast" → "gastown-Toast"
// - "gastown/refinery" → "gastown-refinery"
// - "gastown/" → "gastown" (rig broadcast)
func addressToIdentity(address string) string {
// Trim trailing slash
if len(address) > 0 && address[len(address)-1] == '/' {
address = address[:len(address)-1]
}
// Mayor special case
if address == "mayor" {
return "mayor"
}
// Replace / with - for beads identity
// gastown/Toast → gastown-Toast
result := ""
for _, c := range address {
if c == '/' {
result += "-"
} else {
result = result + string(c)
}
}
return result
}
// identityToAddress converts a beads identity back to a GGT address.
//
// Examples:
// - "mayor" → "mayor/"
// - "gastown-Toast" → "gastown/Toast"
// - "gastown-refinery" → "gastown/refinery"
func identityToAddress(identity string) string {
if identity == "mayor" {
return "mayor/"
}
// Find first dash and replace with /
// gastown-Toast → gastown/Toast
for i, c := range identity {
if c == '-' {
return identity[:i] + "/" + identity[i+1:]
}
}
// No dash found, return as-is with trailing slash
return identity + "/"
}