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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user