Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a110829
fix: route BrainBar search through Python hybrid helper
EtanHey May 18, 2026
e0323c7
fix: harden BrainBar hybrid helper startup
EtanHey May 18, 2026
c232dc2
fix: address BrainBar hybrid review followups
EtanHey May 18, 2026
f8e2b8e
fix: report hybrid helper launch failures
EtanHey May 18, 2026
aa5448d
fix: keep source all unfiltered for entity routing
EtanHey May 18, 2026
fee3ae3
fix: bound hybrid helper socket waits
EtanHey May 18, 2026
f3d4af5
fix: harden hybrid helper fallback cleanup
EtanHey May 18, 2026
942d195
fix: sanitize hybrid helper responses
EtanHey May 18, 2026
b65df04
feat: stream BrainBar brain bus events
EtanHey May 18, 2026
82d02ae
feat: stream BrainBar brain bus events
EtanHey May 18, 2026
740e90c
refactor: use NSStatusItem popover shell
EtanHey May 18, 2026
d984f06
refactor: use NSStatusItem popover shell
EtanHey May 18, 2026
3d8ec78
fix: prevent SIGPIPE from hybrid helper socket writes
EtanHey May 18, 2026
ea9064f
fix: route BrainBar search through Python hybrid helper
EtanHey May 18, 2026
0564f88
fix: harden BrainBar hybrid helper startup
EtanHey May 18, 2026
34f5bc6
fix: address BrainBar hybrid review followups
EtanHey May 18, 2026
1f1e3e5
fix: report hybrid helper launch failures
EtanHey May 18, 2026
6c66db2
fix: keep source all unfiltered for entity routing
EtanHey May 18, 2026
5768b4b
fix: bound hybrid helper socket waits
EtanHey May 18, 2026
ecdafd5
fix: harden hybrid helper fallback cleanup
EtanHey May 18, 2026
4b2b4ce
fix: sanitize hybrid helper responses
EtanHey May 18, 2026
ca64399
feat: stream BrainBar brain bus events
EtanHey May 18, 2026
28343e6
refactor: use NSStatusItem popover shell
EtanHey May 18, 2026
17750c2
fix: prevent SIGPIPE from hybrid helper socket writes
EtanHey May 18, 2026
6348695
fix: serialize BrainBus socket writes
EtanHey May 18, 2026
183da6a
Merge remote PR branch after main rebase
EtanHey May 18, 2026
fed97bb
refactor: split BrainBar daemon and UI (#298)
EtanHey May 18, 2026
6e29ad9
fix: refresh BrainBar stats before bus events
EtanHey May 18, 2026
7da8ae8
Merge remote-tracking branch 'origin/fix/brainbar-hybrid-parity' into…
EtanHey May 18, 2026
7286baf
Merge remote-tracking branch 'origin/main' into fix/brainbar-hybrid-p…
EtanHey May 18, 2026
095bc5b
test: wait for BrainBar socket readiness
EtanHey May 18, 2026
47d829a
test: relax arbitration drain deadline
EtanHey May 18, 2026
931fa93
fix: open BrainBar UI stats database read-only (#300)
EtanHey May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ graph LR
KG --> D
E["JSONL conversations"] --> W["Real-time Watcher<br/>~1s latency"]
W --> D
I["BrainBar<br/>macOS menu bar"] -->|Unix socket| B
I["BrainBar UI<br/>NSStatusItem + NSPopover"] -->|UDS /tmp/brainbar.sock| BB["BrainBarDaemon<br/>MCP + brain bus"]
BB -->|MCP socket protocol| B
```

**Everything runs locally.** Cloud enrichment (Gemini/Groq) and Axiom telemetry are optional.
Expand All @@ -138,13 +139,22 @@ graph LR

## BrainBar — macOS Companion

Optional native Swift menu bar app. Quick capture, live dashboard, knowledge graph viewer — all over a Unix socket. Auto-restarts after quit via LaunchAgent.
Optional native Swift menu bar companion split into two launchd-managed processes:

```mermaid
flowchart LR
UI["BrainBar<br/>LSUIElement UI"] -->|"watch-brain-bus + commands<br/>/tmp/brainbar.sock"| D["BrainBarDaemon<br/>headless MCP server"]
D -->|"single writer queue + reads"| DB["SQLite WAL<br/>~/.local/share/brainlayer/brainlayer.db"]
D -->|"helper subprocess IPC"| H["Hybrid search helper"]
```

`BrainBarDaemon` owns the MCP server, `/tmp/brainbar.sock`, the single-writer path, the `watch-brain-bus` stream, and helper subprocess lifecycle. `BrainBar` owns only the `NSStatusItem`, transient `NSPopover`, SwiftUI surfaces, hotkey routing, and a reconnecting socket subscriber. Killing the UI does not stop the daemon socket.

```bash
bash brain-bar/build-app.sh # Build, sign, install LaunchAgent
```

Requires the BrainLayer MCP server. The build script refuses non-canonical checkouts and dirty trees by default ([#265](https://github.com/EtanHey/brainlayer/pull/265)) and stamps each bundle with `GitCommit`, `GitDescribe`, and `BuildTimeUTC` in `Info.plist` ([#264](https://github.com/EtanHey/brainlayer/pull/264)) so a stale install is diagnosable in seconds.
The build script builds both `BrainBar` and `BrainBarDaemon`, embeds both binaries in `BrainBar.app`, then installs `com.brainlayer.brainbar.plist` and `com.brainlayer.brainbar-daemon.plist` with `ProcessType=Interactive`. It refuses non-canonical checkouts and dirty trees by default ([#265](https://github.com/EtanHey/brainlayer/pull/265)) and stamps each bundle with `GitCommit`, `GitDescribe`, and `BuildTimeUTC` in `Info.plist` ([#264](https://github.com/EtanHey/brainlayer/pull/264)) so a stale install is diagnosable in seconds.

## Writer Arbitration

Expand Down
14 changes: 14 additions & 0 deletions brain-bar/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ let package = Package(
platforms: [
.macOS(.v14),
],
products: [
.executable(name: "BrainBar", targets: ["BrainBar"]),
.executable(name: "BrainBarDaemon", targets: ["BrainBarDaemon"]),
],
dependencies: [
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.5.0"),
],
Expand All @@ -20,6 +24,16 @@ let package = Package(
.linkedLibrary("sqlite3"),
]
),
.executableTarget(
name: "BrainBarDaemon",
dependencies: [
.product(name: "GRDB", package: "GRDB.swift"),
],
path: "Sources/BrainBarDaemon",
linkerSettings: [
.linkedLibrary("sqlite3"),
]
),
.testTarget(
name: "BrainBarTests",
dependencies: [
Expand Down
172 changes: 62 additions & 110 deletions brain-bar/Sources/BrainBar/BrainBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,51 @@ enum BrainBarAppSupport {
static func hotkeyPermissionFailureMessage(permissions: HotkeyPermissionStatus) -> String {
"BrainBar could not start the fallback hotkey listener. Enable \(permissions.missingPermissionsMessage) in System Settings. The CGEventTap fallback requires both Input Monitoring and Accessibility."
}

@MainActor
static func makeStatsCollector(
dbPath: String,
targetPID: pid_t,
brainBusEvents: BrainBusEventSource? = BrainBusClient(),
databaseOpenConfiguration: BrainDatabase.OpenConfiguration = BrainDatabase.OpenConfiguration()
) -> StatsCollector {
StatsCollector(
dbPath: dbPath,
daemonMonitor: DaemonHealthMonitor(targetPID: targetPID),
brainBusEvents: brainBusEvents,
databaseOpenConfiguration: databaseOpenConfiguration
)
}

@MainActor
static func makeUIStatsCollector(
dbPath: String,
brainBusEvents: BrainBusEventSource? = BrainBusClient()
) -> StatsCollector {
makeStatsCollector(
dbPath: dbPath,
targetPID: 0,
brainBusEvents: brainBusEvents,
databaseOpenConfiguration: BrainDatabase.OpenConfiguration(readOnly: true)
)
}
}

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let runtime = BrainBarRuntime()
private static let menuBarWindowAutosaveKey = "NSWindow Frame BrainBarMenuBarExtraWindow"

private var server: BrainBarServer?
private var statusPopoverController: BrainBarStatusPopoverController?
private var legacyStatusItem: NSStatusItem?
private var legacyPopover: NSPopover?
private var collector: StatsCollector?
private var injectionStore: InjectionStore?
private var quickCapturePanel: QuickCapturePanelController?
private var searchPanel: SearchPanelController?
private var dashboardPanel: BrainBarDashboardPanelController?
private var quickCaptureHotkey: HotkeyManager?
private weak var menuBarExtraWindow: NSWindow?
private weak var discoveredMenuBarWindow: NSWindow?
private var cancellables: Set<AnyCancellable> = []
private var sharedDatabase: BrainDatabase?
private var pendingBrainBarURLs: [URL] = []
private var hotkeyFileWatcher: DispatchSourceFileSystemObject?
private var menuBarWindowObservers: [NSObjectProtocol] = []
Expand All @@ -47,11 +72,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

startHotkeyFileWatcher()

if launchMode == .menuBarWindow {
UserDefaults.standard.removeObject(forKey: Self.menuBarWindowAutosaveKey)
dashboardPanel = BrainBarDashboardPanelController(runtime: runtime)
}

let runningInstances = NSRunningApplication.runningApplications(
withBundleIdentifier: Bundle.main.bundleIdentifier ?? "com.brainlayer.BrainBar"
)
Expand All @@ -69,47 +89,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.configureQuickCaptureHotkey()
}

if launchMode == .legacyStatusItem {
if launchMode == .menuBarWindow {
statusPopoverController = BrainBarStatusPopoverController(runtime: runtime)
} else if launchMode == .legacyStatusItem {
createLegacyStatusItem()
}

let dbPath = BrainBarServer.defaultDBPath()
NSLog("[BrainBar] Starting server before database readiness at %@", dbPath)
let server = BrainBarServer(dbPath: dbPath)
server.onStartRejected = { reason in
NSLog("[BrainBar] Startup rejected: %@", reason)
Task { @MainActor in
NSApp.terminate(nil)
}
}
server.onDatabaseReady = { [weak self] database in
Task { @MainActor in
guard let self else { return }
self.sharedDatabase = database
self.configureQuickCapture(database: database)
self.runtime.install(
collector: self.collector ?? StatsCollector(
dbPath: dbPath,
daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier)
),
injectionStore: self.injectionStore,
database: database
)
self.flushPendingBrainBarURLs()
}
}

let collector = StatsCollector(
NSLog("[BrainBar] Starting UI shell; daemon owns %@", BrainBarServer.defaultSocketPath())
let collector = BrainBarAppSupport.makeUIStatsCollector(
dbPath: dbPath,
daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier)
brainBusEvents: BrainBusClient()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI pipeline state always shows degraded with PID 0

Medium Severity

makeUIStatsCollector passes targetPID: 0 to DaemonHealthMonitor. Since DaemonHealthMonitor.sample() has guard targetPID > 0 else { return nil }, it always returns nil. Both refresh and handleBrainBusEvent(.healthTick) derive pipeline state from this nil snapshot via PipelineState.derive(daemon: nil, ...), which the test suite confirms always yields .degraded. The UI sparkline and status indicators will perpetually show degraded state even while the daemon is healthy and actively sending healthTick events over BrainBus.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 931fa93. Configure here.

)
let injectionStore = try? InjectionStore(databasePath: dbPath)

self.server = server
self.collector = collector
self.injectionStore = injectionStore
runtime.install(
collector: collector,
injectionStore: nil,
database: nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore a database-backed command bar

When BrainBar is launched in the normal menu-bar UI mode, quick search/capture now only calls runtime.presentQuickAction, but BrainBarWindowRootView.handleRequestedQuickAction immediately returns unless commandBarProvider.viewModel(database: runtime.database) can build a view model. This install path permanently sets runtime.database to nil and no longer updates it from onDatabaseReady, so hotkey/URL/status-popover search and capture requests are left pending and the command bar never opens. Either keep a read/write DB handle for the UI command bar or route those actions through the daemon before dropping the database from runtime.

Useful? React with 👍 / 👎.

)
Comment on lines +105 to +109
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Install a database in the UI runtime

When the UI shell starts in the default menu-bar mode, this now installs runtime.database as nil and never updates it later. The command bar depends on runtime.database to create its QuickCaptureViewModel (BrainBarWindowRootView.commandBarViewModel returns nil until a database exists), so Search/Capture hotkeys and brainbar://search only open the popover but leave the pending action stuck; the Graph tab also stays permanently unavailable. The daemon can own writes, but the UI still needs a read-only DB handle or an IPC-backed command view model here.

Useful? React with 👍 / 👎.


server.start()
flushPendingBrainBarURLs()

if launchMode == .legacyStatusItem {
installLegacyMenuBarSurface(with: collector)
Expand All @@ -123,13 +122,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
menuBarWindowObservers.forEach(NotificationCenter.default.removeObserver)
menuBarWindowObservers.removeAll()
statusPopoverController?.stop()
statusPopoverController = nil
menuBarWindowSyncTask?.cancel()
menuBarWindowSyncTask = nil
hotkeyFileWatcher?.cancel()
quickCaptureHotkey?.stop()
collector?.stop()
injectionStore?.stop()
server?.stop()
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
Expand All @@ -147,17 +146,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

runtime.presentQuickAction(.search)
showMenuBarWindow(nil)
statusPopoverController?.show(nil)
}

func showQuickCapturePanel() {
guard launchMode == .menuBarWindow else {
quickCapturePanel?.toggle()
NSLog("[BrainBar] Quick capture requires the menu-bar command surface in UI-split mode")
return
}

runtime.presentQuickAction(.capture)
showMenuBarWindow(nil)
statusPopoverController?.show(nil)
}

private func configureRuntimeCallbacks() {
Expand Down Expand Up @@ -236,6 +235,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}

if launchMode == .menuBarWindow, let statusPopoverController {
statusPopoverController.toggle(sender)
return
}

if let dashboardPanel {
dashboardPanel.toggle()
return
Expand Down Expand Up @@ -284,10 +288,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}

if let statusPopoverController {
statusPopoverController.show(nil)
return
}

dashboardPanel?.show()
}

private func showMenuBarWindow(_ sender: Any?) {
if launchMode == .menuBarWindow, let statusPopoverController {
statusPopoverController.show(sender)
return
}

if launchMode == .menuBarWindow, let dashboardPanel {
dashboardPanel.show()
return
Expand Down Expand Up @@ -797,16 +811,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

private func configureQuickCapture(database: BrainDatabase) {
guard launchMode == .legacyStatusItem else { return }
guard quickCapturePanel == nil else { return }
quickCapturePanel = QuickCapturePanelController(db: database)
if searchPanel == nil {
searchPanel = SearchPanelController(db: database)
}
flushPendingBrainBarURLs()
}

private func ingestBrainBarURLs(_ urls: [URL]) {
for url in urls {
guard BrainBarURLAction.parse(url: url) != nil else { continue }
Expand All @@ -827,18 +831,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

/// URL actions are dispatched once the backing surface is ready. In
/// menuBarWindow mode the command bar is driven by `runtime.database` +
/// the MenuBarExtra window, so readiness means the database has been
/// installed into the runtime. In legacy mode the floating panel still
/// drives routing.
private func isReadyToHandleBrainBarURL() -> Bool {
switch launchMode {
case .menuBarWindow:
return runtime.database != nil
case .legacyStatusItem:
return quickCapturePanel != nil
}
true
}

private func handleBrainBarURL(_ url: URL) {
Expand All @@ -859,26 +853,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@main
struct BrainBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
private let launchMode = BrainBarLaunchMode.resolve()

var body: some Scene {
MenuBarExtra(isInserted: .constant(launchMode == .menuBarWindow)) {
Button("Open Dashboard") {
appDelegate.showDashboardPanel()
}

Button("Search BrainLayer") {
appDelegate.showSearchPanel()
}

Button("Capture Note") {
appDelegate.showQuickCapturePanel()
}
} label: {
BrainBarMenuBarLabel(runtime: appDelegate.runtime)
}
.menuBarExtraStyle(.menu)

Settings {
EmptyView()
}
Expand All @@ -900,27 +876,3 @@ struct BrainBarApp: App {
}
}
}

private struct BrainBarMenuBarLabel: View {
@ObservedObject var runtime: BrainBarRuntime

var body: some View {
if let collector = runtime.collector {
let livePresentation = BrainBarLivePresentation.derive(stats: collector.stats)
HStack(spacing: 6) {
Image(systemName: "brain")
Image(
nsImage: SparklineRenderer.render(
state: collector.state,
values: collector.stats.recentEnrichmentBuckets,
size: NSSize(width: 22, height: 12),
accentColor: livePresentation.accentColor
)
)
.interpolation(.high)
}
} else {
Image(systemName: "brain")
}
}
}
2 changes: 1 addition & 1 deletion brain-bar/Sources/BrainBar/BrainBarRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ final class BrainBarRuntime: ObservableObject {
func install(
collector: StatsCollector,
injectionStore: InjectionStore?,
database: BrainDatabase
database: BrainDatabase?
) {
self.collector = collector
self.injectionStore = injectionStore
Expand Down
Loading
Loading