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.
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "🎩",
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user