feat: mail uses wisps for ephemeral orchestration messages (gt-lg66)

Add dual-inbox architecture where ephemeral messages go to
.beads-wisp/.beads/ instead of .beads/. Lifecycle messages
(POLECAT_STARTED, NUDGE, etc.) auto-detect as ephemeral.

Changes:
- Add Ephemeral and Source fields to mail.Message
- Add --ephemeral flag to gt mail send
- Router auto-detects lifecycle message patterns
- Mailbox merges messages from both persistent and wisp storage
- Inbox displays (ephemeral) indicator for wisp messages
- Delete/archive works for both message types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-24 20:03:37 -08:00
parent 9d1ebfc54d
commit 200a09a02d
5 changed files with 166 additions and 19 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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=<identity> --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

View File

@@ -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(),

View File

@@ -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.