# Notification Rings — How Agents Get Your Attention

> When an AI coding agent needs input, cmux lights up a blue ring around the pane and badges the sidebar tab. This page explains the queue, policy, and store that decide when to ring, how rings are cleared, and why the removal calls run off the main thread to avoid UI freezes.

- Repository: manaflow-ai/cmux
- GitHub: https://github.com/manaflow-ai/cmux
- Human wiki: https://grok-wiki.com/public/wiki/manaflow-ai-cmux-e789cf316a9a
- Complete Markdown: https://grok-wiki.com/public/wiki/manaflow-ai-cmux-e789cf316a9a/llms-full.txt

## Source Files

- `Sources/TerminalNotificationStore.swift`
- `Sources/TerminalNotificationQueue.swift`
- `Sources/TerminalNotificationPolicy.swift`
- `Sources/NotificationsPage.swift`
- `Sources/TerminalNotificationCallerResolver.swift`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [Sources/TerminalNotificationStore.swift](Sources/TerminalNotificationStore.swift)
- [Sources/TerminalNotificationQueue.swift](Sources/TerminalNotificationQueue.swift)
- [Sources/TerminalNotificationPolicy.swift](Sources/TerminalNotificationPolicy.swift)
- [Sources/NotificationsPage.swift](Sources/NotificationsPage.swift)
- [Sources/TerminalNotificationCallerResolver.swift](Sources/TerminalNotificationCallerResolver.swift)
- [Sources/Panels/TerminalPanelView.swift](Sources/Panels/TerminalPanelView.swift)
- [Sources/ContentView.swift](Sources/ContentView.swift)
</details>

# Notification Rings — How Agents Get Your Attention

When an AI coding agent running inside a cmux terminal pane finishes a task (or needs input), cmux lights up a **blue ring** around that pane and adds a **badge** to the matching workspace tab in the sidebar. These two visual signals let you notice important events without having to watch every terminal window constantly.

This page explains the full pipeline: how a notification is born from a socket command, queued, policy-evaluated, stored, and finally rendered as that ring — and why the code that removes rings is deliberately moved off the main thread.

---

## The Big Picture

```text
Terminal process / agent
        │
        │ (socket command: notify)
        ▼
TerminalNotificationCallerResolver  ← resolves which pane/workspace
        │
        ▼
TerminalMutationBus (queue)         ← coalesces rapid-fire events
        │  drain on MainActor
        ▼
TerminalNotificationPolicyEngine    ← runs optional hook scripts
        │
        ▼
TerminalNotificationStore           ← single source of truth
        │  @Published notifications
        ├──► TerminalPanelView       ← blue ring (pane-level)
        ├──► ContentView / TabItem   ← badge on sidebar tab
        └──► NotificationsPage       ← in-app notification list
```

---

## Step 1 — Resolving the Caller

A socket command `notification.create` arrives from the terminal process. Before any UI changes can happen, cmux must figure out **which pane** fired the notification, because the agent may not know its own workspace ID.

`TerminalNotificationCallerResolver.swift` (via `TerminalController.v2NotificationCreateForCaller`) resolves the target through a priority chain:

1. **Preferred workspace + surface IDs** supplied directly by the caller.
2. **Caller TTY name** — matched by scanning `workspace.surfaceTTYNames` across all open tab managers.
3. **Currently selected workspace**, used as a final fallback.

Sources: [Sources/TerminalNotificationCallerResolver.swift:53-89]()

---

## Step 2 — The Mutation Queue

Once a target pane is resolved, the notification is handed to `TerminalMutationBus`, which is the serializing queue between off-main-thread socket handlers and the main-actor `TerminalNotificationStore`.

### Why a queue at all?

Socket events can arrive faster than SwiftUI can re-render. Without queuing, each event would trigger a full SwiftUI diffing pass. The bus coalesces duplicate notifications for the same (workspace, surface) pair so only the most recent content lands in the store.

### Coalescing

Every enqueued notification carries a `TerminalNotificationCoalescingKey` keyed on `(generation, tabId, surfaceId)`. When a new notification arrives for the same key, any existing pending entry for that key is removed before the new one is appended:

```swift
// Sources/TerminalNotificationQueue.swift:136-140
if let coalescingKey {
    pending.removeAll { entry in
        entry.notificationCoalescingKey == coalescingKey
    }
}
```

### Batched drain

The bus drains up to 16 mutations per tick (`maxMutationsPerDrain = 16`) on the `@MainActor`, then schedules another drain if more remain. This keeps the main thread responsive even during bursts.

Sources: [Sources/TerminalNotificationQueue.swift:43-165]()

### Generation fencing

`markNotificationClearBoundary()` returns a monotonic generation counter. Clear commands enqueued before a given generation can be discarded by `discardPendingNotifications(forTabId:through:)`, preventing cleared notifications from re-appearing if a slow drain delivers them late.

