You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(server,web,mobile): let plugins send notifications - #16062
Rebased 2026-10-10 onto #15010's current head (1fe8efd69a, on main 57b3780). The notifications handler is registered in main's instrumentation map, and the session-bound notifications subscription is added to main's raw-client allowlist for no-rpc-permission-bypass. The follow-up commits in this PR answer review-bot findings; GPT-6.1 Sol (high) reviewed them: SHIP. PluginNotifications imports normalizeContributionStatusText from the status store's new home in @t3tools/provider-core. Main moved the host process references into a HostProcess module (#17641), so the plugin notifications test provides the process arguments through HostProcess.Arguments. GPT-6.1 Sol (high) reviewed this port: SHIP. A bot review asked for main's Effect service rules here; "refactor(server): follow the Effect service rules in plugin notifications" applies them with no behaviour change (GPT-6.1 Sol (high): SHIP). Captures below were taken at the revisions they name. At this head (7d0a8ad6c3) these pass: focused tests (7 files, 55 tests), typecheck (@t3tools/mobile, t3, @t3tools/web, @t3tools/client-runtime, @t3tools/contracts), lint and fmt on the changed files, knip, lint:mobile.
iOS: this PR's mobile surfaces were also captured on an iPhone 17 Pro simulator (iOS 26.5), at the top-of-stack head 5ec8b17, which contains this PR: dark-notification-banner · light-notification-banner. These are after-only captures; the before/after comparison below is on Android.
Problem
A plugin cannot tell the user that something happened. A plugin that watches turns, CI or a deploy can react to events, but the only way it can reach the user is a status on one thread, which is easy to miss when the user is on another thread or another device.
Why this qualifies
This is the proposal route in CONTRIBUTING, and no maintainer has agreed to it yet. It needs the plugin-system approval on #6714 / #6837, like the plugin PRs below it.
It stacks on the plugin statuses PR, which adds the host's per-process lifetime and rate limiter that this PR reuses. Statuses and notifications were built together but are separate channels (statuses ride the existing thread status stream; notifications need their own stream, retention and client hosts), so they are separate PRs. If the answer is no, we close both. Previous PR in this stack: feat(server,web,mobile): let plugins show statuses on threads (#16061).
Fix
Contract (pluginNotifications.ts): PluginNotification (sequence, plugin id and name, title ≤ 80, body ≤ 240, tone, optional thread, created-at) and PluginNotificationFrame { epoch, notifications }; plugins.notifications.subscribe stream; pluginNotifications environment capability; PLUGIN_NOTIFICATIONS_CAPABILITY = "notifications".
Delivery model: the server's bounded retained set is the state. It keeps the newest 20 notifications for 2 minutes of elapsed (monotonic) time, in memory, under a random epoch per server start. Every subscription's first frame is the current set, and the whole set is sent again after every change: a notification sent, the oldest evicted, one expiring (a timer wakes at the next expiry), or a stopped process's notifications withdrawn. Each notification is at most 4,096 bytes encoded, so a frame is at most 82,432 bytes. Nothing is durable.
Clients keep a per-environment high-water mark (epoch, sequence): the first frame on launch only sets it, so nothing old pops; a notification above the mark toasts once; a toast whose notification left the set closes. A reconnect therefore shows what it missed once and closes what was withdrawn meanwhile; a new epoch (restart) closes the old run's toasts. Each client subscribes only on a session whose own server config advertises the capability.
Server (PluginNotifications.ts): host method notifications.show; notifications belong to the producing process's lifetime and are withdrawn when it stops. Per process: 5 at once, then one every 5 seconds; refusals for size, bad input, undeclared capability or a stopped process reach the plugin as errors.
Authorization: the stream is in the RPC group's scope middleware at orchestration read scope (standard pairings see plugin notifications, like statuses). Producing them needs the consented capability.
Author API: context.proposed.notify({ title, body?, tone?, threadId? }), present only with the notifications capability, which needs "proposedApi": true.
Web/desktop: PluginNotificationCoordinator shows toasts per environment, with Open thread when a thread is named; removing an environment closes its toasts. Mobile: an in-app banner under the status bar, one at a time for 5 seconds (queue ≤ 5), tap opens the thread; withdrawn and removed-environment banners are dropped.
Plugin details: the capability list now explains notifications ("Can show you notifications."); before this PR the server refused the capability, so the list showed it by name only.
Docs: docs/user/plugins.md gains a "Notifications" section (where they show, limits, best-effort catch-up after a reconnect).
Size: 33 files, +1887 / −5; 1,043 of the added lines are tests.
Evidence
Environment: macOS arm64; this PR on top of the plugin statuses PR.
How to exercise it: isolated vp run dev on fresh state; add two scratch plugins with "capabilities": ["notifications", "actions"], "proposedApi": true whose thread actions call context.proposed.notify({ title, body, tone, threadId }) now or after 8 seconds ("Deploy finished", success; "Build failed", error); approve and enable them. Before = the plugin statuses PR, after = this PR. Captured on web (Chromium), the built desktop app (isolated profile, its own bundled server), Android (emulator) and a remote browser with a standard pairing over vp run dev --share.
Before: adding the plugin is refused (this server does not support notifications.); nothing can notify.
After: run "notify in 8 s" in the Deploy thread, move to another thread: a toast names the plugin and offers Open thread, which goes back to the Deploy thread (web MP4, desktop MP4).
Disabling a plugin from a third tab closes its toast in both other tabs (MP4). With toasts from two environments, removing one environment closes only its toast (MP4).
Remote offline/reconnect (standard pairing over --share, HTTPS) (MP4): with "Build failed" showing, the remote browser's socket was closed and the network taken offline for well under 2 minutes. Meanwhile an admin client sent "Deploy finished" and disabled the Build plugin. On reconnect (resubscribe payload {}) "Build failed" closed and "Deploy finished" appeared once. A second reconnect added nothing, a reload replayed nothing, and restarting the server (new epoch) closed the open toast without replaying it.
Old server: a client from this PR connected to both this server and one from the statuses PR sent plugins.notifications.subscribe only to the new one (0 calls to the old one) and shows no errors.
Android: a banner under the status bar shows the title and "From the proof fixture. · Deploy watcher (proof)". It dismisses itself after about 5 seconds, and tapping the next one opens the Deploy thread. Disabling the plugin from the web drops its banner (MP4).
Plugin details also explain the capability ("Can show you notifications."). Light and dark shots of every step are in the media folder.
Checks at this head (372c97edfc), re-run 2026-10-05 (CI=true vp test run …, all exit 0):
server PluginNotifications.test.ts, PluginNotificationsRpc.test.ts, PluginStatus.test.ts, RpcAuthorization.test.ts: 4 files, 29 tests pass. They cover the retained set first then the whole set per change; restart = new epoch with nothing retained; newest 20 kept and live expiry at 2 minutes (TestClock), including a backward wall-clock step; two subscribers both see a withdrawal and a late call is refused; rate limit; the per-notification and per-frame byte bounds; normalization and refusals; the manifest opt-in; a real plugin child process whose notification is withdrawn by disable and by a crash; and, through the real scope middleware, a reconnecting client whose new subscription's first frame is exactly the retained set without what was withdrawn while it was away, a relay:read client refused before the handler runs, and an orchestration:read client allowed.
client-runtime state/pluginNotifications.test.ts (9): per-session capability gate (zero calls on a session that does not advertise it, even with a cached config claiming support), launch shows nothing old, reconnect shows only what was missed and closes what was withdrawn, eviction and restart close toasts, bounded state over 10,000 notifications.
web PluginNotificationCoordinator.test.tsx (2, jsdom mount): toasts once across reconnect frames, closes on disable-while-away and shows a new epoch; removing one environment closes only its toasts. mobile plugin-notification-banners.test.ts (2): the same frames queue each banner once; environment removal.
Recorded on an earlier revision with an identical patch: on the parent the new suites do not load (new modules). Mutations: sending an empty first frame on resubscribe fails the reconnect test; registering the stream at relay:read fails both scope tests.
vp run --filter typecheck for contracts, t3, client-runtime, web and mobile; vp lint --report-unused-disable-directives and vp fmt --check on the touched files (5 warnings, all on unchanged lines of ws.ts and __root.tsx, the same at the parent); vp run knip:check; vp run lint:mobile; web build; vp run build:desktop; node scripts/release-smoke.ts. All pass.
Surfaces
Entry points: toasts (web, desktop) and banners (mobile), with Open thread. The way out is disabling or removing the plugin in Settings > Plugins; there is no per-plugin mute or user setting yet.
Clients: web, desktop (same coordinator, mounted at the root) and mobile (banner host in the root stack, iOS and Android).
Providers: independent of the provider; notifications come from plugins, not provider sessions. Codex, Claude, Cursor, Grok, OpenCode, Antigravity, Pi: unchanged.
Contracts: additive: one stream RPC, one optional capability flag, one schema module. New client + old server: never subscribes (per-session capability). Old client + new server: never calls the method.
Reverse states: a toast or banner closes when the notification leaves the retained set (process stopped, evicted, expired, restart) or its environment is removed; users can dismiss toasts.
Connection modes: the stream is the same over local, remote/relay and tunnel. A short disconnect (under 2 minutes and fewer than 20 newer notifications) catches up exactly once on reconnect; longer gaps miss notifications by design.
Docs: new "Notifications" section in docs/user/plugins.md. No internals doc.
Not verified
iOS was not captured: its native build is slow on the capture host and was not attempted; iOS shares the banner host with Android, which was captured. Removing an environment on Android was not captured (the development build's tools overlay covered Settings); the web capture and the queue tests cover it. The remote pass ran over the --share HTTPS origin, not the relay/T3 Connect tunnel. The mobile banner host has no React Native mount test.
The rate limiter reads wall time (shared with the statuses PR): a backward clock step delays refills until the clock catches up. Retention itself uses elapsed time.
Best-effort by design: a client away for more than 2 minutes, or while more than 20 newer notifications arrive, misses some; a notification sent and withdrawn between two frames a slow subscriber reads is never seen; a restart drops everything.
Web toasts also show while the tab is hidden; no OS notification is used. The mobile banner does not draw above native modal sheets.
Claude Opus 5.5 (build), GPT-6.1 Sol (review) and GPT-6 Astra (captures) via T3 Code
🤖 Generated with Claude Code
Stacked on #16061 (and #15010). Review only the top 3 commits: 7d0a8ad.
Problem
A plugin cannot tell the user that something happened. A plugin that watches turns, CI or a deploy can react to events, but the only way it can reach the user is a status on one thread, which is easy to miss when the user is on another thread or another device.
Why this qualifies
This is the proposal route in CONTRIBUTING, and no maintainer has agreed to it yet. It needs the plugin-system approval on #6714 / #6837, like the plugin PRs below it.
It stacks on the plugin statuses PR, which adds the host's per-process lifetime and rate limiter that this PR reuses. Statuses and notifications were built together but are separate channels (statuses ride the existing thread status stream; notifications need their own stream, retention and client hosts), so they are separate PRs. If the answer is no, we close both. Previous PR in this stack: feat(server,web,mobile): let plugins show statuses on threads (#16061).
Fix
pluginNotifications.ts):PluginNotification(sequence, plugin id and name, title ≤ 80, body ≤ 240, tone, optional thread, created-at) andPluginNotificationFrame { epoch, notifications };plugins.notifications.subscribestream;pluginNotificationsenvironment capability;PLUGIN_NOTIFICATIONS_CAPABILITY = "notifications".(epoch, sequence): the first frame on launch only sets it, so nothing old pops; a notification above the mark toasts once; a toast whose notification left the set closes. A reconnect therefore shows what it missed once and closes what was withdrawn meanwhile; a new epoch (restart) closes the old run's toasts. Each client subscribes only on a session whose own server config advertises the capability.PluginNotifications.ts): host methodnotifications.show; notifications belong to the producing process's lifetime and are withdrawn when it stops. Per process: 5 at once, then one every 5 seconds; refusals for size, bad input, undeclared capability or a stopped process reach the plugin as errors.context.proposed.notify({ title, body?, tone?, threadId? }), present only with thenotificationscapability, which needs"proposedApi": true.PluginNotificationCoordinatorshows toasts per environment, with Open thread when a thread is named; removing an environment closes its toasts. Mobile: an in-app banner under the status bar, one at a time for 5 seconds (queue ≤ 5), tap opens the thread; withdrawn and removed-environment banners are dropped.notifications("Can show you notifications."); before this PR the server refused the capability, so the list showed it by name only.docs/user/plugins.mdgains a "Notifications" section (where they show, limits, best-effort catch-up after a reconnect).Size: 33 files, +1887 / −5; 1,043 of the added lines are tests.
Evidence
Environment: macOS arm64; this PR on top of the plugin statuses PR.
How to exercise it: isolated
vp run devon fresh state; add two scratch plugins with"capabilities": ["notifications", "actions"], "proposedApi": truewhose thread actions callcontext.proposed.notify({ title, body, tone, threadId })now or after 8 seconds ("Deploy finished", success; "Build failed", error); approve and enable them. Before = the plugin statuses PR, after = this PR. Captured on web (Chromium), the built desktop app (isolated profile, its own bundled server), Android (emulator) and a remote browser with a standard pairing overvp run dev --share.Before: adding the plugin is refused (
this server does not support notifications.); nothing can notify.After: run "notify in 8 s" in the Deploy thread, move to another thread: a toast names the plugin and offers Open thread, which goes back to the Deploy thread (web MP4, desktop MP4).
Disabling a plugin from a third tab closes its toast in both other tabs (MP4). With toasts from two environments, removing one environment closes only its toast (MP4).
Remote offline/reconnect (standard pairing over
--share, HTTPS) (MP4): with "Build failed" showing, the remote browser's socket was closed and the network taken offline for well under 2 minutes. Meanwhile an admin client sent "Deploy finished" and disabled the Build plugin. On reconnect (resubscribe payload{}) "Build failed" closed and "Deploy finished" appeared once. A second reconnect added nothing, a reload replayed nothing, and restarting the server (new epoch) closed the open toast without replaying it.Old server: a client from this PR connected to both this server and one from the statuses PR sent
plugins.notifications.subscribeonly to the new one (0 calls to the old one) and shows no errors.Android: a banner under the status bar shows the title and "From the proof fixture. · Deploy watcher (proof)". It dismisses itself after about 5 seconds, and tapping the next one opens the Deploy thread. Disabling the plugin from the web drops its banner (MP4).
Plugin details also explain the capability ("Can show you notifications."). Light and dark shots of every step are in the media folder.
Checks at this head (
372c97edfc), re-run 2026-10-05 (CI=true vp test run …, all exit 0):PluginNotifications.test.ts,PluginNotificationsRpc.test.ts,PluginStatus.test.ts,RpcAuthorization.test.ts: 4 files, 29 tests pass. They cover the retained set first then the whole set per change; restart = new epoch with nothing retained; newest 20 kept and live expiry at 2 minutes (TestClock), including a backward wall-clock step; two subscribers both see a withdrawal and a late call is refused; rate limit; the per-notification and per-frame byte bounds; normalization and refusals; the manifest opt-in; a real plugin child process whose notification is withdrawn by disable and by a crash; and, through the real scope middleware, a reconnecting client whose new subscription's first frame is exactly the retained set without what was withdrawn while it was away, arelay:readclient refused before the handler runs, and anorchestration:readclient allowed.state/pluginNotifications.test.ts(9): per-session capability gate (zero calls on a session that does not advertise it, even with a cached config claiming support), launch shows nothing old, reconnect shows only what was missed and closes what was withdrawn, eviction and restart close toasts, bounded state over 10,000 notifications.PluginNotificationCoordinator.test.tsx(2, jsdom mount): toasts once across reconnect frames, closes on disable-while-away and shows a new epoch; removing one environment closes only its toasts. mobileplugin-notification-banners.test.ts(2): the same frames queue each banner once; environment removal.relay:readfails both scope tests.vp run --filtertypecheck for contracts, t3, client-runtime, web and mobile;vp lint --report-unused-disable-directivesandvp fmt --checkon the touched files (5 warnings, all on unchanged lines ofws.tsand__root.tsx, the same at the parent);vp run knip:check;vp run lint:mobile; web build;vp run build:desktop;node scripts/release-smoke.ts. All pass.Surfaces
docs/user/plugins.md. No internals doc.Not verified
--shareHTTPS origin, not the relay/T3 Connect tunnel. The mobile banner host has no React Native mount test.Claude Opus 5.5 (build), GPT-6.1 Sol (review) and GPT-6 Astra (captures) via T3 Code
🤖 Generated with Claude Code