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:
Steve Yegge
2025-12-19 16:30:53 -08:00
parent 1c56eed44b
commit 6479dddb9b
3 changed files with 172 additions and 7 deletions
+114 -5
View File
@@ -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
View File
@@ -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 {
+17
View File
@@ -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"`