Notifications¶
Roost's notification pipeline has three input paths and four output surfaces. All input paths converge on the same internal events, so the user-visible behavior is identical no matter how the notification was triggered.
Input paths¶
| Source | Triggered by | Best for |
|---|---|---|
roostctl notify |
A process running inside a Roost tab | Claude Code hooks, build scripts, structured pings |
| OSC 9 escape sequence | Any process printing \x1b]9;<message>\x07 |
iTerm2-style apps that already emit OSC 9 |
| OSC 777 escape sequence | Any process printing \x1b]777;notify;<title>;<body>\x07 |
Konsole / KDE-style apps |
roostctl is the preferred path because it carries structured fields (separate title and body, target tab) and bypasses VT parsing. The OSC paths exist as a fallback for tools that can't be modified.
OSC 9 disambiguation: bodies starting with a digit followed by ; (or only digits) are treated as ConEmu extensions (sleep / progress / message-box / etc.) and dropped — they are not iTerm2-style notifications. This is why Claude Code's frequent OSC 9;4 progress pings don't surface as banners. A genuine iTerm2 notification whose text happens to start with a digit (\x1b]9;1 file changed\x07) still passes through unchanged.
Output surfaces¶
A notification has four places it can show up. Under normal use three of them move together — see Focus policy below for why — while the sticky indicator is the deliberate exception.
- Pending-attention badge on the tab. A small dot on the trailing edge of the tab pill. Set when a notification is delivered for a non-focused tab; cleared when you select that tab.
- Sticky agent-state indicator on the tab. A small colored circle next to the title: blue (running), orange (needs your input), gray (idle / turn complete), red (agent failed), or none. This is the tab's effective state — the shell's own OSC 133 marks (a foreground process reads blue) when no agent owns the tab, or the owning agent's own lifecycle (
roostctl agent-hook ...,roostctl tab set-state) when one does. It is never focus-cleared; see Focus policy. - Project rollup stripe on the sidebar row. A 3px left-edge color stripe colored by the highest-ranked state across the project's tabs — ranked
failed > needs-input > running > idle, so a dead/errored agent's own color outranks a merely-blocked one. A tab a hook owns participates in this ranking like any other tab; it is not excluded (a project whose only blocked tab is a Claude session gets a stripe, not silence). - Desktop notification banner.
- macOS:
UNUserNotificationCenter(mac/Sources/Roost/DesktopNotifications.swift). Click the banner and the tab is focused in-process — noroostctlround-trip needed. Each banner gets a unique identifier (tab id + timestamp), so repeated notifications on the same tab stack in Notification Center rather than replacing one another. - Linux:
org.freedesktop.Notificationson the session bus (the Desktop Notifications spec — GNOME, KDE, COSMIC, and other spec servers). The UI speaks that interface directly over zbus (defaultaction +ActionInvoked;crates/roost-iced/src/notifications.rs) rather than through any desktop-environment-specific stack. The notification id is fixed per tab, so a new banner does replace the previous one for that tab. Click-to-focus is the spec default action → focus that tab, clear its badge, and best-effort raise the window (Wayland raise still needs anxdg-activationtoken iced 0.14 cannot consume — see Limits and caveats).
The macOS bullet above describes the Swift app. Roost-Iced on macOS (experimental) uses UNUserNotificationCenter too, but with a stable per-tab identifier rather than a unique one per event: a new notification for a tab replaces that tab's live banner instead of stacking another one in Notification Center — matching the Linux behavior above, not the Swift stacking. One live banner per tab, and a new event updates it in place. Banner clicks focus the tab while the app is running; a banner clicked after the app has quit and relaunched is not routed (unlike the Swift app).
A macOS permission change takes effect live, on both apps. Deny the authorization prompt (or later flip it in System Settings → Notifications) and Roost doesn't need a relaunch to notice: both the Swift app and Roost-Iced re-read the OS authorization state whenever the app regains focus, so granting permission and switching back to Roost is enough — the very next notification shows a banner. (Linux has no such prompt; a spec-compliant org.freedesktop.Notifications server is assumed present.)
Focus policy¶
One predicate governs the badge, the inbox row, and the banner together:
When it holds, the notification is dropped before it's recorded anywhere — no badge, no inbox row, no banner fires. This is decided once, in the workspace, at the moment the notification arrives (the attention branch inside Workspace::raise_attention / apply_agent_reports on Linux, the mirrored path on Mac) — not independently by each surface, and not as a UI-side filter applied after the fact. That is what makes "switch away afterward" safe: it does not retroactively produce a badge, because nothing was ever recorded to begin with.
The three surfaces are coupled on purpose. Inbox membership is the tab's pending-notification bit — the same has_notification flag that drives the badge — so "suppress the banner and badge but keep the inbox row" isn't expressible without tracking notification history separately from pending state. That's a durable-notification-history feature, and it's explicitly out of scope; rather than smuggle half of it in, the three stay coherently coupled: a notification for the tab you are actively looking at is considered seen.
On a host session, the window decides — not the session¶
The predicate above reads one window, and a host session has none. So a session suppresses nothing: every raise sets the pending bit and is pushed to every connected client. Each window then answers for itself, applying exactly the same predicate to its own focus and its own selection:
- The window showing that tab, with the focus, shows no banner and sends a quiet acknowledgement, which takes the badge and the inbox row down on every client — not just its own. It also never paints the tab as pending in the meantime, so nothing flashes at the person who is already looking.
- Every other window banners as usual, and keeps the badge until somebody clears it or selects the tab.
This matters even with one machine: on a fresh install your own tabs run in a session, so this is your laptop's own path and not only the multi-device one.
Because the answer now travels, four things are true that were not when the session made the decision:
- Another window may show the badge for the moment the acknowledgement takes to arrive. The window that is looking never does.
- Two windows on the same tab both acknowledge. The second one is simply told it arrived second; nothing breaks, and nothing is cleared twice.
- Bringing a window back to the front does not clear a badge. Focus alone has never acknowledged anything — locally or on a host. Selecting the tab does.
- A desktop banner already on your screen stays until you dismiss it, even when another client clears the tab. The badge and the inbox row go; the banner is yours.
The badge is state and is rebuilt from the session's tab list whenever a client resyncs. The banner is a moment and is never replayed — a client that resumes after a gap does not get a banner for something that has since been answered.
The sticky agent-state indicator is the one exception — it is never focus-cleared. That is what covers "I walked away and came back": even though the badge and inbox row for an earlier notification are long gone, the dot still shows needs_input (or the failed/red state) for a tab that wants you.
Triaging across projects¶
Pending notifications are also a navigable list, so you can jump straight to what needs attention instead of hunting through the sidebar:
- Jump to unread (
Cmd-Shift-U/Alt-Shift-U) focuses the next tab with a pending notification — the active project first, then the others. Focusing the tab clears its badge, so repeating the shortcut walks through everything pending. - The notification inbox is the command palette's View Notifications entry: one row per pending tab, labeled
<project> · <tab>with the message body. Activating a row jumps to that tab and clears it. - Clear All Notifications (also in the command palette) empties the inbox and drops every pending badge at once.
- Sidebar reveal. On Roost-Iced, none of these "jump to a tab" paths — jump-to-unread, activating a notification-inbox row, activating a row in the agent palette (
Cmd-Shift-O/Alt-Shift-O; see Keybindings), or clicking a notification banner — opens a collapsed projects sidebar; it stays collapsed, as it does forCmd-1..9and new tabs. The Swift Mac app still reveals the sidebar on each of them, once the jump actually succeeds (the target tab still exists).
Tab targeting¶
roostctl resolves the target tab in this order:
- The
--tab <id>flag, if provided - The
ROOST_TAB_IDenvironment variable, set by Roost when it spawns each tab's shell - Error: tab id required
Roost injects these environment variables into every spawned shell:
| Variable | Value |
|---|---|
ROOST_TAB_ID |
The integer tab id this shell is bound to |
ROOST_SOCKET |
The Unix-socket path of whatever spawned the tab (the GUI, or a session) |
So roostctl invoked from inside any tab needs no flags or config — it knows where to send and which tab to mark. On a project living on a host session, a tab's shell is spawned by that roost-session daemon, not the GUI, so ROOST_SOCKET there is the session's own socket. For this machine's local session roostctl sends each command to the window that owns the session, except the hooks, events, wait and tab prompt, which stay on the session (why). On a remote host it dials the host, not the window.
Hook-session OSC suppression¶
When a live agent session owns a tab — Claude Code, Codex, grok/gx, cursor-agent, or OpenCode driving it via roostctl agent-hook (see Agent Hooks), or any other source that has claimed ownership through tab.agent_report — raw OSC 9 / 99 / 777 notifications from inside that tab are dropped. Hooks are the trusted channel: the owning agent already reports its own attention through structured events, so letting its raw OSC noise through on top would double-notify for the same thing. Structured notifications (notification.create / roostctl notify) are never suppressed this way — gating an agent's own trusted channel would mute the very thing the suppression exists to protect; only the raw-OSC fallback path is gated.
Ownership is normally claimed by a SessionStart hook and released by a matching SessionEnd. But an agent can also disappear with no SessionEnd — killed, crashed, Ctrl-C'd out from under the shell — which would otherwise mute the tab's raw OSC forever. The failsafe: an OSC 133 D (command end) or A/B (prompt) mark drops the tab's agent lifecycle to inactive while leaving ownership in place as a label. A prompt mark only fires once the shell reaches it, which means whatever the agent was running has exited — so it's a safe trigger. With the lifecycle inactive, derivation falls through to the shell state and raw OSC re-opens immediately: a dead agent degrades to "the tab still remembers claude used to own it" cosmetically, instead of silently swallowing every notification from then on.
Manual override (tab set-state)¶
roostctl tab set-state --tab N --state STATE claims ownership as manual (an empty session id), which supersedes whatever currently owns the tab — if Claude is mid-turn, its subsequent hook events are dropped until its next SessionStart. That's intentional: running the command means you're taking the wheel.
--state none is the one case that releases ownership rather than claiming an inactive one, so the tab falls back to shell-derived state instead. Concretely: a tab with a live foreground process now shows running under --state none, not unconditionally none — "no state" means "no agent owns this tab," not "force the indicator blank."
Examples¶
From inside a Roost tab:
That works because every Roost tab sets ROOST_TAB_ID; without it or --tab, notify exits 2 rather than guess (why).
From outside Roost, target a specific tab:
ROOST_SOCKET="$HOME/Library/Caches/roost/roost.sock" \
roostctl notify --title "From CI" --body "deploy ready" --tab 3
OSC 9 from any shell inside a Roost tab:
OSC 777 with a separate title and body:
What you do not have to do¶
- You do not have to configure the socket path manually (
ROOST_SOCKETis auto-set). - You do not have to track tab ids manually (
ROOST_TAB_IDis auto-set). - You do not have to add anything to your shell config — the env vars come from the parent process.
Limits and caveats¶
- Body length is capped at 1 MiB on the OSC parser to bound buffer growth on a misbehaving sender. Longer bodies are truncated.
- On Wayland, a client cannot raise its own window without an
xdg-activationtoken handed to it by the compositor, and iced 0.14 has no way to consume one. So a programmatic raise — a click on a notification banner, or a CLI script callingroostctl tab focus— may only flash the taskbar entry instead of bringing the window forward. The tab focus itself always applies; it is only the window raise that the compositor can refuse.
See the Agent Hooks guide for how the sticky indicator and the hook-session OSC suppression above get wired up for all five supported agents, and Claude Code Hooks for what's specific to Claude.