Skip to content

desktop: "needs permission" system notification never fires although setting is enabled (completion notifications work) #51507

Closed

Description

@eltPlay

Summary

OpenCode Desktop 2.0.18 (Windows): Preferences → Notifications → "Show system notification when permission is needed" (系统通知→权限) is enabled, and Windows itself allows OpenCode notifications (banner + sound, Do-Not-Disturb off) — yet when a permission request is pending, no system notification ever fires: no toast, no sound, no notification-center entry. Agent-completion notifications ("回复已就绪") from the same app are delivered fine, so the notification pipeline works; only the permission trigger is dead.

Environment

  • opencode version: 2.0.18 (desktop app, Electron)
  • OS: Windows 11, 10.0.26200 (win32 x64)
  • Terminal: n/a (desktop app)
  • Shell: PowerShell 7 (portable)
  • Install/channel: latest release
  • Active plugins: @prevalentware/opencode-goal-plugin, local handoff-on-compact (irrelevant — the notification is desktop-client behavior)

Reproduction

  1. In OpenCode Desktop: Preferences → Notifications → enable 系统通知→权限 ("Show system notification when permission is needed"); optionally enable 音效→权限.
  2. In Windows Settings → System → Notifications: global ON, 勿扰 OFF, OpenCode allowed with banner + sound.
  3. Configure an ask permission rule for any tool (e.g. { "action": "wincontrol_Clipboard", "resource": "*", "effect": "ask" }) and trigger it so a permission request goes pending. The in-app approval card ("需要权限", resource *, buttons 拒绝 / 始终允许 / 允许一次) appears and the tool call blocks.
  4. Switch focus to a different application and wait.

Expected Behavior

A Windows toast using the app's own locale string {{sessionTitle}}({{projectName}})需要权限 (this template exists in the shipped zh locale bundle, so the feature is implemented at the UI level), plus the configured permission sound.

Actual Behavior

Nothing fires: no banner, no sound, and no entry in the notification center (verified via Win+N). The pending request exists only inside the unfocused OpenCode window, so the call blocks indefinitely with zero external signal.

Timeline of today's observations (2026-09-26, local UTC+8)

~Time Event System notification?
21:07 / 21:10 Two permission dialogs appeared (earlier session), user clicked Allow once none recorded
22:29 Added ask rules for wincontrol tools, config hot-reloaded —
~22:31 Test A, window focused: wincontrol.Wait blocked 4.96 s total (~3 s human click latency), in-app dialog shown n/a (focused)
~22:35 Test B, deliberately Alt+Tabbed away: wincontrol.Wait blocked 43 s awaiting decision none — no banner, no sound (user report)
~22:40 Opened Windows notification center: four "回复已就绪" completion notifications present (21:03/21:57/22:13/22:41), zero "需要权限" —
~23:05 Test C, user defocused: wincontrol.Clipboard(get) blocked 59 s; in-app "需要权限" card visible in screenshot none (user report)

Frequency: 100% — every pending permission request today (at least 4) produced no system notification; two of them had the user explicitly defocused and watching for one.

Additional Context

  • Completion notifications prove the Windows notification path works for this app; only the permission event never emits.
  • The zh locale bundle contains both the setting description ("当需要权限时显示系统通知") and the notification title template ("{{sessionTitle}}({{projectName}})需要权限"), so the trigger appears simply not wired (or gated on a condition that never holds, e.g. a different event type than what the server emits for MCP-tool asks).
  • Impact: users relying on ask to gate risky tools get no signal that a call is stuck while working in another window. Combined with the in-app prompt not stealing focus, the ask gate becomes a silent indefinite wait.
  • Related: permissions: ask rules for MCP tools are silently degraded to allow #51504 follow-up comment (retested there: ask itself works for MCP tools, including while unfocused — the request genuinely blocks; this issue covers the missing notification that is supposed to surface exactly that state).

