fix(session-notification): revert PR #543 and add proper notification plugin conflict detection (#575)

* revert: undo PR #543 changes (bun shell GC crash was misdiagnosed)

This reverts commit 4a38e70 (PR #543) and 2064568 (follow-up fix).

## Why This Revert

The original diagnosis was incorrect. PR #543 assumed Bun's
ShellInterpreter GC bug was causing Windows crashes, but further
investigation revealed the actual root cause:

**The crash occurs when oh-my-opencode's session-notification runs
alongside external notification plugins (e.g., @mohak34/opencode-notifier).**

Evidence:
- User removed opencode-notifier plugin → crashes stopped
- Release version (with original ctx.$ code) works fine when used alone
- No widespread crash reports from users without external notifiers
- Both plugins listen to session.idle and send concurrent notifications

The real issue is a conflict between two notification systems:
1. oh-my-opencode: ctx.$ → PowerShell → Windows.UI.Notifications
2. opencode-notifier: node-notifier → SnoreToast.exe

A proper fix will detect and handle this conflict gracefully.

Refs: #543, oven-sh/bun#23177, oven-sh/bun#24368
See: docs/CRASH_INVESTIGATION_TIMELINE.md (in follow-up commit)

* fix(session-notification): detect and avoid conflict with external notification plugins

When oh-my-opencode's session-notification runs alongside external
notification plugins like opencode-notifier, both listen to session.idle
and send concurrent notifications. This can cause crashes on Windows
due to resource contention between different notification mechanisms:
- oh-my-opencode: ctx.$ → PowerShell → Windows.UI.Notifications
- opencode-notifier: node-notifier → SnoreToast.exe

This commit adds:
1. External plugin detection (checks opencode.json for known notifiers)
2. Auto-disable of session-notification when conflict detected
3. Console warning explaining the situation
4. Config option 'notification.force_enable' to override

Known notification plugins detected:
- opencode-notifier
- @mohak34/opencode-notifier
- mohak34/opencode-notifier

This is the actual fix for the Windows crash issue previously
misdiagnosed as a Bun.spawn GC bug (PR #543).

Refs: #543

* docs: add crash investigation timeline explaining the real root cause

Documents the investigation journey from initial misdiagnosis (Bun GC bug)
to discovering the actual root cause (notification plugin conflict).

Key findings:
- PR #543 was based on incorrect assumption
- The real issue is concurrent notification plugins
- oh-my-opencode + opencode-notifier = crash on Windows
- Either plugin alone works fine

* fix: address review feedback - add PowerShell escaping and use existing JSONC parser

- Add back single-quote escaping for PowerShell soundPath to prevent command failures
- Replace custom stripJsonComments with existing parseJsoncSafe from jsonc-parser
- All 655 tests pass

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
João Carlos Magalhães de Castro
2026-01-07 11:44:03 -03:00
committed by GitHub
parent cccd159f7d
commit 1570e292fb
8 changed files with 463 additions and 52 deletions

View File

@@ -238,6 +238,11 @@ export const BackgroundTaskConfigSchema = z.object({
modelConcurrency: z.record(z.string(), z.number().min(1)).optional(),
})
export const NotificationConfigSchema = z.object({
/** Force enable session-notification even if external notification plugins are detected (default: false) */
force_enable: z.boolean().optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
@@ -255,6 +260,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
skills: SkillsConfigSchema.optional(),
ralph_loop: RalphLoopConfigSchema.optional(),
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
@@ -272,5 +278,6 @@ export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningCo
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -1,6 +1,4 @@
import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:test"
import { EventEmitter } from "node:events"
import * as childProcess from "node:child_process"
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { createSessionNotification } from "./session-notification"
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
@@ -8,11 +6,20 @@ import * as utils from "./session-notification-utils"
describe("session-notification", () => {
let notificationCalls: string[]
let spawnMock: ReturnType<typeof spyOn>
function createMockPluginInput() {
return {
$: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
$: 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 }
},
client: {
session: {
todo: async () => ({ data: [] }),
@@ -25,18 +32,6 @@ 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
}) as typeof childProcess.spawn)
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")

View File

@@ -1,6 +1,5 @@
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,
@@ -12,21 +11,6 @@ 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<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
@@ -81,17 +65,14 @@ async function sendNotification(
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(() => {})
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.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(() => {})
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
break
}
case "win32": {
@@ -112,8 +93,7 @@ $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(() => {})
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
break
}
}
@@ -124,19 +104,17 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
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(() => {})
ctx.$`${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(() => {})
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
} else {
const aplayPath = await getAplayPath()
if (aplayPath) {
execCommand(aplayPath, [soundPath]).catch(() => {})
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
}
}
break
@@ -144,9 +122,7 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
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(() => {})
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath.replace(/'/g, "''") + "').PlaySync()"}`.catch(() => {})
break
}
}

View File

@@ -62,7 +62,7 @@ import {
import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager";
import { type HookName } from "./config";
import { log } from "./shared";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning } from "./shared";
import { loadPluginConfig } from "./plugin-config";
import { createModelCacheState, getModelLimit } from "./plugin-state";
import { createConfigHandler } from "./plugin-handlers";
@@ -83,9 +83,24 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const sessionRecovery = isHookEnabled("session-recovery")
? createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental })
: null;
const sessionNotification = isHookEnabled("session-notification")
? createSessionNotification(ctx)
: null;
// Check for conflicting notification plugins before creating session-notification
let sessionNotification = null;
if (isHookEnabled("session-notification")) {
const forceEnable = pluginConfig.notification?.force_enable ?? false;
const externalNotifier = detectExternalNotificationPlugin(ctx.directory);
if (externalNotifier.detected && !forceEnable) {
// External notification plugin detected - skip our notification to avoid conflicts
console.warn(getNotificationConflictWarning(externalNotifier.pluginName!));
log("session-notification disabled due to external notifier conflict", {
detected: externalNotifier.pluginName,
allPlugins: externalNotifier.allPlugins,
});
} else {
sessionNotification = createSessionNotification(ctx);
}
}
const commentChecker = isHookEnabled("comment-checker")
? createCommentCheckerHooks(pluginConfig.comment_checker)

