oh-my-opencode/src/hooks/session-notification.ts
João Carlos Magalhães de Castro 4a38e70fa8
fix(session-notification): use node:child_process to avoid Bun shell GC crash (#543)
Replace Bun shell template literals (ctx.$) with node:child_process.spawn
to work around Bun's ShellInterpreter garbage collection bug on Windows.

This bug causes segmentation faults in deinitFromFinalizer during heap
sweeping when shell operations are used repeatedly over time.

Bug references:
- oven-sh/bun#23177 (closed incomplete)
- oven-sh/bun#24368 (still open)
- Pending fix: oven-sh/bun#24093

The fix applies to all platforms for consistency and safety.
2026-01-07 01:37:15 +09:00

355 lines
12 KiB
TypeScript

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,
getNotifySendPath,
getPowershellPath,
getAfplayPath,
getPaplayPath,
getAplayPath,
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<void> {
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
priority: string
id: string
}
interface SessionNotificationConfig {
title?: string
message?: string
playSound?: boolean
soundPath?: string
/** Delay in ms before sending notification to confirm session is still idle (default: 1500) */
idleConfirmationDelay?: number
/** Skip notification if there are incomplete todos (default: true) */
skipIfIncompleteTodos?: boolean
/** Maximum number of sessions to track before cleanup (default: 100) */
maxTrackedSessions?: number
}
type Platform = "darwin" | "linux" | "win32" | "unsupported"
function detectPlatform(): Platform {
const p = platform()
if (p === "darwin" || p === "linux" || p === "win32") return p
return "unsupported"
}
function getDefaultSoundPath(p: Platform): string {
switch (p) {
case "darwin":
return "/System/Library/Sounds/Glass.aiff"
case "linux":
return "/usr/share/sounds/freedesktop/stereo/complete.oga"
case "win32":
return "C:\\Windows\\Media\\notify.wav"
default:
return ""
}
}
async function sendNotification(
ctx: PluginInput,
p: Platform,
title: string,
message: string
): Promise<void> {
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, '\\"')
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
// Use node:child_process instead of Bun shell to avoid potential GC issues
await execCommand(notifySendPath, [title, message]).catch(() => {})
break
}
case "win32": {
const powershellPath = await getPowershellPath()
if (!powershellPath) return
const psTitle = title.replace(/'/g, "''")
const psMessage = message.replace(/'/g, "''")
const toastScript = `
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
$RawXml = [xml] $Template.GetXml()
($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '1'}).AppendChild($RawXml.CreateTextNode('${psTitle}')) | Out-Null
($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '2'}).AppendChild($RawXml.CreateTextNode('${psMessage}')) | Out-Null
$SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument
$SerializedXml.LoadXml($RawXml.OuterXml)
$Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
$Notifier.Show($Toast)
`.trim().replace(/\n/g, "; ")
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
await execCommand(powershellPath, ["-Command", toastScript]).catch(() => {})
break
}
}
}
async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Promise<void> {
switch (p) {
case "darwin": {
const afplayPath = await getAfplayPath()
if (!afplayPath) return
// 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) {
// Use node:child_process instead of Bun shell to avoid potential GC issues
execCommand(paplayPath, [soundPath]).catch(() => {})
} else {
const aplayPath = await getAplayPath()
if (aplayPath) {
execCommand(aplayPath, [soundPath]).catch(() => {})
}
}
break
}
case "win32": {
const powershellPath = await getPowershellPath()
if (!powershellPath) return
// 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
}
}
}
async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise<boolean> {
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
const todos = (response.data ?? response) as Todo[]
if (!todos || todos.length === 0) return false
return todos.some((t) => t.status !== "completed" && t.status !== "cancelled")
} catch {
return false
}
}
export function createSessionNotification(
ctx: PluginInput,
config: SessionNotificationConfig = {}
) {
const currentPlatform = detectPlatform()
const defaultSoundPath = getDefaultSoundPath(currentPlatform)
startBackgroundCheck(currentPlatform)
const mergedConfig = {
title: "OpenCode",
message: "Agent is ready for input",
playSound: false,
soundPath: defaultSoundPath,
idleConfirmationDelay: 1500,
skipIfIncompleteTodos: true,
maxTrackedSessions: 100,
...config,
}
const notifiedSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
const sessionActivitySinceIdle = new Set<string>()
// Track notification execution version to handle race conditions
const notificationVersions = new Map<string, number>()
// Track sessions currently executing notification (prevents duplicate execution)
const executingNotifications = new Set<string>()
function cleanupOldSessions() {
const maxSessions = mergedConfig.maxTrackedSessions
if (notifiedSessions.size > maxSessions) {
const sessionsToRemove = Array.from(notifiedSessions).slice(0, notifiedSessions.size - maxSessions)
sessionsToRemove.forEach(id => notifiedSessions.delete(id))
}
if (sessionActivitySinceIdle.size > maxSessions) {
const sessionsToRemove = Array.from(sessionActivitySinceIdle).slice(0, sessionActivitySinceIdle.size - maxSessions)
sessionsToRemove.forEach(id => sessionActivitySinceIdle.delete(id))
}
if (notificationVersions.size > maxSessions) {
const sessionsToRemove = Array.from(notificationVersions.keys()).slice(0, notificationVersions.size - maxSessions)
sessionsToRemove.forEach(id => notificationVersions.delete(id))
}
if (executingNotifications.size > maxSessions) {
const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions)
sessionsToRemove.forEach(id => executingNotifications.delete(id))
}
}
function cancelPendingNotification(sessionID: string) {
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
sessionActivitySinceIdle.add(sessionID)
// Increment version to invalidate any in-flight notifications
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
}
function markSessionActivity(sessionID: string) {
cancelPendingNotification(sessionID)
notifiedSessions.delete(sessionID)
}
async function executeNotification(sessionID: string, version: number) {
if (executingNotifications.has(sessionID)) {
pendingTimers.delete(sessionID)
return
}
if (notificationVersions.get(sessionID) !== version) {
pendingTimers.delete(sessionID)
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
pendingTimers.delete(sessionID)
return
}
if (notifiedSessions.has(sessionID)) {
pendingTimers.delete(sessionID)
return
}
executingNotifications.add(sessionID)
try {
if (mergedConfig.skipIfIncompleteTodos) {
const hasPendingWork = await hasIncompleteTodos(ctx, sessionID)
if (notificationVersions.get(sessionID) !== version) {
return
}
if (hasPendingWork) return
}
if (notificationVersions.get(sessionID) !== version) {
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
return
}
notifiedSessions.add(sessionID)
await sendNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.message)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSound(ctx, currentPlatform, mergedConfig.soundPath)
}
} finally {
executingNotifications.delete(sessionID)
pendingTimers.delete(sessionID)
}
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.updated" || event.type === "session.created") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.id as string | undefined
if (sessionID) {
markSessionActivity(sessionID)
}
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (subagentSessions.has(sessionID)) return
// Only trigger notifications for the main session (not subagent sessions)
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return
if (notifiedSessions.has(sessionID)) return
if (pendingTimers.has(sessionID)) return
if (executingNotifications.has(sessionID)) return
sessionActivitySinceIdle.delete(sessionID)
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
notificationVersions.set(sessionID, currentVersion)
const timer = setTimeout(() => {
executeNotification(sessionID, currentVersion)
}, mergedConfig.idleConfirmationDelay)
pendingTimers.set(sessionID, timer)
cleanupOldSessions()
return
}
if (event.type === "message.updated" || event.type === "message.created") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (sessionID) {
markSessionActivity(sessionID)
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
markSessionActivity(sessionID)
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
cancelPendingNotification(sessionInfo.id)
notifiedSessions.delete(sessionInfo.id)
sessionActivitySinceIdle.delete(sessionInfo.id)
notificationVersions.delete(sessionInfo.id)
executingNotifications.delete(sessionInfo.id)
}
}
}
}