Skip to content

tui: multiple running instances send duplicate notifications for the same event #51014

Open

Description

@creichert

Description

When multiple OpenCode TUI instances are running, a notification event from one session appears to be handled by every running TUI instance, resulting in duplicate desktop notifications.

This appears related to #20371, but I can reproduce it with the TUI rather than Electron.

dbus-monitor "interface='org.freedesktop.Notifications',member='Notify'"

For a single Permission needs input event, I receive two separate Notify calls from different D-Bus senders at essentially the exact same time:

method call time=1790213981.317990 sender=:1.261 -> destination=org.freedesktop.Notifications serial=120 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify
   string "kitty"
   uint32 0
   string "/usr/lib/kitty/logo/kitty.png"
   string "OpenCode"
   string "Permission needs input"
   array [
      string "default"
      string " "
   ]
   array [
      dict entry(
         string "urgency"
         variant             byte 1
      )
   ]
   int32 -1

method call time=1790213981.318016 sender=:1.285 -> destination=org.freedesktop.Notifications serial=63 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify
   string "kitty"
   uint32 0
   string "/usr/lib/kitty/logo/kitty.png"
   string "[project] Refactor"
   string "Permission needs input"
   array [
      string "default"
      string " "
   ]
   array [
      dict entry(
         string "urgency"
         variant             byte 1
      )
   ]
   int32 -1

The calls are from two distinct D-Bus senders (:1.261 and :1.285), so the duplication occurs before the notification daemon receives them. What's odd is only one has the recognized window title while the other is generic "OpenCode", even though a titled sesion is open in that window.

Additionally, invoking the default action on each duplicate notification focuses a different OpenCode/kitty TUI instance. This suggests each running TUI is independently handling the same session event and creating its own notification/action.

This seems consistent with the behavior described in #20371, including the potential shared event-listener cause identified there.

Expected behavior

Only the TUI associated with the session/event should emit the desktop notification.

Plugins

none

OpenCode version

v2.0.14