View File

@@ -0,0 +1,133 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { detectExternalNotificationPlugin, getNotificationConflictWarning } from "./external-plugin-detector"
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
describe("external-plugin-detector", () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omo-test-"))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
describe("detectExternalNotificationPlugin", () => {
test("should return detected=false when no plugins configured", () => {
// #given - empty directory
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(false)
expect(result.pluginName).toBeNull()
})
test("should return detected=false when only oh-my-opencode is configured", () => {
// #given - opencode.json with only oh-my-opencode
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(false)
expect(result.pluginName).toBeNull()
expect(result.allPlugins).toContain("oh-my-opencode")
})
test("should detect opencode-notifier plugin", () => {
// #given - opencode.json with opencode-notifier
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode", "opencode-notifier"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
test("should detect opencode-notifier with version suffix", () => {
// #given - opencode.json with versioned opencode-notifier
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode", "opencode-notifier@1.2.3"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
test("should detect @mohak34/opencode-notifier", () => {
// #given - opencode.json with scoped package name
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode", "@mohak34/opencode-notifier"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then - returns the matched known plugin pattern, not the full entry
expect(result.detected).toBe(true)
expect(result.pluginName).toContain("opencode-notifier")
})
test("should handle JSONC format with comments", () => {
// #given - opencode.jsonc with comments
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.jsonc"),
`{
// This is a comment
"plugin": [
"oh-my-opencode",
"opencode-notifier" // Another comment
]
}`
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
})
describe("getNotificationConflictWarning", () => {
test("should generate warning message with plugin name", () => {
// #when
const warning = getNotificationConflictWarning("opencode-notifier")
// #then
expect(warning).toContain("opencode-notifier")
expect(warning).toContain("session.idle")
expect(warning).toContain("auto-disabled")
expect(warning).toContain("force_enable")
})
})
})

View File

@@ -0,0 +1,132 @@
/**
* Detects external plugins that may conflict with oh-my-opencode features.
* Used to prevent crashes from concurrent notification plugins.
*/
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import { log } from "./logger"
import { parseJsoncSafe } from "./jsonc-parser"
interface OpencodeConfig {
plugin?: string[]
}
/**
* Known notification plugins that conflict with oh-my-opencode's session-notification.
* Both plugins listen to session.idle and send notifications simultaneously,
* which can cause crashes on Windows due to resource contention.
*/
const KNOWN_NOTIFICATION_PLUGINS = [
"opencode-notifier",
"@mohak34/opencode-notifier",
"mohak34/opencode-notifier",
]
function getWindowsAppdataDir(): string | null {
return process.env.APPDATA || null
}
function getConfigPaths(directory: string): string[] {
const crossPlatformDir = path.join(os.homedir(), ".config")
const paths = [
path.join(directory, ".opencode", "opencode.json"),
path.join(directory, ".opencode", "opencode.jsonc"),
path.join(crossPlatformDir, "opencode", "opencode.json"),
path.join(crossPlatformDir, "opencode", "opencode.jsonc"),
]
if (process.platform === "win32") {
const appdataDir = getWindowsAppdataDir()
if (appdataDir) {
paths.push(path.join(appdataDir, "opencode", "opencode.json"))
paths.push(path.join(appdataDir, "opencode", "opencode.jsonc"))
}
}
return paths
}
function loadOpencodePlugins(directory: string): string[] {
for (const configPath of getConfigPaths(directory)) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
const result = parseJsoncSafe<OpencodeConfig>(content)
if (result.data) {
return result.data.plugin ?? []
}
} catch {
continue
}
}
return []
}
/**
* Check if a plugin entry matches a known notification plugin.
* Handles various formats: "name", "name@version", "npm:name", "file://path/name"
*/
function matchesNotificationPlugin(entry: string): string | null {
const normalized = entry.toLowerCase()
for (const known of KNOWN_NOTIFICATION_PLUGINS) {
if (
normalized === known ||
normalized.startsWith(`${known}@`) ||
normalized.includes(`/${known}`) ||
normalized.endsWith(`/${known}`)
) {
return known
}
}
return null
}
export interface ExternalNotifierResult {
detected: boolean
pluginName: string | null
allPlugins: string[]
}
/**
* Detect if any external notification plugin is configured.
* Returns information about detected plugins for logging/warning.
*/
export function detectExternalNotificationPlugin(directory: string): ExternalNotifierResult {
const plugins = loadOpencodePlugins(directory)
for (const plugin of plugins) {
const match = matchesNotificationPlugin(plugin)
if (match) {
log(`Detected external notification plugin: ${plugin}`)
return {
detected: true,
pluginName: match,
allPlugins: plugins,
}
}
}
return {
detected: false,
pluginName: null,
allPlugins: plugins,
}
}
/**
* Generate a warning message for users with conflicting notification plugins.
*/
export function getNotificationConflictWarning(pluginName: string): string {
return `[oh-my-opencode] External notification plugin detected: ${pluginName}
⚠️ Both oh-my-opencode and ${pluginName} listen to session.idle events.
Running both simultaneously can cause crashes on Windows.
oh-my-opencode's session-notification has been auto-disabled.
To use oh-my-opencode's notifications instead, either:
1. Remove ${pluginName} from your opencode.json plugins
2. Or set "notification": { "force_enable": true } in oh-my-opencode.json`
}

View File

@@ -19,3 +19,4 @@ export * from "./migration"
export * from "./opencode-config-dir"
export * from "./opencode-version"
export * from "./permission-compat"
export * from "./external-plugin-detector"