diff --git a/App/Info.plist b/App/Info.plist
index 41cab1d..1bc0d93 100644
--- a/App/Info.plist
+++ b/App/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.2
+ 0.1.3
CFBundleVersion
- 5
+ 6
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/README.md b/README.md
index e2a4bb5..548343b 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Meanwhile is a macOS 14 menu-bar app that turns coding-agent wait time into one
small, actionable GitHub task. It is built in Swift 5.10 with no third-party
dependencies.
-## v0.1.2 behavior
+## v0.1.3 behavior
- Uses Claude Code and Codex lifecycle hooks—not process or CPU guesses—to track
each session as `thinking`, `needs-you`, or `idle`.
@@ -29,6 +29,15 @@ dependencies.
- Offers off-by-default, needs-you-only notifications with a selectable delay.
Meanwhile confirms the task is still waiting before posting one quiet reminder;
reviews and CI never notify.
+- Lets you run a clearly labelled six-second attention test from Settings,
+ including a test notification when reminders are available. The test never
+ creates work or replaces a real interruption.
+- Shows independent freshness for review and failing-CI sources, with manual
+ refresh and in-context recovery for GitHub authentication or agent hooks.
+- Keeps agent requests always on while letting you independently enable reviews
+ and failing CI from Settings, with no relaunch required.
+- Keeps failed agent and browser handoffs active, offering Settings or Copy Link
+ instead of silently losing the route back.
- Shows the installed version and latest GitHub release in Settings without
downloading or installing anything automatically.
- Identifies agent sessions that may be stuck and lets you clear only those
@@ -126,9 +135,9 @@ cask with:
GITHUB_REPOSITORY="tcballard/Meanwhile" ./Scripts/release-unsigned.sh
```
-This produces `dist/Meanwhile-0.1.2-unsigned.zip` and `dist/meanwhile.rb`.
+This produces `dist/Meanwhile-0.1.3-unsigned.zip` and `dist/meanwhile.rb`.
Publish the archive only as a GitHub **pre-release** tagged
-`v0.1.2-unsigned`. The generated cask identifies it as unsigned and tells users
+`v0.1.3-unsigned`. The generated cask identifies it as unsigned and tells users
that Gatekeeper will block the first launch. Users who trust the build must
explicitly remove quarantine themselves; the cask does not bypass Gatekeeper.
@@ -144,7 +153,7 @@ GITHUB_REPOSITORY="owner/Meanwhile" \
```
The script builds and signs the app in a temporary local directory, then
-produces `dist/Meanwhile-0.1.2.zip` and `dist/meanwhile.rb`. It verifies the
+produces `dist/Meanwhile-0.1.3.zip` and `dist/meanwhile.rb`. It verifies the
stapled app and a clean extraction of the final archive before returning.
Publishing the archive, cask, and GitHub release remains an explicit
release-owner action. Set `RELEASE_OUTPUT_DIR` to write the final artifacts
diff --git a/Sources/Meanwhile/AttentionSourcesSettingsSection.swift b/Sources/Meanwhile/AttentionSourcesSettingsSection.swift
new file mode 100644
index 0000000..df7984c
--- /dev/null
+++ b/Sources/Meanwhile/AttentionSourcesSettingsSection.swift
@@ -0,0 +1,61 @@
+import MeanwhileCore
+import SwiftUI
+
+struct AttentionSourcesSettingsSection: View {
+ @Binding var selection: AttentionSourceSelection
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ sourceRow(
+ title: "Agent requests",
+ detail: "Permission prompts, questions, and other agent handoffs.",
+ isOn: .constant(true),
+ isLocked: true
+ )
+ sourceRow(
+ title: "Failing CI",
+ detail: "Failed checks on your open pull requests while an agent is thinking.",
+ isOn: $selection.failingCIEnabled
+ )
+ sourceRow(
+ title: "Review requests",
+ detail: "Pull requests waiting for your review while an agent is thinking.",
+ isOn: $selection.reviewsEnabled
+ )
+ Text("GitHub sources remain wait-gated: enabling them does not show work while every agent is idle.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ private func sourceRow(
+ title: String,
+ detail: String,
+ isOn: Binding,
+ isLocked: Bool = false
+ ) -> some View {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.callout.weight(.semibold))
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 12)
+ if isLocked {
+ Label("Always on", systemImage: "checkmark.circle.fill")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.green)
+ .accessibilityLabel("Agent requests always on")
+ } else {
+ Toggle("", isOn: isOn)
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .accessibilityLabel(title)
+ }
+ }
+ }
+}
diff --git a/Sources/Meanwhile/AttentionTestSettingsSection.swift b/Sources/Meanwhile/AttentionTestSettingsSection.swift
new file mode 100644
index 0000000..fefe857
--- /dev/null
+++ b/Sources/Meanwhile/AttentionTestSettingsSection.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+enum AttentionTestRunResult: Equatable {
+ case startedMenuBarOnly
+ case startedWithNotification
+ case blockedByRealAttention
+ case failedNotification
+
+ var message: String {
+ switch self {
+ case .startedMenuBarOnly:
+ return "The menu bar will show a test for six seconds. Reminders are off or unavailable."
+ case .startedWithNotification:
+ return "The menu bar and a clearly labelled test notification are active."
+ case .blockedByRealAttention:
+ return "A real task needs you now, so Meanwhile did not replace it with a test."
+ case .failedNotification:
+ return "The menu-bar test worked, but macOS did not accept the test notification."
+ }
+ }
+
+ var isError: Bool {
+ self == .blockedByRealAttention || self == .failedNotification
+ }
+}
+
+struct AttentionTestSettingsSection: View {
+ let isRunning: Bool
+ let result: AttentionTestRunResult?
+ let run: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Test the attention loop")
+ .font(.callout.weight(.semibold))
+ Text("Preview the menu-bar bloom and, when enabled, a test reminder. No agent or GitHub activity is created.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ Button(isRunning ? "Testing…" : "Run Attention Test", action: run)
+ .disabled(isRunning)
+ .accessibilityHint("Shows a labelled six-second test without creating work")
+ }
+ if let result {
+ SettingsInlineMessage(
+ result.message,
+ systemImage: result.isError
+ ? "exclamationmark.triangle.fill"
+ : "checkmark.circle.fill",
+ tint: result.isError ? .orange : .secondary
+ )
+ }
+ }
+ }
+}
diff --git a/Sources/Meanwhile/ConnectionHealthSection.swift b/Sources/Meanwhile/ConnectionHealthSection.swift
index a6f0b0d..868ed9c 100644
--- a/Sources/Meanwhile/ConnectionHealthSection.swift
+++ b/Sources/Meanwhile/ConnectionHealthSection.swift
@@ -6,12 +6,17 @@ struct ConnectionHealthSection: View {
let integrationHealthError: String?
let lastAgentEvent: AgentSessionState?
let githubAuthenticationStatus: GitHubAuthenticationStatus
+ let githubLoginCopyMessage: String?
let sessionInspection: AgentSessionInspection
let sessionRecoveryMessage: String?
let sessionRecoveryIsError: Bool
let isClearingStuckSessions: Bool
+ let sourceRefreshSnapshot: SourceRefreshSnapshot
let staleAfter: String
+ let repairIntegrations: () -> Void
let clearStuckSessions: () -> Void
+ let copyGitHubLoginCommand: () -> Void
+ let refreshGitHubSources: () -> Void
let now: Date
@State private var isConfirmingClear = false
@@ -22,7 +27,10 @@ struct ConnectionHealthSection: View {
tint: integrationHealthTint,
title: "Agent hooks",
value: integrationHealthTitle,
- detail: integrationHealthDetail
+ detail: integrationHealthDetail,
+ actionTitle: integrationHealth.state == .installed ? nil : "Repair…",
+ actionAccessibilityLabel: "Repair agent integrations",
+ action: repairIntegrations
)
HealthRow(
systemImage: sessionHealthSymbol,
@@ -47,7 +55,24 @@ struct ConnectionHealthSection: View {
tint: githubHealthTint,
title: "GitHub",
value: githubHealthTitle,
- detail: githubHealthDetail
+ detail: githubHealthDetail,
+ actionTitle: githubAuthenticationStatus == .authenticated
+ ? nil
+ : "Copy Login Command",
+ actionAccessibilityLabel: "Copy GitHub login command",
+ action: copyGitHubLoginCommand
+ )
+ SourceHealthRow(
+ title: "Review source",
+ record: sourceRefreshSnapshot.reviews,
+ now: now,
+ refresh: refreshGitHubSources
+ )
+ SourceHealthRow(
+ title: "Failing CI source",
+ record: sourceRefreshSnapshot.failingCI,
+ now: now,
+ refresh: refreshGitHubSources
)
}
.confirmationDialog(
@@ -181,7 +206,8 @@ struct ConnectionHealthSection: View {
}
private var githubHealthDetail: String {
- githubAuthenticationStatus == .authenticated
+ if let githubLoginCopyMessage { return githubLoginCopyMessage }
+ return githubAuthenticationStatus == .authenticated
? "Using the GitHub CLI session on this Mac."
: "Run gh auth login, then refresh repository sources."
}
@@ -197,6 +223,64 @@ struct ConnectionHealthSection: View {
}
}
+private struct SourceHealthRow: View {
+ let title: String
+ let record: SourceRefreshRecord
+ let now: Date
+ let refresh: () -> Void
+
+ var body: some View {
+ HealthRow(
+ systemImage: symbol,
+ tint: tint,
+ title: title,
+ value: value,
+ detail: detail,
+ actionTitle: record.isEnabled ? (record.lastFailureAt == nil ? "Refresh All" : "Retry All") : nil,
+ actionAccessibilityLabel: "\(record.lastFailureAt == nil ? "Refresh" : "Retry") all GitHub sources",
+ action: refresh,
+ actionDisabled: record.isRefreshing
+ )
+ }
+
+ private var value: String {
+ guard record.isEnabled else { return "Disabled" }
+ if record.isRefreshing { return "Refreshing…" }
+ if let failure = record.lastFailureAt {
+ guard let success = record.lastSuccessAt else { return "Refresh failed" }
+ if failure > success { return "Refresh failed" }
+ }
+ if let success = record.lastSuccessAt {
+ return "Updated \(relativeDateString(success, relativeTo: now))"
+ }
+ return "Not checked yet"
+ }
+
+ private var detail: String {
+ guard record.isEnabled else { return "Disabled in Attention sources." }
+ if record.isRefreshing { return "Checking GitHub now." }
+ if record.lastFailureAt != nil { return "Check GitHub CLI authentication, then retry." }
+ if record.lastSuccessAt != nil {
+ return "Automatic checks pause while agents are idle. Refresh works at any time."
+ }
+ return "Run a manual refresh, or start an agent session to enable automatic checks."
+ }
+
+ private var symbol: String {
+ if !record.isEnabled { return "minus.circle" }
+ if record.isRefreshing { return "arrow.triangle.2.circlepath" }
+ if record.lastFailureAt != nil { return "exclamationmark.triangle.fill" }
+ if record.lastSuccessAt != nil { return "checkmark.circle.fill" }
+ return "clock.badge.questionmark"
+ }
+
+ private var tint: Color {
+ if record.lastFailureAt != nil { return .orange }
+ if record.lastSuccessAt != nil { return .green }
+ return .secondary
+ }
+}
+
private struct HealthRow: View {
let systemImage: String
let tint: Color
diff --git a/Sources/Meanwhile/MeanwhileApp.swift b/Sources/Meanwhile/MeanwhileApp.swift
index 8541b6f..7672c7a 100644
--- a/Sources/Meanwhile/MeanwhileApp.swift
+++ b/Sources/Meanwhile/MeanwhileApp.swift
@@ -19,6 +19,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
private let recentSignalStore = RecentSignalStore()
private let launchAtLoginController = LaunchAtLoginController()
private let needsYouNotificationPreferences = NeedsYouNotificationPreferences()
+ private lazy var attentionSourcePreferences = AttentionSourcePreferences(
+ defaultSelection: AttentionSourceSelection(
+ reviewsEnabled: configuration.enableReviews,
+ failingCIEnabled: configuration.enableFailingCI
+ )
+ )
private lazy var integrationInstaller = AgentIntegrationInstaller(helperURL: helperURL())
private lazy var hotKeyPreferences = HotKeyPreferences(defaultHotKey: configuration.hotKey)
private lazy var needsYouNotificationController = NeedsYouNotificationController()
@@ -35,6 +41,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
private var needsYouNotificationState = NeedsYouNotificationState()
private var bloomExpirationWorkItem: DispatchWorkItem?
private var pendingBloomSettlement = false
+ private var attentionTestState = AttentionTestState()
+ private var attentionTestExpirationWorkItem: DispatchWorkItem?
private lazy var agentFocusRouter = AgentFocusRouter(
focusTerminal: { [terminalFocuser] session in
terminalFocuser.focus(session)
@@ -47,6 +55,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
eventStore: eventStore,
recentSignalStore: recentSignalStore,
notificationPreferences: needsYouNotificationPreferences,
+ attentionSourcePreferences: attentionSourcePreferences,
notificationController: needsYouNotificationController,
sessionStaleAfter: configuration.sessionStaleSeconds,
appVersion: Bundle.main.object(
@@ -92,6 +101,22 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
},
notificationSettingsDidChange: { [weak self] in
self?.reconcileNeedsYouNotification()
+ },
+ runAttentionTest: { [weak self] completion in
+ self?.runAttentionTest(completion: completion)
+ },
+ sourceRefreshSnapshot: { [weak self] in
+ self?.runtime?.sourceRefreshSnapshot
+ ?? SourceRefreshSnapshot(
+ reviewsEnabled: self?.attentionSourcePreferences.selection.reviewsEnabled ?? true,
+ failingCIEnabled: self?.attentionSourcePreferences.selection.failingCIEnabled ?? true
+ )
+ },
+ refreshSources: { [weak self] completion in
+ self?.runtime?.refreshSources(completion: completion)
+ },
+ sourceSelectionDidChange: { [weak self] selection in
+ self?.runtime?.sourceSelectionDidChange(selection)
}
)
@@ -104,6 +129,9 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
needsYouNotificationController.onResponse = { [weak self] identifier in
self?.handleNeedsYouNotificationResponse(identifier: identifier)
}
+ needsYouNotificationController.onTestResponse = { [weak self] in
+ self?.openSettings()
+ }
needsYouNotificationController.start()
if !needsYouNotificationPreferences.settings.isEnabled {
needsYouNotificationController.cancelAllManaged()
@@ -140,7 +168,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
eventStore: eventStore,
reviewSource: reviewSource,
ciSource: ciSource,
- configuration: configuration
+ configuration: configuration,
+ sourceSelection: attentionSourcePreferences.selection
) { [weak self] presentation in
Task { @MainActor [weak self] in self?.present(presentation) }
}
@@ -155,6 +184,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
bloomExpirationWorkItem?.cancel()
+ attentionTestExpirationWorkItem?.cancel()
runtime?.stop()
}
@@ -167,6 +197,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
currentItem = presentation.item
recordPresentationIfNeeded(presentation.item)
reconcileNeedsYouNotification(presentation)
+ if attentionTestState.observeRealAttention(isActive: presentation.item != nil) == .preempted {
+ attentionTestExpirationWorkItem?.cancel()
+ attentionTestExpirationWorkItem = nil
+ settingsModel.attentionTestDidEnd()
+ }
+ if attentionTestState.isActive { return }
let transition = bloomState.observe(
phase: presentation.phase,
item: presentation.item
@@ -343,6 +379,10 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
}
@objc private func openCurrentItem() {
+ if attentionTestState.isActive {
+ openSettings()
+ return
+ }
guard let item = currentItem else { return }
settleBloomBeforeAction()
cancelNeedsYouNotification(for: item)
@@ -355,7 +395,24 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
showFocusFailure(for: item)
}
} else if let url = MenuBarPresenter.destinationURL(item: item) {
- NSWorkspace.shared.open(url)
+ if !NSWorkspace.shared.open(url) {
+ showLinkFailure(for: item, url: url)
+ }
+ }
+ }
+
+ private func showLinkFailure(for _: WorkItem, url: URL) {
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = "Couldn’t open this GitHub item"
+ alert.informativeText = "The interruption is still active. Copy the link and open it in a browser when you’re ready."
+ alert.addButton(withTitle: "Copy Link")
+ alert.addButton(withTitle: "OK")
+ NSApp.activate(ignoringOtherApps: true)
+ if alert.runModal() == .alertFirstButtonReturn {
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ _ = pasteboard.setString(url.absoluteString, forType: .string)
}
}
@@ -375,9 +432,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
The interruption is still active.
"""
}
+ alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "OK")
NSApp.activate(ignoringOtherApps: true)
- alert.runModal()
+ if alert.runModal() == .alertFirstButtonReturn {
+ openSettings()
+ }
}
private func registerHotKeyFromPreferences() {
@@ -600,6 +660,74 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func quitMeanwhile() {
NSApplication.shared.terminate(nil)
}
+
+ private func runAttentionTest(
+ completion: @escaping (AttentionTestRunResult) -> Void
+ ) {
+ let realAttentionIsActive = currentItem != nil
+ guard case .started(let generation) = attentionTestState.start(
+ realAttentionIsActive: realAttentionIsActive
+ ) else {
+ completion(.blockedByRealAttention)
+ return
+ }
+
+ attentionTestExpirationWorkItem?.cancel()
+ renderAttentionTest()
+ menuBar?.announce("Meanwhile attention test")
+
+ let notificationsAvailable = needsYouNotificationPreferences.settings.isEnabled
+ && needsYouNotificationController.permission == .authorized
+ if notificationsAvailable {
+ needsYouNotificationController.deliverTest { outcome in
+ switch outcome {
+ case .delivered: completion(.startedWithNotification)
+ case .cancelled: completion(.startedMenuBarOnly)
+ case .failed: completion(.failedNotification)
+ }
+ }
+ } else {
+ completion(.startedMenuBarOnly)
+ }
+
+ let workItem = DispatchWorkItem { [weak self] in
+ Task { @MainActor [weak self] in
+ guard let self,
+ self.attentionTestState.finish(generation: generation) == .finished else {
+ return
+ }
+ self.attentionTestExpirationWorkItem = nil
+ self.settingsModel.attentionTestDidEnd()
+ if let latestPresentation = self.latestPresentation {
+ self.render(latestPresentation, isBlooming: self.bloomState.isActive)
+ }
+ }
+ }
+ attentionTestExpirationWorkItem = workItem
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + Self.statusItemBloomDuration,
+ execute: workItem
+ )
+ }
+
+ private func renderAttentionTest() {
+ menuBar?.setTitle("Test needs attention")
+ menuBar?.statusItem.length = NSStatusItem.variableLength
+ menuBar?.setIcon(
+ systemName: MenuBarPresenter.iconName(phase: .needsYou),
+ accessibilityDescription: nil,
+ tintColor: .systemRed
+ )
+ menuBar?.setAccessibility(
+ label: "Meanwhile attention test",
+ help: "Click to return to Settings. This is not a real task."
+ )
+ menuBar?.statusItem.button?.toolTip = "Meanwhile attention test — click to return to Settings"
+ openItemMenuItem?.title = "Return to Settings"
+ openItemMenuItem?.isEnabled = true
+ snoozeMenuItem?.isEnabled = false
+ hideMenuItem?.isEnabled = false
+ }
}
@main
diff --git a/Sources/Meanwhile/NeedsYouNotificationController.swift b/Sources/Meanwhile/NeedsYouNotificationController.swift
index 583cb6c..3ac9952 100644
--- a/Sources/Meanwhile/NeedsYouNotificationController.swift
+++ b/Sources/Meanwhile/NeedsYouNotificationController.swift
@@ -12,9 +12,11 @@ enum NeedsYouNotificationDeliveryOutcome: Equatable {
@MainActor
final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDelegate {
nonisolated static let identifierPrefix = "Meanwhile.needs-you."
+ nonisolated static let testIdentifier = "Meanwhile.attention-test"
var onPermissionChange: ((NeedsYouNotificationPermission) -> Void)?
var onResponse: ((String) -> Void)?
+ var onTestResponse: (() -> Void)?
private(set) var permission: NeedsYouNotificationPermission = .unknown {
didSet {
@@ -105,6 +107,35 @@ final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDe
}
}
+ func deliverTest(
+ completion: @escaping (NeedsYouNotificationDeliveryOutcome) -> Void
+ ) {
+ guard permission == .authorized else {
+ completion(.cancelled)
+ return
+ }
+ center.removePendingNotificationRequests(withIdentifiers: [Self.testIdentifier])
+ center.removeDeliveredNotifications(withIdentifiers: [Self.testIdentifier])
+ let content = UNMutableNotificationContent()
+ content.title = "Meanwhile attention test"
+ content.body = "This is a test. Click to return to Meanwhile Settings."
+ let request = UNNotificationRequest(
+ identifier: Self.testIdentifier,
+ content: content,
+ trigger: nil
+ )
+ center.add(request) { [weak self] error in
+ Task { @MainActor [weak self] in
+ if error == nil {
+ completion(.delivered)
+ } else {
+ self?.refreshPermission()
+ completion(.failed)
+ }
+ }
+ }
+ }
+
func cancel(itemID: String) {
let identifier = Self.identifier(for: itemID)
desiredRequests.removeValue(forKey: identifier)
@@ -258,7 +289,8 @@ final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDe
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
- guard notification.request.identifier.hasPrefix(Self.identifierPrefix) else {
+ guard notification.request.identifier.hasPrefix(Self.identifierPrefix)
+ || notification.request.identifier == Self.testIdentifier else {
completionHandler([])
return
}
@@ -271,6 +303,13 @@ final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDe
withCompletionHandler completionHandler: @escaping () -> Void
) {
let identifier = response.notification.request.identifier
+ if identifier == Self.testIdentifier,
+ response.actionIdentifier == UNNotificationDefaultActionIdentifier {
+ center.removeDeliveredNotifications(withIdentifiers: [identifier])
+ Task { @MainActor [weak self] in self?.onTestResponse?() }
+ completionHandler()
+ return
+ }
guard identifier.hasPrefix(Self.identifierPrefix),
response.actionIdentifier == UNNotificationDefaultActionIdentifier else {
completionHandler()
diff --git a/Sources/Meanwhile/RepositorySettingsModel.swift b/Sources/Meanwhile/RepositorySettingsModel.swift
index 031ca2a..559176c 100644
--- a/Sources/Meanwhile/RepositorySettingsModel.swift
+++ b/Sources/Meanwhile/RepositorySettingsModel.swift
@@ -37,6 +37,11 @@ final class RepositorySettingsModel: ObservableObject {
@Published private(set) var needsYouNotificationSettings: NeedsYouNotificationSettings
@Published private(set) var needsYouNotificationPermission: NeedsYouNotificationPermission
@Published private(set) var isRequestingNotificationPermission = false
+ @Published private(set) var attentionTestIsRunning = false
+ @Published private(set) var attentionTestResult: AttentionTestRunResult?
+ @Published private(set) var sourceRefreshSnapshot: SourceRefreshSnapshot
+ @Published private(set) var githubLoginCopyMessage: String?
+ @Published private(set) var attentionSourceSelection: AttentionSourceSelection
let appVersion: String
let buildVersion: String
@@ -92,6 +97,7 @@ final class RepositorySettingsModel: ObservableObject {
private let eventStore: AgentEventStore
private let recentSignalStore: RecentSignalStore
private let notificationPreferences: NeedsYouNotificationPreferences
+ private let attentionSourcePreferences: AttentionSourcePreferences
private let notificationController: NeedsYouNotificationController
private let releaseUpdateChecker: ReleaseUpdateChecker
private let sessionStaleAfter: TimeInterval
@@ -105,6 +111,10 @@ final class RepositorySettingsModel: ObservableObject {
private let hotKeyDidChange: (HotKeyConfiguration?) -> Void
private let integrationDidInstall: (AgentIntegrationInstallResult) -> Void
private let notificationSettingsDidChange: () -> Void
+ private let runAttentionTestAction: (@escaping (AttentionTestRunResult) -> Void) -> Void
+ private let sourceRefreshSnapshotProvider: () -> SourceRefreshSnapshot
+ private let refreshSourcesAction: (@escaping @Sendable (SourceRefreshSnapshot) -> Void) -> Void
+ private let sourceSelectionDidChange: (AttentionSourceSelection) -> Void
private var hasLoaded = false
private var diagnosticsFeedbackID = UUID()
@@ -117,6 +127,7 @@ final class RepositorySettingsModel: ObservableObject {
eventStore: AgentEventStore,
recentSignalStore: RecentSignalStore,
notificationPreferences: NeedsYouNotificationPreferences,
+ attentionSourcePreferences: AttentionSourcePreferences,
notificationController: NeedsYouNotificationController,
releaseUpdateChecker: ReleaseUpdateChecker = ReleaseUpdateChecker(),
sessionStaleAfter: TimeInterval,
@@ -131,7 +142,11 @@ final class RepositorySettingsModel: ObservableObject {
selectionDidChange: @escaping () -> Void,
hotKeyDidChange: @escaping (HotKeyConfiguration?) -> Void,
integrationDidInstall: @escaping (AgentIntegrationInstallResult) -> Void,
- notificationSettingsDidChange: @escaping () -> Void
+ notificationSettingsDidChange: @escaping () -> Void,
+ runAttentionTest: @escaping (@escaping (AttentionTestRunResult) -> Void) -> Void,
+ sourceRefreshSnapshot: @escaping () -> SourceRefreshSnapshot,
+ refreshSources: @escaping (@escaping @Sendable (SourceRefreshSnapshot) -> Void) -> Void,
+ sourceSelectionDidChange: @escaping (AttentionSourceSelection) -> Void
) {
self.preferences = preferences
self.hotKeyPreferences = hotKeyPreferences
@@ -141,6 +156,7 @@ final class RepositorySettingsModel: ObservableObject {
self.eventStore = eventStore
self.recentSignalStore = recentSignalStore
self.notificationPreferences = notificationPreferences
+ self.attentionSourcePreferences = attentionSourcePreferences
self.notificationController = notificationController
self.releaseUpdateChecker = releaseUpdateChecker
self.sessionStaleAfter = sessionStaleAfter
@@ -156,6 +172,10 @@ final class RepositorySettingsModel: ObservableObject {
self.hotKeyDidChange = hotKeyDidChange
self.integrationDidInstall = integrationDidInstall
self.notificationSettingsDidChange = notificationSettingsDidChange
+ runAttentionTestAction = runAttentionTest
+ sourceRefreshSnapshotProvider = sourceRefreshSnapshot
+ refreshSourcesAction = refreshSources
+ self.sourceSelectionDidChange = sourceSelectionDidChange
let snapshot = preferences.snapshot
includesAllRepositories = snapshot.includesAllRepositories
selectedRepositories = snapshot.selectedRepositories
@@ -163,6 +183,8 @@ final class RepositorySettingsModel: ObservableObject {
self.launchAtLoginStatus = launchAtLoginStatus()
needsYouNotificationSettings = notificationPreferences.settings
needsYouNotificationPermission = notificationController.permission
+ self.sourceRefreshSnapshot = sourceRefreshSnapshot()
+ attentionSourceSelection = attentionSourcePreferences.selection
}
func loadRepositories(force: Bool = false) {
@@ -231,11 +253,78 @@ final class RepositorySettingsModel: ObservableObject {
}
self.sessionInspection = sessionInspection
launchAtLoginStatus = launchAtLoginStatusProvider()
+ sourceRefreshSnapshot = sourceRefreshSnapshotProvider()
isCheckingHealth = false
}
}
}
+ func runAttentionTest() {
+ guard !attentionTestIsRunning else { return }
+ attentionTestIsRunning = true
+ attentionTestResult = nil
+ runAttentionTestAction { [weak self] result in
+ Task { @MainActor [weak self] in
+ self?.attentionTestResult = result
+ if result == .blockedByRealAttention {
+ self?.attentionTestIsRunning = false
+ }
+ }
+ }
+ Task { [weak self] in
+ try? await Task.sleep(nanoseconds: 6_500_000_000)
+ self?.attentionTestIsRunning = false
+ }
+ }
+
+ func attentionTestDidEnd() {
+ attentionTestIsRunning = false
+ }
+
+ func refreshGitHubSources() {
+ let now = Date()
+ sourceRefreshSnapshot = sourceRefreshSnapshotProvider()
+ sourceRefreshSnapshot.reviews.begin(at: now)
+ sourceRefreshSnapshot.failingCI.begin(at: now)
+ refreshSourcesAction { [weak self] snapshot in
+ Task { @MainActor [weak self] in
+ self?.sourceRefreshSnapshot = snapshot
+ self?.refreshStatus()
+ }
+ }
+ }
+
+ func setAttentionSourceSelection(_ selection: AttentionSourceSelection) {
+ if selection.reviewsEnabled != attentionSourceSelection.reviewsEnabled {
+ attentionSourcePreferences.setReviewsEnabled(selection.reviewsEnabled)
+ }
+ if selection.failingCIEnabled != attentionSourceSelection.failingCIEnabled {
+ attentionSourcePreferences.setFailingCIEnabled(selection.failingCIEnabled)
+ }
+ attentionSourceSelection = attentionSourcePreferences.selection
+ sourceSelectionDidChange(attentionSourceSelection)
+ sourceRefreshSnapshot.reviews.isEnabled = attentionSourceSelection.reviewsEnabled
+ sourceRefreshSnapshot.failingCI.isEnabled = attentionSourceSelection.failingCIEnabled
+ if !attentionSourceSelection.reviewsEnabled {
+ sourceRefreshSnapshot.reviews.isRefreshing = false
+ }
+ if !attentionSourceSelection.failingCIEnabled {
+ sourceRefreshSnapshot.failingCI.isRefreshing = false
+ }
+ }
+
+ func copyGitHubLoginCommand() {
+ githubLoginCopyMessage = copyText("gh auth login")
+ ? "Copied `gh auth login` to the clipboard."
+ : "Meanwhile could not copy the login command."
+ let message = githubLoginCopyMessage
+ Task { [weak self] in
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
+ guard self?.githubLoginCopyMessage == message else { return }
+ self?.githubLoginCopyMessage = nil
+ }
+ }
+
func checkForUpdates() {
guard updateState != .checking else { return }
updateState = .checking
@@ -371,6 +460,7 @@ final class RepositorySettingsModel: ObservableObject {
repositoryScopeIncludesAll: includesAllRepositories,
accessibleRepositoryCount: availableRepositories.count,
selectedRepositoryCount: selectedRepositories.count,
+ attentionSourceSelection: attentionSourceSelection,
hotKeyConfigured: hotKey != nil,
sessionInspection: sessionInspection,
lastAgentEvent: lastAgentEvent,
diff --git a/Sources/Meanwhile/RepositorySettingsView.swift b/Sources/Meanwhile/RepositorySettingsView.swift
index 731690a..4722715 100644
--- a/Sources/Meanwhile/RepositorySettingsView.swift
+++ b/Sources/Meanwhile/RepositorySettingsView.swift
@@ -7,6 +7,8 @@ struct RepositorySettingsView: View {
@AppStorage("settings.section.connectionHealth.expanded") private var isConnectionHealthExpanded = true
@AppStorage("settings.section.aboutSupport.expanded") private var isAboutSupportExpanded = true
@AppStorage("settings.section.notifications.expanded") private var isNotificationsExpanded = true
+ @AppStorage("settings.section.attentionTest.expanded") private var isAttentionTestExpanded = true
+ @AppStorage("settings.section.attentionSources.expanded") private var isAttentionSourcesExpanded = true
@AppStorage("settings.section.repositorySources.expanded") private var isRepositorySourcesExpanded = true
@AppStorage("settings.section.recentSignals.expanded") private var isRecentSignalsExpanded = true
@State private var searchText = ""
@@ -82,6 +84,32 @@ struct RepositorySettingsView: View {
)
}
Divider()
+ CollapsibleSettingsSection(
+ title: "Attention test",
+ trailing: model.attentionTestIsRunning ? "Testing" : nil,
+ isExpanded: $isAttentionTestExpanded,
+ showsProgress: model.attentionTestIsRunning
+ ) {
+ AttentionTestSettingsSection(
+ isRunning: model.attentionTestIsRunning,
+ result: model.attentionTestResult,
+ run: model.runAttentionTest
+ )
+ }
+ Divider()
+ CollapsibleSettingsSection(
+ title: "Attention sources",
+ trailing: sourceSelectionTrailing,
+ isExpanded: $isAttentionSourcesExpanded
+ ) {
+ AttentionSourcesSettingsSection(
+ selection: Binding(
+ get: { model.attentionSourceSelection },
+ set: { model.setAttentionSourceSelection($0) }
+ )
+ )
+ }
+ Divider()
ControlsSettingsSection(
hotKey: Binding(
get: { model.hotKey },
@@ -161,12 +189,17 @@ struct RepositorySettingsView: View {
integrationHealthError: model.integrationHealthError,
lastAgentEvent: model.lastAgentEvent,
githubAuthenticationStatus: model.githubAuthenticationStatus,
+ githubLoginCopyMessage: model.githubLoginCopyMessage,
sessionInspection: model.sessionInspection,
sessionRecoveryMessage: model.sessionRecoveryMessage,
sessionRecoveryIsError: model.sessionRecoveryIsError,
isClearingStuckSessions: model.isClearingStuckSessions,
+ sourceRefreshSnapshot: model.sourceRefreshSnapshot,
staleAfter: model.sessionStaleAfterDescription,
+ repairIntegrations: model.installIntegrations,
clearStuckSessions: model.clearStuckSessions,
+ copyGitHubLoginCommand: model.copyGitHubLoginCommand,
+ refreshGitHubSources: model.refreshGitHubSources,
now: now
)
}
@@ -218,6 +251,16 @@ struct RepositorySettingsView: View {
}
}
}
+
+ private var sourceSelectionTrailing: String {
+ let githubSourceCount = [
+ model.attentionSourceSelection.reviewsEnabled,
+ model.attentionSourceSelection.failingCIEnabled
+ ].filter { $0 }.count
+ return githubSourceCount == 0
+ ? "Agents only"
+ : "\(githubSourceCount + 1) enabled"
+ }
}
private extension NeedsYouNotificationDelay {
diff --git a/Sources/MeanwhileCore/AttentionSourcePreferences.swift b/Sources/MeanwhileCore/AttentionSourcePreferences.swift
new file mode 100644
index 0000000..e6773fc
--- /dev/null
+++ b/Sources/MeanwhileCore/AttentionSourcePreferences.swift
@@ -0,0 +1,66 @@
+import Foundation
+
+public struct AttentionSourceSelection: Equatable, Sendable {
+ public var reviewsEnabled: Bool
+ public var failingCIEnabled: Bool
+
+ public init(reviewsEnabled: Bool = true, failingCIEnabled: Bool = true) {
+ self.reviewsEnabled = reviewsEnabled
+ self.failingCIEnabled = failingCIEnabled
+ }
+
+ public func isEnabled(_ source: GitHubSourceKind) -> Bool {
+ switch source {
+ case .reviews: return reviewsEnabled
+ case .failingCI: return failingCIEnabled
+ }
+ }
+}
+
+public final class AttentionSourcePreferences: @unchecked Sendable {
+ private enum Key {
+ static let reviews = "Meanwhile.sources.reviews.enabled"
+ static let failingCI = "Meanwhile.sources.failingCI.enabled"
+ }
+
+ private let lock = NSLock()
+ private let defaults: UserDefaults
+ private var state: AttentionSourceSelection
+
+ public init(
+ defaults: UserDefaults = .standard,
+ defaultSelection: AttentionSourceSelection = AttentionSourceSelection()
+ ) {
+ self.defaults = defaults
+ state = AttentionSourceSelection(
+ reviewsEnabled: defaults.object(forKey: Key.reviews) == nil
+ ? defaultSelection.reviewsEnabled
+ : defaults.bool(forKey: Key.reviews),
+ failingCIEnabled: defaults.object(forKey: Key.failingCI) == nil
+ ? defaultSelection.failingCIEnabled
+ : defaults.bool(forKey: Key.failingCI)
+ )
+ }
+
+ public var selection: AttentionSourceSelection {
+ lock.lock()
+ defer { lock.unlock() }
+ return state
+ }
+
+ public func setReviewsEnabled(_ enabled: Bool) {
+ update { $0.reviewsEnabled = enabled }
+ }
+
+ public func setFailingCIEnabled(_ enabled: Bool) {
+ update { $0.failingCIEnabled = enabled }
+ }
+
+ private func update(_ body: (inout AttentionSourceSelection) -> Void) {
+ lock.lock()
+ defer { lock.unlock() }
+ body(&state)
+ defaults.set(state.reviewsEnabled, forKey: Key.reviews)
+ defaults.set(state.failingCIEnabled, forKey: Key.failingCI)
+ }
+}
diff --git a/Sources/MeanwhileCore/AttentionTestState.swift b/Sources/MeanwhileCore/AttentionTestState.swift
new file mode 100644
index 0000000..bb894ea
--- /dev/null
+++ b/Sources/MeanwhileCore/AttentionTestState.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+public struct AttentionTestState: Equatable, Sendable {
+ public enum Transition: Equatable, Sendable {
+ case none
+ case started(generation: UInt64)
+ case finished
+ case preempted
+ }
+
+ public private(set) var generation: UInt64 = 0
+ public private(set) var isActive = false
+
+ public init() {}
+
+ public mutating func start(realAttentionIsActive: Bool) -> Transition {
+ guard !realAttentionIsActive, !isActive else { return .none }
+ generation &+= 1
+ isActive = true
+ return .started(generation: generation)
+ }
+
+ public mutating func observeRealAttention(isActive: Bool) -> Transition {
+ guard isActive, self.isActive else { return .none }
+ self.isActive = false
+ return .preempted
+ }
+
+ public mutating func finish(generation: UInt64) -> Transition {
+ guard isActive, generation == self.generation else { return .none }
+ isActive = false
+ return .finished
+ }
+
+ public mutating func cancel() -> Transition {
+ guard isActive else { return .none }
+ isActive = false
+ return .finished
+ }
+}
diff --git a/Sources/MeanwhileCore/DiagnosticsReport.swift b/Sources/MeanwhileCore/DiagnosticsReport.swift
index 7322fb5..fd2576a 100644
--- a/Sources/MeanwhileCore/DiagnosticsReport.swift
+++ b/Sources/MeanwhileCore/DiagnosticsReport.swift
@@ -18,6 +18,7 @@ public struct DiagnosticsSnapshot: Sendable {
public var repositoryScopeIncludesAll: Bool
public var accessibleRepositoryCount: Int
public var selectedRepositoryCount: Int
+ public var attentionSourceSelection: AttentionSourceSelection
public var hotKeyConfigured: Bool
public var sessionInspection: AgentSessionInspection
public var lastAgentEvent: AgentSessionState?
@@ -34,6 +35,7 @@ public struct DiagnosticsSnapshot: Sendable {
repositoryScopeIncludesAll: Bool,
accessibleRepositoryCount: Int,
selectedRepositoryCount: Int,
+ attentionSourceSelection: AttentionSourceSelection,
hotKeyConfigured: Bool,
sessionInspection: AgentSessionInspection,
lastAgentEvent: AgentSessionState?,
@@ -49,6 +51,7 @@ public struct DiagnosticsSnapshot: Sendable {
self.repositoryScopeIncludesAll = repositoryScopeIncludesAll
self.accessibleRepositoryCount = accessibleRepositoryCount
self.selectedRepositoryCount = selectedRepositoryCount
+ self.attentionSourceSelection = attentionSourceSelection
self.hotKeyConfigured = hotKeyConfigured
self.sessionInspection = sessionInspection
self.lastAgentEvent = lastAgentEvent
@@ -63,7 +66,7 @@ public enum MeanwhileDiagnosticsReport {
) -> String {
var lines = [
"Meanwhile diagnostics",
- "Schema: 1",
+ "Schema: 2",
"Generated: \(timestamp(generatedAt))",
"App: \(snapshot.appVersion) (\(snapshot.buildVersion))",
"macOS: \(snapshot.operatingSystemVersion)",
@@ -75,6 +78,9 @@ public enum MeanwhileDiagnosticsReport {
"Claude status line conflict: \(yesNo(snapshot.integrationHealth.claudeStatuslineConflict))",
"GitHub CLI: \(snapshot.githubAuthenticationStatus == .authenticated ? "authenticated" : "not authenticated")",
"Repository scope: \(repositoryScopeDescription(snapshot))",
+ "Agent requests: enabled",
+ "Failing CI source: \(enabledDisabled(snapshot.attentionSourceSelection.failingCIEnabled))",
+ "Review source: \(enabledDisabled(snapshot.attentionSourceSelection.reviewsEnabled))",
"Keyboard shortcut: \(snapshot.hotKeyConfigured ? "configured" : "not configured")",
"Active agent sessions: \(snapshot.sessionInspection.activeCount)",
"Sessions that may be stuck: \(snapshot.sessionInspection.stuckCount)"
@@ -110,6 +116,10 @@ public enum MeanwhileDiagnosticsReport {
value ? "yes" : "no"
}
+ private static func enabledDisabled(_ value: Bool) -> String {
+ value ? "enabled" : "disabled"
+ }
+
private static func launchAtLoginDescription(_ status: LaunchAtLoginStatus) -> String {
switch status {
case .disabled: return "off"
diff --git a/Sources/MeanwhileCore/GitHubCISource.swift b/Sources/MeanwhileCore/GitHubCISource.swift
index 957eaf8..01d21b5 100644
--- a/Sources/MeanwhileCore/GitHubCISource.swift
+++ b/Sources/MeanwhileCore/GitHubCISource.swift
@@ -32,7 +32,7 @@ public final class GitHubCISource {
private let runner: any CommandRunning
private let cacheInterval: TimeInterval
private let repositoryIsAllowed: @Sendable (String) -> Bool
- private var lastAttemptDate: Date?
+ public private(set) var lastAttemptDate: Date?
private var allCachedItems: [FailingCIItem] = []
public var cachedItems: [FailingCIItem] {
@@ -49,14 +49,19 @@ public final class GitHubCISource {
self.repositoryIsAllowed = repositoryIsAllowed
}
+ public func isDue(now: Date = Date()) -> Bool {
+ guard let lastAttemptDate else { return true }
+ return now.timeIntervalSince(lastAttemptDate) >= cacheInterval
+ }
+
@discardableResult
public func itemsIfDue(
pollingAllowed: Bool,
+ force: Bool = false,
now: Date = Date()
) throws -> [FailingCIItem] {
guard pollingAllowed else { return cachedItems }
- if let lastAttemptDate,
- now.timeIntervalSince(lastAttemptDate) < cacheInterval {
+ if !force, !isDue(now: now) {
return cachedItems
}
diff --git a/Sources/MeanwhileCore/GitHubReviewSource.swift b/Sources/MeanwhileCore/GitHubReviewSource.swift
index a63ee94..3e45ee8 100644
--- a/Sources/MeanwhileCore/GitHubReviewSource.swift
+++ b/Sources/MeanwhileCore/GitHubReviewSource.swift
@@ -32,7 +32,7 @@ public final class GitHubReviewSource {
private let runner: any CommandRunning
private let cacheInterval: TimeInterval
private let repositoryIsAllowed: @Sendable (String) -> Bool
- private var lastAttemptDate: Date?
+ public private(set) var lastAttemptDate: Date?
private var allCachedReviews: [ReviewItem] = []
public var cachedReviews: [ReviewItem] {
@@ -49,15 +49,20 @@ public final class GitHubReviewSource {
self.repositoryIsAllowed = repositoryIsAllowed
}
+ public func isDue(now: Date = Date()) -> Bool {
+ guard let lastAttemptDate else { return true }
+ return now.timeIntervalSince(lastAttemptDate) >= cacheInterval
+ }
+
/// Returns cached data unless polling is allowed and the 60-second cache is due.
@discardableResult
public func reviewsIfDue(
pollingAllowed: Bool,
+ force: Bool = false,
now: Date = Date()
) throws -> [ReviewItem] {
guard pollingAllowed else { return cachedReviews }
- if let lastAttemptDate,
- now.timeIntervalSince(lastAttemptDate) < cacheInterval {
+ if !force, !isDue(now: now) {
return cachedReviews
}
diff --git a/Sources/MeanwhileCore/MeanwhileEngine.swift b/Sources/MeanwhileCore/MeanwhileEngine.swift
index 8c8d803..2676119 100644
--- a/Sources/MeanwhileCore/MeanwhileEngine.swift
+++ b/Sources/MeanwhileCore/MeanwhileEngine.swift
@@ -41,8 +41,13 @@ public final class MeanwhileEngine: @unchecked Sendable {
sessions: [AgentSessionState],
reviews: [ReviewItem],
failingCI: [FailingCIItem],
+ sourceSelection: AttentionSourceSelection? = nil,
now: Date = Date()
) -> MeanwhilePresentation {
+ let sourceSelection = sourceSelection ?? AttentionSourceSelection(
+ reviewsEnabled: configuration.enableReviews,
+ failingCIEnabled: configuration.enableFailingCI
+ )
let activeSessions = sessions.filter { $0.phase != .idle }
let needsYou = sessions.filter { $0.phase == .needsYou }
let thinking = sessions.filter { $0.phase == .thinking }
@@ -58,7 +63,7 @@ public final class MeanwhileEngine: @unchecked Sendable {
session: session
)
}
- let ciItems = configuration.enableFailingCI ? failingCI.map { item in
+ let ciItems = sourceSelection.failingCIEnabled ? failingCI.map { item in
WorkItem(
id: "ci:\(item.repository)#\(item.number)",
kind: .failingCI,
@@ -68,7 +73,7 @@ public final class MeanwhileEngine: @unchecked Sendable {
createdAt: item.createdAt
)
} : []
- let reviewItems = configuration.enableReviews ? reviews.map { item in
+ let reviewItems = sourceSelection.reviewsEnabled ? reviews.map { item in
WorkItem(
id: "review:\(item.repository)#\(item.number)",
kind: .review,
diff --git a/Sources/MeanwhileCore/MeanwhileRuntime.swift b/Sources/MeanwhileCore/MeanwhileRuntime.swift
index d8bc875..1479a4f 100644
--- a/Sources/MeanwhileCore/MeanwhileRuntime.swift
+++ b/Sources/MeanwhileCore/MeanwhileRuntime.swift
@@ -4,6 +4,7 @@ import Peripheral
public final class MeanwhileRuntime: @unchecked Sendable {
public typealias PresentationHandler = @Sendable (MeanwhilePresentation) -> Void
+ public typealias SourceRefreshHandler = @Sendable (SourceRefreshSnapshot) -> Void
private let logger = Logger(subsystem: "Meanwhile", category: "Runtime")
private let sessionQueue: DispatchQueue
@@ -27,7 +28,19 @@ public final class MeanwhileRuntime: @unchecked Sendable {
var currentItem: WorkItem?
var lastThinkingDate: Date?
var sourcePollInFlight = false
+ var sourceRefresh: SourceRefreshSnapshot
+ var sourceSelection: AttentionSourceSelection
var stopped = false
+
+ init(
+ sourceSelection: AttentionSourceSelection = AttentionSourceSelection()
+ ) {
+ self.sourceSelection = sourceSelection
+ sourceRefresh = SourceRefreshSnapshot(
+ reviewsEnabled: sourceSelection.reviewsEnabled,
+ failingCIEnabled: sourceSelection.failingCIEnabled
+ )
+ }
}
public init(
@@ -35,6 +48,7 @@ public final class MeanwhileRuntime: @unchecked Sendable {
reviewSource: GitHubReviewSource = GitHubReviewSource(),
ciSource: GitHubCISource = GitHubCISource(),
configuration: MeanwhileConfiguration = MeanwhileConfiguration.load(),
+ sourceSelection: AttentionSourceSelection? = nil,
dispositions: ItemDispositionStore = ItemDispositionStore(),
statuslineStore: StatuslineSnapshotStore = StatuslineSnapshotStore(),
presentationHandler: @escaping PresentationHandler
@@ -50,6 +64,12 @@ public final class MeanwhileRuntime: @unchecked Sendable {
engine = MeanwhileEngine(configuration: configuration, dispositions: dispositions)
self.statuslineStore = statuslineStore
self.presentationHandler = presentationHandler
+ state = State(
+ sourceSelection: sourceSelection ?? AttentionSourceSelection(
+ reviewsEnabled: configuration.enableReviews,
+ failingCIEnabled: configuration.enableFailingCI
+ )
+ )
sessionTimer = PollTimer(interval: 0.5, queue: sessionQueue)
sourceTimer = PollTimer(interval: 60, queue: sourceQueue)
}
@@ -82,6 +102,37 @@ public final class MeanwhileRuntime: @unchecked Sendable {
}
}
+ public var sourceRefreshSnapshot: SourceRefreshSnapshot {
+ withState { $0.sourceRefresh }
+ }
+
+ public func refreshSources(completion: SourceRefreshHandler? = nil) {
+ sourceQueue.async { [weak self] in
+ self?.requestSourcePoll(force: true, completion: completion)
+ }
+ }
+
+ public func sourceSelectionDidChange(_ selection: AttentionSourceSelection) {
+ sourceQueue.async { [weak self] in
+ guard let self else { return }
+ let shouldRefresh = withState { state -> Bool in
+ guard state.sourceSelection != selection else { return false }
+ let enabledNewSource = (!state.sourceSelection.reviewsEnabled && selection.reviewsEnabled)
+ || (!state.sourceSelection.failingCIEnabled && selection.failingCIEnabled)
+ state.sourceSelection = selection
+ state.sourceRefresh.reviews.isEnabled = selection.reviewsEnabled
+ state.sourceRefresh.reviews.isRefreshing = false
+ state.sourceRefresh.failingCI.isEnabled = selection.failingCIEnabled
+ state.sourceRefresh.failingCI.isRefreshing = false
+ if !selection.reviewsEnabled { state.reviews = [] }
+ if !selection.failingCIEnabled { state.failingCI = [] }
+ return enabledNewSource
+ }
+ present()
+ if shouldRefresh { requestSourcePoll(force: true) }
+ }
+ }
+
public func snoozeCurrent(now: Date = Date()) {
guard let item = withState({ $0.currentItem }) else { return }
engine.snooze(item, now: now)
@@ -113,41 +164,77 @@ public final class MeanwhileRuntime: @unchecked Sendable {
if shouldPoll { requestSourcePoll(now: now) }
}
- private func requestSourcePoll(now: Date = Date()) {
+ private func requestSourcePoll(
+ now: Date = Date(),
+ force: Bool = false,
+ completion: SourceRefreshHandler? = nil
+ ) {
let shouldStart = withState { state -> Bool in
guard !state.stopped, !state.sourcePollInFlight else { return false }
state.sourcePollInFlight = true
return true
}
- guard shouldStart else { return }
+ guard shouldStart else {
+ if let completion { completion(sourceRefreshSnapshot) }
+ return
+ }
sourceQueue.async { [weak self] in
- self?.pollSources(now: now)
+ self?.pollSources(now: now, force: force, completion: completion)
}
}
- private func pollSources(now: Date) {
- defer { withState { $0.sourcePollInFlight = false } }
- let allowed = withState { state -> Bool in
- guard !state.stopped, let lastThinkingDate = state.lastThinkingDate else {
- return false
+ private func pollSources(
+ now: Date,
+ force: Bool,
+ completion: SourceRefreshHandler?
+ ) {
+ defer {
+ let snapshot = withState { state -> SourceRefreshSnapshot in
+ state.sourcePollInFlight = false
+ return state.sourceRefresh
}
+ completion?(snapshot)
+ }
+ let allowed = withState { state -> Bool in
+ guard !state.stopped else { return false }
+ if force { return true }
+ guard let lastThinkingDate = state.lastThinkingDate else { return false }
return now.timeIntervalSince(lastThinkingDate) <= 600
}
guard allowed else { return }
- if configuration.enableReviews {
+ let selection = withState { $0.sourceSelection }
+ if selection.isEnabled(.reviews), force || reviewSource.isDue(now: now) {
+ withState { $0.sourceRefresh[.reviews].begin(at: now) }
do {
- let reviews = try reviewSource.reviewsIfDue(pollingAllowed: true, now: now)
- withState { $0.reviews = reviews }
+ let reviews = try reviewSource.reviewsIfDue(
+ pollingAllowed: true,
+ force: force,
+ now: now
+ )
+ withState {
+ $0.reviews = reviews
+ $0.sourceRefresh[.reviews].succeed(at: Date())
+ }
} catch {
+ withState { $0.sourceRefresh[.reviews].fail(at: Date()) }
logger.error("Review poll failed: \(error.localizedDescription, privacy: .public)")
}
}
- if configuration.enableFailingCI {
+ if selection.isEnabled(.failingCI), force || ciSource.isDue(now: now) {
+ withState { $0.sourceRefresh[.failingCI].begin(at: now) }
do {
- let items = try ciSource.itemsIfDue(pollingAllowed: true, now: now)
- withState { $0.failingCI = items }
+ let items = try ciSource.itemsIfDue(
+ pollingAllowed: true,
+ force: force,
+ now: now
+ )
+ withState {
+ $0.failingCI = items
+ $0.sourceRefresh[.failingCI].succeed(at: Date())
+ }
} catch {
+ withState { $0.sourceRefresh[.failingCI].fail(at: Date()) }
logger.error("CI poll failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -155,12 +242,15 @@ public final class MeanwhileRuntime: @unchecked Sendable {
}
private func present(now: Date = Date()) {
- let snapshot = withState { ($0.stopped, $0.sessions, $0.reviews, $0.failingCI) }
+ let snapshot = withState {
+ ($0.stopped, $0.sessions, $0.reviews, $0.failingCI, $0.sourceSelection)
+ }
guard !snapshot.0 else { return }
let presentation = engine.presentation(
sessions: snapshot.1,
reviews: snapshot.2,
failingCI: snapshot.3,
+ sourceSelection: snapshot.4,
now: now
)
withState { $0.currentItem = presentation.item }
diff --git a/Sources/MeanwhileCore/SourceRefreshSnapshot.swift b/Sources/MeanwhileCore/SourceRefreshSnapshot.swift
new file mode 100644
index 0000000..3f34cc3
--- /dev/null
+++ b/Sources/MeanwhileCore/SourceRefreshSnapshot.swift
@@ -0,0 +1,76 @@
+import Foundation
+
+public enum GitHubSourceKind: String, CaseIterable, Sendable {
+ case reviews
+ case failingCI
+}
+
+public struct SourceRefreshRecord: Equatable, Sendable {
+ public var isEnabled: Bool
+ public var isRefreshing: Bool
+ public var lastAttemptAt: Date?
+ public var lastSuccessAt: Date?
+ public var lastFailureAt: Date?
+
+ public init(
+ isEnabled: Bool,
+ isRefreshing: Bool = false,
+ lastAttemptAt: Date? = nil,
+ lastSuccessAt: Date? = nil,
+ lastFailureAt: Date? = nil
+ ) {
+ self.isEnabled = isEnabled
+ self.isRefreshing = isRefreshing
+ self.lastAttemptAt = lastAttemptAt
+ self.lastSuccessAt = lastSuccessAt
+ self.lastFailureAt = lastFailureAt
+ }
+
+ public mutating func begin(at date: Date) {
+ guard isEnabled else { return }
+ isRefreshing = true
+ lastAttemptAt = date
+ }
+
+ public mutating func succeed(at date: Date) {
+ guard isEnabled else { return }
+ isRefreshing = false
+ lastSuccessAt = date
+ lastFailureAt = nil
+ }
+
+ public mutating func fail(at date: Date) {
+ guard isEnabled else { return }
+ isRefreshing = false
+ lastFailureAt = date
+ }
+}
+
+public struct SourceRefreshSnapshot: Equatable, Sendable {
+ public var reviews: SourceRefreshRecord
+ public var failingCI: SourceRefreshRecord
+
+ public init(reviewsEnabled: Bool, failingCIEnabled: Bool) {
+ reviews = SourceRefreshRecord(isEnabled: reviewsEnabled)
+ failingCI = SourceRefreshRecord(isEnabled: failingCIEnabled)
+ }
+
+ public subscript(_ source: GitHubSourceKind) -> SourceRefreshRecord {
+ get {
+ switch source {
+ case .reviews: return reviews
+ case .failingCI: return failingCI
+ }
+ }
+ set {
+ switch source {
+ case .reviews: reviews = newValue
+ case .failingCI: failingCI = newValue
+ }
+ }
+ }
+
+ public var isRefreshing: Bool {
+ reviews.isRefreshing || failingCI.isRefreshing
+ }
+}
diff --git a/Tests/MeanwhileCoreTests/AttentionSourcePreferencesTests.swift b/Tests/MeanwhileCoreTests/AttentionSourcePreferencesTests.swift
new file mode 100644
index 0000000..d2d42bd
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/AttentionSourcePreferencesTests.swift
@@ -0,0 +1,38 @@
+import XCTest
+@testable import MeanwhileCore
+
+final class AttentionSourcePreferencesTests: XCTestCase {
+ func testSeedsFromConfigurationAndPersistsIndependentChoices() {
+ let suite = "AttentionSourcePreferencesTests.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let initial = AttentionSourcePreferences(
+ defaults: defaults,
+ defaultSelection: AttentionSourceSelection(
+ reviewsEnabled: false,
+ failingCIEnabled: true
+ )
+ )
+
+ XCTAssertEqual(
+ initial.selection,
+ AttentionSourceSelection(reviewsEnabled: false, failingCIEnabled: true)
+ )
+ initial.setReviewsEnabled(true)
+ initial.setFailingCIEnabled(false)
+
+ let reloaded = AttentionSourcePreferences(
+ defaults: defaults,
+ defaultSelection: AttentionSourceSelection(
+ reviewsEnabled: false,
+ failingCIEnabled: true
+ )
+ )
+ XCTAssertEqual(
+ reloaded.selection,
+ AttentionSourceSelection(reviewsEnabled: true, failingCIEnabled: false)
+ )
+ XCTAssertTrue(reloaded.selection.isEnabled(.reviews))
+ XCTAssertFalse(reloaded.selection.isEnabled(.failingCI))
+ }
+}
diff --git a/Tests/MeanwhileCoreTests/AttentionTestStateTests.swift b/Tests/MeanwhileCoreTests/AttentionTestStateTests.swift
new file mode 100644
index 0000000..cd6fc29
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/AttentionTestStateTests.swift
@@ -0,0 +1,22 @@
+import XCTest
+@testable import MeanwhileCore
+
+final class AttentionTestStateTests: XCTestCase {
+ func testStartsOnceAndIgnoresStaleExpiry() {
+ var state = AttentionTestState()
+ XCTAssertEqual(state.start(realAttentionIsActive: false), .started(generation: 1))
+ XCTAssertEqual(state.start(realAttentionIsActive: false), .none)
+ XCTAssertEqual(state.finish(generation: 0), .none)
+ XCTAssertTrue(state.isActive)
+ XCTAssertEqual(state.finish(generation: 1), .finished)
+ XCTAssertFalse(state.isActive)
+ }
+
+ func testRealAttentionBlocksAndPreemptsTest() {
+ var state = AttentionTestState()
+ XCTAssertEqual(state.start(realAttentionIsActive: true), .none)
+ XCTAssertEqual(state.start(realAttentionIsActive: false), .started(generation: 1))
+ XCTAssertEqual(state.observeRealAttention(isActive: true), .preempted)
+ XCTAssertFalse(state.isActive)
+ }
+}
diff --git a/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift b/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift
index 456c154..60d79b9 100644
--- a/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift
+++ b/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift
@@ -29,6 +29,10 @@ final class DiagnosticsReportTests: XCTestCase {
repositoryScopeIncludesAll: false,
accessibleRepositoryCount: 68,
selectedRepositoryCount: 3,
+ attentionSourceSelection: AttentionSourceSelection(
+ reviewsEnabled: false,
+ failingCIEnabled: true
+ ),
hotKeyConfigured: true,
sessionInspection: AgentSessionInspection(
activeCount: 2,
@@ -64,10 +68,13 @@ final class DiagnosticsReportTests: XCTestCase {
)
XCTAssertTrue(report.contains("App: 0.1.2 (3)"))
- XCTAssertTrue(report.contains("Schema: 1"))
+ XCTAssertTrue(report.contains("Schema: 2"))
XCTAssertTrue(report.contains("Launch at login: needs approval"))
XCTAssertTrue(report.contains("Update: available (v0.1.3)"))
XCTAssertTrue(report.contains("Repository scope: selected only (3)"))
+ XCTAssertTrue(report.contains("Agent requests: enabled"))
+ XCTAssertTrue(report.contains("Failing CI source: enabled"))
+ XCTAssertTrue(report.contains("Review source: disabled"))
XCTAssertTrue(report.contains("Sessions that may be stuck: 1"))
XCTAssertTrue(report.contains("Last agent event: codex, needs-you"))
XCTAssertTrue(report.contains("Recent ciFailed: 1"))
diff --git a/Tests/MeanwhileCoreTests/GitHubCISourceTests.swift b/Tests/MeanwhileCoreTests/GitHubCISourceTests.swift
index 723446d..fc5a4ab 100644
--- a/Tests/MeanwhileCoreTests/GitHubCISourceTests.swift
+++ b/Tests/MeanwhileCoreTests/GitHubCISourceTests.swift
@@ -45,6 +45,24 @@ final class GitHubCISourceTests: XCTestCase {
XCTAssertEqual(runner.commands.count, 2)
}
+ func testForceBypassesCache() throws {
+ let runner = CICommandRunner(result: ShellCommandResult(
+ exitCode: 0,
+ stdout: Self.fixture,
+ stderr: ""
+ ))
+ let source = GitHubCISource(runner: runner, cacheInterval: 60)
+ let start = Date(timeIntervalSince1970: 2_000)
+
+ _ = try source.itemsIfDue(pollingAllowed: true, now: start)
+ _ = try source.itemsIfDue(
+ pollingAllowed: true,
+ force: true,
+ now: start.addingTimeInterval(1)
+ )
+ XCTAssertEqual(runner.commands.count, 2)
+ }
+
private static let fixture = """
{
"data": {"viewer": {"pullRequests": {"nodes": [
diff --git a/Tests/MeanwhileCoreTests/GitHubReviewSourceTests.swift b/Tests/MeanwhileCoreTests/GitHubReviewSourceTests.swift
index 29610cd..d207097 100644
--- a/Tests/MeanwhileCoreTests/GitHubReviewSourceTests.swift
+++ b/Tests/MeanwhileCoreTests/GitHubReviewSourceTests.swift
@@ -47,6 +47,25 @@ final class GitHubReviewSourceTests: XCTestCase {
)
}
+ func testForceBypassesCacheWithoutBypassingPollingGate() throws {
+ let runner = RecordingCommandRunner(results: [successfulResult, successfulResult])
+ let source = GitHubReviewSource(runner: runner)
+ let start = Date(timeIntervalSince1970: 1_000)
+
+ _ = try source.reviewsIfDue(pollingAllowed: true, now: start)
+ _ = try source.reviewsIfDue(
+ pollingAllowed: true,
+ force: true,
+ now: start.addingTimeInterval(1)
+ )
+ _ = try source.reviewsIfDue(
+ pollingAllowed: false,
+ force: true,
+ now: start.addingTimeInterval(2)
+ )
+ XCTAssertEqual(runner.commands.count, 2)
+ }
+
func testFiltersCachedReviewsUsingRepositoryPreferences() throws {
let preferences = RepositoryPreferences(
defaults: UserDefaults(suiteName: UUID().uuidString)!
diff --git a/Tests/MeanwhileCoreTests/MeanwhileEngineTests.swift b/Tests/MeanwhileCoreTests/MeanwhileEngineTests.swift
index b8ca50e..ab01926 100644
--- a/Tests/MeanwhileCoreTests/MeanwhileEngineTests.swift
+++ b/Tests/MeanwhileCoreTests/MeanwhileEngineTests.swift
@@ -66,6 +66,51 @@ final class MeanwhileEngineTests: XCTestCase {
XCTAssertEqual(presentation.item?.id, "ci:acme/repo#2")
}
+ func testLiveSourceSelectionFiltersGitHubWorkWithoutDisablingAgents() {
+ let context = makeEngine()
+ defer { context.cleanup() }
+ let now = Date(timeIntervalSince1970: 4_500)
+ let sessions = [makeSession(phase: .thinking, enteredAt: now.addingTimeInterval(-30))]
+ let review = makeReview(number: 1, createdAt: now.addingTimeInterval(-300))
+ let ci = makeCI(number: 2, createdAt: now.addingTimeInterval(-200))
+
+ let reviewsOnly = context.engine.presentation(
+ sessions: sessions,
+ reviews: [review],
+ failingCI: [ci],
+ sourceSelection: AttentionSourceSelection(
+ reviewsEnabled: true,
+ failingCIEnabled: false
+ ),
+ now: now
+ )
+ XCTAssertEqual(reviewsOnly.item?.kind, .review)
+
+ let agentsOnly = context.engine.presentation(
+ sessions: sessions,
+ reviews: [review],
+ failingCI: [ci],
+ sourceSelection: AttentionSourceSelection(
+ reviewsEnabled: false,
+ failingCIEnabled: false
+ ),
+ now: now
+ )
+ XCTAssertNil(agentsOnly.item)
+
+ let needsYou = context.engine.presentation(
+ sessions: [makeSession(phase: .needsYou, enteredAt: now)],
+ reviews: [review],
+ failingCI: [ci],
+ sourceSelection: AttentionSourceSelection(
+ reviewsEnabled: false,
+ failingCIEnabled: false
+ ),
+ now: now
+ )
+ XCTAssertEqual(needsYou.item?.kind, .needsYou)
+ }
+
func testSnoozeMovesToNextDeterministicItem() {
let context = makeEngine()
defer { context.cleanup() }
diff --git a/Tests/MeanwhileCoreTests/SourceRefreshSnapshotTests.swift b/Tests/MeanwhileCoreTests/SourceRefreshSnapshotTests.swift
new file mode 100644
index 0000000..67fd3c9
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/SourceRefreshSnapshotTests.swift
@@ -0,0 +1,28 @@
+import XCTest
+@testable import MeanwhileCore
+
+final class SourceRefreshSnapshotTests: XCTestCase {
+ func testSuccessAndFailureTransitionsRemainSourceSpecific() {
+ let start = Date(timeIntervalSince1970: 100)
+ let finish = Date(timeIntervalSince1970: 101)
+ var snapshot = SourceRefreshSnapshot(reviewsEnabled: true, failingCIEnabled: true)
+
+ snapshot[.reviews].begin(at: start)
+ XCTAssertTrue(snapshot.isRefreshing)
+ snapshot[.reviews].succeed(at: finish)
+ XCTAssertEqual(snapshot.reviews.lastSuccessAt, finish)
+ XCTAssertNil(snapshot.reviews.lastFailureAt)
+
+ snapshot[.failingCI].begin(at: start)
+ snapshot[.failingCI].fail(at: finish)
+ XCTAssertEqual(snapshot.failingCI.lastFailureAt, finish)
+ XCTAssertNil(snapshot.failingCI.lastSuccessAt)
+ }
+
+ func testDisabledSourceIgnoresRefreshTransitions() {
+ var snapshot = SourceRefreshSnapshot(reviewsEnabled: false, failingCIEnabled: true)
+ snapshot[.reviews].begin(at: Date())
+ XCTAssertFalse(snapshot.reviews.isRefreshing)
+ XCTAssertNil(snapshot.reviews.lastAttemptAt)
+ }
+}
diff --git a/docs/v0.1.3-prove-the-loop.md b/docs/v0.1.3-prove-the-loop.md
new file mode 100644
index 0000000..f9718d7
--- /dev/null
+++ b/docs/v0.1.3-prove-the-loop.md
@@ -0,0 +1,78 @@
+# Meanwhile v0.1.3 — Prove the Loop
+
+v0.1.3 makes Meanwhile’s existing attention loop observable, testable, and
+recoverable. It does not broaden the product into a queue, session browser, or
+general-purpose notification system.
+
+## User contract
+
+### Test the attention loop
+
+- Settings includes a **Run Attention Test** action.
+- The test shows a clearly labelled red menu-bar interruption for six seconds.
+- If reminders are enabled and macOS permission is available, it also sends a
+ clearly labelled test notification.
+- Clicking either test surface returns to Settings.
+- The test never creates an agent session, GitHub item, recent signal, or
+ notification receipt.
+- Real work always pre-empts the test, and a test cannot replace real work.
+
+### See and recover source health
+
+- Connection health reports review and failing-CI freshness independently.
+- Each source distinguishes disabled, never checked, refreshing, successful,
+ and failed states.
+- A manual refresh bypasses the idle polling gate and the sixty-second cache.
+- Manual refresh does not surface GitHub work while agents are idle; the
+ existing wait gate remains authoritative.
+- Failed refreshes expose a Retry action and GitHub authentication recovery.
+
+### Choose what can interrupt you
+
+- Settings keeps agent requests permanently enabled as Meanwhile’s core source.
+- Review requests and failing CI can be enabled independently without relaunching.
+- Source choices persist across launches and upgrades.
+- Disabling a GitHub source immediately removes its work, stops future polling,
+ and marks it Disabled in Connection health.
+- Re-enabling a GitHub source refreshes it immediately, while the waiting-first
+ presentation gate remains unchanged.
+
+### Recover without losing the interruption
+
+- Missing agent integrations can be repaired in Connection health.
+- Missing GitHub authentication provides a copyable `gh auth login` command.
+- A failed browser handoff keeps the item active and offers Copy Link.
+- A failed agent handoff keeps the item active and offers Open Settings.
+
+### Keyboard and accessibility
+
+- New controls use native buttons and disclosure groups with explicit labels
+ and hints.
+- The test status item has a distinct accessible label and help text.
+- Test-only snooze and hide actions are disabled because no real item exists.
+
+## Acceptance checks
+
+- Core state tests prove stale timers cannot end replacement tests and real work
+ pre-empts a test.
+- Source tests prove manual refresh bypasses cache without bypassing the source’s
+ polling permission contract.
+- Preference and engine tests prove source choices persist and disabled GitHub
+ sources neither surface work nor disable agent requests.
+- Full SwiftPM tests pass.
+- The packaged app builds, signs, launches, and renders Settings correctly in
+ light and dark appearance.
+- The extracted release candidate passes strict `codesign` verification.
+
+## Firm exclusions
+
+- No queue, session roster, history view, or work counts.
+- No floating island, detached panel, or Perch-style presence surface.
+- No new work sources or repeated reminder policy.
+- No automatic updater.
+- No change to the waiting-first GitHub gate.
+
+## Release identity
+
+- Version: 0.1.3
+- Build: 6