feat(daemon): add menu bar app showing running tasks

- Track active tasks on daemon struct with start time and metadata
- Expose active tasks via /health endpoint (active_tasks field)
- Show running tasks in `multica daemon status` output
- Create native SwiftUI menu bar app (apps/menubar/) that:
  - Polls daemon health endpoint every 3s
  - Shows daemon status (running/stopped, uptime, agents, workspaces)
  - Lists currently running tasks with agent name, issue ID, duration
  - Allows starting the daemon if stopped
- Add `make menubar` build target
This commit is contained in:
Jiang Bohan
2026-04-01 17:05:12 +08:00
parent 57e48c1d6b
commit 07d542e3ff
8 changed files with 337 additions and 18 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: dev daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
.PHONY: dev daemon cli multica build menubar test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -135,6 +135,10 @@ build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
menubar:
cd apps/menubar && swift build -c release
@echo "✓ Menu bar app built: apps/menubar/.build/release/MulticaMenuBar"
test:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"

1
apps/menubar/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.build/

View File

@@ -0,0 +1,13 @@
// swift-tools-version: 5.8
import PackageDescription
let package = Package(
name: "MulticaMenuBar",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(
name: "MulticaMenuBar",
path: "Sources/MulticaMenuBar"
)
]
)

View File

@@ -0,0 +1,106 @@
import SwiftUI
@main
struct MulticaMenuBarApp: App {
@StateObject private var client = DaemonClient(autoStart: true)
var body: some Scene {
MenuBarExtra {
MenuBarView(client: client)
} label: {
MenuBarLabel(client: client)
}
}
}
/// The menu bar icon and badge.
struct MenuBarLabel: View {
@ObservedObject var client: DaemonClient
var body: some View {
let taskCount = client.activeTasks.count
if client.isRunning && taskCount > 0 {
Label("\(taskCount)", systemImage: "bolt.fill")
} else if client.isRunning {
Image(systemName: "bolt.fill")
} else {
Image(systemName: "bolt.slash")
}
}
}
/// The dropdown menu content.
struct MenuBarView: View {
@ObservedObject var client: DaemonClient
var body: some View {
if client.isRunning, let health = client.health {
Section("Daemon") {
Label("Running (pid \(health.pid))", systemImage: "checkmark.circle.fill")
Label("Uptime: \(health.uptime)", systemImage: "clock")
Label("Agents: \(health.agents.joined(separator: ", "))", systemImage: "cpu")
Label("Workspaces: \(health.workspaces.count)", systemImage: "folder")
}
Divider()
if client.activeTasks.isEmpty {
Section("Tasks") {
Label("No running tasks", systemImage: "tray")
.foregroundStyle(.secondary)
}
} else {
Section("Running Tasks (\(client.activeTasks.count))") {
ForEach(client.activeTasks) { task in
VStack(alignment: .leading, spacing: 2) {
Label {
Text("\(task.agentName) / \(task.provider)")
.fontWeight(.medium)
} icon: {
Image(systemName: "gearshape.2.fill")
}
Text("Issue: \(task.shortIssueID) · \(task.duration)")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
}
} else {
Section("Daemon") {
Label("Stopped", systemImage: "xmark.circle")
.foregroundStyle(.secondary)
}
Divider()
Button("Start Daemon") {
startDaemon()
}
}
Divider()
Button("Refresh") {
// Force a poll
client.stopPolling()
client.startPolling()
}
.keyboardShortcut("r")
Button("Quit") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q")
}
private func startDaemon() {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["multica", "daemon", "start"]
process.standardOutput = nil
process.standardError = nil
try? process.run()
}
}

View File

@@ -0,0 +1,129 @@
import Foundation
/// Represents a task currently being executed by the daemon.
struct ActiveTask: Codable, Identifiable {
let id: String
let issueID: String
let workspaceID: String
let agentName: String
let provider: String
let startedAt: Date
enum CodingKeys: String, CodingKey {
case id
case issueID = "issue_id"
case workspaceID = "workspace_id"
case agentName = "agent_name"
case provider
case startedAt = "started_at"
}
var shortID: String {
String(id.prefix(8))
}
var shortIssueID: String {
String(issueID.prefix(8))
}
var duration: String {
let elapsed = Date().timeIntervalSince(startedAt)
let minutes = Int(elapsed) / 60
let seconds = Int(elapsed) % 60
if minutes > 0 {
return "\(minutes)m \(seconds)s"
}
return "\(seconds)s"
}
}
/// Health response from the daemon's local endpoint.
struct HealthResponse: Codable {
let status: String
let pid: Int
let uptime: String
let daemonID: String
let deviceName: String
let serverURL: String
let agents: [String]
let workspaces: [HealthWorkspace]
let activeTasks: [ActiveTask]?
enum CodingKeys: String, CodingKey {
case status, pid, uptime, agents, workspaces
case daemonID = "daemon_id"
case deviceName = "device_name"
case serverURL = "server_url"
case activeTasks = "active_tasks"
}
}
struct HealthWorkspace: Codable {
let id: String
let runtimes: [String]
}
/// Client for polling the daemon's local health endpoint.
@MainActor
final class DaemonClient: ObservableObject {
@Published var isRunning = false
@Published var health: HealthResponse?
@Published var activeTasks: [ActiveTask] = []
private let port: Int
private var timer: Timer?
private lazy var decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
init(port: Int = 19514, autoStart: Bool = false) {
self.port = port
if autoStart {
// Defer to next run loop to allow MainActor context.
DispatchQueue.main.async { [weak self] in
self?.startPolling()
}
}
}
func startPolling(interval: TimeInterval = 3.0) {
poll()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.poll()
}
}
}
func stopPolling() {
timer?.invalidate()
timer = nil
}
private func poll() {
let url = URL(string: "http://127.0.0.1:\(port)/health")!
var request = URLRequest(url: url)
request.timeoutInterval = 2
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
Task { @MainActor [weak self] in
guard let self else { return }
guard let data, error == nil,
let resp = try? self.decoder.decode(HealthResponse.self, from: data) else {
self.isRunning = false
self.health = nil
self.activeTasks = []
return
}
self.isRunning = resp.status == "running"
self.health = resp
self.activeTasks = resp.activeTasks ?? []
}
}.resume()
}
}

