diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 1549a899..8efb6a72 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -180,6 +180,19 @@ func runInstall(cmd *cobra.Command, args []string) error { // these global agent beads in its beads database. } + // Detect and save overseer identity + overseer, err := config.DetectOverseer(absPath) + if err != nil { + fmt.Printf(" %s Could not detect overseer identity: %v\n", style.Dim.Render("⚠"), err) + } else { + overseerPath := config.OverseerConfigPath(absPath) + if err := config.SaveOverseerConfig(overseerPath, overseer); err != nil { + fmt.Printf(" %s Could not save overseer config: %v\n", style.Dim.Render("⚠"), err) + } else { + fmt.Printf(" ✓ Detected overseer: %s (via %s)\n", overseer.FormatOverseerIdentity(), overseer.Source) + } + } + // Initialize git if requested (--git or --github implies --git) if installGit || installGitHub != "" { fmt.Println() diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 5ee667e4..30d58a21 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -28,6 +28,7 @@ var ( mailReplyTo string mailNotify bool mailSendSelf bool + mailCC []string // CC recipients mailInboxJSON bool mailReadJSON bool mailInboxUnread bool @@ -114,7 +115,8 @@ Examples: gt mail send gastown/Toast -s "Task" -m "Fix bug" --type task --priority 1 gt mail send gastown/Toast -s "Urgent" -m "Help!" --urgent gt mail send mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123 - gt mail send --self -s "Handoff" -m "Context for next session"`, + gt mail send --self -s "Handoff" -m "Context for next session" + gt mail send gastown/Toast -s "Update" -m "Progress report" --cc overseer`, Args: cobra.MaximumNArgs(1), RunE: runMailSend, } @@ -241,6 +243,7 @@ func init() { mailSendCmd.Flags().BoolVar(&mailWisp, "wisp", true, "Send as wisp (ephemeral, default)") mailSendCmd.Flags().BoolVar(&mailPermanent, "permanent", false, "Send as permanent (not ephemeral, synced to remote)") mailSendCmd.Flags().BoolVar(&mailSendSelf, "self", false, "Send to self (auto-detect from cwd)") + mailSendCmd.Flags().StringArrayVar(&mailCC, "cc", nil, "CC recipients (can be used multiple times)") _ = mailSendCmd.MarkFlagRequired("subject") // cobra flags: error only at runtime if missing // Inbox flags @@ -350,6 +353,9 @@ func runMailSend(cmd *cobra.Command, args []string) error { // Set wisp flag (ephemeral message) - default true, --permanent overrides msg.Wisp = mailWisp && !mailPermanent + // Set CC recipients + msg.CC = mailCC + // Handle reply-to: auto-set type to reply and look up thread if mailReplyTo != "" { msg.ReplyTo = mailReplyTo @@ -380,6 +386,9 @@ func runMailSend(cmd *cobra.Command, args []string) error { fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to) fmt.Printf(" Subject: %s\n", mailSubject) + if len(msg.CC) > 0 { + fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", ")) + } if msg.Type != mail.TypeNotification { fmt.Printf(" Type: %s\n", msg.Type) } @@ -689,19 +698,77 @@ func findLocalBeadsDir() (string, error) { } // detectSender determines the current context's address. +// Priority: +// 1. GT_ROLE env var → use the role-based identity (agent session) +// 2. No GT_ROLE → return "overseer" (human at terminal) +// +// All Gas Town agents run in tmux sessions with GT_ROLE set at spawn. +// Humans in regular terminals won't have GT_ROLE, so they're the overseer. func detectSender() string { - // Check environment variables (set by session start) - rig := os.Getenv("GT_RIG") - polecat := os.Getenv("GT_POLECAT") - - if rig != "" && polecat != "" { - return fmt.Sprintf("%s/%s", rig, polecat) + // Check GT_ROLE first (authoritative for agent sessions) + role := os.Getenv("GT_ROLE") + if role != "" { + // Agent session - build address from role and context + return detectSenderFromRole(role) } - // Check current directory + // No GT_ROLE means human at terminal - they're the overseer + return "overseer" +} + +// detectSenderFromRole builds an address from the GT_ROLE and related env vars. +// GT_ROLE can be either a simple role name ("crew", "polecat") or a full address +// ("gastown/crew/joe") depending on how the session was started. +func detectSenderFromRole(role string) string { + rig := os.Getenv("GT_RIG") + + // Check if role is already a full address (contains /) + if strings.Contains(role, "/") { + // GT_ROLE is already a full address, use it directly + return role + } + + // GT_ROLE is a simple role name, build the full address + switch role { + case "mayor": + return "mayor/" + case "deacon": + return "deacon/" + case "polecat": + polecat := os.Getenv("GT_POLECAT") + if rig != "" && polecat != "" { + return fmt.Sprintf("%s/%s", rig, polecat) + } + // Fallback to cwd detection for polecats + return detectSenderFromCwd() + case "crew": + crew := os.Getenv("GT_CREW") + if rig != "" && crew != "" { + return fmt.Sprintf("%s/crew/%s", rig, crew) + } + // Fallback to cwd detection for crew + return detectSenderFromCwd() + case "witness": + if rig != "" { + return fmt.Sprintf("%s/witness", rig) + } + return detectSenderFromCwd() + case "refinery": + if rig != "" { + return fmt.Sprintf("%s/refinery", rig) + } + return detectSenderFromCwd() + default: + // Unknown role, try cwd detection + return detectSenderFromCwd() + } +} + +// detectSenderFromCwd is the legacy cwd-based detection for edge cases. +func detectSenderFromCwd() string { cwd, err := os.Getwd() if err != nil { - return "mayor/" + return "overseer" } // If in a rig's polecats directory, extract address (format: rig/polecats/name) @@ -744,8 +811,8 @@ func detectSender() string { } } - // Default to mayor - return "mayor/" + // Default to overseer (human) + return "overseer" } func runMailCheck(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 9f971643..7345ffc0 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -40,11 +40,21 @@ func init() { // TownStatus represents the overall status of the workspace. type TownStatus struct { - Name string `json:"name"` - Location string `json:"location"` - Agents []AgentRuntime `json:"agents"` // Global agents (Mayor, Deacon) - Rigs []RigStatus `json:"rigs"` - Summary StatusSum `json:"summary"` + Name string `json:"name"` + Location string `json:"location"` + Overseer *OverseerInfo `json:"overseer,omitempty"` // Human operator + Agents []AgentRuntime `json:"agents"` // Global agents (Mayor, Deacon) + Rigs []RigStatus `json:"rigs"` + Summary StatusSum `json:"summary"` +} + +// OverseerInfo represents the human operator's identity and status. +type OverseerInfo struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + Username string `json:"username,omitempty"` + Source string `json:"source"` + UnreadMail int `json:"unread_mail"` } // AgentRuntime represents the runtime state of an agent. @@ -137,10 +147,27 @@ func runStatus(cmd *cobra.Command, args []string) error { // Create mail router for inbox lookups mailRouter := mail.NewRouter(townRoot) + // Load overseer config + var overseerInfo *OverseerInfo + if overseerConfig, err := config.LoadOrDetectOverseer(townRoot); err == nil && overseerConfig != nil { + overseerInfo = &OverseerInfo{ + Name: overseerConfig.Name, + Email: overseerConfig.Email, + Username: overseerConfig.Username, + Source: overseerConfig.Source, + } + // Get overseer mail count + if mailbox, err := mailRouter.GetMailbox("overseer"); err == nil { + _, unread, _ := mailbox.Count() + overseerInfo.UnreadMail = unread + } + } + // Build status status := TownStatus{ Name: townConfig.Name, Location: townRoot, + Overseer: overseerInfo, Agents: discoverGlobalAgents(t, agentBeads, mailRouter), Rigs: make([]RigStatus, 0, len(rigs)), } @@ -207,6 +234,21 @@ func outputStatusText(status TownStatus) error { fmt.Printf("%s %s\n", style.Bold.Render("Town:"), status.Name) fmt.Printf("%s\n\n", style.Dim.Render(status.Location)) + // Overseer info + if status.Overseer != nil { + overseerDisplay := status.Overseer.Name + if status.Overseer.Email != "" { + overseerDisplay = fmt.Sprintf("%s <%s>", status.Overseer.Name, status.Overseer.Email) + } else if status.Overseer.Username != "" && status.Overseer.Username != status.Overseer.Name { + overseerDisplay = fmt.Sprintf("%s (@%s)", status.Overseer.Name, status.Overseer.Username) + } + fmt.Printf("👤 %s %s\n", style.Bold.Render("Overseer:"), overseerDisplay) + if status.Overseer.UnreadMail > 0 { + fmt.Printf(" 📬 %d unread\n", status.Overseer.UnreadMail) + } + fmt.Println() + } + // Role icons roleIcons := map[string]string{ "mayor": "🎩", diff --git a/internal/cmd/whoami.go b/internal/cmd/whoami.go new file mode 100644 index 00000000..a1fc1708 --- /dev/null +++ b/internal/cmd/whoami.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +var whoamiCmd = &cobra.Command{ + Use: "whoami", + GroupID: GroupDiag, + Short: "Show current identity for mail commands", + Long: `Show the identity that will be used for mail commands. + +Identity is determined by: +1. GT_ROLE env var (if set) - indicates an agent session +2. No GT_ROLE - you are the overseer (human) + +Use --identity flag with mail commands to override. + +Examples: + gt whoami # Show current identity + gt mail inbox # Check inbox for current identity + gt mail inbox --identity mayor/ # Check Mayor's inbox instead`, + RunE: runWhoami, +} + +func init() { + rootCmd.AddCommand(whoamiCmd) +} + +func runWhoami(cmd *cobra.Command, args []string) error { + // Get current identity using same logic as mail commands + identity := detectSender() + + fmt.Printf("%s %s\n", style.Bold.Render("Identity:"), identity) + + // Show how it was determined + gtRole := os.Getenv("GT_ROLE") + if gtRole != "" { + fmt.Printf("%s GT_ROLE=%s\n", style.Dim.Render("Source:"), gtRole) + + // Show additional env vars if present + if rig := os.Getenv("GT_RIG"); rig != "" { + fmt.Printf("%s GT_RIG=%s\n", style.Dim.Render(" "), rig) + } + if polecat := os.Getenv("GT_POLECAT"); polecat != "" { + fmt.Printf("%s GT_POLECAT=%s\n", style.Dim.Render(" "), polecat) + } + if crew := os.Getenv("GT_CREW"); crew != "" { + fmt.Printf("%s GT_CREW=%s\n", style.Dim.Render(" "), crew) + } + } else { + fmt.Printf("%s no GT_ROLE set (human at terminal)\n", style.Dim.Render("Source:")) + + // If overseer, show their configured identity + if identity == "overseer" { + townRoot, err := workspace.FindFromCwd() + if err == nil && townRoot != "" { + if overseerConfig, err := config.LoadOverseerConfig(config.OverseerConfigPath(townRoot)); err == nil { + fmt.Printf("\n%s\n", style.Bold.Render("Overseer Identity:")) + fmt.Printf(" Name: %s\n", overseerConfig.Name) + if overseerConfig.Email != "" { + fmt.Printf(" Email: %s\n", overseerConfig.Email) + } + if overseerConfig.Username != "" { + fmt.Printf(" User: %s\n", overseerConfig.Username) + } + fmt.Printf(" %s %s\n", style.Dim.Render("(detected via"), style.Dim.Render(overseerConfig.Source+")")) + } + } + } + } + + return nil +} diff --git a/internal/config/overseer.go b/internal/config/overseer.go new file mode 100644 index 00000000..c58045a8 --- /dev/null +++ b/internal/config/overseer.go @@ -0,0 +1,246 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// OverseerConfig represents the human operator's identity (mayor/overseer.json). +// The overseer is the human who controls Gas Town, distinct from AI agents. +type OverseerConfig struct { + Type string `json:"type"` // "overseer" + Version int `json:"version"` // schema version + Name string `json:"name"` // display name + Email string `json:"email,omitempty"` // email address + Username string `json:"username,omitempty"` // username/handle + Source string `json:"source"` // how identity was detected +} + +// CurrentOverseerVersion is the current schema version for OverseerConfig. +const CurrentOverseerVersion = 1 + +// OverseerConfigPath returns the standard path for overseer config in a town. +func OverseerConfigPath(townRoot string) string { + return filepath.Join(townRoot, "mayor", "overseer.json") +} + +// LoadOverseerConfig loads and validates an overseer configuration file. +func LoadOverseerConfig(path string) (*OverseerConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotFound, path) + } + return nil, fmt.Errorf("reading overseer config: %w", err) + } + + var config OverseerConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("parsing overseer config: %w", err) + } + + if err := validateOverseerConfig(&config); err != nil { + return nil, err + } + + return &config, nil +} + +// SaveOverseerConfig saves an overseer configuration to a file. +func SaveOverseerConfig(path string, config *OverseerConfig) error { + if err := validateOverseerConfig(config); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("encoding overseer config: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing overseer config: %w", err) + } + + return nil +} + +// validateOverseerConfig validates an OverseerConfig. +func validateOverseerConfig(c *OverseerConfig) error { + if c.Type != "overseer" && c.Type != "" { + return fmt.Errorf("%w: expected type 'overseer', got '%s'", ErrInvalidType, c.Type) + } + if c.Version > CurrentOverseerVersion { + return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentOverseerVersion) + } + if c.Name == "" { + return fmt.Errorf("%w: name", ErrMissingField) + } + return nil +} + +// DetectOverseer attempts to detect the overseer's identity from available sources. +// Priority order: +// 1. Existing config file (if path provided and exists) +// 2. Git config (user.name + user.email) +// 3. GitHub CLI (gh api user) +// 4. Environment ($USER or whoami) +func DetectOverseer(townRoot string) (*OverseerConfig, error) { + configPath := OverseerConfigPath(townRoot) + + // Priority 1: Check existing config + if existing, err := LoadOverseerConfig(configPath); err == nil { + return existing, nil + } + + // Priority 2: Try git config + if config := detectFromGitConfig(townRoot); config != nil { + return config, nil + } + + // Priority 3: Try GitHub CLI + if config := detectFromGitHub(); config != nil { + return config, nil + } + + // Priority 4: Fall back to environment + return detectFromEnvironment(), nil +} + +// detectFromGitConfig attempts to get identity from git config. +func detectFromGitConfig(dir string) *OverseerConfig { + // Try to get user.name + nameCmd := exec.Command("git", "config", "user.name") + nameCmd.Dir = dir + nameOut, err := nameCmd.Output() + if err != nil { + return nil + } + name := strings.TrimSpace(string(nameOut)) + if name == "" { + return nil + } + + config := &OverseerConfig{ + Type: "overseer", + Version: CurrentOverseerVersion, + Name: name, + Source: "git-config", + } + + // Try to get user.email (optional) + emailCmd := exec.Command("git", "config", "user.email") + emailCmd.Dir = dir + if emailOut, err := emailCmd.Output(); err == nil { + config.Email = strings.TrimSpace(string(emailOut)) + } + + // Extract username from email if available + if config.Email != "" { + if idx := strings.Index(config.Email, "@"); idx > 0 { + config.Username = config.Email[:idx] + } + } + + return config +} + +// detectFromGitHub attempts to get identity from GitHub CLI. +func detectFromGitHub() *OverseerConfig { + cmd := exec.Command("gh", "api", "user", "--jq", ".login + \"|\" + .name + \"|\" + .email") + out, err := cmd.Output() + if err != nil { + return nil + } + + parts := strings.Split(strings.TrimSpace(string(out)), "|") + if len(parts) < 1 || parts[0] == "" { + return nil + } + + config := &OverseerConfig{ + Type: "overseer", + Version: CurrentOverseerVersion, + Username: parts[0], + Source: "github-cli", + } + + // Use name if available, otherwise username + if len(parts) >= 2 && parts[1] != "" { + config.Name = parts[1] + } else { + config.Name = parts[0] + } + + // Add email if available + if len(parts) >= 3 && parts[2] != "" { + config.Email = parts[2] + } + + return config +} + +// detectFromEnvironment falls back to environment variables. +func detectFromEnvironment() *OverseerConfig { + username := os.Getenv("USER") + if username == "" { + // Try whoami as last resort + if out, err := exec.Command("whoami").Output(); err == nil { + username = strings.TrimSpace(string(out)) + } + } + if username == "" { + username = "overseer" + } + + return &OverseerConfig{ + Type: "overseer", + Version: CurrentOverseerVersion, + Name: username, + Username: username, + Source: "environment", + } +} + +// LoadOrDetectOverseer loads existing config or detects and saves a new one. +func LoadOrDetectOverseer(townRoot string) (*OverseerConfig, error) { + configPath := OverseerConfigPath(townRoot) + + // Try loading existing + if config, err := LoadOverseerConfig(configPath); err == nil { + return config, nil + } + + // Detect new + config, err := DetectOverseer(townRoot) + if err != nil { + return nil, err + } + + // Save for next time + if err := SaveOverseerConfig(configPath, config); err != nil { + // Non-fatal - we can still use the detected config + fmt.Fprintf(os.Stderr, "warning: could not save overseer config: %v\n", err) + } + + return config, nil +} + +// FormatOverseerIdentity returns a formatted string for display. +// Example: "Steve Yegge " +func (c *OverseerConfig) FormatOverseerIdentity() string { + if c.Email != "" { + return fmt.Sprintf("%s <%s>", c.Name, c.Email) + } + if c.Username != "" && c.Username != c.Name { + return fmt.Sprintf("%s (@%s)", c.Name, c.Username) + } + return c.Name +} diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index 0cb5c282..e68fdb11 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -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= --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", ) diff --git a/internal/mail/router.go b/internal/mail/router.go index 1041d5ca..066f1da2 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -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 --type=message --assignee= -d args := []string{"create", msg.Subject, diff --git a/internal/mail/types.go b/internal/mail/types.go index 01a00f96..8d715f3a 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -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/"