Steps to reproduce

  1. Run two OpenCode TUI instances in separate terminal windows (I'm using kitty).
  2. Have a session in one instance reach an event that generates a notification (e.g. permission input or session completion).
  3. Observe two desktop notifications.
  4. Monitor the notification calls with:

Screenshot and/or share link

No response

Operating System

Arch Linux

Terminal

Kitty

Activity

  1. kirin-xiao commented on Oct 9, 2026

    @kirin-xiao

    Confirmed on v2.0.26; Same root cause as #53203

  2. dylanrussellmd commented on Oct 17, 2026

    @dylanrussellmd

    Still reproducible on v2.0.29 (Linux 6.18, kitty 0.49.2, niri/Wayland, one shared background service, five TUI instances in separate kitty windows).

    With dbus-monitor "interface='org.freedesktop.Notifications',member='Notify'" running, one main session finishing produced four identical Notify calls ("Session done", same session title) within 0.4 ms: one from every TUI that was unfocused and had loaded attention.notifications: true. Only the TUI being looked at stayed quiet.

    Cause (from reading the bundled opencode.notifications CLI plugin): its handlers for session.execution.succeeded / interrupted / failed, form.created and permission.asked call attention.notify(...) for whatever sessionID the event carries. Nothing checks whether that session belongs to the current TUI, and the event stream is server-wide (#36441 / #36443), so every connected TUI alerts for every session. This is the same root cause as #53203.

    Suggested fix: in the notifications plugin, skip events for sessions this TUI doesn't own, e.g. only alert when data.session.root(sessionID) is the session on screen (ui.router.current()) or is in ui.tabs.list(). Subagent sessions are then covered through their root session.

    Workaround that works: disable the built-in plugin in cli.json ("plugins": ["-opencode.notifications"]) and load a small replacement CLI plugin with the same handlers plus that ownership check. After switching, a 2-hour dbus-monitor capture showed exactly one Notify per session completion, sent only by the TUI that has the session open.

  3. MaxGB23 commented on Oct 19, 2026

    @MaxGB23

    Also hit this on v2.0.32 (Windows, several TUIs against one shared server). Tried the ownership-check route first; had to drop it.

    • Ownership check (ui.router.current() / ui.tabs.list()) works while a session is in a tab. With tabs.mode: "off" and the TUI sitting on the home screen, no window owns the session, so nothing fires — the echo is fixed but the notifications are gone too.
    • What held up instead: a cross-process claim file. Every TUI sees the same top-level event, so fs.writeFileSync(file, pid, { flag: "wx" }) (O_EXCL is atomic between processes) elects exactly one TUI per event. Key it by sessionID, not event id — a failed execution and its later retry carry different ids, so keying by event lets one TUI fire "done" right after another TUI's "error". Clear the done: claim on session.execution.started so the next turn can sound again, and TTL the claims (~30s) so a crashed TUI doesn't hold one forever.
    • Both routes still need the builtin disabled ("plugins": ["-opencode.notifications"]) plus a replacement plugin — there's no API to deactivate another TUI plugin at runtime.

    Recommendation: don't ship the plain ownership check as-is; it silently drops notifications for anyone on the home screen. Scoping the event stream (#36441 / #36443) would make all of this unnecessary, and that's the fix worth having.

  4. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report and the dbus-monitor capture. I reproduced this on v2.0.14, and it still happens on the latest dev (fa1260d).

    What I did:

    1. Ran two opencode TUIs in separate kitty-like terminals, both connected to the same background server, with attention.notifications turned on.
    2. Took focus away from both windows, then sent a prompt in TUI A that triggers a permission request.
    3. TUI A and TUI B each sent the same desktop notification ("Permission needs input"), even though TUI B never opened that session.

    The script below reproduces it without a real provider. It starts a mock model and prints the notification escape sequences each TUI sent. In my run both notifications used the session title. The "OpenCode" title you saw is most likely the other TUI not having the session's details, which would be the same cause.

    The likely cause is that the built-in notifications handler reacts to permission, question and session-finished events for every session on the shared server, not just the ones that TUI is showing. This matches what you described and is related to #20371 (the same problem in the desktop app).

    Reproduction script (harness.py)
    #!/usr/bin/env python3
    """Reproduces anomalyco/opencode#51014.
    Runs two real `opencode` TUIs in PTYs (kitty-like terminal) sharing one managed server,
    blurs both, submits a prompt in TUI A whose (mocked) model reply asks for a shell
    permission, then reports which TUIs emitted desktop-notification escapes (OSC 99/777/9).
    Self-contained: starts a mock OpenAI-compatible provider on :18080, writes the project
    config, and enables `attention.notifications` in ~/.config/opencode/cli.json.
    Set OC_CMD to run another opencode command (e.g. from a source checkout)."""
    import os, pty, re, select, struct, fcntl, termios, time, sys, json, threading, subprocess
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    
    def chunk(delta, finish=None):
        return {"id": "x", "object": "chat.completion.chunk", "created": 0, "model": "mock",
                "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
    class Mock(BaseHTTPRequestHandler):
        def log_message(self, *a): pass
        def do_GET(self):
            b = json.dumps({"data": [{"id": "mock", "object": "model"}]}).encode()
            self.send_response(200); self.send_header("content-type", "application/json"); self.end_headers(); self.wfile.write(b)
        def do_POST(self):
            body = json.loads(self.rfile.read(int(self.headers.get("content-length", 0))) or b"{}")
            tools = [t.get("function", {}).get("name") for t in body.get("tools") or []]
            if not tools:
                out = [chunk({"role": "assistant", "content": "Refactor"}), chunk({}, "stop")]
            elif any(m.get("role") == "tool" for m in body.get("messages", [])):
                out = [chunk({"role": "assistant", "content": "Done."}), chunk({}, "stop")]
            else:
                name = next((t for t in tools if t in ("shell", "bash")), tools[0])
                args = json.dumps({"command": "rm -rf /tmp/opencode/nothing-here", "description": "remove"})
                out = [chunk({"role": "assistant", "tool_calls": [{"index": 0, "id": "call_1", "type": "function",
                       "function": {"name": name, "arguments": args}}]}), chunk({}, "tool_calls")]
            data = "".join(f"data: {json.dumps(c)}\n\n" for c in out) + "data: [DONE]\n\n"
            self.send_response(200); self.send_header("content-type", "text/event-stream"); self.end_headers(); self.wfile.write(data.encode())
    threading.Thread(target=ThreadingHTTPServer(("127.0.0.1", 18080), Mock).serve_forever, daemon=True).start()
    
    PROJ = os.path.join(os.path.dirname(os.path.abspath(__file__)), "proj")
    os.makedirs(PROJ, exist_ok=True)
    subprocess.run(["git", "init", "-q"], cwd=PROJ)
    json.dump({"model": "mock/mock", "autoupdate": False, "permission": {"shell": "ask", "bash": "ask"},
               "provider": {"mock": {"npm": "@ai-sdk/openai-compatible", "name": "Mock",
               "options": {"baseURL": "http://127.0.0.1:18080/v1", "apiKey": "x"}, "models": {"mock": {"name": "Mock"}}}}},
              open(os.path.join(PROJ, "opencode.json"), "w"))
    cfgdir = os.path.expanduser("~/.config/opencode"); os.makedirs(cfgdir, exist_ok=True)
    json.dump({"attention": {"notifications": True}}, open(os.path.join(cfgdir, "cli.json"), "w"))
    CMD = os.environ.get("OC_CMD", "opencode").split()
    env = dict(os.environ, TERM="xterm-kitty", KITTY_WINDOW_ID="1", TERM_PROGRAM="kitty", COLORTERM="truecolor")
    
    def spawn(name):
        pid, fd = pty.fork()
        if pid == 0:
            os.chdir(PROJ)
            os.execvpe(CMD[0], CMD, env)
        fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0))
        return {"name": name, "pid": pid, "fd": fd, "buf": b""}
    
    tuis = [spawn("A"), spawn("B")]
    def pump(sec):
        end = time.time() + sec
        while time.time() < end:
            r, _, _ = select.select([t["fd"] for t in tuis], [], [], 0.1)
            for t in tuis:
                if t["fd"] in r:
                    try: data = os.read(t["fd"], 65536)
                    except OSError: data = b""
                    t["buf"] += data
                    # answer kitty notification capability query so OSC 99 is used
                    if b"\x1b]99;i=opentui-notifications:p=?" in data:
                        os.write(t["fd"], b"\x1b]99;i=opentui-notifications:p=?;p=title,body:a=focus,report:o=always,unfocused,invisible\x1b\\")
                    # answer cursor position / device attribute queries
                    if b"\x1b[6n" in data: os.write(t["fd"], b"\x1b[1;1R")
                    if b"\x1b[c" in data: os.write(t["fd"], b"\x1b[?62;22c")
                    if b"\x1b[>0q" in data: os.write(t["fd"], b"\x1bP>|kitty(0.39.1)\x1b\\")
                    if b"\x1b[?u" in data: os.write(t["fd"], b"\x1b[?0u")
    def w(t, s): os.write(t["fd"], s)
    
    pump(15)                        # let both TUIs start (second attaches to the shared service)
    for t in tuis: w(t, b"\x1b[I")  # focus events so state is known
    pump(1)
    A = tuis[0]
    for ch in "run the cleanup": w(A, ch.encode()); pump(0.03)
    for t in tuis: w(t, b"\x1b[O")  # both windows lose focus (user switches away right after submitting)
    pump(0.5)
    w(A, b"\r")
    pump(12)
    pat = re.compile(rb"\x1b\](?:99|777|9);(?!i=opentui-notifications:p=\?)(?!4;)[^\x07\x1b]*(?:\x07|\x1b\\)")
    for t in tuis:
        hits = [m.group(0) for m in pat.finditer(t["buf"])]
        print(f"TUI {t['name']}: {len(hits)} notification sequence(s)")
        for h in hits: print("   ", h)
        open(os.path.join(PROJ, f"../out-{t['name']}.bin"), "wb").write(t["buf"])
    for t in tuis:
        try: os.kill(t["pid"], 9)
        except Exception: pass

    Run it: python3 harness.py

  5. evgenyzh commented on Oct 30, 2026

    @evgenyzh

    Same problem on v2.2.1 (Linux): with several TUI clients attached to one shared server, a single session completion produces one Session done notification per unfocused TUI (5-6 in my case). The focused TUI stays quiet. Following this issue for updates.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions