Skip to content

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

Open

Description

@HecterF

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 bell channel enabled (notification: false, sound: false, command: false; bell: true for 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:

  1. Is process.stdout.isTTY actually 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.
  2. 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 for session.execution.* / form.created / permission.asked on current v2.0.32?
  3. Is the TUI entry reading the same ~/.config/opencode/opencode-notifier.json config (my per-event bell: true settings)?

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 \x07 directly, ideally without an isTTY guard 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/* via Bun.plugin)

Activity

  1. mohak34 commented on Oct 21, 2026

    @mohak34
    Owner

    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.isTTY is true inside the TUI plugin and the BEL reaches the terminal. So the isTTY guard 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?

    1. Do you run OpenCode inside tmux or Zellij on the Ubuntu box? Over SSH the plugin can't see your Windows desktop, so it trusts tmux. An active tmux pane counts as "focused" even when the Tabby tab is in the background, and the bell is skipped. Quick test: add "suppressWhenFocused": false to opencode-notifier.json and restart. If tmux is involved, also send the output of:
      tmux display-message -t "$TMUX_PANE" -p '#{session_attached} #{window_active} #{pane_active}'
    2. Do you have other OpenCode terminals open under the same Ubuntu user? That includes other SSH sessions on the same project. Only one terminal alerts per event, and right now the one that wins can stay silent.
    3. Which config file is the TUI reading? It reads ~/.config/opencode/opencode-notifier.json, or OPENCODE_NOTIFIER_CONFIG_PATH if that's set. It does not follow XDG_CONFIG_HOME. Invalid JSON silently falls back to the defaults, and every bell is off by default.
    4. Debug log. Debug output is hidden while the TUI owns the screen. This writes it to a file:
      log=$(mktemp /tmp/opencode-notifier-debug.XXXXXX)
      OPENCODE_NOTIFIER_DEBUG=1 OTUI_USE_CONSOLE=false opencode --standalone --print-logs 2>"$log"
      grep '\[opencode-notifier\]' "$log"
      Trigger an event, then paste the matching lines, especially any linux focus: line.

    Your full working plugin file would help too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions