diff --git a/internal/mail/router.go b/internal/mail/router.go index 771dc72b..b679a227 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -991,47 +991,85 @@ func (r *Router) GetMailbox(address string) (*Mailbox, error) { // notifyRecipient sends a notification to a recipient's tmux session. // Uses NudgeSession to add the notification to the agent's conversation history. -// Supports mayor/, rig/polecat, and rig/refinery addresses. +// Supports mayor/, deacon/, rig/crew/name, rig/polecats/name, and rig/name addresses. func (r *Router) notifyRecipient(msg *Message) error { - sessionID := addressToSessionID(msg.To) - if sessionID == "" { + sessionIDs := addressToSessionIDs(msg.To) + if len(sessionIDs) == 0 { return nil // Unable to determine session ID } - // Check if session exists - hasSession, err := r.tmux.HasSession(sessionID) - if err != nil || !hasSession { - return nil // No active session, skip notification + // Try each possible session ID until we find one that exists. + // This handles the ambiguity where canonical addresses (rig/name) don't + // distinguish between crew workers (gt-rig-crew-name) and polecats (gt-rig-name). + for _, sessionID := range sessionIDs { + hasSession, err := r.tmux.HasSession(sessionID) + if err != nil || !hasSession { + continue + } + + // Send notification to the agent's conversation history + notification := fmt.Sprintf("📬 You have new mail from %s. Subject: %s. Run 'gt mail inbox' to read.", msg.From, msg.Subject) + return r.tmux.NudgeSession(sessionID, notification) } - // Send notification to the agent's conversation history - notification := fmt.Sprintf("📬 You have new mail from %s. Subject: %s. Run 'gt mail inbox' to read.", msg.From, msg.Subject) - return r.tmux.NudgeSession(sessionID, notification) + return nil // No active session found } -// addressToSessionID converts a mail address to a tmux session ID. -// Returns empty string if address format is not recognized. -func addressToSessionID(address string) string { +// addressToSessionIDs converts a mail address to possible tmux session IDs. +// Returns multiple candidates since the canonical address format (rig/name) +// doesn't distinguish between crew workers (gt-rig-crew-name) and polecats +// (gt-rig-name). The caller should try each and use the one that exists. +// +// This supersedes the approach in PR #896 which only handled slash-to-dash +// conversion but didn't address the crew/polecat ambiguity. +func addressToSessionIDs(address string) []string { // Mayor address: "mayor/" or "mayor" if strings.HasPrefix(address, "mayor") { - return session.MayorSessionName() + return []string{session.MayorSessionName()} } // Deacon address: "deacon/" or "deacon" if strings.HasPrefix(address, "deacon") { - return session.DeaconSessionName() + return []string{session.DeaconSessionName()} } - // Rig-based address: "rig/target" + // Rig-based address: "rig/target" or "rig/crew/name" or "rig/polecats/name" parts := strings.SplitN(address, "/", 2) if len(parts) != 2 || parts[1] == "" { - return "" + return nil } rig := parts[0] target := parts[1] - // Polecat: gt-rig-polecat - // Refinery: gt-rig-refinery (if refinery has its own session) - return fmt.Sprintf("gt-%s-%s", rig, target) + // If target already has crew/ or polecats/ prefix, use it directly + // e.g., "gastown/crew/holden" → "gt-gastown-crew-holden" + if strings.HasPrefix(target, "crew/") || strings.HasPrefix(target, "polecats/") { + return []string{fmt.Sprintf("gt-%s-%s", rig, strings.ReplaceAll(target, "/", "-"))} + } + + // Special cases that don't need crew variant + if target == "witness" || target == "refinery" { + return []string{fmt.Sprintf("gt-%s-%s", rig, target)} + } + + // For normalized addresses like "gastown/holden", try both: + // 1. Crew format: gt-gastown-crew-holden + // 2. Polecat format: gt-gastown-holden + // Return crew first since crew workers are more commonly missed. + return []string{ + session.CrewSessionName(rig, target), // gt-rig-crew-name + session.PolecatSessionName(rig, target), // gt-rig-name + } +} + +// addressToSessionID converts a mail address to a tmux session ID. +// Returns empty string if address format is not recognized. +// Deprecated: Use addressToSessionIDs for proper crew/polecat handling. +func addressToSessionID(address string) string { + ids := addressToSessionIDs(address) + if len(ids) == 0 { + return "" + } + return ids[0] } diff --git a/internal/mail/router_test.go b/internal/mail/router_test.go index 0132bd6c..9d9fdf52 100644 --- a/internal/mail/router_test.go +++ b/internal/mail/router_test.go @@ -87,7 +87,52 @@ func TestIsTownLevelAddress(t *testing.T) { } } +func TestAddressToSessionIDs(t *testing.T) { + tests := []struct { + address string + want []string + }{ + // Town-level addresses - single session + {"mayor", []string{"hq-mayor"}}, + {"mayor/", []string{"hq-mayor"}}, + {"deacon", []string{"hq-deacon"}}, + + // Rig singletons - single session (no crew/polecat ambiguity) + {"gastown/refinery", []string{"gt-gastown-refinery"}}, + {"beads/witness", []string{"gt-beads-witness"}}, + + // Ambiguous addresses - try both crew and polecat variants + {"gastown/Toast", []string{"gt-gastown-crew-Toast", "gt-gastown-Toast"}}, + {"beads/ruby", []string{"gt-beads-crew-ruby", "gt-beads-ruby"}}, + + // Explicit crew/polecat - single session + {"gastown/crew/max", []string{"gt-gastown-crew-max"}}, + {"gastown/polecats/nux", []string{"gt-gastown-polecats-nux"}}, + + // Invalid addresses - empty result + {"gastown/", nil}, // Empty target + {"gastown", nil}, // No slash + {"", nil}, // Empty address + } + + for _, tt := range tests { + t.Run(tt.address, func(t *testing.T) { + got := addressToSessionIDs(tt.address) + if len(got) != len(tt.want) { + t.Errorf("addressToSessionIDs(%q) = %v, want %v", tt.address, got, tt.want) + return + } + for i, v := range got { + if v != tt.want[i] { + t.Errorf("addressToSessionIDs(%q)[%d] = %q, want %q", tt.address, i, v, tt.want[i]) + } + } + }) + } +} + func TestAddressToSessionID(t *testing.T) { + // Deprecated wrapper - returns first candidate from addressToSessionIDs tests := []struct { address string want string @@ -96,7 +141,7 @@ func TestAddressToSessionID(t *testing.T) { {"mayor/", "hq-mayor"}, {"deacon", "hq-deacon"}, {"gastown/refinery", "gt-gastown-refinery"}, - {"gastown/Toast", "gt-gastown-Toast"}, + {"gastown/Toast", "gt-gastown-crew-Toast"}, // First candidate is crew {"beads/witness", "gt-beads-witness"}, {"gastown/", ""}, // Empty target {"gastown", ""}, // No slash