diff --git a/App/Info.plist b/App/Info.plist
index 57177ed..b151367 100644
--- a/App/Info.plist
+++ b/App/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.1
+ 0.1.2
CFBundleVersion
- 2
+ 3
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/README.md b/README.md
index 89f11db..0c36144 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.1 behavior
+## v0.1.2 behavior
- Uses Claude Code and Codex lifecycle hooks—not process or CPU guesses—to track
each session as `thinking`, `needs-you`, or `idle`.
@@ -21,6 +21,15 @@ dependencies.
available.
- Tracks parallel Claude and Codex sessions independently and filters both
GitHub sources using the repository settings.
+- Offers a native **Launch at Login** switch that reflects macOS's real Login
+ Items state, including approval when the system requires it.
+- 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
+ sessions after confirmation.
+- Copies a privacy-safe diagnostics report with coarse state, counts, and
+ timestamps—never repository names, paths, prompts, session IDs, or
+ credentials.
The wait-gate is the invariant: ordinary items never appear while no agent is
thinking.
@@ -38,14 +47,17 @@ On first launch, Meanwhile opens Settings with integration health and one clear
`~/.claude/settings.json` and `~/.codex/hooks.json` without replacing unrelated
settings. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honored when set.
Tool-boundary hooks refresh active sessions, while active or blocked sessions
-use a separate 24-hour crash-safety expiry. If Claude already has a custom status line, Meanwhile
-preserves it and reports the conflict instead of overwriting it.
+use a separate 24-hour crash-safety expiry. Settings flags non-idle sessions
+that have not received an event for the configured stale interval and provides
+an explicit recovery action. If Claude already has a custom status line,
+Meanwhile preserves it and reports the conflict instead of overwriting it.
Codex may require one additional trust step: open `/hooks` in Codex and approve
the new Meanwhile hooks. Settings reports hook installation, GitHub
authentication, and the last agent event so setup failures are visible. Hook
events, the latest event, and a bounded recent-signals list stay on the Mac;
-Meanwhile adds no telemetry.
+Meanwhile adds no telemetry. Launch at login uses macOS Service Management and
+can be changed directly in Settings.
Terminal focus uses the terminal metadata captured with the agent working
directory. Terminal.app and iTerm sessions are selected by TTY; other supported
@@ -78,11 +90,12 @@ fields retain their defaults:
}
```
-Repository selection, the optional global shortcut, and agent integration
-installation are managed through **Settings…** in the right-click menu. Click
-the shortcut recorder and press a modified letter, digit, Space, Tab, Return,
-or Escape. The shortcut opens the current menu-bar item, the same as clicking
-the status item.
+Repository selection, launch at login, the optional global shortcut, agent
+integration installation, update visibility, diagnostics, and stuck-session
+recovery are managed through **Settings…** in the right-click menu. Click the
+shortcut recorder and press a modified letter, digit, Space, Tab, Return, or
+Escape. The shortcut opens the current menu-bar item, the same as clicking the
+status item.
Settings also explains the menu-bar language and keeps the five newest agent,
review, CI, snooze, hide, and installation signals visible for lightweight
@@ -107,9 +120,9 @@ cask with:
GITHUB_REPOSITORY="tcballard/Meanwhile" ./Scripts/release-unsigned.sh
```
-This produces `dist/Meanwhile-0.1.1-unsigned.zip` and `dist/meanwhile.rb`.
+This produces `dist/Meanwhile-0.1.2-unsigned.zip` and `dist/meanwhile.rb`.
Publish the archive only as a GitHub **pre-release** tagged
-`v0.1.1-unsigned`. The generated cask identifies it as unsigned and tells users
+`v0.1.2-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.
@@ -125,7 +138,7 @@ GITHUB_REPOSITORY="owner/Meanwhile" \
```
The script builds and signs the app in a temporary local directory, then
-produces `dist/Meanwhile-0.1.1.zip` and `dist/meanwhile.rb`. It verifies the
+produces `dist/Meanwhile-0.1.2.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/AppSettingsSection.swift b/Sources/Meanwhile/AppSettingsSection.swift
new file mode 100644
index 0000000..11b4b1a
--- /dev/null
+++ b/Sources/Meanwhile/AppSettingsSection.swift
@@ -0,0 +1,127 @@
+import MeanwhileCore
+import SwiftUI
+
+struct AppSettingsSection: View {
+ let appVersion: String
+ let buildVersion: String
+ let updateState: ReleaseUpdateState
+ let updateErrorMessage: String?
+ let checkForUpdates: () -> Void
+ let openLatestRelease: () -> Void
+ let diagnosticsCopyMessage: String?
+ let diagnosticsCopyIsError: Bool
+ let copyDiagnostics: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 11) {
+ HStack(alignment: .top, spacing: 10) {
+ Text("Software update")
+ .frame(width: 132, alignment: .leading)
+
+ Image(systemName: updateSymbol)
+ .foregroundStyle(updateTint)
+ .frame(width: 18, height: 18)
+ .accessibilityHidden(true)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(updateTitle)
+ .font(.callout.weight(.medium))
+ Text(updateDetail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer(minLength: 8)
+
+ if updateState.releaseURL != nil {
+ Button("View Release…", action: openLatestRelease)
+ }
+ Button("Check Again", action: checkForUpdates)
+ .disabled(updateState == .checking)
+ }
+
+ if let updateErrorMessage {
+ SettingsInlineMessage(
+ updateErrorMessage,
+ systemImage: "exclamationmark.triangle.fill",
+ tint: .orange
+ )
+ }
+
+ HStack(alignment: .top, spacing: 10) {
+ Text("Support")
+ .frame(width: 132, alignment: .leading)
+
+ Button(action: copyDiagnostics) {
+ if let diagnosticsCopyMessage {
+ Label(
+ diagnosticsCopyMessage,
+ systemImage: diagnosticsCopyIsError
+ ? "exclamationmark.triangle.fill"
+ : "checkmark.circle.fill"
+ )
+ .foregroundStyle(diagnosticsCopyIsError ? .orange : .green)
+ } else {
+ Text("Copy Diagnostics")
+ }
+ }
+ .frame(minWidth: 116)
+ .accessibilityLabel(diagnosticsCopyMessage ?? "Copy Diagnostics")
+ .accessibilityHint("Copies a privacy-safe support summary to the clipboard.")
+
+ Text("Copies a privacy-safe status summary without repository names, paths, prompts, session IDs, or credentials.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+
+ private var updateTitle: String {
+ switch updateState {
+ case .notChecked: return "Version \(appVersion)"
+ case .checking: return "Checking for updates…"
+ case .current: return "Meanwhile is up to date"
+ case .updateAvailable(let version, _): return "\(version) is available"
+ case .developmentBuild: return "Development build"
+ case .unavailable: return "Update check unavailable"
+ }
+ }
+
+ private var updateDetail: String {
+ switch updateState {
+ case .notChecked:
+ return "Installed version \(appVersion) (\(buildVersion))."
+ case .checking:
+ return "Comparing \(appVersion) with the latest GitHub release."
+ case .current(let version, _):
+ return "Installed \(appVersion) (\(buildVersion)); latest release \(version)."
+ case .updateAvailable:
+ return "Installed \(appVersion) (\(buildVersion)). Meanwhile never updates automatically."
+ case .developmentBuild(let version, _):
+ return "Installed \(appVersion) (\(buildVersion)); latest public release \(version)."
+ case .unavailable:
+ return "Installed \(appVersion) (\(buildVersion))."
+ }
+ }
+
+ private var updateSymbol: String {
+ switch updateState {
+ case .current: return "checkmark.circle.fill"
+ case .updateAvailable: return "arrow.down.circle.fill"
+ case .developmentBuild: return "hammer.circle.fill"
+ case .unavailable: return "exclamationmark.triangle.fill"
+ case .notChecked, .checking: return "arrow.triangle.2.circlepath"
+ }
+ }
+
+ private var updateTint: Color {
+ switch updateState {
+ case .current: return .green
+ case .updateAvailable: return .accentColor
+ case .developmentBuild: return .purple
+ case .unavailable: return .orange
+ case .notChecked, .checking: return .secondary
+ }
+ }
+}
diff --git a/Sources/Meanwhile/ConnectionHealthSection.swift b/Sources/Meanwhile/ConnectionHealthSection.swift
index 683deee..a6f0b0d 100644
--- a/Sources/Meanwhile/ConnectionHealthSection.swift
+++ b/Sources/Meanwhile/ConnectionHealthSection.swift
@@ -6,7 +6,14 @@ struct ConnectionHealthSection: View {
let integrationHealthError: String?
let lastAgentEvent: AgentSessionState?
let githubAuthenticationStatus: GitHubAuthenticationStatus
+ let sessionInspection: AgentSessionInspection
+ let sessionRecoveryMessage: String?
+ let sessionRecoveryIsError: Bool
+ let isClearingStuckSessions: Bool
+ let staleAfter: String
+ let clearStuckSessions: () -> Void
let now: Date
+ @State private var isConfirmingClear = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
@@ -17,6 +24,17 @@ struct ConnectionHealthSection: View {
value: integrationHealthTitle,
detail: integrationHealthDetail
)
+ HealthRow(
+ systemImage: sessionHealthSymbol,
+ tint: sessionHealthTint,
+ title: "Agent sessions",
+ value: sessionHealthTitle,
+ detail: sessionHealthDetail,
+ actionTitle: sessionInspection.stuckCount > 0 ? "Clear…" : nil,
+ actionAccessibilityLabel: "Clear stuck agent sessions",
+ action: { isConfirmingClear = true },
+ actionDisabled: isClearingStuckSessions
+ )
HealthRow(
systemImage: agentEventSymbol,
tint: agentEventTint,
@@ -32,6 +50,54 @@ struct ConnectionHealthSection: View {
detail: githubHealthDetail
)
}
+ .confirmationDialog(
+ "Clear sessions that may be stuck?",
+ isPresented: $isConfirmingClear
+ ) {
+ Button("Clear Stuck Sessions", role: .destructive) {
+ clearStuckSessions()
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("Meanwhile will recheck and forget only non-idle sessions with no event for more than \(staleAfter). The latest event summary is kept.")
+ }
+ }
+
+ private var sessionHealthTitle: String {
+ if isClearingStuckSessions { return "Clearing stuck sessions…" }
+ if sessionInspection.stuckCount == 1 { return "1 session may be stuck" }
+ if sessionInspection.stuckCount > 1 {
+ return "\(sessionInspection.stuckCount) sessions may be stuck"
+ }
+ if sessionInspection.activeCount == 1 { return "1 active session" }
+ if sessionInspection.activeCount > 1 {
+ return "\(sessionInspection.activeCount) active sessions"
+ }
+ return "No active sessions"
+ }
+
+ private var sessionHealthDetail: String {
+ if let sessionRecoveryMessage { return sessionRecoveryMessage }
+ if sessionInspection.stuckCount > 0 {
+ return "No agent event for more than \(staleAfter). Clear only if the agent has stopped."
+ }
+ if sessionInspection.activeCount > 0 {
+ return "No sessions currently look stuck."
+ }
+ return "Agent sessions will appear here while Claude or Codex is active."
+ }
+
+ private var sessionHealthSymbol: String {
+ if sessionRecoveryIsError { return "exclamationmark.triangle.fill" }
+ if sessionInspection.stuckCount > 0 { return "clock.badge.exclamationmark.fill" }
+ if sessionInspection.activeCount > 0 { return "waveform.circle.fill" }
+ return "circle.dashed"
+ }
+
+ private var sessionHealthTint: Color {
+ if sessionRecoveryIsError || sessionInspection.stuckCount > 0 { return .orange }
+ if sessionInspection.activeCount > 0 { return .green }
+ return .secondary
}
private var integrationHealthTitle: String {
@@ -137,24 +203,36 @@ private struct HealthRow: View {
let title: String
let value: String
let detail: String
+ var actionTitle: String? = nil
+ var actionAccessibilityLabel: String? = nil
+ var action: (() -> Void)? = nil
+ var actionDisabled = false
var body: some View {
HStack(alignment: .top, spacing: 10) {
- Image(systemName: systemImage)
- .foregroundStyle(tint)
- .frame(width: 18, height: 18)
- Text(title)
- .frame(width: 118, alignment: .leading)
- VStack(alignment: .leading, spacing: 2) {
- Text(value)
- .font(.callout.weight(.semibold))
- Text(detail)
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: systemImage)
+ .foregroundStyle(tint)
+ .frame(width: 18, height: 18)
+ .accessibilityHidden(true)
+ Text(title)
+ .frame(width: 118, alignment: .leading)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(value)
+ .font(.callout.weight(.semibold))
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 0)
+ }
+ .accessibilityElement(children: .combine)
+ if let actionTitle, let action {
+ Button(actionTitle, action: action)
+ .disabled(actionDisabled)
+ .accessibilityLabel(actionAccessibilityLabel ?? actionTitle)
}
- Spacer(minLength: 0)
}
- .accessibilityElement(children: .combine)
}
}
diff --git a/Sources/Meanwhile/ControlsSettingsSection.swift b/Sources/Meanwhile/ControlsSettingsSection.swift
index 1e4ef84..4e3a1c5 100644
--- a/Sources/Meanwhile/ControlsSettingsSection.swift
+++ b/Sources/Meanwhile/ControlsSettingsSection.swift
@@ -9,6 +9,10 @@ struct ControlsSettingsSection: View {
let integrationActionMessage: String?
let integrationActionIsError: Bool
let installIntegrations: () -> Void
+ let launchAtLoginStatus: LaunchAtLoginStatus
+ let launchAtLoginError: String?
+ let setLaunchAtLoginEnabled: (Bool) -> Void
+ let openLoginItemsSettings: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 11) {
@@ -48,6 +52,48 @@ struct ControlsSettingsSection: View {
.padding(.leading, 142)
}
+ HStack(alignment: .top, spacing: 10) {
+ Text("Launch at login")
+ .frame(width: 132, alignment: .leading)
+
+ Toggle(
+ "Launch Meanwhile when you log in",
+ isOn: Binding(
+ get: { launchAtLoginStatus.isRequested },
+ set: { setLaunchAtLoginEnabled($0) }
+ )
+ )
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .accessibilityHint("Controls whether Meanwhile opens automatically after login.")
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(launchAtLoginTitle)
+ .font(.callout.weight(.medium))
+ Text(launchAtLoginDetail)
+ .font(.caption)
+ .foregroundStyle(
+ launchAtLoginStatus == .requiresApproval || launchAtLoginError != nil
+ ? .orange
+ : .secondary
+ )
+ }
+
+ Spacer(minLength: 8)
+
+ if launchAtLoginStatus == .requiresApproval || launchAtLoginError != nil {
+ Button("Open Login Items…", action: openLoginItemsSettings)
+ }
+ }
+
+ if let launchAtLoginError {
+ SettingsInlineMessage(
+ launchAtLoginError,
+ systemImage: "exclamationmark.triangle.fill",
+ tint: .orange
+ )
+ }
+
HStack(spacing: 10) {
Text("Agent integrations")
.frame(width: 132, alignment: .leading)
@@ -79,4 +125,32 @@ struct ControlsSettingsSection: View {
}
}
}
+
+ private var launchAtLoginTitle: String {
+ switch launchAtLoginStatus {
+ case .disabled: return "Off"
+ case .enabled: return "On"
+ case .requiresApproval: return "Approval needed"
+ case .unavailable: return "Unavailable"
+ }
+ }
+
+ private var launchAtLoginDetail: String {
+ switch launchAtLoginStatus {
+ case .disabled:
+ return "Meanwhile opens only when you launch it."
+ case .enabled:
+ return "Meanwhile will be ready after your next login."
+ case .requiresApproval:
+ return "Allow Meanwhile in System Settings to finish enabling it."
+ case .unavailable:
+ return "macOS could not find the app's login item."
+ }
+ }
+}
+
+private extension LaunchAtLoginStatus {
+ var isRequested: Bool {
+ self == .enabled || self == .requiresApproval
+ }
}
diff --git a/Sources/Meanwhile/LaunchAtLoginController.swift b/Sources/Meanwhile/LaunchAtLoginController.swift
new file mode 100644
index 0000000..28e9717
--- /dev/null
+++ b/Sources/Meanwhile/LaunchAtLoginController.swift
@@ -0,0 +1,38 @@
+import MeanwhileCore
+import ServiceManagement
+
+@MainActor
+final class LaunchAtLoginController {
+ private var service: SMAppService { .mainApp }
+
+ var status: LaunchAtLoginStatus {
+ switch service.status {
+ case .notRegistered:
+ return .disabled
+ case .enabled:
+ return .enabled
+ case .requiresApproval:
+ return .requiresApproval
+ case .notFound:
+ return .unavailable
+ @unknown default:
+ return .unavailable
+ }
+ }
+
+ @discardableResult
+ func setEnabled(_ enabled: Bool) throws -> LaunchAtLoginStatus {
+ if enabled {
+ if service.status == .notRegistered || service.status == .notFound {
+ try service.register()
+ }
+ } else if service.status != .notRegistered {
+ try service.unregister()
+ }
+ return status
+ }
+
+ func openSystemSettings() {
+ SMAppService.openSystemSettingsLoginItems()
+ }
+}
diff --git a/Sources/Meanwhile/MeanwhileApp.swift b/Sources/Meanwhile/MeanwhileApp.swift
index bef16d9..c7f4fab 100644
--- a/Sources/Meanwhile/MeanwhileApp.swift
+++ b/Sources/Meanwhile/MeanwhileApp.swift
@@ -15,6 +15,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
private let configuration = MeanwhileConfiguration.load()
private let eventStore = AgentEventStore()
private let recentSignalStore = RecentSignalStore()
+ private let launchAtLoginController = LaunchAtLoginController()
private lazy var integrationInstaller = AgentIntegrationInstaller(helperURL: helperURL())
private lazy var hotKeyPreferences = HotKeyPreferences(defaultHotKey: configuration.hotKey)
private var menuBar: MenuBarController?
@@ -25,12 +26,42 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
private var hideMenuItem: NSMenuItem?
private var hotKey: GlobalHotKey?
private var lastRecordedItemID: String?
+ private lazy var agentFocusRouter = AgentFocusRouter(
+ focusTerminal: { [terminalFocuser] session in
+ terminalFocuser.focus(session)
+ }
+ )
private lazy var settingsModel = RepositorySettingsModel(
preferences: repositoryPreferences,
hotKeyPreferences: hotKeyPreferences,
integrationInstaller: integrationInstaller,
eventStore: eventStore,
recentSignalStore: recentSignalStore,
+ sessionStaleAfter: configuration.sessionStaleSeconds,
+ appVersion: Bundle.main.object(
+ forInfoDictionaryKey: "CFBundleShortVersionString"
+ ) as? String ?? "Unknown",
+ buildVersion: Bundle.main.object(
+ forInfoDictionaryKey: "CFBundleVersion"
+ ) as? String ?? "Unknown",
+ operatingSystemVersion: ProcessInfo.processInfo.operatingSystemVersionString,
+ launchAtLoginStatus: { [launchAtLoginController] in
+ launchAtLoginController.status
+ },
+ setLaunchAtLoginEnabled: { [launchAtLoginController] enabled in
+ try launchAtLoginController.setEnabled(enabled)
+ },
+ openLoginItemsSettings: { [launchAtLoginController] in
+ launchAtLoginController.openSystemSettings()
+ },
+ copyText: { text in
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ return pasteboard.setString(text, forType: .string)
+ },
+ openURL: { url in
+ NSWorkspace.shared.open(url)
+ },
selectionDidChange: { [weak self] in
self?.runtime?.repositorySelectionDidChange()
},
@@ -63,6 +94,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
}
menuBar.statusItem.length = NSStatusItem.squareLength
menuBar.statusItem.button?.toolTip = "Meanwhile — waiting for an agent event"
+ menuBar.setAccessibility(label: "Meanwhile, idle")
self.menuBar = menuBar
let preferences = repositoryPreferences
@@ -109,44 +141,34 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
}
menuBar?.setIcon(
systemName: MenuBarPresenter.iconName(phase: presentation.phase),
- accessibilityDescription: accessibilityDescription(for: presentation),
+ accessibilityDescription: nil,
tintColor: iconColor
)
- menuBar?.statusItem.button?.toolTip = tooltip(for: presentation)
+ menuBar?.setAccessibility(
+ label: MenuBarPresenter.accessibilityLabel(
+ phase: presentation.phase,
+ item: presentation.item
+ ),
+ help: MenuBarPresenter.accessibilityHelp(
+ phase: presentation.phase,
+ item: presentation.item
+ )
+ )
+ menuBar?.statusItem.button?.toolTip = MenuBarPresenter.tooltip(
+ phase: presentation.phase,
+ item: presentation.item
+ )
updateItemMenu(presentation.item)
}
private func updateItemMenu(_ item: WorkItem?) {
- openItemMenuItem?.title = item.map { item in
- switch item.kind {
- case .review, .failingCI:
- return "Open \(item.title) — \(item.detail)"
- case .needsYou:
- return "Open \(item.title)"
- }
- } ?? "No Item Available"
+ openItemMenuItem?.title = item.map(MenuBarPresenter.openActionTitle(item:))
+ ?? "No Item Available"
openItemMenuItem?.isEnabled = item != nil
snoozeMenuItem?.isEnabled = item != nil
hideMenuItem?.isEnabled = item != nil
}
- private func accessibilityDescription(for presentation: MeanwhilePresentation) -> String {
- switch presentation.phase {
- case .idle: return "Meanwhile — idle"
- case .thinking: return "Meanwhile — agent thinking"
- case .needsYou: return "Meanwhile — agent needs you"
- }
- }
-
- private func tooltip(for presentation: MeanwhilePresentation) -> String {
- if let item = presentation.item { return "\(item.title): \(item.detail)" }
- switch presentation.phase {
- case .idle: return "Meanwhile — idle"
- case .thinking: return "Agent thinking — no eligible items"
- case .needsYou: return "Agent needs you"
- }
- }
-
private func makeContextMenu() -> NSMenu {
let menu = NSMenu()
let openItem = NSMenuItem(
@@ -203,12 +225,35 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openCurrentItem() {
guard let item = currentItem else { return }
if item.kind == .needsYou, let session = item.session {
- _ = terminalFocuser.focus(session)
+ if agentFocusRouter.focus(session) == .unavailable {
+ showFocusFailure(for: item)
+ }
} else if let url = item.url {
NSWorkspace.shared.open(url)
}
}
+ private func showFocusFailure(for item: WorkItem) {
+ let provider = item.session.map { providerDisplayName($0.provider) } ?? "agent"
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = "Couldn’t return to \(provider)"
+ if item.session?.provider == .codex {
+ alert.informativeText = """
+ Meanwhile couldn’t open this task in Codex or focus its original terminal. \
+ The interruption is still active.
+ """
+ } else {
+ alert.informativeText = """
+ Meanwhile couldn’t focus this task’s original terminal. \
+ The interruption is still active.
+ """
+ }
+ alert.addButton(withTitle: "OK")
+ NSApp.activate(ignoringOtherApps: true)
+ alert.runModal()
+ }
+
private func registerHotKeyFromPreferences() {
hotKey = nil
settingsModel.setHotKeyRegistrationError(nil)
diff --git a/Sources/Meanwhile/RepositorySettingsModel.swift b/Sources/Meanwhile/RepositorySettingsModel.swift
index f565281..81b24dc 100644
--- a/Sources/Meanwhile/RepositorySettingsModel.swift
+++ b/Sources/Meanwhile/RepositorySettingsModel.swift
@@ -24,6 +24,19 @@ final class RepositorySettingsModel: ObservableObject {
@Published private(set) var githubAuthenticationStatus: GitHubAuthenticationStatus = .notAuthenticated
@Published private(set) var lastAgentEvent: AgentSessionState?
@Published private(set) var recentSignals: [RecentSignal] = []
+ @Published private(set) var launchAtLoginStatus: LaunchAtLoginStatus
+ @Published private(set) var launchAtLoginError: String?
+ @Published private(set) var updateState: ReleaseUpdateState = .notChecked
+ @Published private(set) var updateErrorMessage: String?
+ @Published private(set) var diagnosticsCopyMessage: String?
+ @Published private(set) var diagnosticsCopyIsError = false
+ @Published private(set) var sessionInspection = AgentSessionInspection.empty
+ @Published private(set) var sessionRecoveryMessage: String?
+ @Published private(set) var sessionRecoveryIsError = false
+ @Published private(set) var isClearingStuckSessions = false
+
+ let appVersion: String
+ let buildVersion: String
var connectedRepositoryCount: Int {
includesAllRepositories ? availableRepositories.count : selectedRepositories.count
@@ -59,6 +72,15 @@ final class RepositorySettingsModel: ObservableObject {
return "Reviews and failing CI ready"
}
+ var sessionStaleAfterDescription: String {
+ let minutes = max(1, Int(sessionStaleAfter / 60))
+ if minutes.isMultiple(of: 60) {
+ let hours = minutes / 60
+ return hours == 1 ? "1 hour" : "\(hours) hours"
+ }
+ return minutes == 1 ? "1 minute" : "\(minutes) minutes"
+ }
+
private let preferences: RepositoryPreferences
private let hotKeyPreferences: HotKeyPreferences
private let catalog: GitHubRepositoryCatalog
@@ -66,10 +88,19 @@ final class RepositorySettingsModel: ObservableObject {
private let integrationInstaller: AgentIntegrationInstaller
private let eventStore: AgentEventStore
private let recentSignalStore: RecentSignalStore
+ private let releaseUpdateChecker: ReleaseUpdateChecker
+ private let sessionStaleAfter: TimeInterval
+ private let operatingSystemVersion: String
+ private let launchAtLoginStatusProvider: () -> LaunchAtLoginStatus
+ private let launchAtLoginSetter: (Bool) throws -> LaunchAtLoginStatus
+ private let openLoginItemsSettingsAction: () -> Void
+ private let copyText: (String) -> Bool
+ private let openURL: (URL) -> Void
private let selectionDidChange: () -> Void
private let hotKeyDidChange: (HotKeyConfiguration?) -> Void
private let integrationDidInstall: (AgentIntegrationInstallResult) -> Void
private var hasLoaded = false
+ private var diagnosticsFeedbackID = UUID()
init(
preferences: RepositoryPreferences,
@@ -79,6 +110,16 @@ final class RepositorySettingsModel: ObservableObject {
integrationInstaller: AgentIntegrationInstaller,
eventStore: AgentEventStore,
recentSignalStore: RecentSignalStore,
+ releaseUpdateChecker: ReleaseUpdateChecker = ReleaseUpdateChecker(),
+ sessionStaleAfter: TimeInterval,
+ appVersion: String,
+ buildVersion: String,
+ operatingSystemVersion: String,
+ launchAtLoginStatus: @escaping () -> LaunchAtLoginStatus,
+ setLaunchAtLoginEnabled: @escaping (Bool) throws -> LaunchAtLoginStatus,
+ openLoginItemsSettings: @escaping () -> Void,
+ copyText: @escaping (String) -> Bool,
+ openURL: @escaping (URL) -> Void,
selectionDidChange: @escaping () -> Void,
hotKeyDidChange: @escaping (HotKeyConfiguration?) -> Void,
integrationDidInstall: @escaping (AgentIntegrationInstallResult) -> Void
@@ -90,6 +131,16 @@ final class RepositorySettingsModel: ObservableObject {
self.integrationInstaller = integrationInstaller
self.eventStore = eventStore
self.recentSignalStore = recentSignalStore
+ self.releaseUpdateChecker = releaseUpdateChecker
+ self.sessionStaleAfter = sessionStaleAfter
+ self.appVersion = appVersion
+ self.buildVersion = buildVersion
+ self.operatingSystemVersion = operatingSystemVersion
+ launchAtLoginStatusProvider = launchAtLoginStatus
+ launchAtLoginSetter = setLaunchAtLoginEnabled
+ openLoginItemsSettingsAction = openLoginItemsSettings
+ self.copyText = copyText
+ self.openURL = openURL
self.selectionDidChange = selectionDidChange
self.hotKeyDidChange = hotKeyDidChange
self.integrationDidInstall = integrationDidInstall
@@ -97,6 +148,7 @@ final class RepositorySettingsModel: ObservableObject {
includesAllRepositories = snapshot.includesAllRepositories
selectedRepositories = snapshot.selectedRepositories
hotKey = hotKeyPreferences.hotKey
+ self.launchAtLoginStatus = launchAtLoginStatus()
}
func loadRepositories(force: Bool = false) {
@@ -132,12 +184,14 @@ final class RepositorySettingsModel: ObservableObject {
let integrationInstaller = self.integrationInstaller
let eventStore = self.eventStore
let recentSignalStore = self.recentSignalStore
+ let sessionStaleAfter = self.sessionStaleAfter
Task.detached {
let healthResult = Result { try integrationInstaller.health() }
let authenticationStatus = authenticationChecker.status()
let latestEvent = eventStore.latestEvent()
let signals = recentSignalStore.signals
+ let sessionInspection = eventStore.inspectSessions(staleAfter: sessionStaleAfter)
await MainActor.run { [weak self] in
guard let self else { return }
switch healthResult {
@@ -156,11 +210,130 @@ final class RepositorySettingsModel: ObservableObject {
githubAuthenticationStatus = authenticationStatus
lastAgentEvent = latestEvent
recentSignals = signals
+ if self.sessionInspection != sessionInspection {
+ sessionRecoveryMessage = nil
+ sessionRecoveryIsError = false
+ }
+ self.sessionInspection = sessionInspection
+ launchAtLoginStatus = launchAtLoginStatusProvider()
isCheckingHealth = false
}
}
}
+ func checkForUpdates() {
+ guard updateState != .checking else { return }
+ updateState = .checking
+ updateErrorMessage = nil
+ let checker = releaseUpdateChecker
+ let currentVersion = appVersion
+
+ Task.detached {
+ let result = Result {
+ try checker.check(currentVersion: currentVersion)
+ }
+ await MainActor.run { [weak self] in
+ guard let self else { return }
+ switch result {
+ case .success(let state):
+ updateState = state
+ case .failure(let error):
+ updateState = .unavailable
+ updateErrorMessage = (
+ error as? ReleaseUpdateCheckerError
+ )?.localizedDescription
+ ?? "Could not check GitHub releases. Confirm the GitHub CLI is installed and authenticated."
+ }
+ }
+ }
+ }
+
+ func setLaunchAtLoginEnabled(_ enabled: Bool) {
+ launchAtLoginError = nil
+ do {
+ launchAtLoginStatus = try launchAtLoginSetter(enabled)
+ } catch {
+ launchAtLoginStatus = launchAtLoginStatusProvider()
+ launchAtLoginError =
+ "Meanwhile could not change this Login Item. Open Login Items in System Settings and try again."
+ }
+ }
+
+ func openLoginItemsSettings() {
+ openLoginItemsSettingsAction()
+ }
+
+ func openLatestRelease() {
+ guard let releaseURL = updateState.releaseURL else { return }
+ openURL(releaseURL)
+ }
+
+ func clearStuckSessions() {
+ guard !isClearingStuckSessions, sessionInspection.stuckCount > 0 else { return }
+ isClearingStuckSessions = true
+ sessionRecoveryMessage = nil
+ sessionRecoveryIsError = false
+ let eventStore = self.eventStore
+ let staleAfter = sessionStaleAfter
+
+ Task.detached {
+ let result = Result {
+ try eventStore.clearStuckSessions(staleAfter: staleAfter)
+ }
+ let inspection = eventStore.inspectSessions(staleAfter: staleAfter)
+ await MainActor.run { [weak self] in
+ guard let self else { return }
+ isClearingStuckSessions = false
+ sessionInspection = inspection
+ switch result {
+ case .success(let count):
+ let message = count == 1
+ ? "Cleared 1 stuck session."
+ : "Cleared \(count) stuck sessions."
+ sessionRecoveryMessage = message
+ Task { [weak self] in
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
+ guard self?.sessionRecoveryMessage == message else { return }
+ self?.sessionRecoveryMessage = nil
+ }
+ case .failure:
+ sessionRecoveryMessage = "Meanwhile could not clear every stuck session."
+ sessionRecoveryIsError = true
+ }
+ }
+ }
+ }
+
+ func copyDiagnostics() {
+ let snapshot = DiagnosticsSnapshot(
+ appVersion: appVersion,
+ buildVersion: buildVersion,
+ operatingSystemVersion: operatingSystemVersion,
+ launchAtLoginStatus: launchAtLoginStatus,
+ updateState: updateState,
+ integrationHealth: integrationHealth,
+ githubAuthenticationStatus: githubAuthenticationStatus,
+ repositoryScopeIncludesAll: includesAllRepositories,
+ accessibleRepositoryCount: availableRepositories.count,
+ selectedRepositoryCount: selectedRepositories.count,
+ hotKeyConfigured: hotKey != nil,
+ sessionInspection: sessionInspection,
+ lastAgentEvent: lastAgentEvent,
+ recentSignals: recentSignals
+ )
+ let copied = copyText(MeanwhileDiagnosticsReport.make(snapshot: snapshot))
+ let feedbackID = UUID()
+ diagnosticsFeedbackID = feedbackID
+ diagnosticsCopyMessage = copied ? "Copied" : "Try Again"
+ diagnosticsCopyIsError = !copied
+ Task { [weak self] in
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
+ guard self?.diagnosticsFeedbackID == feedbackID else { return }
+ self?.diagnosticsCopyMessage = nil
+ self?.diagnosticsCopyIsError = false
+ }
+ }
+
func setIncludesAllRepositories(_ includesAll: Bool) {
if !includesAll, selectedRepositories.isEmpty {
selectedRepositories = Set(availableRepositories)
diff --git a/Sources/Meanwhile/RepositorySettingsView.swift b/Sources/Meanwhile/RepositorySettingsView.swift
index b9185ff..879cf13 100644
--- a/Sources/Meanwhile/RepositorySettingsView.swift
+++ b/Sources/Meanwhile/RepositorySettingsView.swift
@@ -5,6 +5,7 @@ struct RepositorySettingsView: View {
@ObservedObject var model: RepositorySettingsModel
@AppStorage("settings.section.statusItem.expanded") private var isStatusItemExpanded = true
@AppStorage("settings.section.connectionHealth.expanded") private var isConnectionHealthExpanded = true
+ @AppStorage("settings.section.aboutSupport.expanded") private var isAboutSupportExpanded = true
@AppStorage("settings.section.repositorySources.expanded") private var isRepositorySourcesExpanded = true
@AppStorage("settings.section.recentSignals.expanded") private var isRecentSignalsExpanded = true
@State private var searchText = ""
@@ -17,6 +18,30 @@ struct RepositorySettingsView: View {
}
}
+ private var aboutSupportTrailing: String {
+ switch model.updateState {
+ case .updateAvailable(let version, _):
+ return "\(version) available"
+ case .unavailable:
+ return "Check unavailable"
+ case .checking:
+ return "Checking"
+ case .notChecked, .current, .developmentBuild:
+ return "Version \(model.appVersion)"
+ }
+ }
+
+ private var aboutSupportTrailingTint: Color {
+ switch model.updateState {
+ case .updateAvailable:
+ return .accentColor
+ case .unavailable:
+ return .orange
+ case .notChecked, .checking, .current, .developmentBuild:
+ return Color(nsColor: .tertiaryLabelColor)
+ }
+ }
+
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
@@ -32,17 +57,48 @@ struct RepositorySettingsView: View {
}
Divider()
ControlsSettingsSection(
- hotKey: Binding(get: { model.hotKey }, set: model.setHotKey),
+ hotKey: Binding(
+ get: { model.hotKey },
+ set: { model.setHotKey($0) }
+ ),
hotKeyRegistrationError: model.hotKeyRegistrationError,
setHotKeyRegistrationError: model.setHotKeyRegistrationError,
isInstallingIntegrations: model.isInstallingIntegrations,
integrationActionMessage: model.integrationActionMessage,
integrationActionIsError: model.integrationActionIsError,
- installIntegrations: model.installIntegrations
+ installIntegrations: model.installIntegrations,
+ launchAtLoginStatus: model.launchAtLoginStatus,
+ launchAtLoginError: model.launchAtLoginError,
+ setLaunchAtLoginEnabled: { model.setLaunchAtLoginEnabled($0) },
+ openLoginItemsSettings: model.openLoginItemsSettings
)
Divider()
+ CollapsibleSettingsSection(
+ title: "About & support",
+ trailing: aboutSupportTrailing,
+ trailingTint: aboutSupportTrailingTint,
+ isExpanded: $isAboutSupportExpanded,
+ showsProgress: model.updateState == .checking
+ ) {
+ AppSettingsSection(
+ appVersion: model.appVersion,
+ buildVersion: model.buildVersion,
+ updateState: model.updateState,
+ updateErrorMessage: model.updateErrorMessage,
+ checkForUpdates: model.checkForUpdates,
+ openLatestRelease: model.openLatestRelease,
+ diagnosticsCopyMessage: model.diagnosticsCopyMessage,
+ diagnosticsCopyIsError: model.diagnosticsCopyIsError,
+ copyDiagnostics: model.copyDiagnostics
+ )
+ }
+ Divider()
CollapsibleSettingsSection(
title: "Connection health",
+ trailing: model.sessionInspection.stuckCount > 0
+ ? "\(model.sessionInspection.stuckCount) may be stuck"
+ : nil,
+ trailingTint: .orange,
isExpanded: $isConnectionHealthExpanded,
showsProgress: model.isCheckingHealth
) {
@@ -51,6 +107,12 @@ struct RepositorySettingsView: View {
integrationHealthError: model.integrationHealthError,
lastAgentEvent: model.lastAgentEvent,
githubAuthenticationStatus: model.githubAuthenticationStatus,
+ sessionInspection: model.sessionInspection,
+ sessionRecoveryMessage: model.sessionRecoveryMessage,
+ sessionRecoveryIsError: model.sessionRecoveryIsError,
+ isClearingStuckSessions: model.isClearingStuckSessions,
+ staleAfter: model.sessionStaleAfterDescription,
+ clearStuckSessions: model.clearStuckSessions,
now: now
)
}
@@ -66,7 +128,7 @@ struct RepositorySettingsView: View {
searchText: $searchText,
includesAllRepositories: Binding(
get: { model.includesAllRepositories },
- set: model.setIncludesAllRepositories
+ set: { model.setIncludesAllRepositories($0) }
),
availableRepositories: model.availableRepositories,
filteredRepositories: filteredRepositories,
@@ -93,6 +155,7 @@ struct RepositorySettingsView: View {
.task {
model.loadRepositories()
model.refreshStatus()
+ model.checkForUpdates()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 30_000_000_000)
guard !Task.isCancelled else { break }
diff --git a/Sources/Meanwhile/SettingsSupport.swift b/Sources/Meanwhile/SettingsSupport.swift
index 323e636..8e78bce 100644
--- a/Sources/Meanwhile/SettingsSupport.swift
+++ b/Sources/Meanwhile/SettingsSupport.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct SettingsSectionHeader: View {
let title: String
var trailing: String? = nil
+ var trailingTint: Color = Color(nsColor: .tertiaryLabelColor)
var showsProgress = false
var body: some View {
@@ -15,7 +16,7 @@ struct SettingsSectionHeader: View {
if let trailing {
Text(trailing)
.font(.caption2)
- .foregroundStyle(.tertiary)
+ .foregroundStyle(trailingTint)
}
if showsProgress {
ProgressView()
@@ -28,6 +29,7 @@ struct SettingsSectionHeader: View {
struct CollapsibleSettingsSection: View {
let title: String
var trailing: String? = nil
+ var trailingTint: Color = Color(nsColor: .tertiaryLabelColor)
@Binding var isExpanded: Bool
var showsProgress = false
private let content: Content
@@ -35,12 +37,14 @@ struct CollapsibleSettingsSection: View {
init(
title: String,
trailing: String? = nil,
+ trailingTint: Color = Color(nsColor: .tertiaryLabelColor),
isExpanded: Binding,
showsProgress: Bool = false,
@ViewBuilder content: () -> Content
) {
self.title = title
self.trailing = trailing
+ self.trailingTint = trailingTint
_isExpanded = isExpanded
self.showsProgress = showsProgress
self.content = content()
@@ -54,12 +58,32 @@ struct CollapsibleSettingsSection: View {
SettingsSectionHeader(
title: title,
trailing: trailing,
+ trailingTint: trailingTint,
showsProgress: showsProgress
)
}
}
}
+struct SettingsInlineMessage: View {
+ let message: String
+ let systemImage: String
+ let tint: Color
+
+ init(_ message: String, systemImage: String, tint: Color) {
+ self.message = message
+ self.systemImage = systemImage
+ self.tint = tint
+ }
+
+ var body: some View {
+ Label(message, systemImage: systemImage)
+ .font(.caption)
+ .foregroundStyle(tint)
+ .padding(.leading, 142)
+ }
+}
+
func providerDisplayName(_ provider: AgentProvider) -> String {
switch provider {
case .claude: return "Claude"
diff --git a/Sources/Meanwhile/StatusItemSettingsSection.swift b/Sources/Meanwhile/StatusItemSettingsSection.swift
index 427b83a..d70c1d4 100644
--- a/Sources/Meanwhile/StatusItemSettingsSection.swift
+++ b/Sources/Meanwhile/StatusItemSettingsSection.swift
@@ -28,8 +28,8 @@ struct StatusItemSettingsSection: View {
StatusLanguageRow(
systemImage: "exclamationmark.bubble.fill",
tint: .red,
- label: "Needs you",
- meaning: "An agent is blocked on you"
+ label: "Codex needs you",
+ meaning: "Click to return; hover for the project"
)
StatusLanguageRow(
systemImage: "rectangle.stack.fill",
diff --git a/Sources/MeanwhileCore/AgentEventStore.swift b/Sources/MeanwhileCore/AgentEventStore.swift
index 22a8c11..cc19f7d 100644
--- a/Sources/MeanwhileCore/AgentEventStore.swift
+++ b/Sources/MeanwhileCore/AgentEventStore.swift
@@ -1,5 +1,27 @@
import Foundation
+public struct AgentSessionInspection: Equatable, Sendable {
+ public var activeCount: Int
+ public var stuckCount: Int
+ public var oldestStuckUpdate: Date?
+
+ public init(
+ activeCount: Int,
+ stuckCount: Int,
+ oldestStuckUpdate: Date?
+ ) {
+ self.activeCount = activeCount
+ self.stuckCount = stuckCount
+ self.oldestStuckUpdate = oldestStuckUpdate
+ }
+
+ public static let empty = AgentSessionInspection(
+ activeCount: 0,
+ stuckCount: 0,
+ oldestStuckUpdate: nil
+ )
+}
+
public final class AgentEventStore: @unchecked Sendable {
public let directory: URL
public let latestEventURL: URL
@@ -78,11 +100,59 @@ public final class AgentEventStore: @unchecked Sendable {
return current.sorted { $0.id < $1.id }
}
+ public func inspectSessions(
+ now: Date = Date(),
+ staleAfter: TimeInterval = 3_600
+ ) -> AgentSessionInspection {
+ lock.lock()
+ defer { lock.unlock() }
+ let active = sessionFiles()
+ .compactMap(decode(url:))
+ .filter { $0.phase != .idle }
+ let stuck = active.filter {
+ now.timeIntervalSince($0.updatedAt) > staleAfter
+ }
+ return AgentSessionInspection(
+ activeCount: active.count,
+ stuckCount: stuck.count,
+ oldestStuckUpdate: stuck.map(\.updatedAt).min()
+ )
+ }
+
+ @discardableResult
+ public func clearStuckSessions(
+ now: Date = Date(),
+ staleAfter: TimeInterval = 3_600
+ ) throws -> Int {
+ lock.lock()
+ defer { lock.unlock() }
+ var removed = 0
+ for url in sessionFiles() {
+ guard let session = decode(url: url),
+ session.phase != .idle,
+ now.timeIntervalSince(session.updatedAt) > staleAfter else {
+ continue
+ }
+ try fileManager.removeItem(at: url)
+ removed += 1
+ }
+ return removed
+ }
+
private func decode(url: URL) -> AgentSessionState? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? decoder.decode(AgentSessionState.self, from: data)
}
+ private func sessionFiles() -> [URL] {
+ guard let urls = try? fileManager.contentsOfDirectory(
+ at: directory,
+ includingPropertiesForKeys: nil,
+ options: [.skipsHiddenFiles]
+ ) else { return [] }
+ return urls.filter { $0.pathExtension == "json" }
+ }
+
private func url(for session: AgentSessionState) -> URL {
let raw = Data(session.sessionID.utf8).base64EncodedString()
.replacingOccurrences(of: "/", with: "_")
diff --git a/Sources/MeanwhileCore/AgentFocusRouter.swift b/Sources/MeanwhileCore/AgentFocusRouter.swift
new file mode 100644
index 0000000..4efe6b0
--- /dev/null
+++ b/Sources/MeanwhileCore/AgentFocusRouter.swift
@@ -0,0 +1,35 @@
+import AppKit
+import Foundation
+
+public enum AgentFocusOutcome: Equatable, Sendable {
+ case codexTask
+ case terminalFallback
+ case unavailable
+}
+
+@MainActor
+public final class AgentFocusRouter {
+ private let openURL: (URL) -> Bool
+ private let focusTerminal: (AgentSessionState) -> Bool
+
+ public init(
+ openURL: @escaping (URL) -> Bool = { NSWorkspace.shared.open($0) },
+ focusTerminal: @escaping (AgentSessionState) -> Bool
+ ) {
+ self.openURL = openURL
+ self.focusTerminal = focusTerminal
+ }
+
+ public func focus(_ session: AgentSessionState) -> AgentFocusOutcome {
+ if let url = Self.codexThreadURL(for: session), openURL(url) {
+ return .codexTask
+ }
+ return focusTerminal(session) ? .terminalFallback : .unavailable
+ }
+
+ nonisolated static func codexThreadURL(for session: AgentSessionState) -> URL? {
+ guard session.provider == .codex,
+ UUID(uuidString: session.sessionID) != nil else { return nil }
+ return URL(string: "codex://threads/\(session.sessionID)")
+ }
+}
diff --git a/Sources/MeanwhileCore/DiagnosticsReport.swift b/Sources/MeanwhileCore/DiagnosticsReport.swift
new file mode 100644
index 0000000..7322fb5
--- /dev/null
+++ b/Sources/MeanwhileCore/DiagnosticsReport.swift
@@ -0,0 +1,151 @@
+import Foundation
+
+public enum LaunchAtLoginStatus: Equatable, Sendable {
+ case disabled
+ case enabled
+ case requiresApproval
+ case unavailable
+}
+
+public struct DiagnosticsSnapshot: Sendable {
+ public var appVersion: String
+ public var buildVersion: String
+ public var operatingSystemVersion: String
+ public var launchAtLoginStatus: LaunchAtLoginStatus
+ public var updateState: ReleaseUpdateState
+ public var integrationHealth: AgentIntegrationHealth
+ public var githubAuthenticationStatus: GitHubAuthenticationStatus
+ public var repositoryScopeIncludesAll: Bool
+ public var accessibleRepositoryCount: Int
+ public var selectedRepositoryCount: Int
+ public var hotKeyConfigured: Bool
+ public var sessionInspection: AgentSessionInspection
+ public var lastAgentEvent: AgentSessionState?
+ public var recentSignals: [RecentSignal]
+
+ public init(
+ appVersion: String,
+ buildVersion: String,
+ operatingSystemVersion: String,
+ launchAtLoginStatus: LaunchAtLoginStatus,
+ updateState: ReleaseUpdateState,
+ integrationHealth: AgentIntegrationHealth,
+ githubAuthenticationStatus: GitHubAuthenticationStatus,
+ repositoryScopeIncludesAll: Bool,
+ accessibleRepositoryCount: Int,
+ selectedRepositoryCount: Int,
+ hotKeyConfigured: Bool,
+ sessionInspection: AgentSessionInspection,
+ lastAgentEvent: AgentSessionState?,
+ recentSignals: [RecentSignal]
+ ) {
+ self.appVersion = appVersion
+ self.buildVersion = buildVersion
+ self.operatingSystemVersion = operatingSystemVersion
+ self.launchAtLoginStatus = launchAtLoginStatus
+ self.updateState = updateState
+ self.integrationHealth = integrationHealth
+ self.githubAuthenticationStatus = githubAuthenticationStatus
+ self.repositoryScopeIncludesAll = repositoryScopeIncludesAll
+ self.accessibleRepositoryCount = accessibleRepositoryCount
+ self.selectedRepositoryCount = selectedRepositoryCount
+ self.hotKeyConfigured = hotKeyConfigured
+ self.sessionInspection = sessionInspection
+ self.lastAgentEvent = lastAgentEvent
+ self.recentSignals = recentSignals
+ }
+}
+
+public enum MeanwhileDiagnosticsReport {
+ public static func make(
+ snapshot: DiagnosticsSnapshot,
+ generatedAt: Date = Date()
+ ) -> String {
+ var lines = [
+ "Meanwhile diagnostics",
+ "Schema: 1",
+ "Generated: \(timestamp(generatedAt))",
+ "App: \(snapshot.appVersion) (\(snapshot.buildVersion))",
+ "macOS: \(snapshot.operatingSystemVersion)",
+ "Launch at login: \(launchAtLoginDescription(snapshot.launchAtLoginStatus))",
+ "Update: \(updateDescription(snapshot.updateState))",
+ "Integrations: \(integrationDescription(snapshot.integrationHealth.state))",
+ "Claude hooks: \(yesNo(snapshot.integrationHealth.claudeHooksInstalled))",
+ "Codex hooks: \(yesNo(snapshot.integrationHealth.codexHooksInstalled))",
+ "Claude status line conflict: \(yesNo(snapshot.integrationHealth.claudeStatuslineConflict))",
+ "GitHub CLI: \(snapshot.githubAuthenticationStatus == .authenticated ? "authenticated" : "not authenticated")",
+ "Repository scope: \(repositoryScopeDescription(snapshot))",
+ "Keyboard shortcut: \(snapshot.hotKeyConfigured ? "configured" : "not configured")",
+ "Active agent sessions: \(snapshot.sessionInspection.activeCount)",
+ "Sessions that may be stuck: \(snapshot.sessionInspection.stuckCount)"
+ ]
+
+ if let event = snapshot.lastAgentEvent {
+ lines.append(
+ "Last agent event: \(event.provider.rawValue), \(event.phase.rawValue), \(timestamp(event.updatedAt))"
+ )
+ } else {
+ lines.append("Last agent event: none")
+ }
+
+ lines.append("Recent signals: \(snapshot.recentSignals.count)")
+ for kind in RecentSignalKind.allCases {
+ let count = snapshot.recentSignals.lazy.filter { $0.kind == kind }.count
+ lines.append("Recent \(kind.rawValue): \(count)")
+ }
+ if let newest = snapshot.recentSignals.map(\.date).max() {
+ lines.append("Newest signal: \(timestamp(newest))")
+ } else {
+ lines.append("Newest signal: none")
+ }
+
+ return lines.joined(separator: "\n") + "\n"
+ }
+
+ private static func timestamp(_ date: Date) -> String {
+ ISO8601DateFormatter().string(from: date)
+ }
+
+ private static func yesNo(_ value: Bool) -> String {
+ value ? "yes" : "no"
+ }
+
+ private static func launchAtLoginDescription(_ status: LaunchAtLoginStatus) -> String {
+ switch status {
+ case .disabled: return "off"
+ case .enabled: return "on"
+ case .requiresApproval: return "needs approval"
+ case .unavailable: return "unavailable"
+ }
+ }
+
+ private static func updateDescription(_ state: ReleaseUpdateState) -> String {
+ switch state {
+ case .notChecked: return "not checked"
+ case .checking: return "checking"
+ case .current(let version, _): return "current (\(version))"
+ case .updateAvailable(let version, _): return "available (\(version))"
+ case .developmentBuild(let version, _): return "development build; latest release \(version)"
+ case .unavailable: return "unavailable"
+ }
+ }
+
+ private static func integrationDescription(
+ _ state: AgentIntegrationHealthState
+ ) -> String {
+ switch state {
+ case .installed: return "installed"
+ case .needsReview: return "needs review"
+ case .notInstalled: return "not installed"
+ }
+ }
+
+ private static func repositoryScopeDescription(
+ _ snapshot: DiagnosticsSnapshot
+ ) -> String {
+ if snapshot.repositoryScopeIncludesAll {
+ return "all accessible (\(snapshot.accessibleRepositoryCount))"
+ }
+ return "selected only (\(snapshot.selectedRepositoryCount))"
+ }
+}
diff --git a/Sources/MeanwhileCore/MenuBarPresenter.swift b/Sources/MeanwhileCore/MenuBarPresenter.swift
index 75b77ca..b182cc5 100644
--- a/Sources/MeanwhileCore/MenuBarPresenter.swift
+++ b/Sources/MeanwhileCore/MenuBarPresenter.swift
@@ -1,3 +1,5 @@
+import Foundation
+
public enum MenuBarPresenter {
public static let idleIconName = "rectangle.stack"
public static let thinkingIconName = "rectangle.stack.fill"
@@ -14,7 +16,7 @@ public enum MenuBarPresenter {
public static func statusText(item: WorkItem?) -> String? {
guard let item else { return nil }
switch item.kind {
- case .needsYou: return "Needs you"
+ case .needsYou: return item.title
case .failingCI: return "CI!"
case .review:
let number = item.title.split(separator: "#").last.map(String.init)
@@ -22,6 +24,101 @@ public enum MenuBarPresenter {
}
}
+ public static func projectName(item: WorkItem) -> String? {
+ guard let cwd = item.session?.cwd.trimmingCharacters(in: .whitespacesAndNewlines),
+ !cwd.isEmpty,
+ cwd.rangeOfCharacter(from: .controlCharacters) == nil else { return nil }
+ let url = URL(fileURLWithPath: cwd).standardizedFileURL
+ guard url != FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL else {
+ return nil
+ }
+ let name = (cwd as NSString).lastPathComponent
+ guard !name.isEmpty,
+ name != "/",
+ name != ".",
+ name != "..",
+ name.rangeOfCharacter(from: .controlCharacters) == nil else { return nil }
+ return middleTruncated(name, limit: 32)
+ }
+
+ public static func openActionTitle(item: WorkItem) -> String {
+ switch item.kind {
+ case .needsYou:
+ let action: String
+ switch item.session?.provider {
+ case .claude: action = "Return to Claude"
+ case .codex: action = "Return to Codex"
+ case .unknown, nil: action = "Return to waiting task"
+ }
+ if let project = projectName(item: item) {
+ return "\(action) — \(project)"
+ }
+ return action
+ case .review, .failingCI:
+ return "Open \(item.title) — \(item.detail)"
+ }
+ }
+
+ public static func tooltip(phase: AgentDisplayPhase, item: WorkItem?) -> String {
+ if let item {
+ if item.kind == .needsYou {
+ if let project = projectName(item: item) {
+ return "\(item.title) in “\(project)” — click to return"
+ }
+ return "\(item.title) — click to return"
+ }
+ return "\(item.title): \(item.detail)"
+ }
+ switch phase {
+ case .idle: return "Meanwhile — idle"
+ case .thinking: return "Agent thinking — no eligible items"
+ case .needsYou: return "Waiting task hidden"
+ }
+ }
+
+ public static func accessibilityLabel(
+ phase: AgentDisplayPhase,
+ item: WorkItem?
+ ) -> String {
+ if let item {
+ switch item.kind {
+ case .needsYou:
+ if let project = projectName(item: item) {
+ return "\(item.title) in the \(project) project"
+ }
+ return item.title
+ case .review, .failingCI:
+ return "\(item.title), \(item.detail)"
+ }
+ }
+ switch phase {
+ case .idle: return "Meanwhile, idle"
+ case .thinking: return "Meanwhile, agent thinking"
+ case .needsYou: return "Waiting task hidden"
+ }
+ }
+
+ public static func accessibilityHelp(
+ phase: AgentDisplayPhase,
+ item: WorkItem?
+ ) -> String? {
+ if let item {
+ switch item.kind {
+ case .needsYou:
+ guard let provider = item.session?.provider,
+ provider != .unknown else {
+ return "Returns to the waiting task."
+ }
+ return "Returns to the waiting \(providerName(provider)) task."
+ case .review, .failingCI:
+ return "Opens \(item.title)."
+ }
+ }
+ return phase == .needsYou
+ ? "It will reappear when its state changes."
+ : nil
+ }
+
public static func statuslineText(item: WorkItem) -> String {
switch item.kind {
case .needsYou: return "Meanwhile: \(item.title)"
@@ -29,4 +126,19 @@ public enum MenuBarPresenter {
case .review: return "Meanwhile: \(item.title) — \(item.detail)"
}
}
+
+ private static func providerName(_ provider: AgentProvider) -> String {
+ switch provider {
+ case .claude: return "Claude"
+ case .codex: return "Codex"
+ case .unknown: return "Agent"
+ }
+ }
+
+ private static func middleTruncated(_ value: String, limit: Int) -> String {
+ guard value.count > limit, limit > 1 else { return value }
+ let leadingCount = (limit - 1) / 2
+ let trailingCount = limit - leadingCount - 1
+ return "\(value.prefix(leadingCount))…\(value.suffix(trailingCount))"
+ }
}
diff --git a/Sources/MeanwhileCore/RecentSignalStore.swift b/Sources/MeanwhileCore/RecentSignalStore.swift
index 1848fb3..e0e2549 100644
--- a/Sources/MeanwhileCore/RecentSignalStore.swift
+++ b/Sources/MeanwhileCore/RecentSignalStore.swift
@@ -1,6 +1,6 @@
import Foundation
-public enum RecentSignalKind: String, Codable, Sendable {
+public enum RecentSignalKind: String, Codable, Sendable, CaseIterable {
case agentNeedsYou
case reviewSurfaced
case ciFailed
diff --git a/Sources/MeanwhileCore/ReleaseUpdateChecker.swift b/Sources/MeanwhileCore/ReleaseUpdateChecker.swift
new file mode 100644
index 0000000..cbb11ad
--- /dev/null
+++ b/Sources/MeanwhileCore/ReleaseUpdateChecker.swift
@@ -0,0 +1,120 @@
+import Foundation
+
+public enum ReleaseUpdateState: Equatable, Sendable {
+ case notChecked
+ case checking
+ case current(latestVersion: String, releaseURL: URL)
+ case updateAvailable(version: String, releaseURL: URL)
+ case developmentBuild(latestVersion: String, releaseURL: URL)
+ case unavailable
+
+ public var releaseURL: URL? {
+ switch self {
+ case .current(_, let releaseURL),
+ .updateAvailable(_, let releaseURL),
+ .developmentBuild(_, let releaseURL):
+ return releaseURL
+ case .notChecked, .checking, .unavailable:
+ return nil
+ }
+ }
+}
+
+public enum ReleaseUpdateCheckerError: Error, Equatable {
+ case commandFailed
+ case invalidResponse
+ case invalidVersion
+}
+
+extension ReleaseUpdateCheckerError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .commandFailed:
+ return "Could not check GitHub releases. Confirm the GitHub CLI is installed and authenticated."
+ case .invalidResponse:
+ return "GitHub returned an unreadable release response."
+ case .invalidVersion:
+ return "The installed and latest release versions could not be compared."
+ }
+ }
+}
+
+public struct ReleaseUpdateChecker: Sendable {
+ public static let command =
+ "gh release view --repo tcballard/Meanwhile --json tagName,url"
+
+ private struct ReleasePayload: Decodable {
+ var tagName: String
+ var url: String
+ }
+
+ private let runner: any CommandRunning
+
+ public init(runner: any CommandRunning = PeripheralCommandRunner()) {
+ self.runner = runner
+ }
+
+ public func check(currentVersion: String) throws -> ReleaseUpdateState {
+ let result = try runner.run(Self.command, timeoutSeconds: 15)
+ guard result.exitCode == 0 else {
+ throw ReleaseUpdateCheckerError.commandFailed
+ }
+
+ guard let data = result.stdout.data(using: .utf8),
+ let payload = try? JSONDecoder().decode(ReleasePayload.self, from: data),
+ let releaseURL = URL(string: payload.url),
+ releaseURL.scheme == "https",
+ releaseURL.host == "github.com" else {
+ throw ReleaseUpdateCheckerError.invalidResponse
+ }
+
+ guard let current = SemanticVersion(currentVersion),
+ let latest = SemanticVersion(payload.tagName) else {
+ throw ReleaseUpdateCheckerError.invalidVersion
+ }
+
+ if current < latest {
+ return .updateAvailable(version: payload.tagName, releaseURL: releaseURL)
+ }
+ if latest < current {
+ return .developmentBuild(
+ latestVersion: payload.tagName,
+ releaseURL: releaseURL
+ )
+ }
+ return .current(latestVersion: payload.tagName, releaseURL: releaseURL)
+ }
+}
+
+public struct SemanticVersion: Comparable, Equatable, Sendable {
+ private let components: [Int]
+
+ public init?(_ value: String) {
+ var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if normalized.lowercased().hasPrefix("v") {
+ normalized.removeFirst()
+ }
+ let core = normalized.split(separator: "-", maxSplits: 1).first.map(String.init) ?? ""
+ let parts = core.split(separator: ".", omittingEmptySubsequences: false)
+ guard (2...4).contains(parts.count),
+ parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) else {
+ return nil
+ }
+ var parsed = parts.compactMap { Int($0) }
+ guard parsed.count == parts.count else { return nil }
+ while parsed.count < 3 {
+ parsed.append(0)
+ }
+ components = parsed
+ }
+
+ public static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
+ let count = max(lhs.components.count, rhs.components.count)
+ for index in 0.. Bool
- @discardableResult
- public func focus(_ session: AgentSessionState) -> Bool {
- if let tty = session.terminal.tty {
- if isITerm(session.terminal.program), run(script: iTermScript(tty: tty)) {
- return true
- }
- if isAppleTerminal(session.terminal.program), run(script: terminalScript(tty: tty)) {
- return true
- }
- if run(script: terminalScript(tty: tty)) || run(script: iTermScript(tty: tty)) {
- return true
- }
+ public convenience init() {
+ self.init { script in
+ var error: NSDictionary?
+ let result = NSAppleScript(source: script)?.executeAndReturnError(&error)
+ return error == nil && result?.booleanValue == true
}
- return activateTerminal(named: session.terminal.program)
}
- private func activateTerminal(named program: String?) -> Bool {
- let bundleIdentifiers: [String]
- switch program?.lowercased() {
- case "apple_terminal", "terminal", "terminal.app":
- bundleIdentifiers = ["com.apple.Terminal"]
- case "iterm.app", "iterm2", "iterm":
- bundleIdentifiers = ["com.googlecode.iterm2"]
- case "vscode", "visual studio code":
- bundleIdentifiers = ["com.microsoft.VSCode"]
- case "warpterminal", "warp":
- bundleIdentifiers = ["dev.warp.Warp-Stable", "dev.warp.Warp"]
- case "ghostty":
- bundleIdentifiers = ["com.mitchellh.ghostty"]
- default:
- bundleIdentifiers = ["com.apple.Terminal", "com.googlecode.iterm2"]
+ init(scriptRunner: @escaping (String) -> Bool) {
+ self.scriptRunner = scriptRunner
+ }
+
+ @discardableResult
+ public func focus(_ session: AgentSessionState) -> Bool {
+ guard let tty = session.terminal.tty else { return false }
+ if isITerm(session.terminal.program) {
+ return scriptRunner(iTermScript(tty: tty))
}
- for identifier in bundleIdentifiers {
- if let app = NSRunningApplication.runningApplications(
- withBundleIdentifier: identifier
- ).first {
- return app.activate(options: [.activateAllWindows])
- }
+ if isAppleTerminal(session.terminal.program) {
+ return scriptRunner(terminalScript(tty: tty))
}
return false
}
- private func run(script: String) -> Bool {
- var error: NSDictionary?
- let result = NSAppleScript(source: script)?.executeAndReturnError(&error)
- return error == nil && result?.booleanValue == true
- }
-
private func terminalScript(tty: String) -> String {
let tty = appleScriptString(tty)
return """
@@ -100,11 +76,18 @@ public final class TerminalFocuser {
}
private func isAppleTerminal(_ program: String?) -> Bool {
- guard let program = program?.lowercased() else { return false }
- return program.contains("terminal") && !program.contains("iterm")
+ guard let program = normalizedProgram(program) else { return false }
+ return ["apple_terminal", "terminal", "terminal.app"].contains(program)
}
private func isITerm(_ program: String?) -> Bool {
- program?.lowercased().contains("iterm") == true
+ guard let program = normalizedProgram(program) else { return false }
+ return ["iterm", "iterm2", "iterm.app"].contains(program)
+ }
+
+ private func normalizedProgram(_ program: String?) -> String? {
+ guard let program = program?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !program.isEmpty else { return nil }
+ return (program as NSString).lastPathComponent.lowercased()
}
}
diff --git a/Sources/Peripheral/MenuBarController.swift b/Sources/Peripheral/MenuBarController.swift
index 9cf0a45..fa6a629 100644
--- a/Sources/Peripheral/MenuBarController.swift
+++ b/Sources/Peripheral/MenuBarController.swift
@@ -45,7 +45,7 @@ public final class MenuBarController: NSObject, NSMenuDelegate {
public func setIcon(
systemName: String?,
- accessibilityDescription: String = "Peripheral",
+ accessibilityDescription: String? = "Peripheral",
tintColor: NSColor? = nil
) {
guard let systemName else {
@@ -71,6 +71,11 @@ public final class MenuBarController: NSObject, NSMenuDelegate {
updateImagePosition()
}
+ public func setAccessibility(label: String, help: String? = nil) {
+ statusItem.button?.setAccessibilityLabel(label)
+ statusItem.button?.setAccessibilityHelp(help)
+ }
+
public func showPopover() {
guard let button = statusItem.button else { return }
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
diff --git a/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift b/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift
index f9076f0..3a5a747 100644
--- a/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift
+++ b/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift
@@ -74,6 +74,59 @@ final class AgentEventIntegrationTests: XCTestCase {
XCTAssertEqual(store.latestEvent(), session)
}
+ func testInspectsAndExplicitlyClearsOnlyStuckActiveSessions() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let store = AgentEventStore(directory: directory)
+ let now = Date(timeIntervalSince1970: 100_000)
+ let fresh = AgentSessionState(
+ provider: .claude,
+ sessionID: "fresh",
+ cwd: "/private/fresh",
+ phase: .thinking,
+ enteredAt: now.addingTimeInterval(-300),
+ updatedAt: now.addingTimeInterval(-300)
+ )
+ let stuck = AgentSessionState(
+ provider: .codex,
+ sessionID: "stuck",
+ cwd: "/private/stuck",
+ phase: .needsYou,
+ enteredAt: now.addingTimeInterval(-7_200),
+ updatedAt: now.addingTimeInterval(-7_200)
+ )
+ try store.write(fresh)
+ try store.write(stuck)
+
+ XCTAssertEqual(
+ store.inspectSessions(now: now, staleAfter: 3_600),
+ AgentSessionInspection(
+ activeCount: 2,
+ stuckCount: 1,
+ oldestStuckUpdate: stuck.updatedAt
+ )
+ )
+ XCTAssertEqual(
+ try store.clearStuckSessions(now: now, staleAfter: 3_600),
+ 1
+ )
+ XCTAssertEqual(
+ store.inspectSessions(now: now, staleAfter: 3_600),
+ AgentSessionInspection(
+ activeCount: 1,
+ stuckCount: 0,
+ oldestStuckUpdate: nil
+ )
+ )
+ XCTAssertEqual(
+ store.session(provider: .claude, sessionID: fresh.sessionID),
+ fresh
+ )
+ XCTAssertNil(store.session(provider: .codex, sessionID: stuck.sessionID))
+ XCTAssertEqual(store.latestEvent(), stuck)
+ }
+
func testNotificationPermissionPromptMapsToNeedsYou() throws {
let data = """
{"session_id":"abc","cwd":"/tmp","hook_event_name":"Notification","notification_type":"permission_prompt"}
diff --git a/Tests/MeanwhileCoreTests/AgentFocusRouterTests.swift b/Tests/MeanwhileCoreTests/AgentFocusRouterTests.swift
new file mode 100644
index 0000000..7f8bb0b
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/AgentFocusRouterTests.swift
@@ -0,0 +1,142 @@
+import Foundation
+import XCTest
+@testable import MeanwhileCore
+
+final class AgentFocusRouterTests: XCTestCase {
+ func testBuildsExactCodexThreadURLForUUIDSession() {
+ let session = makeSession(
+ provider: .codex,
+ sessionID: "019f5ee8-576e-74b3-8e9a-2b6e2404970b"
+ )
+
+ XCTAssertEqual(
+ AgentFocusRouter.codexThreadURL(for: session)?.absoluteString,
+ "codex://threads/019f5ee8-576e-74b3-8e9a-2b6e2404970b"
+ )
+ }
+
+ func testRejectsUnvalidatedFocusURLs() {
+ XCTAssertNil(
+ AgentFocusRouter.codexThreadURL(
+ for: makeSession(provider: .codex, sessionID: "not-a-uuid")
+ )
+ )
+ XCTAssertNil(
+ AgentFocusRouter.codexThreadURL(
+ for: makeSession(
+ provider: .claude,
+ sessionID: "019f5ee8-576e-74b3-8e9a-2b6e2404970b"
+ )
+ )
+ )
+ }
+
+ @MainActor
+ func testAcceptedCodexURLDoesNotInvokeTerminalFallback() {
+ var openedURL: URL?
+ var terminalFocusCount = 0
+ let router = AgentFocusRouter(
+ openURL: { url in
+ openedURL = url
+ return true
+ },
+ focusTerminal: { _ in
+ terminalFocusCount += 1
+ return true
+ }
+ )
+
+ XCTAssertEqual(
+ router.focus(makeSession(provider: .codex, sessionID: validSessionID)),
+ .codexTask
+ )
+ XCTAssertEqual(
+ openedURL?.absoluteString,
+ "codex://threads/\(validSessionID)"
+ )
+ XCTAssertEqual(terminalFocusCount, 0)
+ }
+
+ @MainActor
+ func testRejectedCodexURLFallsBackToTerminalOnce() {
+ var terminalFocusCount = 0
+ let router = AgentFocusRouter(
+ openURL: { _ in false },
+ focusTerminal: { _ in
+ terminalFocusCount += 1
+ return true
+ }
+ )
+
+ XCTAssertEqual(
+ router.focus(makeSession(provider: .codex, sessionID: validSessionID)),
+ .terminalFallback
+ )
+ XCTAssertEqual(terminalFocusCount, 1)
+ }
+
+ @MainActor
+ func testInvalidCodexIDSkipsURLAndFallsBackToTerminal() {
+ var openURLCount = 0
+ let router = AgentFocusRouter(
+ openURL: { _ in
+ openURLCount += 1
+ return true
+ },
+ focusTerminal: { _ in true }
+ )
+
+ XCTAssertEqual(
+ router.focus(makeSession(provider: .codex, sessionID: "not-a-uuid")),
+ .terminalFallback
+ )
+ XCTAssertEqual(openURLCount, 0)
+ }
+
+ @MainActor
+ func testClaudeNeverAttemptsCodexURL() {
+ var openURLCount = 0
+ let router = AgentFocusRouter(
+ openURL: { _ in
+ openURLCount += 1
+ return true
+ },
+ focusTerminal: { _ in true }
+ )
+
+ XCTAssertEqual(
+ router.focus(makeSession(provider: .claude, sessionID: validSessionID)),
+ .terminalFallback
+ )
+ XCTAssertEqual(openURLCount, 0)
+ }
+
+ @MainActor
+ func testReportsUnavailableWhenEveryRouteFails() {
+ let router = AgentFocusRouter(
+ openURL: { _ in false },
+ focusTerminal: { _ in false }
+ )
+
+ XCTAssertEqual(
+ router.focus(makeSession(provider: .codex, sessionID: validSessionID)),
+ .unavailable
+ )
+ }
+
+ private let validSessionID = "019f5ee8-576e-74b3-8e9a-2b6e2404970b"
+
+ private func makeSession(
+ provider: AgentProvider,
+ sessionID: String
+ ) -> AgentSessionState {
+ AgentSessionState(
+ provider: provider,
+ sessionID: sessionID,
+ cwd: "/Users/example/Developer/Meanwhile",
+ phase: .needsYou,
+ enteredAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 1)
+ )
+ }
+}
diff --git a/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift b/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift
new file mode 100644
index 0000000..456c154
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift
@@ -0,0 +1,88 @@
+import Foundation
+import XCTest
+@testable import MeanwhileCore
+
+final class DiagnosticsReportTests: XCTestCase {
+ func testReportIsUsefulButOmitsPrivateContext() {
+ let generatedAt = Date(timeIntervalSince1970: 1_700_000_000)
+ let eventDate = generatedAt.addingTimeInterval(-120)
+ let signalDate = generatedAt.addingTimeInterval(-60)
+ let privateRepository = "private-owner/secret-repository"
+ let privatePath = "/Users/tom/Clients/Secret"
+ let privateSessionID = "session-super-secret"
+ let snapshot = DiagnosticsSnapshot(
+ appVersion: "0.1.2",
+ buildVersion: "3",
+ operatingSystemVersion: "macOS 15.5 (24F74)",
+ launchAtLoginStatus: .requiresApproval,
+ updateState: .updateAvailable(
+ version: "v0.1.3",
+ releaseURL: URL(string: "https://example.com/private-release-url")!
+ ),
+ integrationHealth: AgentIntegrationHealth(
+ state: .needsReview,
+ claudeHooksInstalled: true,
+ codexHooksInstalled: false,
+ claudeStatuslineConflict: true
+ ),
+ githubAuthenticationStatus: .authenticated,
+ repositoryScopeIncludesAll: false,
+ accessibleRepositoryCount: 68,
+ selectedRepositoryCount: 3,
+ hotKeyConfigured: true,
+ sessionInspection: AgentSessionInspection(
+ activeCount: 2,
+ stuckCount: 1,
+ oldestStuckUpdate: eventDate
+ ),
+ lastAgentEvent: AgentSessionState(
+ provider: .codex,
+ sessionID: privateSessionID,
+ cwd: privatePath,
+ phase: .needsYou,
+ enteredAt: eventDate,
+ updatedAt: eventDate,
+ terminal: TerminalContext(
+ program: "SecretTerminal",
+ sessionID: "private-terminal-id",
+ tty: "/dev/private-tty"
+ )
+ ),
+ recentSignals: [
+ RecentSignal(
+ kind: .ciFailed,
+ title: "Secret CI title",
+ detail: privateRepository,
+ date: signalDate
+ )
+ ]
+ )
+
+ let report = MeanwhileDiagnosticsReport.make(
+ snapshot: snapshot,
+ generatedAt: generatedAt
+ )
+
+ XCTAssertTrue(report.contains("App: 0.1.2 (3)"))
+ XCTAssertTrue(report.contains("Schema: 1"))
+ 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("Sessions that may be stuck: 1"))
+ XCTAssertTrue(report.contains("Last agent event: codex, needs-you"))
+ XCTAssertTrue(report.contains("Recent ciFailed: 1"))
+
+ for privateValue in [
+ privateRepository,
+ privatePath,
+ privateSessionID,
+ "SecretTerminal",
+ "private-terminal-id",
+ "/dev/private-tty",
+ "Secret CI title",
+ "private-release-url"
+ ] {
+ XCTAssertFalse(report.contains(privateValue))
+ }
+ }
+}
diff --git a/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift b/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift
index 50a7537..cc19096 100644
--- a/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift
+++ b/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift
@@ -11,18 +11,133 @@ final class MenuBarPresenterTests: XCTestCase {
func testFormatsEachItemKindCompactly() {
XCTAssertNil(MenuBarPresenter.statusText(item: nil))
- XCTAssertEqual(MenuBarPresenter.statusText(item: item(.needsYou, title: "Claude needs you")), "Needs you")
+ XCTAssertEqual(
+ MenuBarPresenter.statusText(item: item(.needsYou, title: "Claude needs you")),
+ "Claude needs you"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.statusText(item: item(.needsYou, title: "Codex needs you")),
+ "Codex needs you"
+ )
XCTAssertEqual(MenuBarPresenter.statusText(item: item(.failingCI, title: "CI failed on #4")), "CI!")
XCTAssertEqual(MenuBarPresenter.statusText(item: item(.review, title: "Review #78")), "#78")
}
- private func item(_ kind: WorkItemKind, title: String) -> WorkItem {
+ func testNeedsYouCopyAddsProjectContextOutsideTheStatusTitle() {
+ let item = item(
+ .needsYou,
+ title: "Codex needs you",
+ session: session(provider: .codex, cwd: "/Users/example/Developer/Meanwhile")
+ )
+
+ XCTAssertEqual(MenuBarPresenter.statusText(item: item), "Codex needs you")
+ XCTAssertEqual(MenuBarPresenter.projectName(item: item), "Meanwhile")
+ XCTAssertEqual(
+ MenuBarPresenter.openActionTitle(item: item),
+ "Return to Codex — Meanwhile"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.tooltip(phase: .needsYou, item: item),
+ "Codex needs you in “Meanwhile” — click to return"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityLabel(phase: .needsYou, item: item),
+ "Codex needs you in the Meanwhile project"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityHelp(phase: .needsYou, item: item),
+ "Returns to the waiting Codex task."
+ )
+ }
+
+ func testNeedsYouCopyFallsBackCleanlyWithoutAProject() {
+ let item = item(
+ .needsYou,
+ title: "Claude needs you",
+ session: session(provider: .claude, cwd: "/")
+ )
+
+ XCTAssertNil(MenuBarPresenter.projectName(item: item))
+ XCTAssertEqual(MenuBarPresenter.openActionTitle(item: item), "Return to Claude")
+ XCTAssertEqual(
+ MenuBarPresenter.tooltip(phase: .needsYou, item: item),
+ "Claude needs you — click to return"
+ )
+ }
+
+ func testAccessibilityNamesVisibleReviewAndCIItemsWhileAgentIsThinking() {
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityLabel(
+ phase: .thinking,
+ item: item(.review, title: "Review #78")
+ ),
+ "Review #78, acme/repo"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityLabel(
+ phase: .thinking,
+ item: item(.failingCI, title: "CI failed on #4")
+ ),
+ "CI failed on #4, acme/repo"
+ )
+ }
+
+ func testProjectNameRejectsPrivateOrUnhelpfulContexts() {
+ let rejected = [
+ "/",
+ FileManager.default.homeDirectoryForCurrentUser.path,
+ "/tmp/.",
+ "/tmp/..",
+ "/tmp/bad\nname"
+ ]
+
+ for cwd in rejected {
+ let item = item(
+ .needsYou,
+ title: "Codex needs you",
+ session: session(provider: .codex, cwd: cwd)
+ )
+ XCTAssertNil(MenuBarPresenter.projectName(item: item), cwd)
+ }
+ }
+
+ func testProjectNameMiddleTruncatesLongBasename() throws {
+ let item = item(
+ .needsYou,
+ title: "Codex needs you",
+ session: session(
+ provider: .codex,
+ cwd: "/tmp/very-long-project-name-that-needs-a-readable-tail"
+ )
+ )
+
+ let project = try XCTUnwrap(MenuBarPresenter.projectName(item: item))
+ XCTAssertEqual(project, "very-long-proje…-a-readable-tail")
+ }
+
+ private func item(
+ _ kind: WorkItemKind,
+ title: String,
+ session: AgentSessionState? = nil
+ ) -> WorkItem {
WorkItem(
id: UUID().uuidString,
kind: kind,
title: title,
detail: "acme/repo",
- createdAt: Date(timeIntervalSince1970: 1)
+ createdAt: Date(timeIntervalSince1970: 1),
+ session: session
+ )
+ }
+
+ private func session(provider: AgentProvider, cwd: String) -> AgentSessionState {
+ AgentSessionState(
+ provider: provider,
+ sessionID: "019f5ee8-576e-74b3-8e9a-2b6e2404970b",
+ cwd: cwd,
+ phase: .needsYou,
+ enteredAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 1)
)
}
}
diff --git a/Tests/MeanwhileCoreTests/ReleaseUpdateCheckerTests.swift b/Tests/MeanwhileCoreTests/ReleaseUpdateCheckerTests.swift
new file mode 100644
index 0000000..ea838ad
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/ReleaseUpdateCheckerTests.swift
@@ -0,0 +1,116 @@
+import Foundation
+import XCTest
+@testable import MeanwhileCore
+
+final class ReleaseUpdateCheckerTests: XCTestCase {
+ func testReportsNewerRelease() throws {
+ let checker = ReleaseUpdateChecker(
+ runner: StubRunner(stdout: payload(tag: "v0.1.3"))
+ )
+
+ XCTAssertEqual(
+ try checker.check(currentVersion: "0.1.2"),
+ .updateAvailable(
+ version: "v0.1.3",
+ releaseURL: URL(string: "https://github.com/tcballard/Meanwhile/releases/tag/v0.1.3")!
+ )
+ )
+ }
+
+ func testReportsCurrentAndDevelopmentBuilds() throws {
+ let checker = ReleaseUpdateChecker(
+ runner: StubRunner(stdout: payload(tag: "v0.1.2"))
+ )
+ XCTAssertEqual(
+ try checker.check(currentVersion: "0.1.2"),
+ .current(
+ latestVersion: "v0.1.2",
+ releaseURL: URL(string: "https://github.com/tcballard/Meanwhile/releases/tag/v0.1.2")!
+ )
+ )
+
+ let developmentChecker = ReleaseUpdateChecker(
+ runner: StubRunner(stdout: payload(tag: "v0.1.1"))
+ )
+ XCTAssertEqual(
+ try developmentChecker.check(currentVersion: "0.1.2"),
+ .developmentBuild(
+ latestVersion: "v0.1.1",
+ releaseURL: URL(string: "https://github.com/tcballard/Meanwhile/releases/tag/v0.1.1")!
+ )
+ )
+ }
+
+ func testSemanticVersionComparisonIsNumeric() throws {
+ XCTAssertLessThan(
+ try XCTUnwrap(SemanticVersion("v0.1.9")),
+ try XCTUnwrap(SemanticVersion("0.1.10"))
+ )
+ XCTAssertEqual(
+ SemanticVersion("1.2"),
+ SemanticVersion("v1.2.0")
+ )
+ XCTAssertNil(SemanticVersion("release-next"))
+ }
+
+ func testReturnsPurposefulErrorsWithoutCommandOutput() {
+ let failed = ReleaseUpdateChecker(
+ runner: StubRunner(
+ exitCode: 1,
+ stdout: "",
+ stderr: "token secret and private path"
+ )
+ )
+ XCTAssertThrowsError(try failed.check(currentVersion: "0.1.2")) {
+ XCTAssertEqual($0 as? ReleaseUpdateCheckerError, .commandFailed)
+ XCTAssertFalse($0.localizedDescription.contains("token secret"))
+ }
+
+ let malformed = ReleaseUpdateChecker(
+ runner: StubRunner(stdout: "not json")
+ )
+ XCTAssertThrowsError(try malformed.check(currentVersion: "0.1.2")) {
+ XCTAssertEqual($0 as? ReleaseUpdateCheckerError, .invalidResponse)
+ }
+
+ let untrustedURL = ReleaseUpdateChecker(
+ runner: StubRunner(
+ stdout: #"{"tagName":"v0.1.3","url":"https://example.com/fake"}"#
+ )
+ )
+ XCTAssertThrowsError(try untrustedURL.check(currentVersion: "0.1.2")) {
+ XCTAssertEqual($0 as? ReleaseUpdateCheckerError, .invalidResponse)
+ }
+ }
+
+ private func payload(tag: String) -> String {
+ """
+ {"tagName":"\(tag)","url":"https://github.com/tcballard/Meanwhile/releases/tag/\(tag)"}
+ """
+ }
+}
+
+private struct StubRunner: CommandRunning {
+ var result: ShellCommandResult
+
+ init(
+ exitCode: Int32 = 0,
+ stdout: String,
+ stderr: String = ""
+ ) {
+ result = ShellCommandResult(
+ exitCode: exitCode,
+ stdout: stdout,
+ stderr: stderr
+ )
+ }
+
+ func run(
+ _ command: String,
+ timeoutSeconds: TimeInterval
+ ) throws -> ShellCommandResult {
+ XCTAssertEqual(command, ReleaseUpdateChecker.command)
+ XCTAssertEqual(timeoutSeconds, 15)
+ return result
+ }
+}
diff --git a/Tests/MeanwhileCoreTests/TerminalFocuserTests.swift b/Tests/MeanwhileCoreTests/TerminalFocuserTests.swift
new file mode 100644
index 0000000..467c8e6
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/TerminalFocuserTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import XCTest
+@testable import MeanwhileCore
+
+final class TerminalFocuserTests: XCTestCase {
+ @MainActor
+ func testUsesOnlyRecordedAppleTerminalRoute() {
+ var scripts: [String] = []
+ let focuser = TerminalFocuser { script in
+ scripts.append(script)
+ return true
+ }
+
+ XCTAssertTrue(focuser.focus(session(program: "Apple_Terminal", tty: "/dev/ttys001")))
+ XCTAssertEqual(scripts.count, 1)
+ XCTAssertTrue(scripts[0].contains("tell application \"Terminal\""))
+ XCTAssertFalse(scripts[0].contains("tell application \"iTerm2\""))
+ }
+
+ @MainActor
+ func testUsesOnlyRecordedITermRoute() {
+ var scripts: [String] = []
+ let focuser = TerminalFocuser { script in
+ scripts.append(script)
+ return true
+ }
+
+ XCTAssertTrue(focuser.focus(session(program: "iTerm.app", tty: "/dev/ttys001")))
+ XCTAssertEqual(scripts.count, 1)
+ XCTAssertTrue(scripts[0].contains("tell application \"iTerm2\""))
+ XCTAssertFalse(scripts[0].contains("tell application \"Terminal\""))
+ }
+
+ @MainActor
+ func testUnknownMissingAndUnsupportedProgramsFailClosed() {
+ var scriptCount = 0
+ let focuser = TerminalFocuser { _ in
+ scriptCount += 1
+ return true
+ }
+
+ XCTAssertFalse(focuser.focus(session(program: nil, tty: "/dev/ttys001")))
+ XCTAssertFalse(focuser.focus(session(program: "SecretTerminal", tty: "/dev/ttys001")))
+ XCTAssertFalse(focuser.focus(session(program: "WarpTerminal", tty: "/dev/ttys001")))
+ XCTAssertFalse(focuser.focus(session(program: "Apple_Terminal", tty: nil)))
+ XCTAssertEqual(scriptCount, 0)
+ }
+
+ @MainActor
+ func testStaleExactTTYReportsFailureWithoutAppOnlyActivation() {
+ var scriptCount = 0
+ let focuser = TerminalFocuser { _ in
+ scriptCount += 1
+ return false
+ }
+
+ XCTAssertFalse(focuser.focus(session(program: "Apple_Terminal", tty: "/dev/ttys999")))
+ XCTAssertEqual(scriptCount, 1)
+ }
+
+ private func session(program: String?, tty: String?) -> AgentSessionState {
+ AgentSessionState(
+ provider: .claude,
+ sessionID: "session-1",
+ cwd: "/Users/example/Developer/Meanwhile",
+ phase: .needsYou,
+ enteredAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 1),
+ terminal: TerminalContext(program: program, tty: tty)
+ )
+ }
+}
diff --git a/docs/v0.1.2-always-ready.md b/docs/v0.1.2-always-ready.md
new file mode 100644
index 0000000..56c2a2a
--- /dev/null
+++ b/docs/v0.1.2-always-ready.md
@@ -0,0 +1,70 @@
+# Meanwhile v0.1.2 — Always Ready
+
+v0.1.2 is a reliability and self-service release. It makes the existing menu-bar
+workflow easier to trust without expanding Meanwhile into a broader v0.2
+product.
+
+## Product contract
+
+### Agent-aware menu-bar signal
+
+- When an agent needs attention, the existing red speech-bubble status item
+ expands to name the agent: for example, `Codex needs you`.
+- The bloom remains one native menu-bar button; its whole visible area returns
+ the user to the waiting agent session.
+- Hover text and the context-menu return action name the working-directory
+ project without expanding the persistent status-item title.
+- Codex returns through its validated `codex://threads/` task link;
+ supported terminal-backed sessions retain their exact TTY route, while stale
+ or unsupported focus targets fail visibly instead of activating an arbitrary
+ terminal or terminal window.
+- Idle remains icon-only, and review and failing-CI labels remain compact.
+
+### Launch at login
+
+- Settings contains a native Launch at Login switch.
+- The switch reflects the system's real Service Management state.
+- If macOS requires approval, Settings explains that state and offers a direct
+ route to Login Items.
+- Meanwhile never silently changes the login-item setting.
+
+### Update visibility
+
+- Settings shows the installed version and the latest published GitHub release.
+- Meanwhile checks once when Settings opens and supports an explicit recheck.
+- A newer release can be opened in the browser.
+- v0.1.2 does not download, install, or relaunch itself.
+
+### Privacy-safe diagnostics
+
+- Settings can copy a plain-text support report to the clipboard.
+- The report includes versions, coarse health states, counts, and timestamps.
+- It excludes repository names, working directories, file paths, session IDs,
+ terminal metadata, signal titles/details, command output, and credentials.
+- Meanwhile adds no telemetry and sends no report automatically.
+
+### Stuck-session recovery
+
+- Settings identifies non-idle agent sessions with no event for longer than the
+ configured stale-session interval.
+- Suspected sessions remain active until their existing crash-safety expiry so
+ Meanwhile does not silently change the wait-gate.
+- The user can clear only suspected stuck sessions after a destructive-action
+ confirmation.
+- The latest event snapshot remains available for diagnostics after cleanup.
+
+## Non-goals
+
+- Automatic updates or a background update service.
+- Remote diagnostics, analytics, or crash uploads.
+- Session history, per-session management, or new work sources.
+- A multi-session roster, waiting count, or attention inbox.
+- Any change to the needs-you, CI, review, snooze, or hide ordering contract.
+
+## Acceptance
+
+- Existing tests remain green and new contract behavior has deterministic unit
+ coverage.
+- The packaged macOS app builds, signs, launches, and exposes the complete flow
+ through Settings.
+- The version is 0.1.2 (build 3).