From b4acb0adeb269e497814b054c02daff00c21d8d3 Mon Sep 17 00:00:00 2001 From: SculptTechProject <150788324+SculptTechProject@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:29:04 +0200 Subject: [PATCH] Fix agent attention states and menu bar icon for 0.8.2 --- Info.plist | 4 +- README.md | 10 +- Sources/AgentBridge/AgentSession.swift | 30 +++++- Sources/AgentBridge/AgentSessionStore.swift | 6 +- Sources/Broschy/AgentMonitor.swift | 4 +- Sources/Broschy/AgentsView.swift | 38 ++++++-- Sources/Broschy/App.swift | 3 +- Sources/Broschy/PanelPreferences.swift | 14 +-- Sources/Broschy/StatusBarIcon.swift | 29 ++++++ Sources/Broschy/Views.swift | 6 +- Tests/AgentBridgeTests.swift | 102 +++++++++++++++++++- Tests/PanelActivityTests.swift | 11 +++ Tests/PanelPreferencesTests.swift | 11 ++- docs/AGENTS.md | 10 +- docs/DEVELOPMENT.md | 6 +- docs/assets/agents-dark.png | Bin 29478 -> 28383 bytes 16 files changed, 240 insertions(+), 44 deletions(-) create mode 100644 Sources/Broschy/StatusBarIcon.swift diff --git a/Info.plist b/Info.plist index 118f331..966bf99 100644 --- a/Info.plist +++ b/Info.plist @@ -10,8 +10,8 @@ CFBundleDevelopmentRegionen CFBundleLocalizationsen CFBundlePackageTypeAPPL - CFBundleShortVersionString0.8.1 - CFBundleVersion14 + CFBundleShortVersionString0.8.2 + CFBundleVersion15 LSMinimumSystemVersion14.0 LSUIElement NSHighResolutionCapable diff --git a/README.md b/README.md index 064fca5..5fffa7f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. diff --git a/Sources/AgentBridge/AgentSession.swift b/Sources/AgentBridge/AgentSession.swift index af9ee5a..a3ad59a 100644 --- a/Sources/AgentBridge/AgentSession.swift +++ b/Sources/AgentBridge/AgentSession.swift @@ -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 { @@ -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 { diff --git a/Sources/AgentBridge/AgentSessionStore.swift b/Sources/AgentBridge/AgentSessionStore.swift index ad937e1..d3d17e7 100644 --- a/Sources/AgentBridge/AgentSessionStore.swift +++ b/Sources/AgentBridge/AgentSessionStore.swift @@ -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) } diff --git a/Sources/Broschy/AgentMonitor.swift b/Sources/Broschy/AgentMonitor.swift index 006b7cb..fabff55 100644 --- a/Sources/Broschy/AgentMonitor.swift +++ b/Sources/Broschy/AgentMonitor.swift @@ -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 } diff --git a/Sources/Broschy/AgentsView.swift b/Sources/Broschy/AgentsView.swift index ef50305..4477f39 100644 --- a/Sources/Broschy/AgentsView.swift +++ b/Sources/Broschy/AgentsView.swift @@ -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) } } } @@ -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) } } @@ -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) @@ -263,9 +274,11 @@ 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) { @@ -273,7 +286,7 @@ struct CompactAgentsLeading: View { 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) } } @@ -281,12 +294,19 @@ struct CompactAgentsLeading: View { 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) diff --git a/Sources/Broschy/App.swift b/Sources/Broschy/App.swift index 90c5d17..6c13242 100644 --- a/Sources/Broschy/App.swift +++ b/Sources/Broschy/App.swift @@ -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 diff --git a/Sources/Broschy/PanelPreferences.swift b/Sources/Broschy/PanelPreferences.swift index 15cd238..f572b82 100644 --- a/Sources/Broschy/PanelPreferences.swift +++ b/Sources/Broschy/PanelPreferences.swift @@ -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. diff --git a/Sources/Broschy/StatusBarIcon.swift b/Sources/Broschy/StatusBarIcon.swift new file mode 100644 index 0000000..c949504 --- /dev/null +++ b/Sources/Broschy/StatusBarIcon.swift @@ -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 + } +} diff --git a/Sources/Broschy/Views.swift b/Sources/Broschy/Views.swift index 68fb2bd..655bad4 100644 --- a/Sources/Broschy/Views.swift +++ b/Sources/Broschy/Views.swift @@ -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." } diff --git a/Tests/AgentBridgeTests.swift b/Tests/AgentBridgeTests.swift index bf25960..c7d7ee5 100644 --- a/Tests/AgentBridgeTests.swift +++ b/Tests/AgentBridgeTests.swift @@ -58,6 +58,14 @@ enum AgentBridgeTests { object.merge(extra) { _, value in value } return try JSONSerialization.data(withJSONObject: object) } + func saveFixture(_ session: AgentSession, in store: AgentSessionStore) throws { + // Deliberately model a legacy error with an unresolved request in + // an isolated test directory. Never use a real app snapshot. + try FileManager.default.createDirectory(at: store.sessionsDirectory, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + try encoder.encode(session).write(to: store.sessionsDirectory.appendingPathComponent(session.id + ".json"), options: .atomic) + } let store = AgentSessionStore(rootDirectory: root.appendingPathComponent("normal")) try check(try store.readSessions().isEmpty, "Read-only discovery must tolerate an unconfigured bridge.") precondition(!FileManager.default.fileExists(atPath: store.rootDirectory.path)) @@ -91,16 +99,64 @@ enum AgentBridgeTests { session = try waits.ingest(provider: .opencode, data: payload("question.replied", extra: ["request_id": "q1"]), now: now)! precondition(session.status == .working && session.pendingRequestIDs.isEmpty) session = try waits.ingest(provider: .opencode, data: payload("session.idle"), now: now)! - precondition(session.status == .ready && session.needsAttention(at: now)) + precondition(session.status == .ready && session.attentionKind(at: now) == .responseReady && !session.needsAttention(at: now), + "A completed response is ready to read, not a request for input.") _ = try waits.acknowledge(provider: .opencode, sessionID: "session-1", now: now) session = try waits.ingest(provider: .opencode, data: payload("session.status", extra: ["status": "idle"]), now: now)! - precondition(session.acknowledgedAt == now && !session.needsAttention(at: now), "Duplicate idle must preserve review acknowledgment.") + precondition(session.acknowledgedAt == now && session.attentionKind(at: now) == .none && !session.needsAttention(at: now), "Duplicate idle must preserve review acknowledgment.") session = try waits.ingest(provider: .opencode, data: payload("question.replied", extra: ["request_id": "q1"]), now: now)! precondition(session.status == .ready && session.acknowledgedAt == now, "Duplicate reply must not restart a completed turn.") print("PASS: independent permission/question waits, idempotent replies, no false completion, review acknowledgment") + let fixtureOwner = AgentProcessIdentity(pid: 42, startedAtSeconds: 1_000_000, startedAtMicroseconds: 123, + executableName: "codex") + var completedResponse = AgentSession(provider: .codex, sessionID: "attention-kinds", cwd: "/tmp/my-project", + status: .ready, updatedAt: now, ownerProcess: fixtureOwner) + precondition(completedResponse.attentionKind(at: now, ownerLiveness: .alive) == .responseReady + && !completedResponse.needsAttention(at: now, ownerLiveness: .alive)) + precondition(completedResponse.attentionKind(at: now, ownerLiveness: .exited) == .responseReady, + "Completed responses remain available to review when the process exits.") + completedResponse.acknowledgedAt = now + precondition(completedResponse.attentionKind(at: now, ownerLiveness: .alive) == .none) + var reportedError = AgentSession(provider: .codex, sessionID: "error-kinds", cwd: "/tmp/my-project", + status: .error, updatedAt: now, ownerProcess: fixtureOwner) + precondition(reportedError.attentionKind(at: now, ownerLiveness: .alive) == .error + && !reportedError.needsAttention(at: now, ownerLiveness: .alive), + "An error without a request needs review, not input.") + reportedError.pendingRequestIDs = ["permission-1"] + let unchangedPendingError = reportedError + precondition(reportedError.attentionKind(at: now, ownerLiveness: .alive) == .inputRequired + && reportedError.needsAttention(at: now, ownerLiveness: .alive)) + precondition(reportedError.attentionKind(at: now.addingTimeInterval(1_801), ownerLiveness: .alive) == .error, + "A stale pending request must stop being classified as current input; the error stays reviewable.") + precondition(reportedError.attentionKind(at: now.addingTimeInterval(76), ownerLiveness: .unknown) == .error) + precondition(reportedError.attentionKind(at: now, ownerLiveness: .exited) == .error, + "An exited owner cannot still request input.") + precondition(reportedError == unchangedPendingError, "Attention classification must not mutate state or acknowledge anything.") + reportedError.acknowledgedAt = now + precondition(reportedError.attentionKind(at: now, ownerLiveness: .alive) == .inputRequired, + "Acknowledging an error cannot hide its still-active permission request.") + precondition(reportedError.attentionKind(at: now, ownerLiveness: .exited) == .none) + reportedError.pendingRequestIDs = [] + precondition(reportedError.attentionKind(at: now, ownerLiveness: .alive) == .none) + var inputRequest = AgentSession(provider: .codex, sessionID: "input-kinds", cwd: "/tmp/my-project", + status: .needsAttention, updatedAt: now, pendingRequestIDs: ["question-1"], + ownerProcess: fixtureOwner) + precondition(inputRequest.attentionKind(at: now, ownerLiveness: .alive) == .inputRequired) + precondition(inputRequest.attentionKind(at: now.addingTimeInterval(1_801), ownerLiveness: .alive) == .none) + precondition(inputRequest.attentionKind(at: now, ownerLiveness: .exited) == .none) + inputRequest.ownerProcess = nil + precondition(inputRequest.attentionKind(at: now.addingTimeInterval(76)) == .none) + for idleStatus in [AgentStatus.idle, .working, .closed, .unknown] { + let inactive = AgentSession(provider: .codex, sessionID: "non-attention", cwd: "/tmp/my-project", status: idleStatus, updatedAt: now) + precondition(inactive.attentionKind(at: now) == .none) + } + print("PASS: separate input/response/error semantics, pending error freshness and liveness, acknowledgment, and read-only classification") + let oldResponse = session - _ = try waits.ingest(provider: .opencode, data: payload("message.updated", extra: ["role": "user"]), now: now.addingTimeInterval(0.125)) + let newTurn = try waits.ingest(provider: .opencode, data: payload("message.updated", extra: ["role": "user"]), now: now.addingTimeInterval(0.125))! + precondition(newTurn.status == .working && newTurn.acknowledgedAt == nil && newTurn.attentionKind(at: now.addingTimeInterval(0.125)) == .none, + "Starting the next user turn clears response readiness without inventing a request.") _ = try waits.ingest(provider: .opencode, data: payload("session.idle"), now: now.addingTimeInterval(0.250)) session = try waits.acknowledge(provider: .opencode, sessionID: "session-1", expectedUpdatedAt: oldResponse.updatedAt, now: now.addingTimeInterval(0.375))! precondition(session.status == .ready && session.acknowledgedAt == nil, "A stale row must not acknowledge a newer unseen response.") @@ -108,6 +164,31 @@ enum AgentBridgeTests { try check(try waits.readSessions().first?.acknowledgedAt != nil) print("PASS: stale-row acknowledgment does not dismiss newer unseen responses") + let errorReviews = AgentSessionStore(rootDirectory: root.appendingPathComponent("error-reviews")) + let pendingError = AgentSession(provider: .codex, sessionID: "pending-error", cwd: "/tmp/my-project", + status: .error, detail: "Agent reported an error", updatedAt: now, + pendingRequestIDs: ["permission-to-preserve"]) + try saveFixture(pendingError, in: errorReviews) + let blockedErrorReview = try errorReviews.acknowledge(provider: .codex, sessionID: pendingError.sessionID, + expectedUpdatedAt: pendingError.updatedAt, now: now.addingTimeInterval(30))! + precondition(blockedErrorReview.acknowledgedAt == nil && blockedErrorReview.pendingRequestIDs == pendingError.pendingRequestIDs, + "Review must reject a still-fresh permission accompanying an error.") + let reviewedStaleError = try errorReviews.acknowledge(provider: .codex, sessionID: pendingError.sessionID, + expectedUpdatedAt: pendingError.updatedAt, now: now.addingTimeInterval(76))! + precondition(reviewedStaleError.acknowledgedAt == now.addingTimeInterval(76) + && reviewedStaleError.status == .error && reviewedStaleError.pendingRequestIDs == pendingError.pendingRequestIDs + && reviewedStaleError.resolvedRequestIDs == pendingError.resolvedRequestIDs, + "A stale request cannot block error review; review must neither answer nor remove that request.") + precondition(reviewedStaleError.attentionKind(at: now.addingTimeInterval(76)) == .none) + var newerError = pendingError + newerError.updatedAt = now.addingTimeInterval(0.25) + try saveFixture(newerError, in: errorReviews) + let staleErrorClick = try errorReviews.acknowledge(provider: .codex, sessionID: pendingError.sessionID, + expectedUpdatedAt: pendingError.updatedAt, now: now.addingTimeInterval(76))! + precondition(staleErrorClick.acknowledgedAt == nil && staleErrorClick.updatedAt == newerError.updatedAt, + "Even a reviewable stale error cannot be acknowledged from a superseded displayed record.") + print("PASS: stale error review preserves unresolved requests, rejects fresh permissions, and retains exact-record acknowledgment guard") + // Use a fresh session so reducer clock-order protection also stays active. let lifecycle = AgentSessionStore(rootDirectory: root.appendingPathComponent("lifecycle")) session = try lifecycle.ingest(provider: .opencode, data: payload("session.error"), now: now)! @@ -245,12 +326,27 @@ enum AgentBridgeTests { "A question may legitimately await a response while its exact owner stays alive.") precondition(waitingOwner.effectiveStatus(at: owned.updatedAt.addingTimeInterval(1_801)) == .unknown, "A missed reply must not pin the notch indefinitely even if the process remains alive.") + let liveErrorReviews = AgentSessionStore(rootDirectory: root.appendingPathComponent("live-error-reviews")) + let liveOwnerError = AgentSession(provider: .codex, sessionID: "live-owner-error", cwd: "/tmp/my-project", + status: .error, detail: "Agent reported an error", updatedAt: Date().addingTimeInterval(-300), + pendingRequestIDs: ["live-owner-question"], ownerProcess: identity) + try saveFixture(liveOwnerError, in: liveErrorReviews) + let displayedLiveError = try liveErrorReviews.readSessions().first! + let liveReview = try liveErrorReviews.acknowledge(provider: .codex, sessionID: liveOwnerError.sessionID, + expectedUpdatedAt: displayedLiveError.updatedAt)! + precondition(liveReview.acknowledgedAt == nil && liveReview.pendingRequestIDs == liveOwnerError.pendingRequestIDs, + "The store must check actual process liveness under its lock before reviewing a pending error.") let (unrelatedOwner, _) = try launchOwner("owner-2") defer { if unrelatedOwner.isRunning { unrelatedOwner.terminate(); unrelatedOwner.waitUntilExit() } } owner.terminate(); owner.waitUntilExit() precondition(unrelatedOwner.isRunning && identity.liveness() == .exited) precondition(owned.effectiveStatus() == .closed && waitingOwner.effectiveStatus() == .closed, "Owner exit without SessionEnd must immediately clear active work/waits, even with another Codex process open.") + let exitedReview = try liveErrorReviews.acknowledge(provider: .codex, sessionID: liveOwnerError.sessionID, + expectedUpdatedAt: displayedLiveError.updatedAt)! + precondition(exitedReview.acknowledgedAt != nil && exitedReview.pendingRequestIDs == liveOwnerError.pendingRequestIDs + && exitedReview.status == .error && exitedReview.resolvedRequestIDs.isEmpty, + "Review after the exact owner exits preserves its request IDs without marking them resolved.") var reviewAfterExit = owned reviewAfterExit.status = .ready precondition(reviewAfterExit.effectiveStatus() == .ready) diff --git a/Tests/PanelActivityTests.swift b/Tests/PanelActivityTests.swift index 02642e5..ec424ce 100644 --- a/Tests/PanelActivityTests.swift +++ b/Tests/PanelActivityTests.swift @@ -1,3 +1,4 @@ +import AgentBridge import Foundation @main @@ -20,6 +21,16 @@ struct PanelActivityTests { precondition(choose(working: true, build: true) == .buildWatch) precondition(choose(working: true) == .agents) precondition(choose() == .music) + // A completed response stays in Agents without blocking music or an idle notch. + let now = Date() + let completed = AgentSession(provider: .codex, sessionID: "finished", cwd: "/sample/project", + status: .ready, updatedAt: now) + for quiet in [false, true] { + let attention = AgentAttentionPolicy.compactAttention(from: [completed], + visibleTabs: Set(PanelTab.allCases), quietFocusEnabled: quiet, focusActive: quiet, now: now) + precondition(choose(quiet: quiet, attention: !attention.isEmpty) == .music) + precondition(choose(quiet: quiet, attention: !attention.isEmpty, music: false) == .idle) + } precondition(choose([.later], attention: true, working: true, focus: true, local: true, recent: true, build: true) == .idle) precondition(choose([.music], attention: true, focus: true, local: true, recent: true) == .music) diff --git a/Tests/PanelPreferencesTests.swift b/Tests/PanelPreferencesTests.swift index 3b83a6a..302057b 100644 --- a/Tests/PanelPreferencesTests.swift +++ b/Tests/PanelPreferencesTests.swift @@ -95,18 +95,21 @@ enum PanelPreferencesTests { let normal = AgentAttentionPolicy.compactAttention(from: sessions, visibleTabs: Set(PanelTab.allCases), quietFocusEnabled: false, focusActive: true, now: now) - precondition(normal.map(\.sessionID) == ["ready", "waiting", "error"]) + precondition(normal.map(\.sessionID) == ["waiting", "error"], + "A finished response must not take over the compact notch or request input.") let quiet = AgentAttentionPolicy.compactAttention(from: sessions, visibleTabs: Set(PanelTab.allCases), quietFocusEnabled: true, focusActive: true, now: now) precondition(quiet.map(\.sessionID) == ["waiting"], "Only an active question or permission request should interrupt Quiet Focus.") let paused = AgentAttentionPolicy.compactAttention(from: sessions, visibleTabs: Set(PanelTab.allCases), quietFocusEnabled: true, focusActive: false, now: now) - precondition(paused == normal, "Pausing or finishing Focus should reveal queued responses and errors.") + precondition(paused == normal, "Pausing or finishing Focus should reveal errors without promoting finished responses.") let hidden = AgentAttentionPolicy.compactAttention(from: sessions, visibleTabs: [.focus, .music], quietFocusEnabled: false, focusActive: false, now: now) precondition(hidden.isEmpty, "A hidden Agents module must never supply a compact interruption.") - precondition(sessions == originalSessions && ready.needsAttention(at: now) && error.needsAttention(at: now), - "Quiet Focus must not acknowledge or remove items from the full Agents queue.") + precondition(sessions == originalSessions && ready.attentionKind(at: now) == .responseReady + && error.attentionKind(at: now) == .error && waiting.needsAttention(at: now) + && !ready.needsAttention(at: now) && !error.needsAttention(at: now), + "Responses and errors remain reviewable without claiming they require input.") let errorWithRequest = sample("error-request", .error, pending: ["question-1"]) precondition(AgentAttentionPolicy.requiresInterruption(errorWithRequest, quietFocusEnabled: true, diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fcb93a0..c3edf2f 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -19,17 +19,19 @@ Broschy installs a reference to the CLI inside your current app bundle. If you m | Status | Meaning | | :--- | :--- | | Working | A recent prompt, tool call, or busy event was observed. Activity expires without new evidence, even when the process is still open. | -| Needs attention | The agent reported a permission request or a question. Respond in the original tool. | +| Needs attention | A fresh, actionable permission request or question is waiting. Respond in the original tool. | | Ready to review | The agent stopped responding or became idle. Review the result; this does not certify that the task succeeded. | | Error | A supported failure event was observed. Broschy shows a generic error, without recording the error contents. | | Idle | The session started or was interrupted. | | Status unknown | Working activity has not been refreshed for 120 seconds with a verified live process, or 75 seconds without one. Check the original tool. An unanswered request becomes unknown after 30 minutes even if its process remains alive. | -**Needs you** gathers questions, permission requests, unreviewed responses, and errors. Use the status icon at the right of a session for **Copy resume command**, **Open project folder**, or **Mark reviewed**. Paste a copied resume command in your terminal to resume the corresponding provider's session. This does not select a running terminal tab or jump to a particular desktop conversation. +**Needs you** contains only fresh, actionable questions and permission requests. **Ready to review** holds stopped responses, and **Errors** holds unreviewed failures. Use the status icon at the right of a session for **Copy resume command**, **Open project folder**, or **Mark reviewed**. Paste a copied resume command in your terminal to resume the corresponding provider's session. This does not select a running terminal tab or jump to a particular desktop conversation. -Marking a response or error reviewed only clears its attention badge in Broschy. It does not change agent state. An active permission request cannot be marked reviewed. When its originating process exits, an active session is hidden even if no exit hook arrived. Completed responses and errors remain available for review. Ended sessions are hidden; the panel shows sessions updated in the last 24 hours, from the 512 most recently modified snapshots. +Marking a response or error reviewed only clears its review marker in Broschy. It does not change agent state. Active requests cannot be marked reviewed. When its originating process exits, an active session is hidden even if no exit hook arrived. Stopped responses and errors remain available for review. Ended sessions are hidden; the panel shows sessions updated in the last 24 hours, from the 512 most recently modified snapshots. -In the compact notch, attention takes priority over focus, command results, and music. Working agents appear when no focus session or recent command result needs that space. The animation indicates activity, not token throughput or percent complete. It respects Reduce Motion. +Fresh questions and permission requests take priority in the compact notch. Ready responses never take over that space. Unreviewed errors have a separate red compact indicator when Quiet Focus is not running. Working agents appear when no higher-priority activity needs that space. The animation indicates activity, not token throughput or percent complete, and respects Reduce Motion. + +With Quiet Focus enabled and the countdown running, errors, working-agent activity, and command or workflow results stay out of the compact notch. Fresh questions and permission requests can still interrupt. All review items remain available in their panel sections. Pausing or finishing the timer restores normal compact priority. ## What is stored diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a0b9026..25d3734 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,6 +4,8 @@ Broschy uses Swift Package Manager, SwiftUI for views, and AppKit for a borderless panel aligned with the screen's safe area. The main executable is `Broschy`; `broschy-cli` writes local command-result records. The package targets macOS 14, while compiling native Liquid Glass support requires the macOS 26 SDK from Xcode 26 or later. +`StatusBarIcon.makeImage()` draws the 18-point menu bar mark as a native vector template. `isTemplate` lets macOS handle light, dark, and selected appearances; the icon has a Broschy accessibility description and tooltip. + ```text Sources/Broschy/ App, panel, preferences, storage, Spotify, Build Watch Sources/BroschyCLI/ Command wrapper, result notifications, agent hook entry point @@ -49,7 +51,9 @@ Codex CLI 0.144.5 does not dispatch SessionEnd/Interrupt hooks. Keep process che All modules are visible and Quiet Focus is off by default. The compact selector filters hidden modules before choosing an activity or its click destination. Hiding a module changes presentation without stopping background work. Settings are available through the header gear, the menu bar's **Settings…** item, and Command-comma. -Quiet Focus uses `timer.isRunning`; a paused countdown releases its suppression. `AgentAttentionPolicy` keeps active questions and permissions eligible to interrupt while holding response/error review items in the full Agents queue. An error with a pending request must also pass the request's process-liveness and freshness checks. Local command and Build Watch results defer to the running focus timer. Ending or pausing Focus restores normal priority, subject to each result's existing expiry. This policy does not alter macOS Focus or other apps' notifications. +`AgentAttentionPolicy` reserves **Needs you** for fresh, actionable questions and permissions. Stopped responses stay in **Ready to review** and never contribute compact attention; readiness is not proof of task success. Unreviewed failures stay in **Errors** and use a separate red compact indication. A pending request attached to an error must pass the request's process-liveness and freshness checks before it can interrupt as a request. + +Quiet Focus uses `timer.isRunning`; a paused countdown releases its suppression. Fresh questions and permissions remain eligible to interrupt, while error, working-agent, local command result, and Build Watch result indicators defer to the running focus timer. Ready responses remain panel-only regardless of Quiet Focus. Ending or pausing Focus restores normal priority, subject to each result's existing expiry. This policy does not alter macOS Focus or other apps' notifications. ## Build Watch diff --git a/docs/assets/agents-dark.png b/docs/assets/agents-dark.png index 12ce144a2ec3c9589ef701d5e8aa329f952bb448..f349ff51ea6e9d0ecee51a5a68145d16e78227e7 100644 GIT binary patch delta 7453 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