# Agent Notification Rings & Inbox

> The unread-state system that converts terminal notifications, hook events, and CLI notifications into pane rings, sidebar badges, a review page, and jump-to-unread workflows.

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

## Source Files

- `Sources/TerminalNotificationStore.swift`
- `Sources/TerminalNotificationQueue.swift`
- `Sources/TerminalNotificationPolicy.swift`
- `Sources/NotificationsPage.swift`
- `Sources/TerminalNotificationCallerResolver.swift`
- `cmuxTests/TerminalNotificationQueueTests.swift`
- `cmuxUITests/JumpToUnreadUITests.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/TerminalController.swift](Sources/TerminalController.swift)
- [Sources/GhosttyTerminalView.swift](Sources/GhosttyTerminalView.swift)
- [Sources/App/MenuBarExtraController.swift](Sources/App/MenuBarExtraController.swift)
- [Sources/AppDelegate.swift](Sources/AppDelegate.swift)
- [Sources/WorkspaceContentView.swift](Sources/WorkspaceContentView.swift)
- [Sources/Workspace.swift](Sources/Workspace.swift)
- [Sources/CmuxConfig.swift](Sources/CmuxConfig.swift)
- [Sources/Feed/FeedCoordinator.swift](Sources/Feed/FeedCoordinator.swift)
- [cmuxTests/TerminalNotificationQueueTests.swift](cmuxTests/TerminalNotificationQueueTests.swift)
- [cmuxTests/TerminalNotificationCallerTests.swift](cmuxTests/TerminalNotificationCallerTests.swift)
- [cmuxTests/JumpToUnreadUITests.swift](cmuxUITests/JumpToUnreadUITests.swift)
- [cmuxTests/NotificationAndMenuBarTests.swift](cmuxTests/NotificationAndMenuBarTests.swift)
</details>

# Agent Notification Rings & Inbox

cmux turns terminal and agent activity into a shared unread-state system: terminal desktop-notification actions, socket/CLI notification calls, feed permission events, and policy hooks all converge on `TerminalNotificationStore`. From there, the app derives pane rings, Bonsplit tab badges, the Notifications page, menu bar counts, dock badges, and jump-to-unread behavior.

This page is written as a feature scout: it identifies the reusable design, the concrete code paths that make it work, and the failure modes worth preserving if this system is copied or extended. The selected Compound Engineering profile was used only as page-shape guidance; no repository `STRATEGY.md` or `docs/solutions/**` source was present in this checkout, so implementation claims below cite cmux source files only.

Sources: [Sources/TerminalNotificationStore.swift:767-895](), [Sources/GhosttyTerminalView.swift:4268-4291](), [Sources/TerminalController.swift:3493-3513]()

## Product Workflow

A notification can arrive from several places:

- A terminal app action emits a desktop notification through Ghostty integration.
- A socket or CLI client calls `notification.create`, `notification.create_for_surface`, `notification.create_for_target`, or `notification.create_for_caller`.
- Feed/coordinator code builds a policy envelope for hook-controlled external alerts.
- A notification hook rewrites the payload or disables selected effects before the store records or delivers it.

The store then produces four user-facing outcomes:

| Outcome | What the user sees | Backing state |
| --- | --- | --- |
| Inbox row | A row in the Notifications page with unread dot, title, body, time, and workspace title | `notifications: [TerminalNotification]` |
| Pane or tab ring | A visible unread indicator on the pane/tab that owns the surface | `hasVisibleNotificationIndicator` plus workspace panel unread state |
| Menu/status badge | Menu bar icon count, inline recent notification list, menu actions | `NotificationMenuSnapshot` |
| Jump target | `Jump to Latest Unread` opens the newest actionable unread item or workspace unread indicator | `jumpToLatestUnread` and workspace unread fallbacks |

Sources: [Sources/NotificationsPage.swift:16-45](), [Sources/WorkspaceContentView.swift:218-239](), [Sources/App/MenuBarExtraController.swift:166-190](), [Sources/AppDelegate.swift:11026-11047]()

```text
Terminal/Ghostty action       Socket/CLI notification       Feed/hook event
        |                              |                          |
        v                              v                          v
TerminalNotificationStore.addNotification / TerminalMutationBus queue
        |
        v
Policy hooks can rewrite payload and effect flags
        |
        v
Recorded notification + derived indexes + side effects
        |
        +--> pane rings / Bonsplit badges
        +--> Notifications page rows
        +--> menu bar and dock counts
        +--> jump-to-unread navigation
```

## Core Model: Notifications Plus Derived Unread Indexes

`TerminalNotification` is the record type. It carries workspace ID, optional surface and panel IDs, title/subtitle/body, creation time, read state, whether pane flash is allowed, and an optional click action such as reveal-in-Finder. Matching is panel-aware: a notification can match either its `surfaceId` or its resolved `panelId`, which matters when panes and surfaces are rebound.

The store keeps notifications newest-first and rebuilds indexes whenever the array changes. Those indexes track total unread count, unread count by workspace, unread surface keys, latest unread notification by workspace, and latest notification by workspace. Workspace-level manual, panel-derived, and restored unread indicators are counted separately so a workspace can be visibly unread even if it has no notification row.

Sources: [Sources/TerminalNotificationStore.swift:694-739](), [Sources/TerminalNotificationStore.swift:749-786](), [Sources/TerminalNotificationStore.swift:873-895](), [Sources/TerminalNotificationStore.swift:2126-2148]()

```swift
// Sources/TerminalNotificationStore.swift
private static func buildIndexes(for notifications: [TerminalNotification]) -> NotificationIndexes {
    var indexes = NotificationIndexes()
    for notification in notifications {
        if indexes.latestByTabId[notification.tabId] == nil {
            indexes.latestByTabId[notification.tabId] = notification
        }
        guard !notification.isRead else { continue }
        indexes.unreadCount += 1
        indexes.unreadCountByTabId[notification.tabId, default: 0] += 1
        indexes.unreadByTabSurface.insert(
            TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId)
        )
    }
    return indexes
}
```

## Ingress Paths

### Terminal Notifications

Ghostty desktop-notification actions are converted into store notifications. The code resolves the selected workspace and focused surface, respects workspace suppression for raw terminal notifications, uses the tab title as a fallback title, and records through `TerminalNotificationStore.shared.addNotification`.

Sources: [Sources/GhosttyTerminalView.swift:4268-4291](), [Sources/GhosttyTerminalView.swift:4544-4564]()

### CLI and Socket Notifications

The V2 socket router exposes notification operations for create, caller-targeted create, surface-targeted create, target-targeted create, list, clear, dismiss, mark-read, open, and jump-to-unread. Direct create methods resolve a workspace and surface, then call `deliverNotificationSynchronously`, which discards conflicting queued notifications for the same target before adding to the store. The caller-specific path resolves stale environment data by considering preferred workspace/surface IDs, TTY identity, registered window contexts, and the selected workspace.

Sources: [Sources/TerminalController.swift:3493-3513](), [Sources/TerminalController.swift:10222-10250](), [Sources/TerminalController.swift:10254-10323](), [Sources/TerminalNotificationQueue.swift:357-378](), [Sources/TerminalNotificationCallerResolver.swift:11-89]()

### Feed and Hook Events

Feed notifications use the same policy envelope shape, but default to effects that do not record inbox rows, mark unread, reorder workspaces, play sound, run commands, or flash panes. This lets feed events request external notification behavior while staying out of the pane-ring workflow unless policy changes it.

Sources: [Sources/Feed/FeedCoordinator.swift:696-748]()

## Queueing and Coalescing

`TerminalMutationBus` is the async boundary for socket-originated notification mutations. It stores pending mutations behind an `NSLock`, assigns sequences, batches up to 16 mutations per main-actor drain, and can coalesce repeated notification deliveries by generation plus `(tabId, surfaceId)`. Clear mutations drop matching pending notification deliveries before they drain, which prevents stale unread rings from appearing after a clear.

The boundary-generation methods are important: `markNotificationClearBoundary` advances the generation so a clear can discard older queued notifications without deleting fresh ones that arrived after the boundary.

Sources: [Sources/TerminalNotificationQueue.swift:35-90](), [Sources/TerminalNotificationQueue.swift:122-166](), [Sources/TerminalNotificationQueue.swift:168-233](), [Sources/TerminalNotificationQueue.swift:273-354]()

Tests lock in the main queue invariants: clearing drops a queued notification before drain, a queued clear only removes older notifications, regular queueing coalesces repeated same-surface events, and explicit async notify commands still deliver both side effects while only the latest record remains.

Sources: [cmuxTests/TerminalNotificationQueueTests.swift:96-142](), [cmuxTests/TerminalNotificationQueueTests.swift:144-197](), [cmuxTests/TerminalNotificationQueueTests.swift:199-255](), [cmuxTests/TerminalNotificationQueueTests.swift:257-360]()

## Policy Hooks and Provider Neutrality

Notification policy is intentionally provider-neutral. Hooks are resolved from cmux configuration files and executed as local shell commands; they receive JSON on stdin plus environment variables such as `CMUX_NOTIFICATION_TITLE`, `CMUX_NOTIFICATION_BODY`, `CMUX_NOTIFICATION_WORKSPACE_ID`, and `CMUX_NOTIFICATION_POLICY_JSON`. A hook can return a partial JSON patch that changes notification fields, effect flags, or `stop`.

This is BYOC/BYOK friendly because the architecture does not require any hosted model provider or proprietary connector. A hook can be a local script, repo script, catalog-provided command, or user-managed binary. A Grok-Wiki integration should document and surface these hooks as portable file/repository/catalog sources, not as a dependency on one model service.

Sources: [Sources/TerminalNotificationPolicy.swift:5-29](), [Sources/TerminalNotificationPolicy.swift:231-279](), [Sources/TerminalNotificationPolicy.swift:553-562](), [Sources/TerminalNotificationPolicy.swift:763-818](), [Sources/TerminalNotificationPolicy.swift:913-928](), [Sources/CmuxConfig.swift:2282-2342]()

| Effect flag | Meaning in the store |
| --- | --- |
| `record` | Whether to create or replace an inbox row |
| `markUnread` | Whether the new record starts unread |
| `reorderWorkspace` | Whether notification activity can move the workspace to top |
| `desktop` | Whether to schedule a macOS user notification |
| `sound` | Whether to play notification sound or attach sound to desktop notification |
| `command` | Whether to run the custom notification command |
| `paneFlash` | Whether unread state can request pane flash/ring attention |

Tests show hooks can transform notification content, disable desktop delivery, return partial effect patches, and preserve omitted flags.

Sources: [cmuxTests/NotificationAndMenuBarTests.swift:16-50](), [cmuxTests/NotificationAndMenuBarTests.swift:52-90](), [cmuxTests/NotificationAndMenuBarTests.swift:92-119]()

## Store Application and Side Effects

`addNotification` handles cooldowns, builds the policy context, authorizes hooks, evaluates policy, and applies either the hook-modified envelope or default effects on failure. Applying a notification creates a `TerminalNotification` whose `isRead` is the inverse of `effects.markUnread`, then either records it or executes side effects only.

Recording replaces any existing notification for the same workspace/surface pair, clears the workspace manual unread marker, inserts the new record at the front, commits cooldown state, removes old macOS notification requests off-main, and then dispatches side effects. If the app is already focused on the target workspace/surface, external delivery is suppressed and local feedback is used instead.

Sources: [Sources/TerminalNotificationStore.swift:1156-1256](), [Sources/TerminalNotificationStore.swift:1297-1339](), [Sources/TerminalNotificationStore.swift:1368-1472](), [Sources/TerminalNotificationStore.swift:1474-1507]()

The macOS notification path writes `tabId`, `surfaceId`, and `notificationId` into `userInfo`, attaches custom click-action metadata when present, and falls back to local sound/command feedback when desktop delivery is unavailable. Notification removal is deliberately dispatched off-main because `UNUserNotificationCenter` removal can block on XPC.

Sources: [Sources/TerminalNotificationStore.swift:12-36](), [Sources/TerminalNotificationStore.swift:1853-1925](), [Sources/TerminalNotificationStore.swift:1939-1955]()

## Pane Rings, Tab Badges, and Workspace Indicators

Pane rings are not stored as a separate notification object. They are derived from store state and workspace panel state. `hasVisibleNotificationIndicator` returns true when there is an unread notification for the workspace/surface or when a focused read indicator remains for that tab/surface. Workspace rendering combines that with manual and restored panel unread IDs, then passes the result into `Workspace.shouldShowUnreadIndicator`.

This design matters because ring visibility survives more than one source of attention: actual unread notifications, user-marked workspace unread, restored session unread state, and panel-level manual unread state.

Sources: [Sources/TerminalNotificationStore.swift:1122-1137](), [Sources/WorkspaceContentView.swift:218-239](), [Sources/WorkspaceContentView.swift:344-379](), [Sources/WorkspaceContentView.swift:439-483](), [Sources/Workspace.swift:10263-10308]()

## Inbox and Menu Surfaces

The Notifications page is a review inbox. It shows an empty state when there are no notifications or workspace unread indicators, a workspace-unread-only hint when badges exist without notification rows, and otherwise a scrollable list of rows. Opening a row calls `AppDelegate.openTerminalNotification`; clearing a row removes it from the store. The page also has `Clear All` and `Jump to Latest Unread`, with the jump button disabled when there is no unread state.

Sources: [Sources/NotificationsPage.swift:11-67](), [Sources/NotificationsPage.swift:69-88](), [Sources/NotificationsPage.swift:115-156](), [Sources/NotificationsPage.swift:185-253]()

The menu bar surface consumes `NotificationMenuSnapshot`, not raw store internals. The snapshot counts unread rows plus workspace unread indicators, exposes whether any notification exists, and includes up to six recent inline notifications by default. The menu uses that snapshot to enable or disable jump, mark-all-read, and clear-all actions, update the icon, and rebuild inline notification items.

Sources: [Sources/App/MenuBarExtraController.swift:75-82](), [Sources/App/MenuBarExtraController.swift:166-190](), [Sources/App/MenuBarExtraController.swift:209-242](), [Sources/App/MenuBarExtraController.swift:316-361]()

## Jump-to-Unread Workflow

`jumpToLatestUnread` scans notifications newest-first and opens the first unread notification that is actionable. It skips excluded IDs and click-action notifications, because click actions may reveal a file instead of focusing a pane. If no notification row can be opened, it falls back to workspace unread indicators and opens the preferred unread panel.

Opening a notification switches sidebar selection back to tabs, brings the owning window forward, focuses the tab/surface, and marks the notification read after successful focus. If the owning window context is not registered yet, the app falls back to the active tab manager/window path.

Sources: [Sources/AppDelegate.swift:11026-11055](), [Sources/AppDelegate.swift:11057-11098](), [Sources/AppDelegate.swift:14730-14743](), [Sources/AppDelegate.swift:14774-14814](), [Sources/AppDelegate.swift:14816-14860](), [Sources/AppDelegate.swift:14917-14933]()

The UI test covers the user-level promise: pressing `Command-Shift-U` focuses the expected workspace and surface across tabs, using test-only data written by the app to verify the focused model state.

Sources: [cmuxUITests/JumpToUnreadUITests.swift:14-50](), [Sources/AppDelegate.swift:11026-11035](), [Sources/AppDelegate.swift:14936-14952]()

## Read, Clear, and Dismiss Semantics

Read and clear operations are scoped. `markRead(id:)` marks one row read. `markRead(forTabId:)` marks all unread rows in a workspace, clears workspace manual unread, clears panel unread indicators, and removes delivered notifications. The `(tabId, surfaceId)` overload only touches matching notifications, and a `nil` surface also clears workspace-level restored/panel-derived unread state. `markAllRead` clears all notification and workspace unread indicators.

Clear operations remove rows rather than marking them read. `clearAll` discards pending queued notifications by default, empties all unread indicator sets, clears focused read indicators, publishes a notification-cleared event, and removes delivered/pending macOS notifications. Scoped clear methods remove only matching workspace or surface records and publish scoped clear events.

Sources: [Sources/TerminalNotificationStore.swift:1558-1610](), [Sources/TerminalNotificationStore.swift:1612-1687](), [Sources/TerminalNotificationStore.swift:1689-1779](), [Sources/TerminalNotificationStore.swift:1817-1844](), [Sources/TerminalController.swift:10391-10460](), [Sources/TerminalController.swift:10549-10551]()

## What To Preserve When Extending

- Keep one store as the source of truth. UI surfaces should consume `TerminalNotificationStore` or `NotificationMenuSnapshot`, not invent their own unread counts.
- Use `TerminalMutationBus` for socket-driven async mutations so clears and notification arrivals remain ordered.
- Treat policy hooks as local, portable automation. They may call a model provider if a user chooses, but cmux should not require one.
- Preserve target validation before queued delivery. The queue skips notifications whose workspace/surface no longer exists.
- Keep `UNUserNotificationCenter` removal off-main; the code comments identify synchronous XPC as a UI-freeze risk.
- Add behavioral tests for queue boundaries and jump focus. Existing tests assert the cases that are easiest to regress.

Sources: [Sources/TerminalNotificationQueue.swift:381-419](), [Sources/TerminalNotificationStore.swift:12-36](), [cmuxTests/TerminalNotificationQueueTests.swift:431-478](), [cmuxTests/TerminalNotificationCallerTests.swift:22-88](), [cmuxTests/TerminalNotificationCallerTests.swift:90-160]()

In short, the notification system is valuable because it treats unread state as a product workflow, not just a desktop alert. One policy-aware store drives panes, tabs, inbox rows, menu badges, dock counts, and jump navigation while keeping hook execution and provider choice portable. Sources: [Sources/TerminalNotificationStore.swift:767-895](), [Sources/AppDelegate.swift:11026-11047](), [Sources/App/MenuBarExtraController.swift:330-361]().