Sources: [Sources/TerminalNotificationQueue.swift:84-103]()

---

## Step 3 — Policy Evaluation

After the queue drains a `deliverNotification` entry, `TerminalNotificationStore.addNotification` is called on the main actor. Before the notification is stored, the system checks whether the user has configured any **notification hooks** — executable scripts that can inspect and rewrite the notification.

### The policy request

A `TerminalNotificationPolicyRequest` captures the notification content plus context:

| Field | Meaning |
|-------|---------|
| `tabId` / `surfaceId` | Which workspace/pane |
| `title` / `subtitle` / `body` | Notification text |
| `cwd` | Working directory of the pane |
| `isAppFocused` | Whether cmux is the key window |
| `isFocusedPanel` | Whether this specific pane has keyboard focus |

Sources: [Sources/TerminalNotificationPolicy.swift:188-219]()

### Policy effects

Each hook can return a JSON patch that adjusts a `TerminalNotificationPolicyEffects` struct. All effects default to `true`:

| Effect | What it controls |
|--------|-----------------|
| `record` | Whether the notification is saved in the store |
| `markUnread` | Whether the pane ring lights up |
| `paneFlash` | Whether the pane briefly flashes |
| `desktop` | Whether a macOS system notification is sent |
| `sound` | Whether a sound plays |
| `command` | Whether a custom shell command runs |
| `reorderWorkspace` | Whether the workspace moves to the top of the list |

Sources: [Sources/TerminalNotificationPolicy.swift:21-51]()

### Hook execution

If hooks are configured, `TerminalNotificationPolicyEngine.evaluate` spawns each hook via `posix_spawn` with the notification payload as stdin (JSON), environment variables (`CMUX_NOTIFICATION_TITLE`, `CMUX_NOTIFICATION_BODY`, etc.), and a per-hook working directory. Hooks chain: the output of hook N becomes the input of hook N+1. A `"stop": true` in the JSON output short-circuits remaining hooks. Output is capped at 1 MB; hooks that exceed this limit or time out are killed with SIGTERM, then SIGKILL after 750 ms.

Sources: [Sources/TerminalNotificationPolicy.swift:228-311, 691-714]()

---

## Step 4 — The Store

`TerminalNotificationStore` is the single `@MainActor` `ObservableObject` that owns all notification state. It is a shared singleton accessed throughout the app.

### What it holds

```swift
@Published private(set) var notifications: [TerminalNotification]
@Published private(set) var manualUnreadWorkspaceIds: Set<UUID>
@Published private(set) var panelDerivedUnreadWorkspaceIds: Set<UUID>
@Published private(set) var restoredUnreadWorkspaceIds: Set<UUID>
@Published private(set) var focusedReadIndicatorByTabId: [UUID: UUID]
```

Sources: [Sources/TerminalNotificationStore.swift:767-787]()

### The notification struct

`TerminalNotification` is a value type that carries IDs for the workspace (`tabId`), pane (`surfaceId`), and optionally the split panel (`panelId`). `isRead` and `paneFlash` are mutable fields that the store updates in-place.

Sources: [Sources/TerminalNotificationStore.swift:694-740]()

### Fast lookup indexes

Every time `notifications` changes, `buildIndexes` rebuilds a `NotificationIndexes` struct that caches:
- `unreadCount` — total unread across all workspaces
- `unreadCountByTabId` — per-workspace badge count
- `unreadByTabSurface` — set of (tabId, surfaceId) pairs that have unread notifications
- `latestByTabId` — most recent notification per workspace

This means queries like `hasUnreadNotification(forTabId:surfaceId:)` are O(1) set lookups, not O(n) scans.

Sources: [Sources/TerminalNotificationStore.swift:2126-2148]()

---

## Step 5 — Rendering the Ring and Badge

### The blue ring

`TerminalPanelView` receives `hasUnreadNotification` as a plain `Bool` parameter (keeping it outside the snapshot boundary — see the Pitfalls section of `CLAUDE.md`). The ring is rendered via the underlying `GhosttyTerminalView`, which accepts `showsUnreadNotificationRing`:

```swift
// Sources/Panels/TerminalPanelView.swift:36
showsUnreadNotificationRing: hasUnreadNotification && notificationPaneRingEnabled,
```

The `notificationPaneRingEnabled` flag reads `UserDefaults` key `notificationPaneRingEnabled` (default `true`), so users can disable the ring in Settings.

Sources: [Sources/Panels/TerminalPanelView.swift:9-36]()

The `hasUnreadNotification` value itself comes from `TerminalNotificationStore.hasVisibleNotificationIndicator(forTabId:surfaceId:)`, which returns `true` if either there is an unread notification for that pane or there is a focused-read indicator (a transient highlight shown briefly when the focused pane receives a notification while the app is in focus).

