Repository navigation
Terminal bell (BEL) channel no longer works on opencode v2 — server plugin stdout is /dev/null, and TUI-entry bell never reaches the terminal #147
Description
Title
Terminal bell (BEL) channel no longer works on opencode v2 — server plugin stdout is /dev/null, and TUI-entry bell never reaches the terminal
Environment
- opencode v2.0.32 (Ubuntu 22.04, accessed over SSH from Windows / Tabby terminal)
- @mohak34/opencode-notifier v1.1.0
- Config: only the
bellchannel enabled (notification: false,sound: false,command: false;bell: truefor permission/complete/error/question, see~/.config/opencode/opencode-notifier.json)
Symptom
On opencode v1, ringBell() writing \x07 to stdout reached my terminal: Tabby received the BEL, marked the tab as active, and clicking the resulting notification jumped to the correct tab. After upgrading to opencode v2, no BEL ever reaches the terminal — the whole bell notification path is dead.
Confirmed root cause (server entry, dist/index.js)
ringBell() in src/bell.ts has this guard:
function ringBell(now = Date.now()) {
if (!process.stdout.isTTY) {
return Promise.resolve();
}
...
process.stdout.write("\x07", () => { ... });
}opencode v1 hosted the server inside the TUI process (new Worker(...) in packages/opencode/src/cli/cmd/tui/thread.ts at v1.16.2), so server plugins shared the TUI process's stdout — which was the user's terminal. process.stdout.isTTY was true and the BEL landed on the terminal.
opencode v2 split the server into a separate process. In the default managed-service mode the server is spawned detached with stdio: ["ignore", "ignore", "pipe"] (opencode source: packages/client/src/service-contender.ts), so the server plugin's stdout is /dev/null and process.stdout.isTTY is false. The server log confirms the plugin loads with role=server:
msg="loading plugin" id=@mohak34/opencode-notifier@latest entrypoint=file:///home/<user>/.cache/opencode/npm/@mohak34/opencode-notifier@latest/.../dist/index.js role=server
In --standalone mode the server's stdout is a pipe that the TUI parent process drains (opencode: packages/cli/src/services/standalone.ts), so the BEL is swallowed there too. Either way, anything server plugins write to stdout can never reach the terminal on v2.
This means every stdout-based mechanism in the server entry (ringBell, and the ghostty OSC 9 sequence in sendNotification) is structurally dead on v2.
Open question (TUI entry, dist/tui.js)
The package already ships a v2 TUI entry (./tui export) and its own ringBell also guards on process.stdout.isTTY. The plugin is picked up by the TUI plugin reconciliation (plugin count increases, no load failures logged), yet no BEL reaches the terminal in my environment either. Possible causes worth checking:
- Is
process.stdout.isTTYactually true inside the v2 TUI process once the OpenTUI renderer owns the terminal? If the renderer re-wraps or redirects stdout, the guard silently swallows the bell. - Does the TUI entry receive the same events (permission/complete/error/question) that the server entry used to handle, i.e. does
notifier.event(details)actually fire forsession.execution.*/form.created/permission.askedon current v2.0.32? - Is the TUI entry reading the same
~/.config/opencode/opencode-notifier.jsonconfig (my per-eventbell: truesettings)?
Suggested fix
Move all bell/terminal-sequence output into the TUI entry (or a separate kind: "tui" plugin) and drop the stdout dependency assumptions:
- On v2, emit terminal escape sequences from TUI-process code only (TUI process stdout is the terminal), e.g. write
\x07directly, ideally without anisTTYguard that may be false in the TUI process, or via a documented TUI API if one exists. - Keep the server entry for event sources only, or deprecate its bell/notification stdout paths on v2 with a warning so users know why notifications stopped.
For reference, a minimal v2 TUI plugin that restores the v1 bell behavior for me (tested working on v2.0.32):
import { Plugin } from "@opencode/plugin/tui"
const lastBell = { time: 0 }
function ringBell() {
const now = Date.now()
if (now - lastBell.time < 500) return
lastBell.time = now
process.stdout.write("\x07")
}
export default Plugin.define({
id: "user.bell-notifier",
setup(context) {
// subscribe to form.created / permission.asked / session.execution.*
// and call ringBell(), filtering subagent sessions
...
},
})(If it helps, I can attach the full working plugin file.)
References
- opencode v2 server spawn: detached +
stdio: ["ignore","ignore","pipe"]—packages/client/src/service-contender.ts - opencode v2 standalone mode: server stdout piped and drained by the TUI parent —
packages/cli/src/services/standalone.ts - opencode v2 TUI plugin system:
packages/tui/src/plugin/(directory plugins auto-discovered under~/.config/opencode/plugins/, host provides@opencode/plugin/*viaBun.plugin)
Activity
mohak34 commentedon Oct 21, 2026
Thanks for the detailed report. You're right about the server side, and that part is expected: on V2 the server only runs event commands. Bells come from the plugin's terminal component, which runs inside the OpenCode TUI.
That part works in my testing. On v2.0.32,
process.stdout.isTTYistrueinside the TUI plugin and the BEL reaches the terminal. So theisTTYguard isn't what's blocking you. Your minimal plugin also skips our focus check, and that's my main suspect.Could you check a few things?
"suppressWhenFocused": falsetoopencode-notifier.jsonand restart. If tmux is involved, also send the output of:~/.config/opencode/opencode-notifier.json, orOPENCODE_NOTIFIER_CONFIG_PATHif that's set. It does not followXDG_CONFIG_HOME. Invalid JSON silently falls back to the defaults, and every bell is off by default.linux focus:line.Your full working plugin file would help too.