# opencode-notifier

OpenCode plugin that plays sounds and sends system notifications when permission is needed, generation completes, errors occur, or the question tool is invoked. Works on macOS, Linux, and Windows.

## Quick Start

Add this to your `opencode.json`:

```json
{
  "plugin": ["@mohak34/opencode-notifier@latest"]
}
```

Restart OpenCode. Done.

## What it does

You'll get notified when:

- OpenCode needs permission to run something
- Your session finishes
- An error happens
- The question tool pops up

There's also `subagent_complete` for when subagents finish, and `user_cancelled` for when you press ESC to abort -- both are silent by default so you don't get spammed.

## Setup by platform

**macOS**: Nothing to do, works out of the box. Shows the Script Editor icon.

**Linux**: Should work if you already have a notification system setup. If not install libnotify:

```bash
sudo apt install libnotify-bin  # Ubuntu/Debian
sudo dnf install libnotify       # Fedora  
sudo pacman -S libnotify         # Arch
```

For sounds, you need one of: `paplay`, `aplay`, `mpv`, or `ffplay`

**Windows**: Works out of the box. But heads up:

- Only `.wav` files work (not mp3)
- Use full paths like `C:/Users/You/sounds/alert.wav` not `~/`

**WSL**: It's recommeneded to set `customIconPath` pointing to a file on Windows filesystem
due to issues with path translation (can be copied from `logos` folder from this repository).
This path will be passed down to `snoretoast-*.exe`

In `opencode-notifier.json` config:
```json
  "showIcon": true,
  "customIconPath": "C:\\Users\\jhon\\Documents\\opencode-logo-dark.png",
```