Sources: [Sources/TerminalNotificationStore.swift:1134-1137]()

### The sidebar badge

`ContentView` computes `shouldShowUnread` for each workspace tab by combining three sources:
- `notificationStore.hasVisibleNotificationIndicator` (notification-derived unread)
- `workspace.manualUnreadPanelIds` (user-marked unread)
- `workspace.restoredUnreadPanelIds` (persisted unread from session restore)

Sources: [Sources/ContentView.swift:1342-1347]()

### The notification panel

`NotificationsPage` renders the in-app notification list. Each `NotificationRow` shows a filled accent-colored circle (blue dot) for unread entries and a faded circle for read ones. Clicking a row calls `AppDelegate.shared?.openTerminalNotification(notification)`, which focuses the relevant workspace and clears the ring.

Sources: [Sources/NotificationsPage.swift:195-254]()

---

## Step 6 — Clearing Rings

A ring disappears when the notification it represents is marked read. There are several triggers:

| Action | Store method called |
|--------|-------------------|
| User opens/focuses the pane | `markRead(forTabId:surfaceId:)` |
| User clicks "Clear All" in the panel | `markAllRead()` |
| User clicks ✕ on a single row | `remove(id:)` |
| CLI command | `clearNotifications(forTabId:surfaceId:)` |
| Session restore replaces notifications | `restoreSessionNotifications(_:forTabId:)` |

Every one of these methods eventually calls `UNUserNotificationCenter` to remove the corresponding system notification banner.

### Why removal is off the main thread

`UNUserNotificationCenter.removeDeliveredNotifications` and `removePendingNotificationRequests` perform **synchronous XPC** to the `usernoted` daemon. When the system is slow, this call can block indefinitely. Blocking on the main thread would freeze the entire UI.

cmux solves this by adding two extension helpers on `UNUserNotificationCenter` that dispatch the actual removal work to a shared `.utility` serial queue named `com.cmuxterm.notification-removal`:

```swift
// Sources/TerminalNotificationStore.swift:17-35
extension UNUserNotificationCenter {
    private static let removalQueue = DispatchQueue(
        label: "com.cmuxterm.notification-removal",
        qos: .utility
    )

    func removeDeliveredNotificationsOffMain(withIdentifiers ids: [String]) {
        guard !ids.isEmpty else { return }
        Self.removalQueue.async {
            self.removeDeliveredNotifications(withIdentifiers: ids)
        }
    }
    // ... same pattern for pending requests
}
```

Every `markRead`, `clearAll`, `remove`, and `restoreSessionNotifications` call uses these helpers instead of the synchronous versions.

Sources: [Sources/TerminalNotificationStore.swift:12-35]()

---

## Focus Suppression

When the user is actively looking at the pane that fires a notification (cmux is the key window, the correct workspace tab is selected, and the exact surface has keyboard focus), cmux suppresses the macOS system banner and sound — but still records the notification in the store. This avoids interrupting the user when they are already watching.

The check is implemented in `shouldSuppressExternalDelivery`:

```swift
// Sources/TerminalNotificationStore.swift:1474-1481
return AppFocusState.isAppFocused() && isActiveTab && isFocusedSurface
```

When suppressed, a **focused-read indicator** is set (`focusedReadIndicatorByTabId`). This causes the ring to briefly appear even though the notification was immediately read, giving the user a momentary visual acknowledgment.

Sources: [Sources/TerminalNotificationStore.swift:1474-1482, 1445-1446]()

---

## Three Categories of Workspace Unread

The sidebar badge rolls up three separate unread signals, each with its own lifecycle:

| Signal | Set by | Cleared by |
|--------|--------|-----------|
| `manualUnreadWorkspaceIds` | User marks workspace unread | User marks it read, or a new notification arrives |
| `panelDerivedUnreadWorkspaceIds` | A panel inside reports unread activity | User reads the workspace |
| `restoredUnreadWorkspaceIds` | Session restore re-opens a workspace that had unread state | User reads the workspace |

The total workspace badge count is the sum of the notification-derived unread count plus one for each active workspace unread indicator.

Sources: [Sources/TerminalNotificationStore.swift:873-885, 1103-1108]()

---

## Summary

Notification rings in cmux follow a clear assembly line: a socket command is resolved to a specific pane by TTY name or workspace ID, coalesced in a mutex-protected queue, evaluated by optional policy hook scripts that can silence or redirect the notification, stored in a main-actor observable object that computes fast O(1) indexes, and finally rendered as a blue ring in `TerminalPanelView` and a badge in the sidebar. When rings are cleared, the `UNUserNotificationCenter` removal calls run on a dedicated `.utility` serial queue — never on the main thread — to prevent UI freezes caused by slow XPC to `usernoted`. This design is fully documented in the source comment at `Sources/TerminalNotificationStore.swift:12-16`.
