diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index ad6eb53..a169477 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" +import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:test" +import { EventEmitter } from "node:events" +import * as childProcess from "node:child_process" import { createSessionNotification } from "./session-notification" import { setMainSession, subagentSessions } from "../features/claude-code-session-state" @@ -6,20 +8,11 @@ import * as utils from "./session-notification-utils" describe("session-notification", () => { let notificationCalls: string[] + let spawnMock: ReturnType function createMockPluginInput() { return { - $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { - // #given - track notification commands (osascript, notify-send, powershell) - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - - if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) { - notificationCalls.push(cmdStr) - } - return { stdout: "", stderr: "", exitCode: 0 } - }, + $: async () => ({ stdout: "", stderr: "", exitCode: 0 }), client: { session: { todo: async () => ({ data: [] }), @@ -32,6 +25,18 @@ describe("session-notification", () => { beforeEach(() => { notificationCalls = [] + // Mock spawn to track notification commands + // Uses node:child_process.spawn instead of Bun shell to avoid GC crash + spawnMock = spyOn(childProcess, "spawn").mockImplementation((cmd: string, args?: string[]) => { + // Track notification commands (osascript, notify-send, powershell) + if (cmd.includes("osascript") || cmd.includes("notify-send") || cmd.includes("powershell")) { + notificationCalls.push(`${cmd} ${(args ?? []).join(" ")}`) + } + const emitter = new EventEmitter() + setTimeout(() => emitter.emit("close", 0), 0) + return emitter as any + }) + spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send") spyOn(utils, "getPowershellPath").mockResolvedValue("powershell") diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index 44b0142..0f7e375 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { platform } from "os" +import { spawn } from "node:child_process" import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state" import { getOsascriptPath, @@ -11,6 +12,21 @@ import { startBackgroundCheck, } from "./session-notification-utils" +/** + * Execute a command using node:child_process instead of Bun shell. + * This avoids Bun's ShellInterpreter GC bug on Windows (oven-sh/bun#23177, #24368). + */ +function execCommand(command: string, args: string[]): Promise { + return new Promise((resolve) => { + const proc = spawn(command, args, { + stdio: "ignore", + detached: false, + }) + proc.on("close", () => resolve()) + proc.on("error", () => resolve()) + }) +} + interface Todo { content: string status: string @@ -65,14 +81,17 @@ async function sendNotification( const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"') const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {}) + const script = `display notification "${esMessage}" with title "${esTitle}"` + // Use node:child_process instead of Bun shell to avoid potential GC issues + await execCommand(osascriptPath, ["-e", script]).catch(() => {}) break } case "linux": { const notifySendPath = await getNotifySendPath() if (!notifySendPath) return - await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {}) + // Use node:child_process instead of Bun shell to avoid potential GC issues + await execCommand(notifySendPath, [title, message]).catch(() => {}) break } case "win32": { @@ -93,7 +112,8 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml) $Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode') $Notifier.Show($Toast) `.trim().replace(/\n/g, "; ") - await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {}) + // Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177) + await execCommand(powershellPath, ["-Command", toastScript]).catch(() => {}) break } } @@ -104,17 +124,19 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom case "darwin": { const afplayPath = await getAfplayPath() if (!afplayPath) return - ctx.$`${afplayPath} ${soundPath}`.catch(() => {}) + // Use node:child_process instead of Bun shell to avoid potential GC issues + execCommand(afplayPath, [soundPath]).catch(() => {}) break } case "linux": { const paplayPath = await getPaplayPath() if (paplayPath) { - ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + // Use node:child_process instead of Bun shell to avoid potential GC issues + execCommand(paplayPath, [soundPath]).catch(() => {}) } else { const aplayPath = await getAplayPath() if (aplayPath) { - ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + execCommand(aplayPath, [soundPath]).catch(() => {}) } } break @@ -122,7 +144,9 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom case "win32": { const powershellPath = await getPowershellPath() if (!powershellPath) return - ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {}) + // Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177) + const soundScript = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()` + execCommand(powershellPath, ["-Command", soundScript]).catch(() => {}) break } }