diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 6249605d..2f227567 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -22,6 +22,7 @@ var ( mailPriority int mailUrgent bool mailPinned bool + mailEphemeral bool mailType string mailReplyTo string mailNotify bool @@ -235,6 +236,7 @@ func init() { 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(&mailEphemeral, "ephemeral", false, "Send as ephemeral wisp (auto-cleanup on patrol squash)") mailSendCmd.Flags().BoolVar(&mailSendSelf, "self", false, "Send to self (auto-detect from cwd)") _ = mailSendCmd.MarkFlagRequired("subject") @@ -332,6 +334,9 @@ func runMailSend(cmd *cobra.Command, args []string) error { // Set pinned flag msg.Pinned = mailPinned + // Set ephemeral flag + msg.Ephemeral = mailEphemeral + // Handle reply-to: auto-set type to reply and look up thread if mailReplyTo != "" { msg.ReplyTo = mailReplyTo @@ -434,8 +439,12 @@ func runMailInbox(cmd *cobra.Command, args []string) error { if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent { priorityMarker = " " + style.Bold.Render("!") } + ephemeralMarker := "" + if msg.Ephemeral || msg.Source == mail.SourceWisp { + ephemeralMarker = " " + style.Dim.Render("(ephemeral)") + } - fmt.Printf(" %s %s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker) + fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, ephemeralMarker) fmt.Printf(" %s from %s\n", style.Dim.Render(msg.ID), msg.From) diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 05d828c8..b8ac741c 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -395,13 +395,14 @@ func runSpawn(cmd *cobra.Command, args []string) error { sender := detectSender() sessionName := sessMgr.SessionName(polecatName) - // Notify Witness with POLECAT_STARTED message + // Notify Witness with POLECAT_STARTED message (ephemeral - lifecycle ping) witnessAddr := fmt.Sprintf("%s/witness", rigName) witnessNotification := &mail.Message{ - To: witnessAddr, - From: sender, - Subject: fmt.Sprintf("POLECAT_STARTED %s", polecatName), - Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), + To: witnessAddr, + From: sender, + Subject: fmt.Sprintf("POLECAT_STARTED %s", polecatName), + Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), + Ephemeral: true, } if err := townRouter.Send(witnessNotification); err != nil { @@ -410,13 +411,14 @@ func runSpawn(cmd *cobra.Command, args []string) error { fmt.Printf(" %s\n", style.Dim.Render("Witness notified of polecat start")) } - // Notify Deacon with POLECAT_STARTED message (includes full rig/polecat address) + // Notify Deacon with POLECAT_STARTED message (ephemeral - lifecycle ping) deaconAddr := "deacon/" deaconNotification := &mail.Message{ - To: deaconAddr, - From: sender, - Subject: fmt.Sprintf("POLECAT_STARTED %s/%s", rigName, polecatName), - Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), + To: deaconAddr, + From: sender, + Subject: fmt.Sprintf("POLECAT_STARTED %s/%s", rigName, polecatName), + Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), + Ephemeral: true, } if err := townRouter.Send(deaconNotification); err != nil { diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index 58a36b77..8a5bb596 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -23,6 +23,7 @@ type Mailbox struct { identity string // beads identity (e.g., "gastown/polecats/Toast") workDir string // directory to run bd commands in beadsDir string // explicit .beads directory path (set via BEADS_DIR) + wispDir string // .beads-wisp directory for ephemeral messages path string // for legacy JSONL mode (crew workers) legacy bool // true = use JSONL files, false = use beads } @@ -48,20 +49,26 @@ func NewMailboxBeads(identity, workDir string) *Mailbox { // NewMailboxFromAddress creates a beads-backed mailbox from a GGT address. func NewMailboxFromAddress(address, workDir string) *Mailbox { beadsDir := filepath.Join(workDir, ".beads") + // Wisp directory is .beads-wisp/.beads (bd init creates .beads/ subdirectory) + wispDir := filepath.Join(workDir, ".beads-wisp", ".beads") return &Mailbox{ identity: addressToIdentity(address), workDir: workDir, beadsDir: beadsDir, + wispDir: wispDir, legacy: false, } } // NewMailboxWithBeadsDir creates a mailbox with an explicit beads directory. func NewMailboxWithBeadsDir(address, workDir, beadsDir string) *Mailbox { + // Derive wispDir from beadsDir (.beads-wisp/.beads sibling structure) + wispDir := filepath.Join(filepath.Dir(beadsDir), ".beads-wisp", ".beads") return &Mailbox{ identity: addressToIdentity(address), workDir: workDir, beadsDir: beadsDir, + wispDir: wispDir, legacy: false, } } @@ -85,6 +92,29 @@ func (m *Mailbox) List() ([]*Message, error) { } func (m *Mailbox) listBeads() ([]*Message, error) { + // Query persistent beads + persistentMsgs, err := m.listFromDir(m.beadsDir, SourcePersistent) + if err != nil { + return nil, err + } + + // Query wisp beads (ignore errors for missing dir) + var wispMsgs []*Message + if m.wispDir != "" { + wispMsgs, _ = m.listFromDir(m.wispDir, SourceWisp) + } + + // Merge and sort by timestamp (newest first) + all := append(persistentMsgs, wispMsgs...) + sort.Slice(all, func(i, j int) bool { + return all[i].Timestamp.After(all[j].Timestamp) + }) + + return all, nil +} + +// listFromDir queries messages from a specific beads directory. +func (m *Mailbox) listFromDir(beadsDir string, source MessageSource) ([]*Message, error) { // bd list --type=message --assignee= --json --status=open cmd := exec.Command("bd", "list", "--type", "message", @@ -94,7 +124,7 @@ func (m *Mailbox) listBeads() ([]*Message, error) { ) cmd.Dir = m.workDir cmd.Env = append(cmd.Environ(), - "BEADS_DIR="+m.beadsDir, + "BEADS_DIR="+beadsDir, ) var stdout, stderr bytes.Buffer @@ -119,10 +149,13 @@ func (m *Mailbox) listBeads() ([]*Message, error) { return nil, err } - // Convert to GGT messages + // Convert to GGT messages and set source var messages []*Message for _, bm := range beadsMsgs { - messages = append(messages, bm.ToMessage()) + msg := bm.ToMessage() + msg.Source = source + msg.Ephemeral = (source == SourceWisp) + messages = append(messages, msg) } return messages, nil @@ -193,9 +226,28 @@ func (m *Mailbox) Get(id string) (*Message, error) { } func (m *Mailbox) getBeads(id string) (*Message, error) { + // Try persistent first + msg, err := m.getFromDir(id, m.beadsDir, SourcePersistent) + if err == nil { + return msg, nil + } + + // Try wisp storage + if m.wispDir != "" { + msg, err = m.getFromDir(id, m.wispDir, SourceWisp) + if err == nil { + return msg, nil + } + } + + return nil, ErrMessageNotFound +} + +// getFromDir retrieves a message from a specific beads directory. +func (m *Mailbox) getFromDir(id, beadsDir string, source MessageSource) (*Message, error) { cmd := exec.Command("bd", "show", id, "--json") cmd.Dir = m.workDir - cmd.Env = append(cmd.Environ(), "BEADS_DIR="+m.beadsDir) + cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -221,7 +273,10 @@ func (m *Mailbox) getBeads(id string) (*Message, error) { return nil, ErrMessageNotFound } - return bms[0].ToMessage(), nil + msg := bms[0].ToMessage() + msg.Source = source + msg.Ephemeral = (source == SourceWisp) + return msg, nil } func (m *Mailbox) getLegacy(id string) (*Message, error) { @@ -246,9 +301,28 @@ func (m *Mailbox) MarkRead(id string) error { } func (m *Mailbox) markReadBeads(id string) error { + // Try persistent first + err := m.closeInDir(id, m.beadsDir) + if err == nil { + return nil + } + + // Try wisp storage + if m.wispDir != "" { + err = m.closeInDir(id, m.wispDir) + if err == nil { + return nil + } + } + + return ErrMessageNotFound +} + +// closeInDir closes a message in a specific beads directory. +func (m *Mailbox) closeInDir(id, beadsDir string) error { cmd := exec.Command("bd", "close", id) cmd.Dir = m.workDir - cmd.Env = append(cmd.Environ(), "BEADS_DIR="+m.beadsDir) + cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) var stderr bytes.Buffer cmd.Stderr = &stderr diff --git a/internal/mail/router.go b/internal/mail/router.go index e2481b50..092e5f61 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -88,6 +88,40 @@ func isTownLevelAddress(address string) bool { return addr == "mayor" || addr == "deacon" } +// resolveWispDir returns the .beads-wisp/.beads directory for ephemeral mail. +// Like resolveBeadsDir, mail wisps use town-level storage. +// Note: bd init creates .beads/ subdirectory, so the full path is .beads-wisp/.beads +func (r *Router) resolveWispDir() string { + if r.townRoot == "" { + return filepath.Join(r.workDir, ".beads-wisp", ".beads") + } + return filepath.Join(r.townRoot, ".beads-wisp", ".beads") +} + +// shouldBeEphemeral determines if a message should be stored as a wisp. +// Returns true if: +// - Message.Ephemeral is explicitly set +// - Subject matches lifecycle message patterns (POLECAT_*, NUDGE, etc.) +func (r *Router) shouldBeEphemeral(msg *Message) bool { + if msg.Ephemeral { + return true + } + // Auto-detect lifecycle messages by subject prefix + subjectLower := strings.ToLower(msg.Subject) + ephemeralPrefixes := []string{ + "polecat_started", + "polecat_done", + "start_work", + "nudge", + } + for _, prefix := range ephemeralPrefixes { + if strings.HasPrefix(subjectLower, prefix) { + return true + } + } + return false +} + // Send delivers a message via beads message. // Routes the message to the correct beads database based on recipient address. func (r *Router) Send(msg *Message) error { @@ -120,8 +154,17 @@ func (r *Router) Send(msg *Message) error { args = append(args, "--labels", strings.Join(labels, ",")) } - // Resolve the correct beads directory for the recipient - beadsDir := r.resolveBeadsDir(msg.To) + // Resolve the correct beads directory based on ephemeral status + var beadsDir string + if r.shouldBeEphemeral(msg) { + beadsDir = r.resolveWispDir() + // Ensure wisp directory exists + if err := os.MkdirAll(beadsDir, 0755); err != nil { + return fmt.Errorf("creating wisp dir: %w", err) + } + } else { + beadsDir = r.resolveBeadsDir(msg.To) + } cmd := exec.Command("bd", args...) cmd.Env = append(cmd.Environ(), diff --git a/internal/mail/types.go b/internal/mail/types.go index ffe2ec25..b3e9e723 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -28,6 +28,17 @@ const ( // MessageType indicates the purpose of a message. type MessageType string +// MessageSource indicates where a message is stored. +type MessageSource string + +const ( + // SourcePersistent indicates the message is in permanent .beads storage. + SourcePersistent MessageSource = "persistent" + + // SourceWisp indicates the message is in ephemeral .beads-wisp storage. + SourceWisp MessageSource = "wisp" +) + const ( // TypeTask indicates a message requiring action from the recipient. TypeTask MessageType = "task" @@ -97,6 +108,14 @@ type Message struct { // Pinned marks the message as pinned (won't be auto-archived). Pinned bool `json:"pinned,omitempty"` + + // Ephemeral marks this as a transient message stored in wisps. + // Ephemeral messages auto-cleanup on patrol squash. + Ephemeral bool `json:"ephemeral,omitempty"` + + // Source indicates where this message is stored (persistent or wisp). + // Set during List(), not serialized. + Source MessageSource `json:"-"` } // NewMessage creates a new message with a generated ID and thread ID.