Activity

  1. github-actions commented on Sep 26, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #37120: Desktop notifications for pending approval requests fail with New Layout enabled.
    • #48801: Permission-ask sound and native notifications were missing with New Layout.
  2. berkaykrc commented on Oct 10, 2026

    @berkaykrc

    Confirming this same bug on the WebUI (not just the Electron desktop app), which suggests the missing trigger is in the shared notification module rather than anything Electron-specific. I also found two TUI-side problems that may all be the same underlying gap: notifications for sessions that are not in view, and notifications fired in auto mode for requests that were auto-approved anyway.

    Finding 1 — WebUI: permission notifications are never emitted

    Reproduction (WebUI)

    • opencode v2.0.26, WebUI served by opencode serve on http://127.0.0.1:49374
    • Firefox 157.0.1, Fedora 44, KDE Plasma
    • Opened as a normal tab (not installed as a PWA; display-mode: standalone is false)
    • Settings → Notifications: Agent, Permissions, and Errors all enabled
    • The in-page permission card appears with Always approve / Approve one time / Reject, same as described here

    With the tab deliberately unfocused, three consecutive permission requests produced zero notifications. Completion notifications ("Response ready") arrived normally during the same session.

    Root cause: no trigger is wired

    The WebUI notification module has exactly two triggers, both gated on session.execution.*:

    // Fires on session.execution.succeeded -> "Response ready"
    l && n.notifications.agent()
      && e.coordinator.system(`${e.key}\0${o}`, () => t.notify(
           r.t(`notification.session.responseReady.title`), c.title ?? a, () => ps(i, e.key, a)))
    
    // Fires on session.execution.failed -> error notification
    u && n.notifications.errors()
      && e.coordinator.system(`${e.key}\0${s}`, () => t.notify(
           r.t(`notification.session.error.title`), d, () => ps(i, e.key, a)))

    The events this module reacts to are only:

    session.execution.succeeded
    session.execution.failed
    

    No permission-related event is handled in the notification path. Meanwhile notifications.permissions() is read only by the settings UI binding, so the toggle persists a value nothing consumes:

    get checked(){ return t.notifications.permissions() },
    onChange: e => t.notifications.setPermissions(e)
    

    This is not the focus gate

    The shared notify helper intentionally suppresses notifications while the tab is focused:

    || (document.visibilityState === `visible` && document.hasFocus())
    

    That gate is working as designed and is not the cause. Runtime state from the WebUI console while permission notifications were failing:

    {"perm":"granted","vis":"visible","focus":false,"origin":"http://127.0.0.1:49374","standalone":false}
    

    permission: granted with focus: false means the gate is open, which is why the completion notification was delivered under identical conditions.

    Finding 2 — TUI: notifications are not scoped to the session in view

    The TUI does wire a permission trigger, but the listener set is attached to the global event stream with no filter for the session currently on screen:

    function hr(t, n, r, o, i) {
      let a = n ? t.data.session.get(n) : void 0
      let s = a?.parentID !== void 0
      t.attention.notify({
        title: i ?? a?.title,
        message: r,
        notification: s ? !1 : { when: "blurred" },
        sound: { name: o, when: "always" }
      })
    }
    
    t.data.on("form.created",             c => hr(t, c.data.form.sessionID, "Input needs response", "question", ...))
    t.data.on("permission.asked",         c => hr(t, c.data.sessionID, "Permission needs input", "permission"))
    t.data.on("session.execution.started",   c => a(c.data.sessionID))
    t.data.on("session.execution.succeeded", c => s(c.data.sessionID))
    t.data.on("session.execution.interrupted", c => s(c.data.sessionID))
    t.data.on("session.execution.failed",  c => { ... hr(t, d, c.data.error.message, "error") ... })

    hr() receives the session ID from the incoming event and never compares it against the session being displayed. The only distinction made is parentID (subagent sessions get sound only, no desktop notification).

    Reproduction

    1. Have a TUI running in kitty.
    2. Run a different session in the WebUI on the same server.
    3. When the WebUI session finishes, the TUI in kitty raises a "Session done" notification even though that session was never opened there.

    Observed directly: the session was running in the WebUI only, was not open in the kitty TUI, and the notification still arrived there (notification app name kitty, message "Session done").

    Finding 3 — TUI: auto mode still fires permission notifications

    Enabled auto mode (session.permissions: "autoaccept" in cli.json, TUI v2.0.29) and noticed that every permission request still raises a "Permission needs input" desktop notification + the permission sound in kitty — even though the request is auto-approved milliseconds later and the user never has to act on it. A notification for an approval nobody performs is pure noise.

    The attention path never consults the permission mode (built-in opencode.notifications TUI plugin):

    t.data.on("permission.asked", m => {
      if (a.has(m.data.id)) return
      a.add(m.data.id)
      pr(t, m.data.sessionID, "Permission needs input", "permission")  // no autoaccept check
    })
    
    function pr(t, n, r, o, a) {
      let s = n ? t.data.session.get(n) : void 0,
          c = s?.parentID !== void 0
      t.attention.notify({
        title: a ?? s?.title, message: r,
        notification: c ? !1 : { when: "blurred" },
        sound: { name: o, when: "always" }   // fires even in auto mode
      })
    }

    Meanwhile the auto-approver replies to the very same request right after it is created:

    he(() => {
      if (y.permission.mode !== "autoaccept") return
      Y().forEach(r => x.session.permission.reply({ ..., decision: "once" }))
    })

    The pending-request UI is correctly suppressed in auto mode (mode === "autoaccept" hides the request list), but the attention/notification path is not, so each auto-approved request still produces a desktop notification + sound. This is the same unscoped pr() helper as in Finding 2 — same listener set, same missing gating.

    Why the three findings are likely the same gap

    Surface Problem
    WebUI permissions toggle has no consumer — notifications never fire
    TUI triggers are not scoped to the active session — notifications fire for unrelated sessions
    TUI (auto mode) permission.asked trigger ignores auto mode — notifications fire for requests that were auto-approved

    All three concern how permission/session events reach the attention module: one under-fires, the others over-fire.

    Suggested fix

    Wire a permission trigger in the WebUI notification module (subscribe to the permission-ask event and gate on notifications.permissions(), mirroring the existing session.execution.succeeded / .failed handling), scope the TUI attention listeners to the session currently in view so unrelated sessions do not notify, and gate the TUI permission.asked attention trigger (and its sound) on permission.mode !== "autoaccept" — or drop notifications for requests that were already auto-replied.

    Additional notes

    • There is also a possible separate issue worth checking: sw.js (Workbox) does not call clients.claim(), so a newly activated service worker does not take control of already-open tabs. This did not appear to affect this report and may be intentional.
    • Related: this report reproduces in Firefox on Linux, not only in the Electron desktop app.
  3. yiyaozhe commented on Oct 19, 2026

    @yiyaozhe

    Confirming on OpenCode Desktop 2.0.31 (Windows 11 26200): the in-app permission card appears and the session blocks, but no toast, sound, or notification-center entry is produced while the app is unfocused. notifications.permissions is enabled; completion notifications work.

    I checked the shipped desktop bundle and the permission setting has no consumer in the notification path: only session.execution.succeeded and session.execution.failed are wired; there is no permission.asked handler. This matches the WebUI analysis above.

    Suggestion for the fix: in addition to wiring permission.asked, consider an in-app always-on-top popup for pending permissions (or as a fallback when Windows toasts are suppressed by Focus Assist / per-app settings). A blocked permission request otherwise has no external signal. The popup can be notification-only with "open request" and "snooze/dismiss"; allow/deny from the popup would be a bonus.

    Thanks for #56044 — that looks like the right direction.

  4. pskraemer11 commented on Oct 19, 2026

    @pskraemer11

    Independent confirmation on OpenCode Desktop 2.0.32 (Windows 11, 10.0.26300, @opencode-aidesktop), with the gap localised in the default branch.

    Confirmation

    Same behaviour as reported: with notifications.permissions enabled and Windows notifications fully allowed for the app (banner + sound, no per-app mute, DND off), a pending permission request produces no toast, no sound and no notification-center entry while the app is unfocused. Completion notifications from the same app are delivered normally, so the notification pipeline itself works — the permission trigger is simply never reached.

    Root cause (default branch)

    packages/app/src/shell/notifications/notification.tsx (310 lines) inspects only two event types:

    • L268: if (event.type === "session.execution.failed")
    • L274: if (event.type === "session.execution.succeeded") handleSessionIdle(event.data.sessionID, event.id)

    There is no permission.asked branch and no form.created branch anywhere in the file. The setting (notifications.permissions), its defaults and the i18n strings for the permission notification all exist and are rendered by the preferences UI, but nothing reads them in the notification path. This matches the bundle inspection on 2.0.31 earlier in this thread and the WebUI finding above: it is one shared gap in the notification module, not an Electron- or Windows-specific problem.

    The question flow has the same gap: form.created (the question tool blocking for an answer) is likewise unwired, which is the desktop-side part of #49371.

    Workaround (local, no app changes)

    Auto-discovered V2 server plugin, closes the gap for both events:

    • lives at ~/.config/opencode/plugin/<name>/index.js (Plugin.define + ctx.event.subscribe, reacting to permission.asked and form.created)
    • spawns a short PowerShell script that raises a WinRT toast via ToastNotificationManager
    • cross-process dedupe per event id — the plugin is instantiated once per active location, so the same event arrives up to N times
    • focus filter: toast only when the app is not in the foreground, sound always

    Three non-obvious details, useful for anyone building the same thing or triaging this bug:

    1. powershell.exe must not be spawned with detached: true. It then exits silently with code 0 without executing the script. Dropping detached (keeping windowsHide) fixes it.
    2. The toast must use a registered AUMID. An invented one is dropped silently; using the app's own AUMID gets the toast attributed to OpenCode and accepted.
    3. Windows Focus Assist / "Do not disturb" suppresses only the banner. The toast still reaches the notification center and the sound still plays. Worth separating from this bug: under DND no workaround can show a banner unless the source is a priority app.

    Suggestion

    Wiring permission.asked and form.created into the shared notification module would fix the desktop app, the WebUI and every other host in one place. As a complement for the cases where OS toasts are suppressed (Focus Assist, per-app settings, muted channels), the in-app always-on-top popup suggested above would guarantee an external signal for a blocked request.

  5. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I couldn't reproduce this here: it needs the desktop app showing real Windows notifications, which this sandbox can't run. A maintainer with the desktop app will need to confirm it.

    The code supports what you describe, though. In 2.0.18 and on the current dev branch (86ce19d), the app only sends system notifications when a response is ready or a session errors. The "Permissions" notification and sound settings are saved and shown in Preferences, but no code acts on them when a permission request is pending. That matches the behaviour you saw.

    Related:

  6. HardcorePunk commented on Oct 29, 2026

    @HardcorePunk

    Confirming on 2.2.1 desktop app (Debian Trixie Linux, .deb install, /opt/OpenCode).

    Completion sound (sounds.agent, staplebops-01) plays on task finish ✅
    Error sound (sounds.errors, nope-03) plays on failure ✅
    Permission sound (sounds.permissions, staplebops-02) never plays ❌
    The "Permission required" system notification (notifications.permissions, default true) also never appears ❌
    The Settings preview button for the Permissions sound does play, so the audio asset and the setting are fine. Grepping the shipped renderer bundle (out/renderer/assets/main-*.js in app.asar) shows sounds.permissions() / sounds.permissionsEnabled() have no call sites outside the settings UI, and the notification module's event listener only branches on session.execution.failed and session.execution.succeeded — no permission.asked case exists. Same root cause as the description. Verified still missing on dev as well.

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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions