Merge beads-sync branch
Sync beads changes and code updates. Generated with Claude Code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -129,6 +131,16 @@ func runHandoff(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Printf("%s Sent %s request to %s\n", style.Bold.Render("✓"), action, manager)
|
||||
|
||||
// Signal daemon for immediate processing (if manager is deacon)
|
||||
if manager == "deacon/" {
|
||||
if err := signalDaemon(townRoot); err != nil {
|
||||
// Non-fatal: daemon will eventually poll
|
||||
fmt.Printf("%s Could not signal daemon (will poll): %v\n", style.Dim.Render("○"), err)
|
||||
} else {
|
||||
fmt.Printf("%s Signaled daemon for immediate processing\n", style.Bold.Render("✓"))
|
||||
}
|
||||
}
|
||||
|
||||
// Set requesting state
|
||||
if err := setRequestingState(role, action, townRoot); err != nil {
|
||||
fmt.Printf("Warning: failed to set state: %v\n", err)
|
||||
@@ -457,3 +469,28 @@ func setRequestingState(role Role, action HandoffAction, townRoot string) error
|
||||
|
||||
return os.WriteFile(stateFile, data, 0644)
|
||||
}
|
||||
|
||||
// signalDaemon sends SIGUSR1 to the daemon to trigger immediate lifecycle processing.
|
||||
func signalDaemon(townRoot string) error {
|
||||
pidFile := filepath.Join(townRoot, "daemon", "daemon.pid")
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading daemon PID: %w", err)
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing daemon PID: %w", err)
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding daemon process: %w", err)
|
||||
}
|
||||
|
||||
if err := process.Signal(syscall.SIGUSR1); err != nil {
|
||||
return fmt.Errorf("signaling daemon: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ var (
|
||||
mailBody string
|
||||
mailPriority int
|
||||
mailUrgent bool
|
||||
mailPinned bool
|
||||
mailType string
|
||||
mailReplyTo string
|
||||
mailNotify bool
|
||||
@@ -180,6 +181,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(&mailPinned, "pinned", false, "Pin message (for handoff context that persists)")
|
||||
_ = mailSendCmd.MarkFlagRequired("subject")
|
||||
|
||||
// Inbox flags
|
||||
@@ -250,6 +252,9 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
// Set message type
|
||||
msg.Type = mail.ParseMessageType(mailType)
|
||||
|
||||
// Set pinned flag
|
||||
msg.Pinned = mailPinned
|
||||
|
||||
// Handle reply-to: auto-set type to reply and look up thread
|
||||
if mailReplyTo != "" {
|
||||
msg.ReplyTo = mailReplyTo
|
||||
@@ -594,7 +599,7 @@ func runMailCheck(cmd *cobra.Command, args []string) error {
|
||||
messages, _ := mailbox.ListUnread()
|
||||
var subjects []string
|
||||
for _, msg := range messages {
|
||||
subjects = append(subjects, fmt.Sprintf("- From %s: %s", msg.From, msg.Subject))
|
||||
subjects = append(subjects, fmt.Sprintf("- %s from %s: %s", msg.ID, msg.From, msg.Subject))
|
||||
}
|
||||
|
||||
fmt.Println("<system-reminder>")
|
||||
|
||||
@@ -85,7 +85,7 @@ func (d *Daemon) Run() error {
|
||||
|
||||
// Handle signals
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1)
|
||||
|
||||
// Heartbeat ticker
|
||||
ticker := time.NewTicker(d.config.HeartbeatInterval)
|
||||
@@ -103,8 +103,14 @@ func (d *Daemon) Run() error {
|
||||
return d.shutdown(state)
|
||||
|
||||
case sig := <-sigChan:
|
||||
d.logger.Printf("Received signal %v, shutting down", sig)
|
||||
return d.shutdown(state)
|
||||
if sig == syscall.SIGUSR1 {
|
||||
// SIGUSR1: immediate lifecycle processing (from gt handoff)
|
||||
d.logger.Println("Received SIGUSR1, processing lifecycle requests immediately")
|
||||
d.processLifecycleRequests()
|
||||
} else {
|
||||
d.logger.Printf("Received signal %v, shutting down", sig)
|
||||
return d.shutdown(state)
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
d.heartbeat(state)
|
||||
|
||||
@@ -177,6 +177,14 @@ func (d *Daemon) identityToSession(identity string) string {
|
||||
if strings.HasSuffix(identity, "-witness") {
|
||||
return "gt-" + identity
|
||||
}
|
||||
// Pattern: <rig>-refinery → gt-<rig>-refinery
|
||||
if strings.HasSuffix(identity, "-refinery") {
|
||||
return "gt-" + identity
|
||||
}
|
||||
// Pattern: <rig>-crew-<name> → gt-<rig>-crew-<name>
|
||||
if strings.Contains(identity, "-crew-") {
|
||||
return "gt-" + identity
|
||||
}
|
||||
// Unknown identity
|
||||
return ""
|
||||
}
|
||||
@@ -187,19 +195,48 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
// Determine working directory and startup command based on agent type
|
||||
var workDir, startCmd string
|
||||
var rigName string
|
||||
var agentRole string
|
||||
var needsPreSync bool
|
||||
|
||||
if identity == "mayor" {
|
||||
workDir = d.config.TownRoot
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
agentRole = "coordinator"
|
||||
} else if strings.HasSuffix(identity, "-witness") {
|
||||
// Extract rig name: <rig>-witness → <rig>
|
||||
rigName = strings.TrimSuffix(identity, "-witness")
|
||||
workDir = d.config.TownRoot + "/" + rigName
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
agentRole = "witness"
|
||||
} else if strings.HasSuffix(identity, "-refinery") {
|
||||
// Extract rig name: <rig>-refinery → <rig>
|
||||
rigName = strings.TrimSuffix(identity, "-refinery")
|
||||
workDir = filepath.Join(d.config.TownRoot, rigName, "refinery", "rig")
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
agentRole = "refinery"
|
||||
needsPreSync = true
|
||||
} else if strings.Contains(identity, "-crew-") {
|
||||
// Extract rig and crew name: <rig>-crew-<name> → <rig>, <name>
|
||||
parts := strings.SplitN(identity, "-crew-", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid crew identity format: %s", identity)
|
||||
}
|
||||
rigName = parts[0]
|
||||
crewName := parts[1]
|
||||
workDir = filepath.Join(d.config.TownRoot, rigName, "crew", crewName)
|
||||
startCmd = "exec claude --dangerously-skip-permissions"
|
||||
agentRole = "crew"
|
||||
needsPreSync = true
|
||||
} else {
|
||||
return fmt.Errorf("don't know how to restart %s", identity)
|
||||
}
|
||||
|
||||
// Pre-sync workspace for agents with git clones (refinery)
|
||||
if needsPreSync {
|
||||
d.logger.Printf("Pre-syncing workspace for %s at %s", identity, workDir)
|
||||
d.syncWorkspace(workDir)
|
||||
}
|
||||
|
||||
// Create session
|
||||
if err := d.tmux.NewSession(sessionName, workDir); err != nil {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
@@ -214,7 +251,7 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
|
||||
} else if rigName != "" {
|
||||
theme := tmux.AssignTheme(rigName)
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness")
|
||||
_ = d.tmux.ConfigureGasTownSession(sessionName, theme, rigName, agentRole, agentRole)
|
||||
}
|
||||
|
||||
// Send startup command
|
||||
@@ -230,6 +267,32 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncWorkspace syncs a git workspace before starting a new session.
|
||||
// This ensures agents with persistent clones (like refinery) start with current code.
|
||||
func (d *Daemon) syncWorkspace(workDir string) {
|
||||
// Fetch latest from origin
|
||||
fetchCmd := exec.Command("git", "fetch", "origin")
|
||||
fetchCmd.Dir = workDir
|
||||
if err := fetchCmd.Run(); err != nil {
|
||||
d.logger.Printf("Warning: git fetch failed in %s: %v", workDir, err)
|
||||
}
|
||||
|
||||
// Pull with rebase to incorporate changes
|
||||
pullCmd := exec.Command("git", "pull", "--rebase", "origin", "main")
|
||||
pullCmd.Dir = workDir
|
||||
if err := pullCmd.Run(); err != nil {
|
||||
d.logger.Printf("Warning: git pull failed in %s: %v", workDir, err)
|
||||
// Don't fail - agent can handle conflicts
|
||||
}
|
||||
|
||||
// Sync beads
|
||||
bdCmd := exec.Command("bd", "sync")
|
||||
bdCmd.Dir = workDir
|
||||
if err := bdCmd.Run(); err != nil {
|
||||
d.logger.Printf("Warning: bd sync failed in %s: %v", workDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// closeMessage marks a mail message as read by closing the beads issue.
|
||||
func (d *Daemon) closeMessage(id string) error {
|
||||
cmd := exec.Command("bd", "close", id)
|
||||
@@ -289,6 +352,20 @@ func (d *Daemon) identityToStateFile(identity string) string {
|
||||
rigName := strings.TrimSuffix(identity, "-witness")
|
||||
return filepath.Join(d.config.TownRoot, rigName, "witness", "state.json")
|
||||
}
|
||||
// Pattern: <rig>-refinery → <townRoot>/<rig>/refinery/state.json
|
||||
if strings.HasSuffix(identity, "-refinery") {
|
||||
rigName := strings.TrimSuffix(identity, "-refinery")
|
||||
return filepath.Join(d.config.TownRoot, rigName, "refinery", "state.json")
|
||||
}
|
||||
// Pattern: <rig>-crew-<name> → <townRoot>/<rig>/crew/<name>/state.json
|
||||
if strings.Contains(identity, "-crew-") {
|
||||
parts := strings.SplitN(identity, "-crew-", 2)
|
||||
if len(parts) == 2 {
|
||||
rigName := parts[0]
|
||||
crewName := parts[1]
|
||||
return filepath.Join(d.config.TownRoot, rigName, "crew", crewName, "state.json")
|
||||
}
|
||||
}
|
||||
// Unknown identity - can't determine state file
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -209,7 +209,6 @@ func (m *Manager) Start(foreground bool) error {
|
||||
}
|
||||
|
||||
// Start Claude agent with full permissions (like polecats)
|
||||
// The agent will run gt prime to load refinery context and start processing
|
||||
command := "claude --dangerously-skip-permissions"
|
||||
if err := t.SendKeys(sessionID, command); err != nil {
|
||||
// Clean up the session on failure
|
||||
@@ -217,6 +216,12 @@ func (m *Manager) Start(foreground bool) error {
|
||||
return fmt.Errorf("starting Claude agent: %w", err)
|
||||
}
|
||||
|
||||
// Prime the agent after Claude starts to load refinery context
|
||||
if err := t.SendKeysDelayed(sessionID, "gt prime", 2000); err != nil {
|
||||
// Warning only - don't fail startup
|
||||
fmt.Printf("Warning: could not send prime command: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user