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:
@@ -180,6 +180,19 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
|||||||
// these global agent beads in its beads database.
|
// 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)
|
// Initialize git if requested (--git or --github implies --git)
|
||||||
if installGit || installGitHub != "" {
|
if installGit || installGitHub != "" {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ var (
|
|||||||
mailReplyTo string
|
mailReplyTo string
|
||||||
mailNotify bool
|
mailNotify bool
|
||||||
mailSendSelf bool
|
mailSendSelf bool
|
||||||
|
mailCC []string // CC recipients
|
||||||
mailInboxJSON bool
|
mailInboxJSON bool
|
||||||
mailReadJSON bool
|
mailReadJSON bool
|
||||||
mailInboxUnread 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 "Task" -m "Fix bug" --type task --priority 1
|
||||||
gt mail send gastown/Toast -s "Urgent" -m "Help!" --urgent
|
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 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),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: runMailSend,
|
RunE: runMailSend,
|
||||||
}
|
}
|
||||||
@@ -241,6 +243,7 @@ func init() {
|
|||||||
mailSendCmd.Flags().BoolVar(&mailWisp, "wisp", true, "Send as wisp (ephemeral, default)")
|
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(&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().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
|
_ = mailSendCmd.MarkFlagRequired("subject") // cobra flags: error only at runtime if missing
|
||||||
|
|
||||||
// Inbox flags
|
// Inbox flags
|
||||||
@@ -350,6 +353,9 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
|||||||
// Set wisp flag (ephemeral message) - default true, --permanent overrides
|
// Set wisp flag (ephemeral message) - default true, --permanent overrides
|
||||||
msg.Wisp = mailWisp && !mailPermanent
|
msg.Wisp = mailWisp && !mailPermanent
|
||||||
|
|
||||||
|
// Set CC recipients
|
||||||
|
msg.CC = mailCC
|
||||||
|
|
||||||
// Handle reply-to: auto-set type to reply and look up thread
|
// Handle reply-to: auto-set type to reply and look up thread
|
||||||
if mailReplyTo != "" {
|
if mailReplyTo != "" {
|
||||||
msg.ReplyTo = 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("%s Message sent to %s\n", style.Bold.Render("✓"), to)
|
||||||
fmt.Printf(" Subject: %s\n", mailSubject)
|
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 {
|
if msg.Type != mail.TypeNotification {
|
||||||
fmt.Printf(" Type: %s\n", msg.Type)
|
fmt.Printf(" Type: %s\n", msg.Type)
|
||||||
}
|
}
|
||||||
@@ -689,19 +698,77 @@ func findLocalBeadsDir() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectSender determines the current context's address.
|
// 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 {
|
func detectSender() string {
|
||||||
// Check environment variables (set by session start)
|
// Check GT_ROLE first (authoritative for agent sessions)
|
||||||
rig := os.Getenv("GT_RIG")
|
role := os.Getenv("GT_ROLE")
|
||||||
polecat := os.Getenv("GT_POLECAT")
|
if role != "" {
|
||||||
|
// Agent session - build address from role and context
|
||||||
if rig != "" && polecat != "" {
|
return detectSenderFromRole(role)
|
||||||
return fmt.Sprintf("%s/%s", rig, polecat)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "mayor/"
|
return "overseer"
|
||||||
}
|
}
|
||||||
|
|
||||||
// If in a rig's polecats directory, extract address (format: rig/polecats/name)
|
// If in a rig's polecats directory, extract address (format: rig/polecats/name)
|
||||||
@@ -744,8 +811,8 @@ func detectSender() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to mayor
|
// Default to overseer (human)
|
||||||
return "mayor/"
|
return "overseer"
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMailCheck(cmd *cobra.Command, args []string) error {
|
func runMailCheck(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
@@ -40,11 +40,21 @@ func init() {
|
|||||||
|
|
||||||
// TownStatus represents the overall status of the workspace.
|
// TownStatus represents the overall status of the workspace.
|
||||||
type TownStatus struct {
|
type TownStatus struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Agents []AgentRuntime `json:"agents"` // Global agents (Mayor, Deacon)
|
Overseer *OverseerInfo `json:"overseer,omitempty"` // Human operator
|
||||||
Rigs []RigStatus `json:"rigs"`
|
Agents []AgentRuntime `json:"agents"` // Global agents (Mayor, Deacon)
|
||||||
Summary StatusSum `json:"summary"`
|
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.
|
// 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
|
// Create mail router for inbox lookups
|
||||||
mailRouter := mail.NewRouter(townRoot)
|
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
|
// Build status
|
||||||
status := TownStatus{
|
status := TownStatus{
|
||||||
Name: townConfig.Name,
|
Name: townConfig.Name,
|
||||||
Location: townRoot,
|
Location: townRoot,
|
||||||
|
Overseer: overseerInfo,
|
||||||
Agents: discoverGlobalAgents(t, agentBeads, mailRouter),
|
Agents: discoverGlobalAgents(t, agentBeads, mailRouter),
|
||||||
Rigs: make([]RigStatus, 0, len(rigs)),
|
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 %s\n", style.Bold.Render("Town:"), status.Name)
|
||||||
fmt.Printf("%s\n\n", style.Dim.Render(status.Location))
|
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
|
// Role icons
|
||||||
roleIcons := map[string]string{
|
roleIcons := map[string]string{
|
||||||
"mayor": "🎩",
|
"mayor": "🎩",
|
||||||
|
|||||||
80
internal/cmd/whoami.go
Normal file
80
internal/cmd/whoami.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
246
internal/config/overseer.go
Normal file
246
internal/config/overseer.go
Normal file
@@ -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 <stevey@example.com>"
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -104,11 +104,45 @@ func (m *Mailbox) listBeads() ([]*Message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// listFromDir queries messages from a beads directory.
|
// 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) {
|
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",
|
cmd := exec.Command("bd", "list",
|
||||||
"--type", "message",
|
"--type", "message",
|
||||||
"--assignee", m.identity,
|
filterFlag, filterValue,
|
||||||
"--status", "open",
|
"--status", "open",
|
||||||
"--json",
|
"--json",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ func (r *Router) resolveBeadsDir(address string) string {
|
|||||||
return filepath.Join(r.townRoot, ".beads")
|
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 {
|
func isTownLevelAddress(address string) bool {
|
||||||
addr := strings.TrimSuffix(address, "/")
|
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.
|
// 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
|
// Convert addresses to beads identities
|
||||||
toIdentity := addressToIdentity(msg.To)
|
toIdentity := addressToIdentity(msg.To)
|
||||||
|
|
||||||
// Build labels for from/thread/reply-to
|
// Build labels for from/thread/reply-to/cc
|
||||||
var labels []string
|
var labels []string
|
||||||
labels = append(labels, "from:"+msg.From)
|
labels = append(labels, "from:"+msg.From)
|
||||||
if msg.ThreadID != "" {
|
if msg.ThreadID != "" {
|
||||||
@@ -127,6 +127,11 @@ func (r *Router) Send(msg *Message) error {
|
|||||||
if msg.ReplyTo != "" {
|
if msg.ReplyTo != "" {
|
||||||
labels = append(labels, "reply-to:"+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>
|
// Build command: bd create <subject> --type=message --assignee=<recipient> -d <body>
|
||||||
args := []string{"create", msg.Subject,
|
args := []string{"create", msg.Subject,
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ type Message struct {
|
|||||||
// Wisp marks this as a transient message (stored in same DB but filtered from JSONL export).
|
// Wisp marks this as a transient message (stored in same DB but filtered from JSONL export).
|
||||||
// Wisp messages auto-cleanup on patrol squash.
|
// Wisp messages auto-cleanup on patrol squash.
|
||||||
Wisp bool `json:"wisp,omitempty"`
|
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.
|
// 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
|
Priority int `json:"priority"` // 0=urgent, 1=high, 2=normal, 3=low
|
||||||
Status string `json:"status"` // open=unread, closed=read
|
Status string `json:"status"` // open=unread, closed=read
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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"`
|
Pinned bool `json:"pinned,omitempty"`
|
||||||
Wisp bool `json:"wisp,omitempty"` // Ephemeral message (filtered from JSONL export)
|
Wisp bool `json:"wisp,omitempty"` // Ephemeral message (filtered from JSONL export)
|
||||||
|
|
||||||
@@ -170,6 +174,7 @@ type BeadsMessage struct {
|
|||||||
threadID string
|
threadID string
|
||||||
replyTo string
|
replyTo string
|
||||||
msgType string
|
msgType string
|
||||||
|
cc []string // CC recipients
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseLabels extracts metadata from the labels array.
|
// ParseLabels extracts metadata from the labels array.
|
||||||
@@ -183,10 +188,27 @@ func (bm *BeadsMessage) ParseLabels() {
|
|||||||
bm.replyTo = strings.TrimPrefix(label, "reply-to:")
|
bm.replyTo = strings.TrimPrefix(label, "reply-to:")
|
||||||
} else if strings.HasPrefix(label, "msg-type:") {
|
} else if strings.HasPrefix(label, "msg-type:") {
|
||||||
bm.msgType = strings.TrimPrefix(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.
|
// ToMessage converts a BeadsMessage to a GGT Message.
|
||||||
func (bm *BeadsMessage) ToMessage() *Message {
|
func (bm *BeadsMessage) ToMessage() *Message {
|
||||||
// Parse labels to extract metadata
|
// Parse labels to extract metadata
|
||||||
@@ -212,6 +234,12 @@ func (bm *BeadsMessage) ToMessage() *Message {
|
|||||||
msgType = MessageType(bm.msgType)
|
msgType = MessageType(bm.msgType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert CC identities to addresses
|
||||||
|
var ccAddrs []string
|
||||||
|
for _, cc := range bm.cc {
|
||||||
|
ccAddrs = append(ccAddrs, identityToAddress(cc))
|
||||||
|
}
|
||||||
|
|
||||||
return &Message{
|
return &Message{
|
||||||
ID: bm.ID,
|
ID: bm.ID,
|
||||||
From: identityToAddress(bm.sender),
|
From: identityToAddress(bm.sender),
|
||||||
@@ -225,6 +253,7 @@ func (bm *BeadsMessage) ToMessage() *Message {
|
|||||||
ThreadID: bm.threadID,
|
ThreadID: bm.threadID,
|
||||||
ReplyTo: bm.replyTo,
|
ReplyTo: bm.replyTo,
|
||||||
Wisp: bm.Wisp,
|
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).
|
// to canonical form (Postel's Law - be liberal in what you accept).
|
||||||
//
|
//
|
||||||
// Addresses use slash format:
|
// Addresses use slash format:
|
||||||
|
// - "overseer" → "overseer" (human operator, no trailing slash)
|
||||||
// - "mayor/" → "mayor/"
|
// - "mayor/" → "mayor/"
|
||||||
// - "mayor" → "mayor/"
|
// - "mayor" → "mayor/"
|
||||||
// - "deacon/" → "deacon/"
|
// - "deacon/" → "deacon/"
|
||||||
@@ -297,6 +327,11 @@ func ParseMessageType(s string) MessageType {
|
|||||||
// - "gastown/refinery" → "gastown/refinery"
|
// - "gastown/refinery" → "gastown/refinery"
|
||||||
// - "gastown/" → "gastown" (rig broadcast)
|
// - "gastown/" → "gastown" (rig broadcast)
|
||||||
func addressToIdentity(address string) string {
|
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
|
// Town-level agents: mayor and deacon keep trailing slash
|
||||||
if address == "mayor" || address == "mayor/" {
|
if address == "mayor" || address == "mayor/" {
|
||||||
return "mayor/"
|
return "mayor/"
|
||||||
@@ -324,6 +359,7 @@ func addressToIdentity(address string) string {
|
|||||||
// identityToAddress converts a beads identity back to a GGT address.
|
// identityToAddress converts a beads identity back to a GGT address.
|
||||||
//
|
//
|
||||||
// Liberal normalization (Postel's Law):
|
// Liberal normalization (Postel's Law):
|
||||||
|
// - "overseer" → "overseer" (human operator)
|
||||||
// - "mayor/" → "mayor/"
|
// - "mayor/" → "mayor/"
|
||||||
// - "deacon/" → "deacon/"
|
// - "deacon/" → "deacon/"
|
||||||
// - "gastown/polecats/Toast" → "gastown/Toast" (normalized)
|
// - "gastown/polecats/Toast" → "gastown/Toast" (normalized)
|
||||||
@@ -331,6 +367,11 @@ func addressToIdentity(address string) string {
|
|||||||
// - "gastown/Toast" → "gastown/Toast" (already canonical)
|
// - "gastown/Toast" → "gastown/Toast" (already canonical)
|
||||||
// - "gastown/refinery" → "gastown/refinery"
|
// - "gastown/refinery" → "gastown/refinery"
|
||||||
func identityToAddress(identity string) string {
|
func identityToAddress(identity string) string {
|
||||||
|
// Overseer (human operator) - no trailing slash
|
||||||
|
if identity == "overseer" {
|
||||||
|
return "overseer"
|
||||||
|
}
|
||||||
|
|
||||||
// Town-level agents ensure trailing slash
|
// Town-level agents ensure trailing slash
|
||||||
if identity == "mayor" || identity == "mayor/" {
|
if identity == "mayor" || identity == "mayor/" {
|
||||||
return "mayor/"
|
return "mayor/"
|
||||||
|
|||||||
Reference in New Issue
Block a user