package main import ( "fmt" "os" "path/filepath" "strings" "text/tabwriter" "time" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/ui" ) // DaemonStatusReport is a single daemon status entry for JSON output type DaemonStatusReport struct { Workspace string `json:"workspace"` PID int `json:"pid,omitempty"` Version string `json:"version,omitempty"` Status string `json:"status"` Issue string `json:"issue,omitempty"` Started string `json:"started,omitempty"` UptimeSeconds float64 `json:"uptime_seconds,omitempty"` AutoCommit bool `json:"auto_commit,omitempty"` AutoPush bool `json:"auto_push,omitempty"` AutoPull bool `json:"auto_pull,omitempty"` LocalMode bool `json:"local_mode,omitempty"` SyncInterval string `json:"sync_interval,omitempty"` DaemonMode string `json:"daemon_mode,omitempty"` LogPath string `json:"log_path,omitempty"` VersionMismatch bool `json:"version_mismatch,omitempty"` IsCurrent bool `json:"is_current,omitempty"` } // DaemonStatusAllResponse is returned for --all mode type DaemonStatusAllResponse struct { Total int `json:"total"` Healthy int `json:"healthy"` Outdated int `json:"outdated"` Stale int `json:"stale"` Unresponsive int `json:"unresponsive"` Daemons []DaemonStatusReport `json:"daemons"` } var daemonStatusCmd = &cobra.Command{ Use: "status", Short: "Show daemon status", Long: `Show status of the current workspace's daemon, or all daemons with --all. Examples: bd daemon status # Current workspace daemon bd daemon status --all # All running daemons`, Run: func(cmd *cobra.Command, args []string) { showAll, _ := cmd.Flags().GetBool("all") if showAll { showAllDaemonsStatus(cmd) } else { showCurrentDaemonStatus() } }, } func init() { daemonStatusCmd.Flags().Bool("all", false, "Show status of all daemons") daemonStatusCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (with --all)") } // shortenPath replaces home directory with ~ for display func shortenPath(p string) string { home, err := os.UserHomeDir() if err != nil { return p } if strings.HasPrefix(p, home) { return "~" + p[len(home):] } return p } // formatRelativeTime formats a time as relative (e.g., "2h ago") func formatRelativeTime(t time.Time) string { d := time.Since(t) if d < time.Minute { return "just now" } else if d < time.Hour { mins := int(d.Minutes()) if mins == 1 { return "1m ago" } return fmt.Sprintf("%dm ago", mins) } else if d < 24*time.Hour { hours := int(d.Hours()) if hours == 1 { return "1h ago" } return fmt.Sprintf("%dh ago", hours) } days := int(d.Hours() / 24) if days == 1 { return "1d ago" } return fmt.Sprintf("%dd ago", days) } // formatBoolIcon returns a styled checkmark or dash for boolean values func formatBoolIcon(enabled bool) string { if enabled { return ui.RenderPass(ui.IconPass) } return ui.RenderMuted("-") } // renderDaemonStatusIcon renders status with semantic styling func renderDaemonStatusIcon(status string) string { switch status { case "healthy", "running": return ui.RenderPass(ui.IconPass + " " + status) case "outdated", "version_mismatch": return ui.RenderWarn(ui.IconWarn + " outdated") case "stale": return ui.RenderWarn(ui.IconWarn + " stale") case "unresponsive": return ui.RenderFail(ui.IconFail + " unresponsive") case "not_running": return ui.RenderMuted("○ not running") default: return status } } // showCurrentDaemonStatus shows detailed status for current workspace daemon func showCurrentDaemonStatus() { pidFile, err := getPIDFilePath() if err != nil { if jsonOutput { outputJSON(map[string]string{"error": err.Error()}) } else { fmt.Fprintf(os.Stderr, "Error: %v\n", err) } os.Exit(1) } beadsDir := filepath.Dir(pidFile) socketPath := filepath.Join(beadsDir, "bd.sock") workspacePath := filepath.Dir(beadsDir) // Check if daemon is running isRunning, pid := isDaemonRunning(pidFile) if !isRunning { if jsonOutput { outputJSON(DaemonStatusReport{ Workspace: workspacePath, Status: "not_running", }) } else { fmt.Printf("%s\n\n", renderDaemonStatusIcon("not_running")) fmt.Printf(" Workspace: %s\n", shortenPath(workspacePath)) fmt.Printf("\n To start: bd daemon start\n") } return } // Get detailed status via RPC var rpcStatus *rpc.StatusResponse if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil { if status, err := client.Status(); err == nil { rpcStatus = status } _ = client.Close() } // Get started time from PID file var startedTime time.Time if info, err := os.Stat(pidFile); err == nil { startedTime = info.ModTime() } // Determine daemon version and check for mismatch daemonVersion := "" versionMismatch := false if rpcStatus != nil { daemonVersion = rpcStatus.Version if daemonVersion != "" && daemonVersion != Version { versionMismatch = true } } // Determine status status := "running" issue := "" if versionMismatch { status = "outdated" issue = fmt.Sprintf("daemon %s != cli %s", daemonVersion, Version) } // Get log path logPath := filepath.Join(beadsDir, "daemon.log") if _, err := os.Stat(logPath); err != nil { logPath = "" } if jsonOutput { report := DaemonStatusReport{ Workspace: workspacePath, PID: pid, Version: daemonVersion, Status: status, Issue: issue, LogPath: logPath, VersionMismatch: versionMismatch, IsCurrent: true, } if !startedTime.IsZero() { report.Started = startedTime.Format(time.RFC3339) } if rpcStatus != nil { report.UptimeSeconds = rpcStatus.UptimeSeconds report.AutoCommit = rpcStatus.AutoCommit report.AutoPush = rpcStatus.AutoPush report.AutoPull = rpcStatus.AutoPull report.LocalMode = rpcStatus.LocalMode report.SyncInterval = rpcStatus.SyncInterval report.DaemonMode = rpcStatus.DaemonMode } outputJSON(report) return } // Human-readable output with semantic styling // Status line versionStr := "" if daemonVersion != "" { versionStr = fmt.Sprintf(", v%s", daemonVersion) } if versionMismatch { fmt.Printf("%s (PID %d%s)\n", renderDaemonStatusIcon("outdated"), pid, versionStr) fmt.Printf(" %s\n\n", ui.RenderWarn(fmt.Sprintf("CLI version: %s", Version))) } else { fmt.Printf("%s (PID %d%s)\n\n", renderDaemonStatusIcon("running"), pid, versionStr) } // Details fmt.Printf(" Workspace: %s\n", shortenPath(workspacePath)) if !startedTime.IsZero() { fmt.Printf(" Started: %s (%s)\n", startedTime.Format("2006-01-02 15:04:05"), formatRelativeTime(startedTime)) } if rpcStatus != nil { fmt.Printf(" Mode: %s\n", rpcStatus.DaemonMode) fmt.Printf(" Interval: %s\n", rpcStatus.SyncInterval) // Compact sync flags display syncFlags := []string{} if rpcStatus.AutoCommit { syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" commit") } if rpcStatus.AutoPush { syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" push") } if rpcStatus.AutoPull { syncFlags = append(syncFlags, ui.RenderPass(ui.IconPass)+" pull") } if len(syncFlags) > 0 { fmt.Printf(" Sync: %s\n", strings.Join(syncFlags, " ")) } else { fmt.Printf(" Sync: %s\n", ui.RenderMuted("none")) } if rpcStatus.LocalMode { fmt.Printf(" Local: %s\n", ui.RenderWarn("yes (no git sync)")) } } if logPath != "" { // Show relative path for log relLog := ".beads/daemon.log" fmt.Printf(" Log: %s\n", relLog) } // Show hint about other daemons daemons, err := daemon.DiscoverDaemons(nil) if err == nil { aliveCount := 0 for _, d := range daemons { if d.Alive { aliveCount++ } } if aliveCount > 1 { fmt.Printf("\n %s\n", ui.RenderMuted(fmt.Sprintf("%d other daemon(s) running (bd daemon status --all)", aliveCount-1))) } } } // showAllDaemonsStatus shows status of all daemons func showAllDaemonsStatus(cmd *cobra.Command) { searchRoots, _ := cmd.Flags().GetStringSlice("search") // Discover daemons daemons, err := daemon.DiscoverDaemons(searchRoots) if err != nil { if jsonOutput { outputJSON(map[string]string{"error": err.Error()}) } else { fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err) } os.Exit(1) } // Auto-cleanup stale sockets if cleaned, err := daemon.CleanupStaleSockets(daemons); err == nil && cleaned > 0 && !jsonOutput { fmt.Fprintf(os.Stderr, "Cleaned up %d stale socket(s)\n", cleaned) } // Get current workspace to mark it currentWorkspace := "" if pidFile, err := getPIDFilePath(); err == nil { beadsDir := filepath.Dir(pidFile) currentWorkspace = filepath.Dir(beadsDir) } currentVersion := Version var reports []DaemonStatusReport healthyCount := 0 outdatedCount := 0 staleCount := 0 unresponsiveCount := 0 for _, d := range daemons { report := DaemonStatusReport{ Workspace: d.WorkspacePath, PID: d.PID, Version: d.Version, IsCurrent: d.WorkspacePath == currentWorkspace, } if !d.Alive { report.Status = "stale" report.Issue = d.Error staleCount++ } else if d.Version != "" && d.Version != currentVersion { report.Status = "outdated" report.Issue = fmt.Sprintf("daemon %s != cli %s", d.Version, currentVersion) report.VersionMismatch = true outdatedCount++ } else { report.Status = "healthy" healthyCount++ } reports = append(reports, report) } if jsonOutput { outputJSON(DaemonStatusAllResponse{ Total: len(reports), Healthy: healthyCount, Outdated: outdatedCount, Stale: staleCount, Unresponsive: unresponsiveCount, Daemons: reports, }) return } // Human-readable output if len(reports) == 0 { fmt.Println("No daemons found") return } // Summary line fmt.Printf("Daemons: %d total", len(reports)) if healthyCount > 0 { fmt.Printf(", %s", ui.RenderPass(fmt.Sprintf("%d healthy", healthyCount))) } if outdatedCount > 0 { fmt.Printf(", %s", ui.RenderWarn(fmt.Sprintf("%d outdated", outdatedCount))) } if staleCount > 0 { fmt.Printf(", %s", ui.RenderWarn(fmt.Sprintf("%d stale", staleCount))) } if unresponsiveCount > 0 { fmt.Printf(", %s", ui.RenderFail(fmt.Sprintf("%d unresponsive", unresponsiveCount))) } fmt.Println() fmt.Println() // Table w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintln(w, " WORKSPACE\tPID\tVERSION\tSTATUS") for _, r := range reports { workspace := shortenPath(r.Workspace) if workspace == "" { workspace = "(unknown)" } // Add arrow for current workspace prefix := " " if r.IsCurrent { prefix = ui.RenderAccent("→ ") } pidStr := "-" if r.PID != 0 { pidStr = fmt.Sprintf("%d", r.PID) } version := r.Version if version == "" { version = "-" } // Render status with icon and color var statusDisplay string switch r.Status { case "healthy": statusDisplay = ui.RenderPass(ui.IconPass + " healthy") case "outdated": statusDisplay = ui.RenderWarn(ui.IconWarn + " outdated") // Add version hint statusDisplay += ui.RenderMuted(fmt.Sprintf(" (cli: %s)", currentVersion)) case "stale": statusDisplay = ui.RenderWarn(ui.IconWarn + " stale") case "unresponsive": statusDisplay = ui.RenderFail(ui.IconFail + " unresponsive") default: statusDisplay = r.Status } _, _ = fmt.Fprintf(w, "%s%s\t%s\t%s\t%s\n", prefix, workspace, pidStr, version, statusDisplay) } _ = w.Flush() // Exit with error if there are issues if outdatedCount > 0 || staleCount > 0 || unresponsiveCount > 0 { os.Exit(1) } }