View File

@@ -372,6 +372,20 @@ func runDaemonStatus(cmd *cobra.Command, _ []string) error {
if ws, ok := health["workspaces"].([]any); ok {
fmt.Fprintf(os.Stdout, "Workspaces: %d\n", len(ws))
}
if tasks, ok := health["active_tasks"].([]any); ok && len(tasks) > 0 {
fmt.Fprintf(os.Stdout, "Tasks: %d running\n", len(tasks))
for _, t := range tasks {
if tm, ok := t.(map[string]any); ok {
agentName, _ := tm["agent_name"].(string)
issueID, _ := tm["issue_id"].(string)
provider, _ := tm["provider"].(string)
if agentName == "" {
agentName = provider
}
fmt.Fprintf(os.Stdout, " • %s working on issue %s\n", agentName, shortIssueID(issueID))
}
}
}
return nil
}
@@ -421,6 +435,14 @@ func checkDaemonHealthOnPort(ctx context.Context, port int) map[string]any {
return result
}
// shortIssueID returns the first 8 characters of an issue ID.
func shortIssueID(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}
// flagString returns a string flag value or empty string.
func flagString(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)

View File

@@ -24,6 +24,16 @@ type workspaceState struct {
runtimeIDs []string
}
// ActiveTask represents a task currently being executed by the daemon.
type ActiveTask struct {
ID string `json:"id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
AgentName string `json:"agent_name"`
Provider string `json:"provider"`
StartedAt time.Time `json:"started_at"`
}
// Daemon is the local agent runtime that polls for and executes tasks.
type Daemon struct {
cfg Config
@@ -34,7 +44,8 @@ type Daemon struct {
mu sync.Mutex
workspaces map[string]*workspaceState
runtimeIndex map[string]Runtime // runtimeID -> Runtime for provider lookups
reloading sync.Mutex // prevents concurrent reloadWorkspaces
activeTasks map[string]*ActiveTask // taskID -> ActiveTask
reloading sync.Mutex // prevents concurrent reloadWorkspaces
}
// New creates a new Daemon instance.
@@ -47,6 +58,7 @@ func New(cfg Config, logger *slog.Logger) *Daemon {
logger: logger,
workspaces: make(map[string]*workspaceState),
runtimeIndex: make(map[string]Runtime),
activeTasks: make(map[string]*ActiveTask),
}
}
@@ -658,6 +670,17 @@ func (d *Daemon) pollLoop(ctx context.Context) error {
}
}
// GetActiveTasks returns a snapshot of currently running tasks.
func (d *Daemon) GetActiveTasks() []*ActiveTask {
d.mu.Lock()
defer d.mu.Unlock()
tasks := make([]*ActiveTask, 0, len(d.activeTasks))
for _, t := range d.activeTasks {
tasks = append(tasks, t)
}
return tasks
}
func (d *Daemon) handleTask(ctx context.Context, task Task) {
d.mu.Lock()
rt := d.runtimeIndex[task.RuntimeID]
@@ -672,6 +695,23 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
}
taskLog.Info("picked task", "issue", task.IssueID, "agent", agentName, "provider", provider)
// Track active task.
d.mu.Lock()
d.activeTasks[task.ID] = &ActiveTask{
ID: task.ID,
IssueID: task.IssueID,
WorkspaceID: task.WorkspaceID,
AgentName: agentName,
Provider: provider,
StartedAt: time.Now(),
}
d.mu.Unlock()
defer func() {
d.mu.Lock()
delete(d.activeTasks, task.ID)
d.mu.Unlock()
}()
if err := d.client.StartTask(ctx, task.ID); err != nil {
taskLog.Error("start task failed", "error", err)
if failErr := d.client.FailTask(ctx, task.ID, fmt.Sprintf("start task failed: %s", err.Error())); failErr != nil {

View File

@@ -14,14 +14,15 @@ import (
// HealthResponse is returned by the daemon's local health endpoint.
type HealthResponse struct {
Status string `json:"status"`
PID int `json:"pid"`
Uptime string `json:"uptime"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
ServerURL string `json:"server_url"`
Agents []string `json:"agents"`
Workspaces []healthWorkspace `json:"workspaces"`
Status string `json:"status"`
PID int `json:"pid"`
Uptime string `json:"uptime"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
ServerURL string `json:"server_url"`
Agents []string `json:"agents"`
Workspaces []healthWorkspace `json:"workspaces"`
ActiveTasks []*ActiveTask `json:"active_tasks"`
}
type healthWorkspace struct {
@@ -69,15 +70,18 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
agents = append(agents, name)
}
activeTasks := d.GetActiveTasks()
resp := HealthResponse{
Status: "running",
PID: os.Getpid(),
Uptime: time.Since(startedAt).Truncate(time.Second).String(),
DaemonID: d.cfg.DaemonID,
DeviceName: d.cfg.DeviceName,
ServerURL: d.cfg.ServerBaseURL,
Agents: agents,
Workspaces: wsList,
Status: "running",
PID: os.Getpid(),
Uptime: time.Since(startedAt).Truncate(time.Second).String(),
DaemonID: d.cfg.DaemonID,
DeviceName: d.cfg.DeviceName,
ServerURL: d.cfg.ServerBaseURL,
Agents: agents,
Workspaces: wsList,
ActiveTasks: activeTasks,
}
w.Header().Set("Content-Type", "application/json")