Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.1</string>
<string>0.1.2</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>3</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
37 changes: 25 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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
Expand Down
127 changes: 127 additions & 0 deletions Sources/Meanwhile/AppSettingsSection.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
106 changes: 92 additions & 14 deletions Sources/Meanwhile/ConnectionHealthSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Loading
Loading