From 397ed1048a42a9a5eed6b7d1df63f68cba24c5e0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 10:47:27 +0900 Subject: [PATCH] fix(hooks): guard session-notification against missing ctx.$ (refs #3997) --- src/hooks/session-notification-sender.test.ts | 30 +++++++++++++++++++ src/hooks/session-notification-sender.ts | 18 +++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/hooks/session-notification-sender.test.ts b/src/hooks/session-notification-sender.test.ts index 99b08672a..015b66915 100644 --- a/src/hooks/session-notification-sender.test.ts +++ b/src/hooks/session-notification-sender.test.ts @@ -78,6 +78,36 @@ describe("session-notification-sender", () => { }) describe("#given sendSessionNotification", () => { + describe("#when ctx.$ is unavailable", () => { + test("#then it returns early without throwing when ctx has no $", async () => { + const cmuxSpy = spyOn(utils, "getCmuxPath") + const mockCtx = unsafeTestValue({}) + + await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined() + expect(cmuxSpy).not.toHaveBeenCalled() + }) + + test("#then it returns early without throwing when ctx.$ is not a function", async () => { + const cmuxSpy = spyOn(utils, "getCmuxPath") + const mockCtx = unsafeTestValue({ + $: "not-a-function", + }) + + await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined() + expect(cmuxSpy).not.toHaveBeenCalled() + }) + + test("#then it remains non-throwing across sender APIs", async () => { + const afplaySpy = spyOn(utils, "getAfplayPath") + const mockCtx = unsafeTestValue({}) + + await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined() + await expect(sender.playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff")).resolves.toBeUndefined() + + expect(afplaySpy).not.toHaveBeenCalled() + }) + }) + describe("#when calling ctx.$ for notifications", () => { test("#then should call .quiet() on all shell commands to suppress stdout/stderr", async () => { const quietCalls: string[] = [] diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index 8849e1af8..fb374afe5 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { platform } from "os" +import { log } from "../shared" import { getCmuxPath, getOsascriptPath, @@ -38,6 +39,19 @@ type ShellCommand = Promise & { nothrow?: () => ShellCommand } +let hasLoggedUnavailableShellHelper = false + +function canRunNotificationCommand(ctx: PluginInput): boolean { + if (typeof ctx?.$ === "function") return true + + if (!hasLoggedUnavailableShellHelper) { + hasLoggedUnavailableShellHelper = true + log("[session-notification] ctx.$ unavailable; skipping notification command execution") + } + + return false +} + async function runQuietNothrow(command: ShellCommand): Promise { const safeCommand = typeof command.nothrow === "function" ? command.nothrow() : command if (typeof safeCommand.quiet === "function") { @@ -54,6 +68,8 @@ export async function sendSessionNotification( title: string, message: string ): Promise { + if (!canRunNotificationCommand(ctx)) return + switch (platform) { case "darwin": { // Try cmux first - native UNUserNotificationCenter, properly attributed @@ -113,6 +129,8 @@ export async function playSessionNotificationSound( platform: Platform, soundPath: string ): Promise { + if (!canRunNotificationCommand(ctx)) return + switch (platform) { case "darwin": { const afplayPath = await getAfplayPath()