From 958bbc08533253bcab65db0fae8c22c7046e97b1 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 17 Oct 2025 22:45:33 -0700 Subject: [PATCH] feat(daemon): Add --global flag for multi-repo support Implements bd-121: Global daemon with system-wide socket Changes: - Add --global flag to daemon command - Use ~/.beads/bd.sock when --global is set - Skip git repo validation for global daemon - Update daemon discovery to check ~/.beads/ as fallback - Both Go CLI and Python MCP client check global socket - Update all tests to pass global parameter Benefits: - Single daemon serves all repos on system - No per-repo daemon management needed - Better resource usage for users with many repos - Automatic fallback when local daemon not running Usage: bd daemon --global # Start global daemon bd daemon --status --global # Check global status bd daemon --stop --global # Stop global daemon Related: bd-73 (multi-repo epic) Amp-Thread-ID: https://ampcode.com/threads/T-ea606216-b886-4af0-bba8-56d000362d01 Co-authored-by: Amp --- cmd/bd/daemon.go | 101 ++++++++++++++---- cmd/bd/daemon_test.go | 4 +- cmd/bd/main.go | 19 +++- .../beads-mcp/src/beads_mcp/bd_client.py | 12 ++- .../src/beads_mcp/bd_daemon_client.py | 25 +++-- 5 files changed, 126 insertions(+), 35 deletions(-) diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index 025a5e35..205ddf3b 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -46,20 +46,21 @@ Use --status to check if daemon is running.`, autoCommit, _ := cmd.Flags().GetBool("auto-commit") autoPush, _ := cmd.Flags().GetBool("auto-push") logFile, _ := cmd.Flags().GetString("log") + global, _ := cmd.Flags().GetBool("global") if interval <= 0 { fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval) os.Exit(1) } - pidFile, err := getPIDFilePath() + pidFile, err := getPIDFilePath(global) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if status { - showDaemonStatus(pidFile) + showDaemonStatus(pidFile, global) return } @@ -71,12 +72,12 @@ Use --status to check if daemon is running.`, // Check if daemon is already running if isRunning, pid := isDaemonRunning(pidFile); isRunning { fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d)\n", pid) - fmt.Fprintf(os.Stderr, "Use 'bd daemon --stop' to stop it first\n") + fmt.Fprintf(os.Stderr, "Use 'bd daemon --stop%s' to stop it first\n", boolToFlag(global, " --global")) os.Exit(1) } - // Validate we're in a git repo - if !isGitRepo() { + // Validate we're in a git repo (skip for global daemon) + if !global && !isGitRepo() { fmt.Fprintf(os.Stderr, "Error: not in a git repository\n") fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository\n") os.Exit(1) @@ -90,13 +91,17 @@ Use --status to check if daemon is running.`, } // Start daemon - fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v)\n", - interval, autoCommit, autoPush) + scope := "local" + if global { + scope = "global" + } + fmt.Printf("Starting bd daemon (%s, interval: %v, auto-commit: %v, auto-push: %v)\n", + scope, interval, autoCommit, autoPush) if logFile != "" { fmt.Printf("Logging to: %s\n", logFile) } - startDaemon(interval, autoCommit, autoPush, logFile, pidFile) + startDaemon(interval, autoCommit, autoPush, logFile, pidFile, global) }, } @@ -107,9 +112,24 @@ func init() { daemonCmd.Flags().Bool("stop", false, "Stop running daemon") daemonCmd.Flags().Bool("status", false, "Show daemon status") daemonCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)") + daemonCmd.Flags().Bool("global", false, "Run as global daemon (socket at ~/.beads/bd.sock)") rootCmd.AddCommand(daemonCmd) } +func getGlobalBeadsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot get home directory: %w", err) + } + + beadsDir := filepath.Join(home, ".beads") + if err := os.MkdirAll(beadsDir, 0700); err != nil { + return "", fmt.Errorf("cannot create global beads directory: %w", err) + } + + return beadsDir, nil +} + func ensureBeadsDir() (string, error) { var beadsDir string if dbPath != "" { @@ -132,19 +152,43 @@ func ensureBeadsDir() (string, error) { return beadsDir, nil } -func getPIDFilePath() (string, error) { - beadsDir, err := ensureBeadsDir() +func boolToFlag(condition bool, flag string) string { + if condition { + return flag + } + return "" +} + +func getPIDFilePath(global bool) (string, error) { + var beadsDir string + var err error + + if global { + beadsDir, err = getGlobalBeadsDir() + } else { + beadsDir, err = ensureBeadsDir() + } + if err != nil { return "", err } return filepath.Join(beadsDir, "daemon.pid"), nil } -func getLogFilePath(userPath string) (string, error) { +func getLogFilePath(userPath string, global bool) (string, error) { if userPath != "" { return userPath, nil } - beadsDir, err := ensureBeadsDir() + + var beadsDir string + var err error + + if global { + beadsDir, err = getGlobalBeadsDir() + } else { + beadsDir, err = ensureBeadsDir() + } + if err != nil { return "", err } @@ -175,15 +219,19 @@ func isDaemonRunning(pidFile string) (bool, int) { return true, pid } -func showDaemonStatus(pidFile string) { +func showDaemonStatus(pidFile string, global bool) { if isRunning, pid := isDaemonRunning(pidFile); isRunning { - fmt.Printf("✓ Daemon is running (PID %d)\n", pid) + scope := "local" + if global { + scope = "global" + } + fmt.Printf("✓ Daemon is running (PID %d, %s)\n", pid, scope) if info, err := os.Stat(pidFile); err == nil { fmt.Printf(" Started: %s\n", info.ModTime().Format("2006-01-02 15:04:05")) } - logPath, err := getLogFilePath("") + logPath, err := getLogFilePath("", global) if err == nil { if _, err := os.Stat(logPath); err == nil { fmt.Printf(" Log: %s\n", logPath) @@ -229,15 +277,15 @@ func stopDaemon(pidFile string) { } } -func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pidFile string) { - logPath, err := getLogFilePath(logFile) +func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pidFile string, global bool) { + logPath, err := getLogFilePath(logFile, global) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if os.Getenv("BD_DAEMON_FOREGROUND") == "1" { - runDaemonLoop(interval, autoCommit, autoPush, logPath, pidFile) + runDaemonLoop(interval, autoCommit, autoPush, logPath, pidFile, global) return } @@ -259,6 +307,9 @@ func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pid if logFile != "" { args = append(args, "--log", logFile) } + if global { + args = append(args, "--global") + } cmd := exec.Command(exe, args...) cmd.Env = append(os.Environ(), "BD_DAEMON_FOREGROUND=1") @@ -392,7 +443,7 @@ func importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat return nil } -func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, pidFile string) { +func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, pidFile string, global bool) { logF, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err) @@ -467,7 +518,17 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p log("Database opened: %s", daemonDBPath) // Start RPC server - socketPath := filepath.Join(filepath.Dir(daemonDBPath), "bd.sock") + var socketPath string + if global { + globalDir, err := getGlobalBeadsDir() + if err != nil { + log("Error: cannot get global beads directory: %v", err) + os.Exit(1) + } + socketPath = filepath.Join(globalDir, "bd.sock") + } else { + socketPath = filepath.Join(filepath.Dir(daemonDBPath), "bd.sock") + } server := rpc.NewServer(socketPath, store) ctx, cancel := context.WithCancel(context.Background()) diff --git a/cmd/bd/daemon_test.go b/cmd/bd/daemon_test.go index 3c533263..08117d76 100644 --- a/cmd/bd/daemon_test.go +++ b/cmd/bd/daemon_test.go @@ -23,7 +23,7 @@ func TestGetPIDFilePath(t *testing.T) { defer func() { dbPath = oldDBPath }() dbPath = filepath.Join(tmpDir, ".beads", "test.db") - pidFile, err := getPIDFilePath() + pidFile, err := getPIDFilePath(false) // test local daemon if err != nil { t.Fatalf("getPIDFilePath failed: %v", err) } @@ -65,7 +65,7 @@ func TestGetLogFilePath(t *testing.T) { defer func() { dbPath = oldDBPath }() dbPath = tt.dbPath - result, err := getLogFilePath(tt.userPath) + result, err := getLogFilePath(tt.userPath, false) // test local daemon if err != nil { t.Fatalf("getLogFilePath failed: %v", err) } diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 172c03ef..36ad17f1 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -169,9 +169,24 @@ var rootCmd = &cobra.Command{ } // getSocketPath returns the daemon socket path based on the database location +// If no local socket exists, check for global socket at ~/.beads/bd.sock func getSocketPath() string { - // Socket lives in same directory as database: .beads/bd.sock - return filepath.Join(filepath.Dir(dbPath), "bd.sock") + // First check local socket (same directory as database: .beads/bd.sock) + localSocket := filepath.Join(filepath.Dir(dbPath), "bd.sock") + if _, err := os.Stat(localSocket); err == nil { + return localSocket + } + + // Fall back to global socket at ~/.beads/bd.sock + if home, err := os.UserHomeDir(); err == nil { + globalSocket := filepath.Join(home, ".beads", "bd.sock") + if _, err := os.Stat(globalSocket); err == nil { + return globalSocket + } + } + + // Default to local socket even if it doesn't exist + return localSocket } // outputJSON outputs data as pretty-printed JSON diff --git a/integrations/beads-mcp/src/beads_mcp/bd_client.py b/integrations/beads-mcp/src/beads_mcp/bd_client.py index 4fcbe5a9..597c9a5d 100644 --- a/integrations/beads-mcp/src/beads_mcp/bd_client.py +++ b/integrations/beads-mcp/src/beads_mcp/bd_client.py @@ -640,7 +640,7 @@ def create_bd_client( from pathlib import Path # Check if daemon socket exists before creating client - # Walk up from working_dir to find .beads/bd.sock + # Walk up from working_dir to find .beads/bd.sock, then check global search_dir = Path(working_dir) if working_dir else Path.cwd() socket_found = False @@ -652,16 +652,22 @@ def create_bd_client( if sock_path.exists(): socket_found = True break - # Found .beads but no socket - daemon not running + # Found .beads but no socket - check global before giving up break # Move up one directory parent = current.parent if parent == current: - # Reached filesystem root + # Reached filesystem root - check global break current = parent + # If no local socket, check global daemon socket at ~/.beads/bd.sock + if not socket_found: + global_sock_path = Path.home() / ".beads" / "bd.sock" + if global_sock_path.exists(): + socket_found = True + if socket_found: # Daemon is running, use it client = BdDaemonClient( diff --git a/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py b/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py index 78e915f1..ca87501a 100644 --- a/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py +++ b/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py @@ -72,6 +72,8 @@ class BdDaemonClient(BdClientBase): async def _find_socket_path(self) -> str: """Find daemon socket path by searching for .beads directory. + + Checks local .beads/bd.sock first, then falls back to global ~/.beads/bd.sock. Returns: Path to bd.sock file @@ -90,19 +92,26 @@ class BdDaemonClient(BdClientBase): sock_path = beads_dir / "bd.sock" if sock_path.exists(): return str(sock_path) - # Found .beads but no socket - daemon not running - raise DaemonNotRunningError( - f"Daemon socket not found at {sock_path}. Is the daemon running? Try: bd daemon" - ) + # Found .beads but no socket - check global before failing + break # Move up one directory parent = current.parent if parent == current: - # Reached filesystem root - raise DaemonNotRunningError( - "No .beads directory found. Initialize with: bd init" - ) + # Reached filesystem root - check global + break current = parent + + # Check for global daemon socket at ~/.beads/bd.sock + home = Path.home() + global_sock_path = home / ".beads" / "bd.sock" + if global_sock_path.exists(): + return str(global_sock_path) + + # No socket found anywhere + raise DaemonNotRunningError( + "Daemon socket not found. Is the daemon running? Try: bd daemon (local) or bd daemon --global" + ) async def _send_request(self, operation: str, args: Dict[str, Any]) -> Dict[str, Any]: """Send RPC request to daemon and get response.