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

View File

@@ -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)

View File

@@ -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<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.
// Returns empty string if address format is not recognized.
func addressToSessionID(address string) string {

View File

@@ -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"`