feat(mail): add interrupt vs queue delivery semantics (gt-6k8)
Add delivery modes to mail messages: - queue (default): message stored for periodic checking - interrupt: inject system-reminder directly into session New features: - --interrupt flag on gt mail send for urgent/lifecycle messages - --quiet flag on gt mail check for silent non-blocking checks - gt mail wait command to block until mail arrives (with optional timeout) Interrupt delivery injects a system-reminder via tmux send-keys, useful for stuck detection, nudges, and urgent notifications. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+114
-5
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/mail"
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
@@ -23,12 +24,15 @@ var (
|
|||||||
mailType string
|
mailType string
|
||||||
mailReplyTo string
|
mailReplyTo string
|
||||||
mailNotify bool
|
mailNotify bool
|
||||||
|
mailInterrupt bool
|
||||||
mailInboxJSON bool
|
mailInboxJSON bool
|
||||||
mailReadJSON bool
|
mailReadJSON bool
|
||||||
mailInboxUnread bool
|
mailInboxUnread bool
|
||||||
mailCheckInject bool
|
mailCheckInject bool
|
||||||
mailCheckJSON bool
|
mailCheckJSON bool
|
||||||
|
mailCheckQuiet bool
|
||||||
mailThreadJSON bool
|
mailThreadJSON bool
|
||||||
|
mailWaitTimeout int
|
||||||
)
|
)
|
||||||
|
|
||||||
var mailCmd = &cobra.Command{
|
var mailCmd = &cobra.Command{
|
||||||
@@ -60,12 +64,17 @@ Message types:
|
|||||||
Priority levels:
|
Priority levels:
|
||||||
low, normal (default), high, urgent
|
low, normal (default), high, urgent
|
||||||
|
|
||||||
|
Delivery modes:
|
||||||
|
queue (default) - Message stored for periodic checking
|
||||||
|
interrupt - Inject system-reminder directly into session
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt mail send gastown/Toast -s "Status check" -m "How's that bug fix going?"
|
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 mayor/ -s "Work complete" -m "Finished gt-abc"
|
||||||
gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify
|
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 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runMailSend,
|
RunE: runMailSend,
|
||||||
}
|
}
|
||||||
@@ -114,12 +123,16 @@ Exit codes (normal mode):
|
|||||||
0 - New mail available
|
0 - New mail available
|
||||||
1 - No new mail
|
1 - No new mail
|
||||||
|
|
||||||
|
Exit codes (--quiet mode):
|
||||||
|
0 - Always (non-blocking, silent output)
|
||||||
|
|
||||||
Exit codes (--inject mode):
|
Exit codes (--inject mode):
|
||||||
0 - Always (hooks should never block)
|
0 - Always (hooks should never block)
|
||||||
Output: system-reminder if mail exists, silent if no mail
|
Output: system-reminder if mail exists, silent if no mail
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt mail check # Simple check
|
gt mail check # Simple check
|
||||||
|
gt mail check --quiet # Silent non-blocking check for agents
|
||||||
gt mail check --inject # For hooks`,
|
gt mail check --inject # For hooks`,
|
||||||
RunE: runMailCheck,
|
RunE: runMailCheck,
|
||||||
}
|
}
|
||||||
@@ -137,6 +150,25 @@ Examples:
|
|||||||
RunE: runMailThread,
|
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() {
|
func init() {
|
||||||
// Send flags
|
// Send flags
|
||||||
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
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(&mailType, "type", "notification", "Message type (task, scavenge, notification, reply)")
|
||||||
mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to")
|
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().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")
|
mailSendCmd.MarkFlagRequired("subject")
|
||||||
|
|
||||||
// Inbox flags
|
// Inbox flags
|
||||||
@@ -157,10 +190,14 @@ func init() {
|
|||||||
// Check flags
|
// Check flags
|
||||||
mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks")
|
mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks")
|
||||||
mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON")
|
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
|
// Thread flags
|
||||||
mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON")
|
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
|
// Add subcommands
|
||||||
mailCmd.AddCommand(mailSendCmd)
|
mailCmd.AddCommand(mailSendCmd)
|
||||||
mailCmd.AddCommand(mailInboxCmd)
|
mailCmd.AddCommand(mailInboxCmd)
|
||||||
@@ -168,6 +205,7 @@ func init() {
|
|||||||
mailCmd.AddCommand(mailDeleteCmd)
|
mailCmd.AddCommand(mailDeleteCmd)
|
||||||
mailCmd.AddCommand(mailCheckCmd)
|
mailCmd.AddCommand(mailCheckCmd)
|
||||||
mailCmd.AddCommand(mailThreadCmd)
|
mailCmd.AddCommand(mailThreadCmd)
|
||||||
|
mailCmd.AddCommand(mailWaitCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(mailCmd)
|
rootCmd.AddCommand(mailCmd)
|
||||||
}
|
}
|
||||||
@@ -198,6 +236,13 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
|||||||
msg.Priority = mail.PriorityHigh
|
msg.Priority = mail.PriorityHigh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set delivery mode
|
||||||
|
if mailInterrupt {
|
||||||
|
msg.Delivery = mail.DeliveryInterrupt
|
||||||
|
} else {
|
||||||
|
msg.Delivery = mail.DeliveryQueue
|
||||||
|
}
|
||||||
|
|
||||||
// Set message type
|
// Set message type
|
||||||
msg.Type = mail.ParseMessageType(mailType)
|
msg.Type = mail.ParseMessageType(mailType)
|
||||||
|
|
||||||
@@ -484,11 +529,13 @@ func detectSender() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runMailCheck(cmd *cobra.Command, args []string) error {
|
func runMailCheck(cmd *cobra.Command, args []string) error {
|
||||||
|
// Silent modes (inject or quiet) never fail
|
||||||
|
silentMode := mailCheckInject || mailCheckQuiet
|
||||||
|
|
||||||
// Find workspace
|
// Find workspace
|
||||||
workDir, err := findBeadsWorkDir()
|
workDir, err := findBeadsWorkDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if mailCheckInject {
|
if silentMode {
|
||||||
// Inject mode: always exit 0, silent on error
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
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)
|
router := mail.NewRouter(workDir)
|
||||||
mailbox, err := router.GetMailbox(address)
|
mailbox, err := router.GetMailbox(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if mailCheckInject {
|
if silentMode {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("getting mailbox: %w", err)
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
@@ -510,12 +557,17 @@ func runMailCheck(cmd *cobra.Command, args []string) error {
|
|||||||
// Count unread
|
// Count unread
|
||||||
_, unread, err := mailbox.Count()
|
_, unread, err := mailbox.Count()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if mailCheckInject {
|
if silentMode {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("counting messages: %w", err)
|
return fmt.Errorf("counting messages: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quiet mode: completely silent, just exit
|
||||||
|
if mailCheckQuiet {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// JSON output
|
// JSON output
|
||||||
if mailCheckJSON {
|
if mailCheckJSON {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
@@ -629,6 +681,63 @@ func runMailThread(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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.
|
// generateThreadID creates a random thread ID for new message threads.
|
||||||
func generateThreadID() string {
|
func generateThreadID() string {
|
||||||
b := make([]byte, 6)
|
b := make([]byte, 6)
|
||||||
|
|||||||
+40
-1
@@ -71,8 +71,14 @@ func (r *Router) Send(msg *Message) error {
|
|||||||
return fmt.Errorf("sending message: %w", err)
|
return fmt.Errorf("sending message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify recipient if they have an active session
|
// 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)
|
r.notifyRecipient(msg)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -102,6 +108,39 @@ func (r *Router) notifyRecipient(msg *Message) error {
|
|||||||
return r.tmux.DisplayMessageDefault(sessionID, notification)
|
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<system-reminder>\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</system-reminder>\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.
|
// addressToSessionID converts a mail address to a tmux session ID.
|
||||||
// Returns empty string if address format is not recognized.
|
// Returns empty string if address format is not recognized.
|
||||||
func addressToSessionID(address string) string {
|
func addressToSessionID(address string) string {
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ const (
|
|||||||
TypeReply MessageType = "reply"
|
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.
|
// Message represents a mail message between agents.
|
||||||
// This is the GGT-side representation; it gets translated to/from beads messages.
|
// This is the GGT-side representation; it gets translated to/from beads messages.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
@@ -71,6 +84,10 @@ type Message struct {
|
|||||||
// Type indicates the message type (task, scavenge, notification, reply).
|
// Type indicates the message type (task, scavenge, notification, reply).
|
||||||
Type MessageType `json:"type"`
|
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 groups related messages into a conversation thread.
|
||||||
ThreadID string `json:"thread_id,omitempty"`
|
ThreadID string `json:"thread_id,omitempty"`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user