Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleLocalizations</key><array><string>en</string></array>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.8.1</string>
<key>CFBundleVersion</key><string>14</string>
<key>CFBundleShortVersionString</key><string>0.8.2</string>
<key>CFBundleVersion</key><string>15</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ Broschy is a small, native macOS companion that lives around your MacBook's notc
- **Light and Dark, automatically.** Text, accents, and surfaces follow the system appearance, including Auto.
- **Motion with a purpose.** Smooth open/close transitions and small playback indicators; Reduce Motion and Reduce Transparency are respected.
- **Your layout.** Choose which tabs appear, including which modules can use the compact notch.
- **Quiet Focus.** Keep a running focus session in view while responses and build results remain available in the panel. Active agent questions and permission requests can still interrupt.
- **Quiet Focus.** Keep a running focus session in view while working-agent, error, and result indicators stay out of the compact notch. Fresh agent questions and permission requests can still interrupt.
- **Native and small.** SwiftUI, AppKit, a bundled CLI, and no third-party Swift dependencies. English throughout.

## Get started

**Current version: 0.8.1.** Broschy is an early, source-first release. Build it locally with **Xcode 26 or later** and its macOS SDK. The app's deployment target is **macOS 14**; hands-on validation has been on macOS 26 and Apple Silicon. The build produces an app for your Mac's architecture.
**Current version: 0.8.2.** [Download the Apple Silicon preview](https://github.com/SculptTechProject/Broschy/releases/tag/v0.8.2), or build it locally with **Xcode 26 or later** and its macOS SDK. The app's deployment target is **macOS 14**; hands-on validation has been on macOS 26 and Apple Silicon. The local build produces an app for your Mac's architecture.

```sh
git clone https://github.com/SculptTechProject/Broschy.git
Expand All @@ -71,13 +71,15 @@ The build script assembles `build/Broschy.app` and signs it locally with an ad h
| Hide it | **Esc** or the chevron in the panel |
| Quit Broschy | **⋯ → Quit Broschy**, the menu bar menu, or **Command + Q** while the panel has focus |

Look for Broschy's notched-screen icon in the menu bar. It is a native monochrome template that adapts to light and dark menu bars.

Hiding the panel keeps Broschy running. To reopen it after quitting, launch `Broschy.app`. Broschy has no Dock icon and does not automatically start at login. On a screen without a notch, it appears at the center of the top edge.

## Settings

Open **Settings…** to choose your visible tabs and turn **Quiet Focus** on or off. All five tabs are visible by default. Hiding a module also removes its compact indicator; its timer, playback, monitoring, or other background work continues. At least one tab stays visible, and hiding the selected tab moves the panel to a visible one.

Quiet Focus is **off by default** and applies only while the countdown is running. It holds agent responses, reviewable errors, and command or workflow results out of the compact notch. Active agent questions and permission requests can still interrupt. Responses and errors remain in the full **Agents → Needs you** queue. Pausing or finishing the timer restores normal compact priority.
Quiet Focus is **off by default** and applies only while the countdown is running. It keeps working-agent, error, and command or workflow result indicators out of the compact notch. Fresh agent questions and permission requests can still interrupt. Responses stay in **Agents → Ready to review** and never take over the compact notch; unreviewed failures stay in **Agents → Errors**. Pausing or finishing the timer restores normal compact priority, including red error indicators.

These preferences affect Broschy's presentation. They do not change the macOS Focus mode or notifications from other apps. Resetting them restores the default layout and turns Quiet Focus off; connections, notes, timers, and history are retained.

Expand All @@ -87,7 +89,7 @@ These preferences affect Broschy's presentation. They do not change the macOS Fo

Open **Agents → Connections** to connect Codex, Claude Code, or OpenCode. Restart the connected tool; Codex may require you to review the new hooks with `/hooks`. Sessions appear when they send an event.

The **Needs you** queue gathers questions, permission requests, responses ready for review, and errors. The compact notch signals activity without opening a full window. Session actions let you copy a terminal resume command, open the project folder, and mark a response reviewed.
**Needs you** contains only fresh, actionable questions and permission requests. Stopped responses appear separately in **Ready to review** and never take over the compact notch. Unreviewed failures appear in **Errors** and can show a red compact indicator when Quiet Focus is not running. Session actions let you copy a terminal resume command, open the project folder, and mark a response or error reviewed.

This is a local monitor for supported CLI hooks and plugins: it does not open transcripts or store prompts, tool arguments, or output. It does not send prompts, approve tools, or stop agents. The Codex desktop app is not currently a verified integration. Activity is checked against the originating process when available; stale activity becomes “Status unknown” instead of staying “Working”. “Ready to review” means a response stopped, not that the task necessarily succeeded. Setup needs Python 3. See the [integration guide](docs/AGENTS.md) for compatibility, configuration, removal, and limitations.

Expand Down
30 changes: 27 additions & 3 deletions Sources/AgentBridge/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public enum AgentStatus: String, Codable, CaseIterable, Sendable {
case idle, working, needsAttention, ready, error, closed, unknown
}

/// A finished response is available to read; only a pending request needs input.
public enum AgentAttentionKind: Equatable, Sendable {
case inputRequired, responseReady, error, none
}

/// A deliberately small snapshot. It never contains prompts, tool input/output,
/// transcripts, permission descriptions, or generated conversation titles.
public struct AgentSession: Codable, Equatable, Identifiable, Sendable {
Expand Down Expand Up @@ -88,9 +93,28 @@ public struct AgentSession: Codable, Equatable, Identifiable, Sendable {

public func status(at date: Date) -> AgentStatus { effectiveStatus(at: date) }

public func needsAttention(at date: Date = Date()) -> Bool {
let current = effectiveStatus(at: date)
return current == .needsAttention || ((current == .ready || current == .error) && acknowledgedAt == nil)
public func attentionKind(at date: Date = Date(), ownerLiveness: AgentProcessLiveness? = nil) -> AgentAttentionKind {
let current = effectiveStatus(at: date, ownerLiveness: ownerLiveness)
if current == .needsAttention { return .inputRequired }
if current == .error && !pendingRequestIDs.isEmpty {
// An error remains reviewable after exit, but an accompanying
// request must still satisfy the normal freshness/owner rules.
var pending = self
pending.status = .needsAttention
if pending.effectiveStatus(at: date, ownerLiveness: ownerLiveness) == .needsAttention {
return .inputRequired
}
}
guard acknowledgedAt == nil else { return .none }
switch current {
case .ready: return .responseReady
case .error: return .error
default: return .none
}
}

public func needsAttention(at date: Date = Date(), ownerLiveness: AgentProcessLiveness? = nil) -> Bool {
attentionKind(at: date, ownerLiveness: ownerLiveness) == .inputRequired
}

static func projectTitle(_ cwd: String) -> String {
Expand Down
6 changes: 5 additions & 1 deletion Sources/AgentBridge/AgentSessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ public struct AgentSessionStore: Sendable {
let file = sessionsDirectory.appendingPathComponent(id + ".json")
guard var session = try readIfPresent(file) else { return nil }
if let expectedUpdatedAt, session.updatedAt != expectedUpdatedAt { return session }
if session.pendingRequestIDs.isEmpty && [.ready, .error].contains(session.status) {
let reviewable = session.status == .error || (session.status == .ready && session.pendingRequestIDs.isEmpty)
// Re-evaluate the current record while locked. An expired/exited
// request must not make its error impossible to review, but a live
// question or permission must never be acknowledged as answered.
if reviewable && session.attentionKind(at: now) != .inputRequired {
session.acknowledgedAt = now
try write(session, to: file)
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/Broschy/AgentMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ final class AgentMonitor: ObservableObject {
return sessions.filter { $0.updatedAt > cutoff && $0.effectiveStatus() != .closed }
}
var attention: [AgentSession] { visibleSessions.filter { $0.needsAttention() } }
var others: [AgentSession] { visibleSessions.filter { !$0.needsAttention() } }
var readyResponses: [AgentSession] { visibleSessions.filter { $0.attentionKind() == .responseReady } }
var errors: [AgentSession] { visibleSessions.filter { $0.attentionKind() == .error } }
var others: [AgentSession] { visibleSessions.filter { $0.attentionKind() == .none } }
var workingCount: Int { visibleSessions.filter { $0.effectiveStatus() == .working }.count }
var needsYouCount: Int { attention.count }
var hasCompactActivity: Bool { needsYouCount > 0 || workingCount > 0 }
Expand Down
38 changes: 29 additions & 9 deletions Sources/Broschy/AgentsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,19 @@ struct AgentsView: View {
sectionLabel("Needs you", count: monitor.needsYouCount, tint: Ink.primary)
ForEach(monitor.attention) { row($0) }
}
if !monitor.others.isEmpty {
sectionLabel(monitor.attention.isEmpty ? "Sessions" : "Other sessions", count: monitor.others.count, tint: Ink.muted)
if !monitor.errors.isEmpty {
sectionLabel("Errors", count: monitor.errors.count, tint: Ink.failure)
.padding(.top, monitor.attention.isEmpty ? 0 : 9)
ForEach(monitor.errors) { row($0) }
}
if !monitor.readyResponses.isEmpty {
sectionLabel("Ready to review", count: monitor.readyResponses.count, tint: Ink.success)
.padding(.top, monitor.attention.isEmpty && monitor.errors.isEmpty ? 0 : 9)
ForEach(monitor.readyResponses) { row($0) }
}
if !monitor.others.isEmpty {
sectionLabel("Sessions", count: monitor.others.count, tint: Ink.muted)
.padding(.top, monitor.attention.isEmpty && monitor.errors.isEmpty && monitor.readyResponses.isEmpty ? 0 : 9)
ForEach(monitor.others) { row($0) }
}
}
Expand Down Expand Up @@ -175,7 +185,7 @@ struct AgentsView: View {
}
.font(.system(size: 10)).lineLimit(1)
if status == .needsAttention || status == .unknown || status == .error {
Text(status == .unknown ? "Activity not confirmed · check your agent" : (status == .error && !session.pendingRequestIDs.isEmpty ? "Response needed in your agent" : session.detail))
Text(status == .unknown ? "Activity not confirmed · check your agent" : (status == .error && session.needsAttention() ? "Response needed in your agent" : session.detail))
.font(.system(size: 10)).foregroundStyle(Ink.muted).lineLimit(1)
}
}
Expand All @@ -193,7 +203,8 @@ struct AgentsView: View {
if status == .ready || status == .error {
Divider()
Button("Mark reviewed") { monitor.acknowledge(session) }
.disabled(session.acknowledgedAt != nil || !session.pendingRequestIDs.isEmpty)
.disabled(session.acknowledgedAt != nil || session.needsAttention()
|| (status == .ready && !session.pendingRequestIDs.isEmpty))
}
} label: {
Image(systemName: copiedID == session.id ? "checkmark" : status.symbol)
Expand Down Expand Up @@ -263,30 +274,39 @@ struct CompactAgentsLeading: View {
let attention: [AgentSession]
let reduceMotion: Bool
let isVisible: Bool
private var requests: [AgentSession] { attention.filter { $0.needsAttention() } }
private var hasError: Bool { attention.contains { $0.attentionKind() == .error } }
var body: some View {
HStack(spacing: 7) {
Image(systemName: !attention.isEmpty ? "hand.raised.fill" : "circle.dotted")
Image(systemName: !requests.isEmpty ? "hand.raised.fill" : hasError ? "exclamationmark.circle.fill" : "circle.dotted")
.font(.system(size: 16, weight: .medium))
.symbolEffect(.pulse, options: .repeating, isActive: isVisible && !reduceMotion && monitor.hasCompactActivity)
VStack(alignment: .leading, spacing: 1) {
Text("Agents").font(.system(size: 10, weight: .semibold))
Text("\(monitor.workingCount) working").font(.system(size: 9)).foregroundStyle(Ink.muted)
}
}
.foregroundStyle(!attention.isEmpty ? Ink.primary : Ink.success)
.foregroundStyle(!requests.isEmpty ? Ink.primary : hasError ? Ink.failure : Ink.success)
.accessibilityHidden(true)
}
}

struct CompactAgentsTrailing: View {
@ObservedObject var monitor: AgentMonitor
let attention: [AgentSession]
private var requests: [AgentSession] { attention.filter { $0.needsAttention() } }
private var errors: [AgentSession] { attention.filter { $0.attentionKind() == .error } }
private var label: String {
if !requests.isEmpty { return "\(requests.count) need\(requests.count == 1 ? "s" : "") you" }
if !errors.isEmpty { return "\(errors.count) error\(errors.count == 1 ? "" : "s")" }
return "In progress"
}
var body: some View {
VStack(alignment: .leading, spacing: 1) {
Text(!attention.isEmpty ? "\(attention.count) need\(attention.count == 1 ? "s" : "") you" : "In progress")
Text(label)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(!attention.isEmpty ? Ink.primary : Ink.text)
Text(attention.first?.title ?? monitor.visibleSessions.first(where: { $0.effectiveStatus() == .working })?.title ?? "Agents")
.foregroundStyle(!requests.isEmpty ? Ink.primary : !errors.isEmpty ? Ink.failure : Ink.text)
Text(requests.first?.title ?? errors.first?.title ?? monitor.visibleSessions.first(where: { $0.effectiveStatus() == .working })?.title ?? "Agents")
.font(.system(size: 9)).foregroundStyle(Ink.muted)
}
.lineLimit(1).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 13)
Expand Down
3 changes: 2 additions & 1 deletion Sources/Broschy/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
main.addItem(editItem)
NSApp.mainMenu = main
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
statusItem.button?.image = NSImage(systemSymbolName: "inset.filled.topcenter.rectangle", accessibilityDescription: "Broschy")
statusItem.button?.image = StatusBarIcon.makeImage()
statusItem.button?.toolTip = "Broschy"
let menu = NSMenu()
let show = NSMenuItem(title: "Show / Hide Panel", action: #selector(togglePanel), keyEquivalent: "")
show.target = self
Expand Down
14 changes: 4 additions & 10 deletions Sources/Broschy/PanelPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,11 @@ enum AgentAttentionPolicy {
now: Date = Date(),
ownerLiveness: AgentProcessLiveness? = nil
) -> Bool {
let status = session.effectiveStatus(at: now, ownerLiveness: ownerLiveness)
var activeRequest = status == .needsAttention
if status == .error && !session.pendingRequestIDs.isEmpty {
// Preserve the error while applying request freshness and process-exit
// rules to the unanswered question/permission that accompanies it.
var pending = session
pending.status = .needsAttention
activeRequest = pending.effectiveStatus(at: now, ownerLiveness: ownerLiveness) == .needsAttention
switch session.attentionKind(at: now, ownerLiveness: ownerLiveness) {
case .inputRequired: return true
case .error: return !(quietFocusEnabled && focusActive)
case .responseReady, .none: return false
}
if quietFocusEnabled && focusActive { return activeRequest }
return activeRequest || ((status == .ready || status == .error) && session.acknowledgedAt == nil)
}

/// Pass the monitor's visible sessions so its retention and ordering remain unchanged.
Expand Down
29 changes: 29 additions & 0 deletions Sources/Broschy/StatusBarIcon.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import AppKit

enum StatusBarIcon {
/// A vector template keeps the notch mark sharp at menu-bar scale and lets
/// macOS choose its color for light, dark, and highlighted appearances.
static func makeImage() -> NSImage {
let image = NSImage(size: NSSize(width: 18, height: 18), flipped: false) { _ in
NSColor.black.setFill()
NSColor.black.setStroke()

let screen = NSBezierPath(
roundedRect: NSRect(x: 1.75, y: 3.25, width: 14.5, height: 11.5),
xRadius: 2.6, yRadius: 2.6
)
screen.lineWidth = 1.5
screen.stroke()

let notch = NSBezierPath(
roundedRect: NSRect(x: 6, y: 11.75, width: 6, height: 3.5),
xRadius: 1, yRadius: 1
)
notch.fill()
return true
}
image.isTemplate = true
image.accessibilityDescription = "Broschy"
return image
}
}
6 changes: 5 additions & 1 deletion Sources/Broschy/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,11 @@ struct NotchRootView: View {
}
var compactLabel: String {
if state.compactContent == .buildWatch { return "Open Build Watch. \(builds.compactRun?.statusText ?? "GitHub Actions")" }
if state.showsCompactAgents { return "Open Agents. \(agents.workingCount) working, \(compactAttention.count) need your attention." }
if state.showsCompactAgents {
let requests = compactAttention.filter { $0.needsAttention() }.count
let errors = compactAttention.filter { $0.attentionKind() == .error }.count
return "Open Agents. \(agents.workingCount) working, \(requests) input request\(requests == 1 ? "" : "s"), \(errors) error\(errors == 1 ? "" : "s")."
}
guard let track = compactTrack else { return "Open Broschy. \(compactText)" }
return "\(track.isPlaying ? "Playing" : "Paused"): \(track.title) by \(track.artist). Open Music."
}
Expand Down
Loading