From 763ff7e8245cf3090585d480c757daf2e7f61dfe Mon Sep 17 00:00:00 2001 From: mrosnerr Date: Tue, 28 Apr 2026 08:35:43 -0400 Subject: [PATCH] feat(notification): add cmux as notification provider Add cmux as the highest-priority notification provider on macOS. cmux delivers notifications via native UNUserNotificationCenter, properly attributed to the cmux app instead of Script Editor. Notification priority: cmux > terminal-notifier > osascript Tests cover the full fallback chain: - cmux available: uses cmux, skips others - cmux fails: falls back to terminal-notifier - cmux + terminal-notifier fail: falls back to osascript - cmux not available: skips to terminal-notifier Fixes #3628 --- src/hooks/session-notification-sender.test.ts | 74 +++++++++++++++++++ src/hooks/session-notification-sender.ts | 13 +++- src/hooks/session-notification-utils.ts | 4 + 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/hooks/session-notification-sender.test.ts b/src/hooks/session-notification-sender.test.ts index 2747109cf..931443b4b 100644 --- a/src/hooks/session-notification-sender.test.ts +++ b/src/hooks/session-notification-sender.test.ts @@ -66,6 +66,7 @@ function createThrowingShellPromise(shouldThrow: (cmdStr: string) => boolean) { describe("session-notification-sender", () => { beforeEach(() => { jest.restoreAllMocks() + spyOn(utils, "getCmuxPath").mockResolvedValue(null) spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send") @@ -137,6 +138,79 @@ describe("session-notification-sender", () => { expect(quietCalls[0]).toContain("osascript") }) + test("#then should use cmux when available", async () => { + spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") + + const calls: string[] = [] + const mockCtx = { + $: createShellPromise((cmdStr) => { calls.push(cmdStr) }), + } as unknown as PluginInput + + await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") + + expect(calls.length).toBe(1) + expect(calls[0]).toContain("cmux") + expect(calls[0]).not.toContain("terminal-notifier") + expect(calls[0]).not.toContain("osascript") + }) + + test("#then should fall back to terminal-notifier when cmux fails", async () => { + spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") + + const mockCtx = { + $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify")), + } as unknown as PluginInput + + const originalFactory = mockCtx.$ + const trackingCalls: string[] = [] + mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc: string, part: string, i: number) => acc + part + (values[i] ?? ""), "") + trackingCalls.push(cmdStr) + return originalFactory(cmd, ...values) + }) as typeof mockCtx.$ + + await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") + + expect(trackingCalls.some((c) => c.includes("cmux notify"))).toBe(true) + expect(trackingCalls.some((c) => c.includes("terminal-notifier"))).toBe(true) + expect(trackingCalls.some((c) => c.includes("osascript"))).toBe(false) + }) + + test("#then should fall back to osascript when cmux and terminal-notifier both fail", async () => { + spyOn(utils, "getCmuxPath").mockResolvedValue("/usr/local/bin/cmux") + + const trackingCalls: string[] = [] + const mockCtx = { + $: createThrowingShellPromise((cmdStr) => cmdStr.includes("cmux notify") || cmdStr.includes("terminal-notifier")), + } as unknown as PluginInput + + const originalFactory = mockCtx.$ + mockCtx.$ = ((cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc: string, part: string, i: number) => acc + part + (values[i] ?? ""), "") + trackingCalls.push(cmdStr) + return originalFactory(cmd, ...values) + }) as typeof mockCtx.$ + + await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") + + expect(trackingCalls.some((c) => c.includes("cmux notify"))).toBe(true) + expect(trackingCalls.some((c) => c.includes("terminal-notifier"))).toBe(true) + expect(trackingCalls.some((c) => c.includes("osascript"))).toBe(true) + }) + + test("#then should skip cmux when not available and use terminal-notifier", async () => { + const calls: string[] = [] + const mockCtx = { + $: createShellPromise((cmdStr) => { calls.push(cmdStr) }), + } as unknown as PluginInput + + await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") + + expect(calls.length).toBe(1) + expect(calls[0]).toContain("terminal-notifier") + expect(calls[0]).not.toContain("cmux notify") + }) + test("#then should call .quiet() on linux notify-send", async () => { const quietCalls: string[] = [] const mockCtx = { diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index 504385ffa..4d33bed77 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { platform } from "os" import { + getCmuxPath, getOsascriptPath, getNotifySendPath, getPowershellPath, @@ -40,7 +41,17 @@ export async function sendSessionNotification( ): Promise { switch (platform) { case "darwin": { - // Try terminal-notifier first - deterministic click-to-focus + // Try cmux first - native UNUserNotificationCenter, properly attributed + const cmuxPath = await getCmuxPath() + if (cmuxPath) { + try { + await ctx.$`${cmuxPath} notify --title ${title} --body ${message}`.quiet() + break + } catch { + } + } + + // Try terminal-notifier - deterministic click-to-focus const terminalNotifierPath = await getTerminalNotifierPath() if (terminalNotifierPath) { const bundleId = process.env.__CFBundleIdentifier diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index cf4ca06ea..b3eb8e32d 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -50,9 +50,13 @@ export const getAfplayPath = createCommandFinder("afplay") export const getPaplayPath = createCommandFinder("paplay") export const getAplayPath = createCommandFinder("aplay") export const getTerminalNotifierPath = createCommandFinder("terminal-notifier") +export const getCmuxPath = createCommandFinder("cmux") export function startBackgroundCheck(platform: Platform): void { if (platform === "darwin") { + getCmuxPath().catch((error) => { + logBackgroundCheckError("cmux", error) + }) getOsascriptPath().catch((error) => { logBackgroundCheckError("osascript", error) })