- If notifications are not showing up, check out: [missing WSL notification](https://github.com/mikaelbr/node-notifier?tab=readme-ov-file#windows-and-wsl2)

## Config file

Create `~/.config/opencode/opencode-notifier.json` with the defaults:

```json
{
  "sound": true,
  "notification": true,
  "bell": false,
  "timeout": 5,
  "showProjectName": true,
  "showFullPath": false,
  "showSessionTitle": false,
  "showIcon": true,
  "customIconPath": null,
  "suppressWhenFocused": true,
  "enableOnDesktop": false,
  "notificationSystem": "osascript",
  "suppressGhosttySound": false,
  "linux": {
    "grouping": false
  },
  "minDuration": 0,
  "command": {
    "enabled": false,
    "path": "/path/to/command",
    "args": ["--event", "{event}", "--message", "{message}"],
    "minDuration": 0
  },
  "events": {
    "permission": { "sound": true, "notification": true, "command": true, "bell": false },
    "complete": { "sound": true, "notification": true, "command": true, "bell": false },
    "subagent_complete": { "sound": false, "notification": false, "command": true, "bell": false },
    "error": { "sound": true, "notification": true, "command": true, "bell": false },
    "question": { "sound": true, "notification": true, "command": true, "bell": false },
    "user_cancelled": { "sound": false, "notification": false, "command": true, "bell": false },
    "plan_exit": { "sound": true, "notification": true, "command": true, "bell": false },
    "session_started": { "sound": true, "notification": false, "command": true, "bell": false },
    "user_message": { "sound": true, "notification": false, "command": true, "bell": false },
    "client_connected": { "sound": true, "notification": false, "command": true, "bell": false }
  },
  "messages": {
    "permission": "Session needs permission: {sessionTitle}",
    "complete": "Session has finished: {sessionTitle}",
    "subagent_complete": "Subagent task completed: {sessionTitle}",
    "error": "Session encountered an error: {sessionTitle}",
    "question": "Session has a question: {sessionTitle}",
    "user_cancelled": "Session was cancelled by user: {sessionTitle}",
    "plan_exit": "Plan ready for review: {sessionTitle}",
    "session_started": "Session started: {sessionTitle}",
    "user_message": "User sent a message: {sessionTitle}",
    "client_connected": "OpenCode connected"
  },
  "sounds": {
    "permission": null,
    "complete": null,
    "subagent_complete": null,
    "error": null,
    "question": null,
    "user_cancelled": null,
    "plan_exit": null,
    "session_started": null,
    "user_message": null,
    "client_connected": null
  },
  "volumes": {
    "permission": 1,
    "complete": 1,
    "subagent_complete": 1,
    "error": 1,
    "question": 1,
    "user_cancelled": 1,
    "plan_exit": 1,
    "session_started": 1,
    "user_message": 1,
    "client_connected": 1
  }
}
```

## All options

### Global options

```json
{
  "sound": true,
  "notification": true,
  "bell": false,
  "timeout": 5,
  "showProjectName": true,
  "showFullPath": false,
  "showSessionTitle": false,
  "showIcon": true,
  "suppressWhenFocused": true,
  "enableOnDesktop": false,
  "notificationSystem": "osascript",
  "suppressGhosttySound": false
}
```

- `sound` - Turn sounds on/off (default: true)
- `notification` - Turn notifications on/off (default: true)
- `bell` - Emit terminal BEL (`\x07`) on events (default: false). Behavior depends on your terminal/WM settings
- `timeout` - How long notifications show in seconds, Linux only (default: 5)
- `showProjectName` - Show folder name in notification title (default: true)
- `showFullPath` - Show full absolute path instead of folder name in notification title and `{projectName}` token (default: false). When true, shows `OpenCode (/home/user/projects/myapp)` instead of `OpenCode (myapp)`
- `showSessionTitle` - Include the session title in notification messages via `{sessionTitle}` placeholder (default: false)
- `showIcon` - Show OpenCode icon, Windows/Linux only (default: true)
- `customIconPath` - Path to a custom icon for notifications. Useful on WSL where Windows paths are needed (default: null)
- `suppressWhenFocused` - Skip notifications and sounds when the terminal is the active window (default: true). See [Focus detection](#focus-detection) for platform details
- `enableOnDesktop` - Run the plugin on Desktop and Web clients (default: false). When false, the plugin only runs on CLI. Set to true if you want notifications/sounds/commands on Desktop/Web — useful if you want custom commands (Telegram, webhooks) but don't care about built-in notifications
- `notificationSystem` - macOS only: `"osascript"`, `"node-notifier"`, or `"ghostty"` (default: "osascript"). Use `"ghostty"` if you're running Ghostty terminal for native OSC 9 notifications
- `suppressGhosttySound` - macOS only: when `true` with `notificationSystem: "ghostty"`, skips the plugin's sound to avoid duplicating macOS Notification Center's default sound (default: false)
- `minDuration` - Suppress `complete` and `subagent_complete` notifications when session finishes faster than this many seconds (default: 0). See [Minimum duration threshold](#minimum-duration-threshold)
- `linux.grouping` - Linux only: replace notifications in-place instead of stacking (default: false). Requires `notify-send` 0.8+

### Events

Control each event separately:

```json
{
  "events": {
    "permission": { "sound": true, "notification": true, "command": true, "bell": false },
    "complete": { "sound": true, "notification": true, "command": true, "bell": false },
    "subagent_complete": { "sound": false, "notification": false, "command": true, "bell": false },
    "error": { "sound": true, "notification": true, "command": true, "bell": false },
    "question": { "sound": true, "notification": true, "command": true, "bell": false },
    "user_cancelled": { "sound": false, "notification": false, "command": true, "bell": false },
    "plan_exit": { "sound": true, "notification": true, "command": true, "bell": false },
    "session_started": { "sound": true, "notification": false, "command": true, "bell": false },
    "user_message": { "sound": true, "notification": false, "command": true, "bell": false },
    "client_connected": { "sound": true, "notification": false, "command": true, "bell": false }
  }
}
```

`user_cancelled` fires when you press ESC to abort a session. It's silent by default so intentional cancellations don't trigger error alerts. Set `sound` or `notification` to `true` if you want confirmation when cancelling.

`session_started` fires when a new top-level session is created. `user_message` fires when a user message is submitted in a top-level session. `client_connected` fires shortly after the plugin initializes and is best-effort (there is no dedicated SDK connection event from plugin context).

The `command` property controls whether the custom command (see [Custom commands](#custom-commands)) runs for that event. Defaults to `true` for all events. Set it to `false` to suppress the command for specific events without disabling it globally.

`bell` is terminal-driven and may be audible, visual, both, or ignored depending on your terminal setup. Quick check: `printf '\a'`.

Or use true/false for both:

```json
{
  "events": {
    "complete": false
  }
}
```

### Messages

Customize the notification text:

```json
{
  "messages": {
    "permission": "Session needs permission: {sessionTitle}",
    "complete": "Session has finished: {sessionTitle}",
    "subagent_complete": "Subagent task completed: {sessionTitle}",
    "error": "Session encountered an error: {sessionTitle}",
    "question": "Session has a question: {sessionTitle}",
    "user_cancelled": "Session was cancelled by user: {sessionTitle}",
    "plan_exit": "Plan ready for review: {sessionTitle}",
    "session_started": "Session started: {sessionTitle}",
    "user_message": "User sent a message: {sessionTitle}",
    "client_connected": "OpenCode connected"
  }
}
```

Messages support placeholder tokens that get replaced with actual values:

- `{sessionTitle}` - The title/summary of the current session (e.g. "Fix login bug")
- `{agentName}` - Subagent name extracted from session titles with `(@name subagent)` suffix (e.g. `builder`, `codebase-researcher`), empty for non-subagent sessions
- `{projectName}` - The project folder name
- `{timestamp}` - Current time in `HH:MM:SS` format (e.g. "14:30:05")
- `{turn}` - Global notification counter that persists across restarts (e.g. 1, 2, 3). Stored in `~/.config/opencode/opencode-notifier-state.json`

When `showSessionTitle` is `false`, `{sessionTitle}` is replaced with an empty string. Any trailing separators (`: `, `-`, `|`) are automatically cleaned up when a placeholder resolves to empty.

To disable session titles in messages without changing `showSessionTitle`, just remove the `{sessionTitle}` placeholder from your custom messages.

The `{timestamp}` and `{turn}` placeholders also work in custom command args.

### Sounds

Use your own sound files:

```json
{
  "sounds": {
    "permission": "/path/to/alert.wav",
    "complete": "/path/to/done.wav",
    "subagent_complete": "/path/to/subagent-done.wav",
    "error": "/path/to/error.wav",
    "question": "/path/to/question.wav",
    "user_cancelled": "/path/to/cancelled.wav",
    "plan_exit": "/path/to/plan-ready.wav",
    "session_started": "/path/to/session-started.wav",
    "user_message": "/path/to/user-message.wav",
    "client_connected": "/path/to/client-connected.wav"
  }
}
```

Platform notes:

- macOS/Linux: .wav or .mp3 files work
- Windows: Only .wav files work
- If file doesn't exist, falls back to bundled sound

### Volumes

Set per-event volume from `0` to `1`:

```json
{
  "volumes": {
    "permission": 0.6,
    "complete": 0.3,
    "subagent_complete": 0.15,
    "error": 1,
    "question": 0.7,
    "user_cancelled": 0.5,
    "plan_exit": 0.6,
    "session_started": 0.35,
    "user_message": 0.2,
    "client_connected": 0.45
  }
}
```

- `0` = mute, `1` = full volume
- Values outside `0..1` are clamped automatically
- On Windows, playback still works but custom volume may not be honored by the default player

### Custom commands

Run your own script when something happens. Use `{event}`, `{message}`, `{sessionTitle}`, `{agentName}`, `{projectName}`, `{timestamp}`, and `{turn}` as placeholders:

```json
{
  "command": {
    "enabled": true,
    "path": "/path/to/your/script",
    "args": ["{event}", "{message}"],
    "minDuration": 10
  }
}
```

- `enabled` - Turn command on/off
- `path` - Path to your script/executable
- `args` - Arguments to pass, can use `{event}`, `{message}`, `{sessionTitle}`, `{agentName}`, `{projectName}`, `{timestamp}`, and `{turn}` tokens
- `minDuration` - Skip if response was quick, avoids spam (seconds)

#### Example: Log events to a file

```json
{
  "command": {
    "enabled": true,
    "path": "/bin/bash",
    "args": [
      "-c",
      "echo '[{event}] {message}' >> /tmp/opencode.log"
    ]
  }
}
```

## macOS: Pick your notification style

**osascript** (default): Reliable but shows Script Editor icon

```json
{ 
  "notificationSystem": "osascript" 
}
```

**node-notifier**: Shows OpenCode icon but might miss notifications sometimes

```json
{ 
  "notificationSystem": "node-notifier" 
}
```

**NOTE:** If you go with node-notifier and start missing notifications, just switch back or remove the option from the config. Users have reported issues with using node-notifier for receiving only sounds and no notification popups.

## Ghostty notifications

If you're using [Ghostty](https://ghostty.org/) terminal, you can use its native notification system via [OSC 9](https://ghostty.org/docs/vt/osc/9) escape sequences:

```json
{
  "notificationSystem": "ghostty"
}
```

This sends notifications directly through the terminal instead of using system notification tools. Works on any platform where Ghostty is running.

**macOS:** Ghostty delivers notifications through macOS Notification Center, which plays its own default sound. This can result in duplicate audio with the plugin's sound effects. Set `suppressGhosttySound` to `true` to skip the plugin's sound:

```json
{
  "notificationSystem": "ghostty",
  "suppressGhosttySound": true
}
```

Note: custom sounds configured via the `sounds` section still play — only default (bundled) sounds are suppressed.

If you're using Ghostty inside tmux, enable passthrough in your tmux config so OSC 9 notifications can pass through:

```tmux
set -g allow-passthrough on
```

Then reload tmux config:

```bash
tmux source-file ~/.tmux.conf
```

## Focus detection

When `suppressWhenFocused` is `true` (the default), notifications and sounds are skipped if the terminal running OpenCode is the active/focused window. The idea is simple: if you're already looking at it, you don't need an alert.

To disable this and always get notified:

```json
{
  "suppressWhenFocused": false
}
```

## Minimum duration threshold

You can suppress `complete` and `subagent_complete` notifications for short-lived sessions. Set `minDuration` to the number of seconds a session must exceed to trigger a done notification:

```json
{
  "minDuration": 10
}
```

With the above, if OpenCode finishes in under 10 seconds, no notification, sound, bell, or command is fired. Default is `0` (no threshold).

This is independent of `command.minDuration`, which only controls whether the custom command runs.

### Platform support

| Platform                                 | Method                                   | Requirements          | Status                         |
| ---------------------------------------- | ---------------------------------------- | --------------------- | ------------------------------ |
| macOS                                    | AppleScript (`System Events`)          | None                  | Untested                       |
| Linux X11                                | `xdotool`                              | `xdotool` installed | Untested                       |
| Linux Wayland (Hyprland)                 | `hyprctl activewindow`                 | None                  | Tested                         |
| Linux Wayland (Niri)                     | `niri msg --json focused-window`       | None                  | Tested                         |
| Linux Wayland (Sway)                     | `swaymsg -t get_tree`                  | None                  | Untested                       |
| Linux Wayland (KDE)                      | `kdotool`                              | `kdotool` installed | Tested                         |
| Linux Wayland (GNOME)                    | Not supported                            | -                     | Falls back to always notifying |
| Linux Wayland (river, dwl, Cosmic, etc.) | Not supported                            | -                     | Falls back to always notifying |
| Windows                                  | `GetForegroundWindow()` via PowerShell | None                  | Untested                       |

**Unsupported compositors**: Wayland has no standard protocol for querying the focused window. Each compositor has its own IPC, and GNOME intentionally doesn't expose focus information. Unsupported compositors fall back to always notifying.

**tmux/screen**: When running inside tmux, focus detection uses tmux pane state (`session_attached`, `window_active`, `pane_active`) via `tmux display-message`. This keeps suppression accurate when switching panes/windows/sessions. On Linux setups where window focus cannot be detected at all, tmux pane state is also used as a best-effort fallback. GNU Screen is not currently handled (falls back to always notifying).

**WezTerm panes**: When running in WezTerm with `WEZTERM_PANE` set, focus suppression is pane-aware via `wezterm cli list-clients --format json`. This means notifications are shown when you switch to a different WezTerm pane/tab.

**Fail-open design**: If detection fails for any reason (missing tools, unknown compositor, permissions), it falls back to always notifying. It never silently eats your notifications.

If you test on a platform marked "Untested" and it works (or doesn't), please open an issue and let us know.

## Linux: Notification Grouping

By default, each notification appears as a separate entry. During active sessions this can create noise when multiple events fire quickly (e.g. permission + complete + question).

Enable grouping to replace notifications in-place instead of stacking:

```json
{
  "linux": {
    "grouping": true
  }
}
```

With grouping enabled, each new notification replaces the previous one so you only see the latest event. This requires `notify-send` 0.8+ (standard on Ubuntu 22.04+, Debian 12+, Fedora 36+, Arch). On older systems it falls back to the default stacking behavior automatically.

Works with all major notification daemons (GNOME, dunst, mako, swaync, etc.) on both X11 and Wayland.

## KDE Plasma: Jump back to terminal from notification

On KDE Plasma/Wayland, clicking the popup body is not consistently delivered as a notification activation event.This plugin uses an explicit notification action button instead:

- **Jump to terminal** (action button on the popup card, or in notification history)

When clicked, the plugin runs its terminal-focus path. On KDE with `kdotool` installed, it auto-captures the startup terminal window ID and jumps back to that pinned window.
The action button is only enabled on Linux KDE sessions where `kdotool` is available.

## Updating

If Opencode does not update the plugin or there is an issue with the cache version:

```bash
# Linux/macOS
rm -rf ~/.cache/opencode/packages/@mohak34/opencode-notifier@beta
rm -rf ~/.cache/opencode/node_modules/@mohak34/opencode-notifier

# Windows
Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\opencode\packages\@mohak34\opencode-notifier@beta"
Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\opencode\node_modules\@mohak34\opencode-notifier"
```

Then restart OpenCode.

Verify installation:
```bash
cat ~/.cache/opencode/packages/@mohak34/opencode-notifier@beta/node_modules/@mohak34/opencode-notifier/package.json | grep version
```

## Troubleshooting

**macOS: Not seeing notifications?**
Go to System Settings > Notifications > Script Editor, make sure it's set to Banners or Alerts.

**macOS: node-notifier not showing notifications?**
Switch back to osascript. Some users report node-notifier works for sounds but not visual notifications on certain macOS versions.

**Linux: No notifications?**
Install libnotify-bin:

```bash
sudo apt install libnotify-bin  # Debian/Ubuntu
sudo dnf install libnotify       # Fedora
sudo pacman -S libnotify         # Arch
```

Test with: `notify-send "Test" "Hello"`

**Linux: No sounds?**
Install one of: `paplay`, `aplay`, `mpv`, or `ffplay`

**KDE Plasma: jumps to wrong terminal window or doesn't jump?**

Jump back feature tested on:

- KWin with default floating windows
- KWin + Krohnkite
- Ghostty and Konsole
- Warp
- OpenCode in tmux inside VS Code terminal

Most terminal emulators should work fine, but there can be exceptions.

Known limitations:

- Kitty is currently unsupported for this jump-back path (unstable focus targeting)
- Yakuake sessions are not supported for activity-specific jump-back behavior

You can still override manually (if needed) by pinning an explicit window ID:

```bash
export OPENCODE_NOTIFIER_WINDOW_ID="$(kdotool getactivewindow)"
opencode
```

Manual pinning bypasses heuristic window matching and should activate that exact window on notification action click.

**X11 deterministic jump-back**

- `xdotool` support is possible for the same startup pin behavior
- not implemented yet

**Windows: Custom sounds not working?**

- Must be .wav format (not .mp3)
- Use full Windows paths: `C:/Users/YourName/sounds/alert.wav` (not `~/`)
- Make sure the file actually plays in Windows Media Player
- If using WSL, the path should be accessible from Windows

**Windows WSL notifications not working?**
WSL doesn't have a native notification daemon. Use PowerShell commands instead:

```json
{
  "notification": false,
  "sound": true,
  "command": {
    "enabled": true,
    "path": "powershell.exe",
    "args": [
      "-Command",
      "$wshell = New-Object -ComObject Wscript.Shell; $wshell.Popup('{message}', 5, 'OpenCode - {event}', 0+64)"
    ]
  }
}
```

**Windows: OpenCode crashes when notifications appear?**
This is a known Bun issue on Windows. Disable native notifications and use PowerShell popups:

```json
{
  "notification": false,
  "sound": true,
  "command": {
    "enabled": true,
    "path": "powershell.exe",
    "args": [
      "-Command",
      "$wshell = New-Object -ComObject Wscript.Shell; $wshell.Popup('{message}', 5, 'OpenCode - {event}', 0+64)"
    ]
  }
}
```

**Plugin not loading?**

- Check your `opencode.json` or `config.json` syntax
- Clear the cache (see Updating section)
- Restart OpenCode

**Plugin installed but no notifications/sounds?**

- Check `suppressWhenFocused`: when `true` (default), notifications are skipped while OpenCode terminal is focused. Set to `false` to always notify.
- Check `enableOnDesktop`: defaults to `false`, so the plugin won't run on Desktop/Web clients. Set to `true` if you need it there.
- Verify the package is actually cached:
  ```bash
  cat ~/.cache/opencode/packages/@mohak34/opencode-notifier@beta/node_modules/@mohak34/opencode-notifier/package.json | grep version
  ```

## Changelog

See [CHANGELOG.md](CHANGELOG.md)

## License

MIT
