Context
Previously had a Claude Code hook in dotfiles (generate-session-url.js, removed March 8 in commit 0764338) that sent Pushover notifications when Claude completed tasks. Rather than restoring a standalone Claude hook, integrate Pushover directly into CMUX's notification system — more powerful because TerminalNotificationStore already has enriched notification content (title/subtitle/body), and forwarding at this layer catches ALL notifications, not just Claude events.
Approach
Add a PushoverSettings enum + settings UI that forwards any CMUX notification to Pushover via HTTPS POST. Inject at the same two delivery points where runCustomCommand is already called.
Files to Modify
| File |
What |
Sources/TerminalNotificationStore.swift |
Add PushoverSettings enum; inject sendNotification calls at 2 delivery points |
Sources/cmuxApp.swift |
Add 3 @AppStorage properties + settings UI rows |
Resources/Localizable.xcstrings |
Localization keys for new UI strings |
Implementation
1. PushoverSettings enum in TerminalNotificationStore.swift
Add after NotificationPaneFlashSettings (line ~579), following the same pattern:
enum PushoverSettings {
static let enabledKey = "pushoverEnabled"
static let defaultEnabled = false
static let apiTokenKey = "pushoverApiToken"
static let defaultApiToken = ""
static let userKeyKey = "pushoverUserKey"
static let defaultUserKey = ""
private static let pushoverQueue = DispatchQueue(
label: "com.cmuxterm.pushover",
qos: .utility
)
static func sendNotification(
title: String,
message: String,
defaults: UserDefaults = .standard
) {
guard isEnabled(defaults: defaults) else { return }
let token = resolveCredential(key: apiTokenKey, envVar: "PUSHOVER_API_TOKEN", defaults: defaults)
let user = resolveCredential(key: userKeyKey, envVar: "PUSHOVER_USER_KEY", defaults: defaults)
guard !token.isEmpty, !user.isEmpty else { return }
pushoverQueue.async {
var request = URLRequest(url: URL(string: "https://api.pushover.net/1/messages.json")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let params = ["token": token, "user": user, "title": title, "message": message]
request.httpBody = params
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
.joined(separator: "&")
.data(using: .utf8)
URLSession.shared.dataTask(with: request) { _, response, error in
if let error { NSLog("Pushover failed: \(error)") }
else if let http = response as? HTTPURLResponse, http.statusCode != 200 {
NSLog("Pushover returned status \(http.statusCode)")
}
}.resume()
}
}
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
defaults.bool(forKey: enabledKey)
}
private static func resolveCredential(key: String, envVar: String, defaults: UserDefaults) -> String {
let stored = (defaults.string(forKey: key) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !stored.isEmpty { return stored }
return ProcessInfo.processInfo.environment[envVar]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
Key design:
- Same
DispatchQueue pattern as customCommandQueue (line 523)
- Fire-and-forget with NSLog on failure
- Env var fallback: reads
PUSHOVER_API_TOKEN/PUSHOVER_USER_KEY from env if UserDefaults empty — works immediately with existing env vars
URLSession.shared — no new dependencies
2. Inject at notification delivery points
Location A — scheduleUserNotification (line ~1084), after runCustomCommand:
PushoverSettings.sendNotification(
title: content.title,
message: content.body.isEmpty ? content.subtitle : content.body
)
Location B — playSuppressedNotificationFeedback (line ~1096), after runCustomCommand:
PushoverSettings.sendNotification(
title: resolvedNotificationTitle(for: notification),
message: notification.body.isEmpty ? notification.subtitle : notification.body
)
3. Settings UI in cmuxApp.swift
3a. @AppStorage properties — add after line ~3809 (after notificationPaneFlashEnabled):
@AppStorage(PushoverSettings.enabledKey) private var pushoverEnabled = PushoverSettings.defaultEnabled
@AppStorage(PushoverSettings.apiTokenKey) private var pushoverApiToken = PushoverSettings.defaultApiToken
@AppStorage(PushoverSettings.userKeyKey) private var pushoverUserKey = PushoverSettings.defaultUserKey
3b. Settings rows — add after the Notification Command row (line ~4575), before the telemetry divider:
SettingsCardDivider()
SettingsCardRow(
"Pushover Notifications",
subtitle: "Forward notifications to your phone via Pushover. Falls back to PUSHOVER_API_TOKEN / PUSHOVER_USER_KEY env vars."
) {
Toggle("", isOn: $pushoverEnabled)
.labelsHidden()
.controlSize(.small)
}
if pushoverEnabled {
SettingsCardDivider()
SettingsCardRow("Pushover API Token", subtitle: "From your Pushover application.") {
SecureField("API Token", text: $pushoverApiToken)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
SettingsCardDivider()
SettingsCardRow("Pushover User Key", subtitle: "Your Pushover user/group key.") {
SecureField("User Key", text: $pushoverUserKey)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
SettingsCardDivider()
SettingsCardRow("Test Pushover", subtitle: "Send a test notification.") {
Button("Send Test") {
PushoverSettings.sendNotification(title: "cmux", message: "Pushover is working!")
}
.controlSize(.small)
.disabled(pushoverApiToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& pushoverUserKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (ProcessInfo.processInfo.environment["PUSHOVER_API_TOKEN"] ?? "").isEmpty)
}
}
4. Localization
Add keys to Resources/Localizable.xcstrings for all new user-facing strings using String(localized:defaultValue:).
5. Reset handler
Add to settings reset function (line ~5573):
pushoverEnabled = PushoverSettings.defaultEnabled
pushoverApiToken = PushoverSettings.defaultApiToken
pushoverUserKey = PushoverSettings.defaultUserKey
Verification
./scripts/reload.sh --tag pushover — build and launch
- Open Settings → find Pushover section after Notification Command
- Toggle on → verify credential fields appear
- Enter credentials (or rely on env vars) → click "Send Test" → confirm push on phone
- Trigger a real notification via Claude Code session → confirm Pushover fires
- Toggle off → confirm no Pushover on next notification
- Clear credentials, set env vars only → confirm fallback works
Context
Previously had a Claude Code hook in dotfiles (
generate-session-url.js, removed March 8 in commit0764338) that sent Pushover notifications when Claude completed tasks. Rather than restoring a standalone Claude hook, integrate Pushover directly into CMUX's notification system — more powerful becauseTerminalNotificationStorealready has enriched notification content (title/subtitle/body), and forwarding at this layer catches ALL notifications, not just Claude events.Approach
Add a
PushoverSettingsenum + settings UI that forwards any CMUX notification to Pushover via HTTPS POST. Inject at the same two delivery points whererunCustomCommandis already called.Files to Modify
Sources/TerminalNotificationStore.swiftPushoverSettingsenum; injectsendNotificationcalls at 2 delivery pointsSources/cmuxApp.swift@AppStorageproperties + settings UI rowsResources/Localizable.xcstringsImplementation
1.
PushoverSettingsenum inTerminalNotificationStore.swiftAdd after
NotificationPaneFlashSettings(line ~579), following the same pattern:Key design:
DispatchQueuepattern ascustomCommandQueue(line 523)PUSHOVER_API_TOKEN/PUSHOVER_USER_KEYfrom env if UserDefaults empty — works immediately with existing env varsURLSession.shared— no new dependencies2. Inject at notification delivery points
Location A —
scheduleUserNotification(line ~1084), afterrunCustomCommand:Location B —
playSuppressedNotificationFeedback(line ~1096), afterrunCustomCommand:3. Settings UI in
cmuxApp.swift3a. @AppStorage properties — add after line ~3809 (after
notificationPaneFlashEnabled):3b. Settings rows — add after the Notification Command row (line ~4575), before the telemetry divider:
4. Localization
Add keys to
Resources/Localizable.xcstringsfor all new user-facing strings usingString(localized:defaultValue:).5. Reset handler
Add to settings reset function (line ~5573):
Verification
./scripts/reload.sh --tag pushover— build and launch