From dec4994fd631bdde7b48d4875c106e0d6a3aeb5c Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 27 Dec 2025 17:17:13 +0900 Subject: [PATCH] fix: check command existence before calling notify-send (#264) --- src/hooks/session-notification-utils.ts | 140 ++++++++++++++++++++++++ src/hooks/session-notification.ts | 58 ++++++++-- 2 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 src/hooks/session-notification-utils.ts diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts new file mode 100644 index 0000000..e3581f6 --- /dev/null +++ b/src/hooks/session-notification-utils.ts @@ -0,0 +1,140 @@ +import { spawn } from "bun" + +type Platform = "darwin" | "linux" | "win32" | "unsupported" + +let notifySendPath: string | null = null +let notifySendPromise: Promise | null = null + +let osascriptPath: string | null = null +let osascriptPromise: Promise | null = null + +let powershellPath: string | null = null +let powershellPromise: Promise | null = null + +let afplayPath: string | null = null +let afplayPromise: Promise | null = null + +let paplayPath: string | null = null +let paplayPromise: Promise | null = null + +let aplayPath: string | null = null +let aplayPromise: Promise | null = null + +async function findCommand(commandName: string): Promise { + const isWindows = process.platform === "win32" + const cmd = isWindows ? "where" : "which" + + try { + const proc = spawn([cmd, commandName], { + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + if (exitCode !== 0) { + return null + } + + const stdout = await new Response(proc.stdout).text() + const path = stdout.trim().split("\n")[0] + + if (!path) { + return null + } + + return path + } catch { + return null + } +} + +export async function getNotifySendPath(): Promise { + if (notifySendPath !== null) return notifySendPath + if (notifySendPromise) return notifySendPromise + + notifySendPromise = (async () => { + const path = await findCommand("notify-send") + notifySendPath = path + return path + })() + + return notifySendPromise +} + +export async function getOsascriptPath(): Promise { + if (osascriptPath !== null) return osascriptPath + if (osascriptPromise) return osascriptPromise + + osascriptPromise = (async () => { + const path = await findCommand("osascript") + osascriptPath = path + return path + })() + + return osascriptPromise +} + +export async function getPowershellPath(): Promise { + if (powershellPath !== null) return powershellPath + if (powershellPromise) return powershellPromise + + powershellPromise = (async () => { + const path = await findCommand("powershell") + powershellPath = path + return path + })() + + return powershellPromise +} + +export async function getAfplayPath(): Promise { + if (afplayPath !== null) return afplayPath + if (afplayPromise) return afplayPromise + + afplayPromise = (async () => { + const path = await findCommand("afplay") + afplayPath = path + return path + })() + + return afplayPromise +} + +export async function getPaplayPath(): Promise { + if (paplayPath !== null) return paplayPath + if (paplayPromise) return paplayPromise + + paplayPromise = (async () => { + const path = await findCommand("paplay") + paplayPath = path + return path + })() + + return paplayPromise +} + +export async function getAplayPath(): Promise { + if (aplayPath !== null) return aplayPath + if (aplayPromise) return aplayPromise + + aplayPromise = (async () => { + const path = await findCommand("aplay") + aplayPath = path + return path + })() + + return aplayPromise +} + +export function startBackgroundCheck(platform: Platform): void { + if (platform === "darwin") { + getOsascriptPath().catch(() => {}) + getAfplayPath().catch(() => {}) + } else if (platform === "linux") { + getNotifySendPath().catch(() => {}) + getPaplayPath().catch(() => {}) + getAplayPath().catch(() => {}) + } else if (platform === "win32") { + getPowershellPath().catch(() => {}) + } +} diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index 56dc1d2..44b0142 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -1,6 +1,15 @@ import type { PluginInput } from "@opencode-ai/plugin" import { platform } from "os" import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state" +import { + getOsascriptPath, + getNotifySendPath, + getPowershellPath, + getAfplayPath, + getPaplayPath, + getAplayPath, + startBackgroundCheck, +} from "./session-notification-utils" interface Todo { content: string @@ -51,15 +60,25 @@ async function sendNotification( ): Promise { switch (p) { case "darwin": { + const osascriptPath = await getOsascriptPath() + if (!osascriptPath) return + const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"') const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await ctx.$`osascript -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}` + await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {}) break } - case "linux": - await ctx.$`notify-send ${title} ${message} 2>/dev/null`.catch(() => {}) + case "linux": { + const notifySendPath = await getNotifySendPath() + if (!notifySendPath) return + + await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {}) break + } case "win32": { + const powershellPath = await getPowershellPath() + if (!powershellPath) return + const psTitle = title.replace(/'/g, "''") const psMessage = message.replace(/'/g, "''") const toastScript = ` @@ -74,7 +93,7 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml) $Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode') $Notifier.Show($Toast) `.trim().replace(/\n/g, "; ") - await ctx.$`powershell -Command ${toastScript}`.catch(() => {}) + await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {}) break } } @@ -82,17 +101,30 @@ $Notifier.Show($Toast) async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Promise { switch (p) { - case "darwin": - ctx.$`afplay ${soundPath}`.catch(() => {}) + case "darwin": { + const afplayPath = await getAfplayPath() + if (!afplayPath) return + ctx.$`${afplayPath} ${soundPath}`.catch(() => {}) break - case "linux": - ctx.$`paplay ${soundPath} 2>/dev/null`.catch(() => { - ctx.$`aplay ${soundPath} 2>/dev/null`.catch(() => {}) - }) + } + case "linux": { + const paplayPath = await getPaplayPath() + if (paplayPath) { + ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + } else { + const aplayPath = await getAplayPath() + if (aplayPath) { + ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + } + } break - case "win32": - ctx.$`powershell -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {}) + } + case "win32": { + const powershellPath = await getPowershellPath() + if (!powershellPath) return + ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {}) break + } } } @@ -114,6 +146,8 @@ export function createSessionNotification( const currentPlatform = detectPlatform() const defaultSoundPath = getDefaultSoundPath(currentPlatform) + startBackgroundCheck(currentPlatform) + const mergedConfig = { title: "OpenCode", message: "Agent is ready for input",