diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index ac2d13b4..35deab3e 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/mail" @@ -23,12 +24,15 @@ var ( mailType string mailReplyTo string mailNotify bool + mailInterrupt bool mailInboxJSON bool mailReadJSON bool mailInboxUnread bool mailCheckInject bool mailCheckJSON bool + mailCheckQuiet bool mailThreadJSON bool + mailWaitTimeout int ) var mailCmd = &cobra.Command{ @@ -60,12 +64,17 @@ Message types: Priority levels: low, normal (default), high, urgent +Delivery modes: + queue (default) - Message stored for periodic checking + interrupt - Inject system-reminder directly into session + Examples: gt mail send gastown/Toast -s "Status check" -m "How's that bug fix going?" gt mail send mayor/ -s "Work complete" -m "Finished gt-abc" gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify gt mail send gastown/Toast -s "Task" -m "Fix bug" --type task --priority high - 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 gastown/Toast -s "STUCK?" -m "Nudge" --interrupt --priority urgent`, Args: cobra.ExactArgs(1), RunE: runMailSend, } @@ -114,12 +123,16 @@ Exit codes (normal mode): 0 - New mail available 1 - No new mail +Exit codes (--quiet mode): + 0 - Always (non-blocking, silent output) + Exit codes (--inject mode): 0 - Always (hooks should never block) Output: system-reminder if mail exists, silent if no mail Examples: gt mail check # Simple check + gt mail check --quiet # Silent non-blocking check for agents gt mail check --inject # For hooks`, RunE: runMailCheck, } @@ -137,6 +150,25 @@ Examples: RunE: runMailThread, } +var mailWaitCmd = &cobra.Command{ + Use: "wait", + Short: "Block until mail arrives", + Long: `Block until new mail arrives in the inbox. + +Useful for idle agents waiting for work assignments. +Polls the inbox every 5 seconds until mail is found. + +Exit codes: + 0 - Mail arrived + 1 - Timeout (if --timeout specified) + 2 - Error + +Examples: + gt mail wait # Wait indefinitely + gt mail wait --timeout 60 # Wait up to 60 seconds`, + RunE: runMailWait, +} + func init() { // Send flags mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") @@ -145,6 +177,7 @@ func init() { mailSendCmd.Flags().StringVar(&mailType, "type", "notification", "Message type (task, scavenge, notification, reply)") mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to") mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient") + mailSendCmd.Flags().BoolVar(&mailInterrupt, "interrupt", false, "Inject message directly into recipient's session (use for lifecycle/urgent)") mailSendCmd.MarkFlagRequired("subject") // Inbox flags @@ -157,10 +190,14 @@ func init() { // Check flags mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks") mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON") + mailCheckCmd.Flags().BoolVarP(&mailCheckQuiet, "quiet", "q", false, "Silent non-blocking check (always exit 0)") // Thread flags mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON") + // Wait flags + mailWaitCmd.Flags().IntVar(&mailWaitTimeout, "timeout", 0, "Timeout in seconds (0 = wait indefinitely)") + // Add subcommands mailCmd.AddCommand(mailSendCmd) mailCmd.AddCommand(mailInboxCmd) @@ -168,6 +205,7 @@ func init() { mailCmd.AddCommand(mailDeleteCmd) mailCmd.AddCommand(mailCheckCmd) mailCmd.AddCommand(mailThreadCmd) + mailCmd.AddCommand(mailWaitCmd) rootCmd.AddCommand(mailCmd) } @@ -198,6 +236,13 @@ func runMailSend(cmd *cobra.Command, args []string) error { msg.Priority = mail.PriorityHigh } + // Set delivery mode + if mailInterrupt { + msg.Delivery = mail.DeliveryInterrupt + } else { + msg.Delivery = mail.DeliveryQueue + } + // Set message type msg.Type = mail.ParseMessageType(mailType) @@ -484,11 +529,13 @@ func detectSender() string { } func runMailCheck(cmd *cobra.Command, args []string) error { + // Silent modes (inject or quiet) never fail + silentMode := mailCheckInject || mailCheckQuiet + // Find workspace workDir, err := findBeadsWorkDir() if err != nil { - if mailCheckInject { - // Inject mode: always exit 0, silent on error + if silentMode { return nil } return fmt.Errorf("not in a Gas Town workspace: %w", err) @@ -501,7 +548,7 @@ func runMailCheck(cmd *cobra.Command, args []string) error { router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(address) if err != nil { - if mailCheckInject { + if silentMode { return nil } return fmt.Errorf("getting mailbox: %w", err) @@ -510,12 +557,17 @@ func runMailCheck(cmd *cobra.Command, args []string) error { // Count unread _, unread, err := mailbox.Count() if err != nil { - if mailCheckInject { + if silentMode { return nil } return fmt.Errorf("counting messages: %w", err) } + // Quiet mode: completely silent, just exit + if mailCheckQuiet { + return nil + } + // JSON output if mailCheckJSON { result := map[string]interface{}{ @@ -629,6 +681,63 @@ func runMailThread(cmd *cobra.Command, args []string) error { return nil } +func runMailWait(cmd *cobra.Command, args []string) error { + // Find workspace + workDir, err := findBeadsWorkDir() + if err != nil { + fmt.Fprintf(os.Stderr, "not in a Gas Town workspace: %v\n", err) + os.Exit(2) + return nil + } + + // Determine which inbox + address := detectSender() + + // Get mailbox + router := mail.NewRouter(workDir) + mailbox, err := router.GetMailbox(address) + if err != nil { + fmt.Fprintf(os.Stderr, "getting mailbox: %v\n", err) + os.Exit(2) + return nil + } + + // Calculate deadline if timeout specified + var deadline time.Time + if mailWaitTimeout > 0 { + deadline = time.Now().Add(time.Duration(mailWaitTimeout) * time.Second) + } + + pollInterval := 5 * time.Second + fmt.Printf("Waiting for mail in %s...\n", address) + + for { + // Check for mail + _, unread, err := mailbox.Count() + if err != nil { + fmt.Fprintf(os.Stderr, "checking mail: %v\n", err) + os.Exit(2) + return nil + } + + if unread > 0 { + fmt.Printf("%s %d message(s) arrived!\n", style.Bold.Render("šŸ“¬"), unread) + os.Exit(0) + return nil + } + + // Check timeout + if mailWaitTimeout > 0 && time.Now().After(deadline) { + fmt.Println("Timeout waiting for mail") + os.Exit(1) + return nil + } + + // Sleep before next poll + time.Sleep(pollInterval) + } +} + // generateThreadID creates a random thread ID for new message threads. func generateThreadID() string { b := make([]byte, 6) diff --git a/internal/mail/router.go b/internal/mail/router.go index 0669b4b4..9c2448d5 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -71,8 +71,14 @@ func (r *Router) Send(msg *Message) error { return fmt.Errorf("sending message: %w", err) } - // Notify recipient if they have an active session - r.notifyRecipient(msg) + // Handle delivery based on mode + if msg.Delivery == DeliveryInterrupt { + // Interrupt: inject system-reminder directly into session + r.interruptRecipient(msg) + } else { + // Queue (default): just notify in status line + r.notifyRecipient(msg) + } return nil } @@ -102,6 +108,39 @@ func (r *Router) notifyRecipient(msg *Message) error { return r.tmux.DisplayMessageDefault(sessionID, notification) } +// interruptRecipient injects a system-reminder directly into the session. +// Uses tmux send-keys to inject text that Claude will see as input. +// This is disruptive - use for lifecycle events, URGENT messages, or stuck detection. +func (r *Router) interruptRecipient(msg *Message) error { + sessionID := addressToSessionID(msg.To) + if sessionID == "" { + 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 interrupt + } + + // Build system-reminder with message content + priorityStr := "" + if msg.Priority == PriorityUrgent { + priorityStr = " [URGENT]" + } else if msg.Priority == PriorityHigh { + priorityStr = " [HIGH PRIORITY]" + } + + reminder := fmt.Sprintf("\n\nšŸ“¬ NEW MAIL%s from %s\nSubject: %s\n", priorityStr, msg.From, msg.Subject) + if msg.Body != "" { + reminder += fmt.Sprintf("\n%s\n", msg.Body) + } + reminder += "\nRun 'gt mail inbox' to see your messages.\n\n" + + // Inject via send-keys (don't press Enter, just paste) + return r.tmux.SendKeysRaw(sessionID, reminder) +} + // addressToSessionID converts a mail address to a tmux session ID. // Returns empty string if address format is not recognized. func addressToSessionID(address string) string { diff --git a/internal/mail/types.go b/internal/mail/types.go index 947789e4..7fea40ff 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -41,6 +41,19 @@ const ( TypeReply MessageType = "reply" ) +// Delivery specifies how a message is delivered to the recipient. +type Delivery string + +const ( + // DeliveryQueue creates the message in the mailbox for periodic checking. + // This is the default delivery mode. Agent checks with `gt mail check`. + DeliveryQueue Delivery = "queue" + + // DeliveryInterrupt injects a system-reminder directly into the agent's session. + // Use for lifecycle events, URGENT priority, or stuck detection. + DeliveryInterrupt Delivery = "interrupt" +) + // Message represents a mail message between agents. // This is the GGT-side representation; it gets translated to/from beads messages. type Message struct { @@ -71,6 +84,10 @@ type Message struct { // Type indicates the message type (task, scavenge, notification, reply). Type MessageType `json:"type"` + // Delivery specifies how the message is delivered (queue or interrupt). + // Queue: agent checks periodically. Interrupt: inject into session. + Delivery Delivery `json:"delivery,omitempty"` + // ThreadID groups related messages into a conversation thread. ThreadID string `json:"thread_id,omitempty"`