feat: Add overseer identity for human operator mail support

Adds first-class support for the human overseer in Gas Town mail:

- New OverseerConfig in internal/config/overseer.go with identity
  detection (git config, gh cli, environment)
- Overseer detected/saved on town install (mayor/overseer.json)
- Simplified detectSender(): GT_ROLE set = agent, else = overseer
- New overseer address alongside mayor/ and deacon/
- Added --cc flag to mail send for CC recipients
- Inbox now includes CC'd messages via label query
- gt status shows overseer identity and unread mail count
- New gt whoami command shows current mail identity

Generated with Claude Code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-29 18:02:49 -08:00
parent cab9e10d30
commit a09027043d
8 changed files with 550 additions and 22 deletions

View File

@@ -104,11 +104,45 @@ func (m *Mailbox) listBeads() ([]*Message, error) {
}
// listFromDir queries messages from a beads directory.
// Returns messages where identity is the assignee OR a CC recipient.
func (m *Mailbox) listFromDir(beadsDir string) ([]*Message, error) {
// bd list --type=message --assignee=<identity> --json --status=open
// Query 1: messages where identity is the primary recipient
directMsgs, err := m.queryMessages(beadsDir, "--assignee", m.identity)
if err != nil {
return nil, err
}
// Query 2: messages where identity is CC'd
ccMsgs, err := m.queryMessages(beadsDir, "--label", "cc:"+m.identity)
if err != nil {
// CC query failing is non-fatal, just use direct messages
return directMsgs, nil
}
// Merge and dedupe (a message could theoretically be in both if someone CCs the primary recipient)
seen := make(map[string]bool)
var messages []*Message
for _, msg := range directMsgs {
if !seen[msg.ID] {
seen[msg.ID] = true
messages = append(messages, msg)
}
}
for _, msg := range ccMsgs {
if !seen[msg.ID] {
seen[msg.ID] = true
messages = append(messages, msg)
}
}
return messages, nil
}
// queryMessages runs a bd list query with the given filter flag and value.
func (m *Mailbox) queryMessages(beadsDir, filterFlag, filterValue string) ([]*Message, error) {
cmd := exec.Command("bd", "list",
"--type", "message",
"--assignee", m.identity,
filterFlag, filterValue,
"--status", "open",
"--json",
)

View File

@@ -82,10 +82,10 @@ func (r *Router) resolveBeadsDir(address string) string {
return filepath.Join(r.townRoot, ".beads")
}
// isTownLevelAddress returns true if the address is for a town-level agent.
// isTownLevelAddress returns true if the address is for a town-level agent or the overseer.
func isTownLevelAddress(address string) bool {
addr := strings.TrimSuffix(address, "/")
return addr == "mayor" || addr == "deacon"
return addr == "mayor" || addr == "deacon" || addr == "overseer"
}
// shouldBeWisp determines if a message should be stored as a wisp.
@@ -118,7 +118,7 @@ func (r *Router) Send(msg *Message) error {
// Convert addresses to beads identities
toIdentity := addressToIdentity(msg.To)
// Build labels for from/thread/reply-to
// Build labels for from/thread/reply-to/cc
var labels []string
labels = append(labels, "from:"+msg.From)
if msg.ThreadID != "" {
@@ -127,6 +127,11 @@ func (r *Router) Send(msg *Message) error {
if msg.ReplyTo != "" {
labels = append(labels, "reply-to:"+msg.ReplyTo)
}
// Add CC labels (one per recipient)
for _, cc := range msg.CC {
ccIdentity := addressToIdentity(cc)
labels = append(labels, "cc:"+ccIdentity)
}
// Build command: bd create <subject> --type=message --assignee=<recipient> -d <body>
args := []string{"create", msg.Subject,

View File

@@ -102,6 +102,10 @@ type Message struct {
// Wisp marks this as a transient message (stored in same DB but filtered from JSONL export).
// Wisp messages auto-cleanup on patrol squash.
Wisp bool `json:"wisp,omitempty"`
// CC contains addresses that should receive a copy of this message.
// CC'd recipients see the message in their inbox but are not the primary recipient.
CC []string `json:"cc,omitempty"`
}
// NewMessage creates a new message with a generated ID and thread ID.
@@ -161,7 +165,7 @@ type BeadsMessage struct {
Priority int `json:"priority"` // 0=urgent, 1=high, 2=normal, 3=low
Status string `json:"status"` // open=unread, closed=read
CreatedAt time.Time `json:"created_at"`
Labels []string `json:"labels"` // Metadata labels (from:X, thread:X, reply-to:X, msg-type:X)
Labels []string `json:"labels"` // Metadata labels (from:X, thread:X, reply-to:X, msg-type:X, cc:X)
Pinned bool `json:"pinned,omitempty"`
Wisp bool `json:"wisp,omitempty"` // Ephemeral message (filtered from JSONL export)
@@ -170,6 +174,7 @@ type BeadsMessage struct {
threadID string
replyTo string
msgType string
cc []string // CC recipients
}
// ParseLabels extracts metadata from the labels array.
@@ -183,10 +188,27 @@ func (bm *BeadsMessage) ParseLabels() {
bm.replyTo = strings.TrimPrefix(label, "reply-to:")
} else if strings.HasPrefix(label, "msg-type:") {
bm.msgType = strings.TrimPrefix(label, "msg-type:")
} else if strings.HasPrefix(label, "cc:") {
bm.cc = append(bm.cc, strings.TrimPrefix(label, "cc:"))
}
}
}
// GetCC returns the parsed CC recipients.
func (bm *BeadsMessage) GetCC() []string {
return bm.cc
}
// IsCCRecipient checks if the given identity is in the CC list.
func (bm *BeadsMessage) IsCCRecipient(identity string) bool {
for _, cc := range bm.cc {
if cc == identity {
return true
}
}
return false
}
// ToMessage converts a BeadsMessage to a GGT Message.
func (bm *BeadsMessage) ToMessage() *Message {
// Parse labels to extract metadata
@@ -212,6 +234,12 @@ func (bm *BeadsMessage) ToMessage() *Message {
msgType = MessageType(bm.msgType)
}
// Convert CC identities to addresses
var ccAddrs []string
for _, cc := range bm.cc {
ccAddrs = append(ccAddrs, identityToAddress(cc))
}
return &Message{
ID: bm.ID,
From: identityToAddress(bm.sender),
@@ -225,6 +253,7 @@ func (bm *BeadsMessage) ToMessage() *Message {
ThreadID: bm.threadID,
ReplyTo: bm.replyTo,
Wisp: bm.Wisp,
CC: ccAddrs,
}
}
@@ -287,6 +316,7 @@ func ParseMessageType(s string) MessageType {
// to canonical form (Postel's Law - be liberal in what you accept).
//
// Addresses use slash format:
// - "overseer" → "overseer" (human operator, no trailing slash)
// - "mayor/" → "mayor/"
// - "mayor" → "mayor/"
// - "deacon/" → "deacon/"
@@ -297,6 +327,11 @@ func ParseMessageType(s string) MessageType {
// - "gastown/refinery" → "gastown/refinery"
// - "gastown/" → "gastown" (rig broadcast)
func addressToIdentity(address string) string {
// Overseer (human operator) - no trailing slash, distinct from agents
if address == "overseer" {
return "overseer"
}
// Town-level agents: mayor and deacon keep trailing slash
if address == "mayor" || address == "mayor/" {
return "mayor/"
@@ -324,6 +359,7 @@ func addressToIdentity(address string) string {
// identityToAddress converts a beads identity back to a GGT address.
//
// Liberal normalization (Postel's Law):
// - "overseer" → "overseer" (human operator)
// - "mayor/" → "mayor/"
// - "deacon/" → "deacon/"
// - "gastown/polecats/Toast" → "gastown/Toast" (normalized)
@@ -331,6 +367,11 @@ func addressToIdentity(address string) string {
// - "gastown/Toast" → "gastown/Toast" (already canonical)
// - "gastown/refinery" → "gastown/refinery"
func identityToAddress(identity string) string {
// Overseer (human operator) - no trailing slash
if identity == "overseer" {
return "overseer"
}
// Town-level agents ensure trailing slash
if identity == "mayor" || identity == "mayor/" {
return "mayor/"