Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
17ade3a
docs: notifications design spec
andrebrait Oct 6, 2026
8962653
feat: desktop and mobile notifications with Web Push
andrebrait Oct 6, 2026
93e6b82
fix(notifications): address review findings
andrebrait Oct 6, 2026
9d5e854
test(notifications): skip POSIX mode check on Windows
andrebrait Oct 6, 2026
2e5098d
fix(notifications): address Codex and CodeRabbit review
andrebrait Oct 6, 2026
6ca2871
fix(notifications): notify failed prompt responses; honor moved-sessi…
andrebrait Oct 6, 2026
1cb4641
fix(notifications): drop out-of-order presence reports; serialize pre…
andrebrait Oct 6, 2026
02891b8
fix(notifications): resync saved prefs on load even when disabled
andrebrait Oct 6, 2026
5aefbb7
fix(notifications): clear moved-session aliases on a real switch; sho…
andrebrait Oct 6, 2026
02ab6e6
fix(notifications): dim notification settings while notifications are…
andrebrait Oct 7, 2026
b2b1afd
feat(notifications): open the session from a click anywhere on its toast
andrebrait Oct 7, 2026
77cbc25
fix(notifications): ignore drags and pressed controls when a toast ca…
andrebrait Oct 7, 2026
a5899f0
fix(notifications): close the overlaid file panel when a notification…
andrebrait Oct 7, 2026
f0f9ab3
test(notifications): find each notification toast by its title
andrebrait Oct 7, 2026
03c88f7
test(notifications): load the toast module the hook uses, so Windows …
andrebrait Oct 7, 2026
4955035
fix(notifications): clip long session names in pushes and resubscribe…
andrebrait Oct 7, 2026
8c66efd
fix(notifications): a drag brought back to its start does not open th…
andrebrait Oct 7, 2026
8f99c19
feat(notifications): drop the Open link; the toast card itself opens …
andrebrait Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ app/api/
agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? }
agent/[id]/route.ts GET state | POST any RPC command
agent/[id]/events/route.ts GET SSE stream
agent/running/events/route.ts GET SSE stream of currently-running session ids
agent/running/events/route.ts GET SSE stream of currently-running session ids (+ this tab's notifications)
notifications/devices/route.ts GET VAPID key + subscribed | PUT { deviceId, prefs, subscription? }
notifications/presence/route.ts POST { clientId, deviceId, visible, sessionId, seq }
notifications/test/route.ts POST { deviceId } send a test push (502 on push-service refusal)
auth/** provider list, login/logout, API keys (via RPC)
cwd/validate/route.ts POST validate/select a cwd
default-cwd/route.ts POST create ~/omp-cwd-YYYYMMDD
Expand Down Expand Up @@ -104,6 +107,9 @@ lib/
pi-types.ts local structural types for agent/RPC objects
project-ordering.ts pure project sort/group/activity helpers (client + tests)
project-registry.ts on-disk managed-project registry (~/.omp/agent/projects.json)
notification-events.ts shared: event/prefs types, frame→event detector, renderNotification()
notification-hub.ts server: presence, device store + VAPID keys, routing, Web Push
notification-client.ts browser: device id, prefs store, service worker + push subscription
rpc-manager.ts session registry + startRpcSession over RpcProcess
session-reader.ts session .jsonl parsing + path cache + buildSessionContext
session-resume.ts running-session list for auto-resume after a restart
Expand Down Expand Up @@ -144,6 +150,7 @@ components/
hooks/
useAgentSession.ts messages + streaming + SSE + fork/navigate/reconciliation logic
useAudio.ts completion sound + browser AudioContext unlock
useNotifications.ts presence reports, toast/system delivery, notification clicks
useDragDrop.ts shared drag/drop state
useIsMobile.ts responsive breakpoint hook
usePrefersReducedMotion.ts OS reduce-motion preference (SMIL-safe)
Expand Down Expand Up @@ -583,6 +590,59 @@ during the wait.
- `hooks/useAudio.ts` stores the toggle in `localStorage` and reuses one `AudioContext`.
- Browser autoplay policy means sound must be unlocked from a user gesture; `ChatInput` calls the unlock hook from interactive controls, and `ChatWindow` plays the tone from `onAgentEnd`.

### Notifications (`lib/notification-*.ts`, `public/sw.js`, Settings → Notifications)
- Detection runs on the server in `AgentSessionWrapper.handleFrame` (and
`handleProcessExit` for crashes), so sessions no tab watches still notify.
`createNotificationDetector()` only inspects top-level frames; subagent
activity is wrapped in `subagent_*` frames and never notifies. "Completed"
fires on `session_settled` (not `prompt_result`), so background work started
by the prompt is done too; a failed or aborted last prompt suppresses it.
Auto-approved tool confirmations return before the hook and never notify.
omp sends `retry_fallback_applied` before the `auto_retry_start` of the same
failure, so the detector holds a reason-less fallback until that frame.
Failures also arrive as omp-web's own `prompt_error` (failed `response`) and
`process_exit` frames; the detector reports one failure per run.
- A session omp moved to a new file keeps its old ids as `aliases` on the
event, so a tab still showing an old id counts as viewing it.
- Presence and streams are separate on purpose. Presence: each tab reports
`{clientId, deviceId, visible, sessionId}` to `/api/notifications/presence`
(visibility/session change, input after idling, every 30 s while present;
expires 60 s after receipt). `visible` means visible AND input within 3 min.
`sessionId` is null while full-page Settings hides the chat. Streams: the
tab's open SSE connections (`attachNotificationClient`), which reconnect and
overlap independently of presence. `clientId` is per page load, `deviceId`
per browser (`localStorage`). Reports carry a per-page `seq`; the server
drops one older than the stored report (requests can arrive out of order).
Prefs PUTs run one at a time and read the prefs when sent, for the same reason.
- Routing (`routeNotification`, unit-tested): a visible tab on the session
drops the event everywhere (presence only, so a reconnecting stream cannot
leak it); else if a visible tab with an open stream exists, `whenActive:
"toast"` devices get a toast on their active tabs only; else push (subscribed
devices) or in-page `showNotification` (tabs with an open stream).
- The server decides before pushing: iOS revokes push permission for pushes
that display nothing, so `public/sw.js` always shows what it receives.
- Notifications ride the sidebar's `/api/agent/running/events?clientId=` stream,
not a new SSE connection: browsers allow six HTTP/1.1 connections per host.
`SessionSidebar` re-dispatches them as a window event. The sidebar unmounts
while Settings is open, so that tab gets no toasts then; it also stops
counting as active, so other devices are not silenced.
- Prefs and push subscriptions are per device, in `localStorage` and mirrored to
`~/.omp/agent/omp-web/notifications.json` (mode 0600; also holds the VAPID
private key). The server reads that file on every use, never caching it: an
installed server and `npm run dev` can share the agent dir. Push text is
rendered server-side in the device's locale. Browsers with saved settings (enabled or not) sync on load; never-configured browsers do not.
- A crash notifies "Run failed" while a run is live or while background work
started by it has not settled yet (`unsettled`, cleared by `session_settled`).
- The Settings test button pushes through the server for subscribed devices
(502 with the push service's answer on failure) and shows a local
notification otherwise, or a toast where none are allowed.
- `/sw.js` and `/badge-96.png` are exempt from the password gate (`proxy.ts`):
browsers re-fetch the worker for updates without the session cookie.
- Notification clicks survive an expired sign-in: `proxy.ts` sends `/?session=…`
to `/login?next=/?session=…`, and `LoginForm` returns there (only `/?…`
targets are accepted). The worker navigates non-app windows to the session
URL; the app falls back to that URL when `/api/sessions` cannot be read.

## omp Session File Format (v3)

Location: `~/.omp/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl`
Expand Down Expand Up @@ -628,7 +688,10 @@ motion: --dur-fast (150ms) --dur-med (220ms) --dur-slow (320ms) --ease-out-warm

`components/ui/` holds the shared primitives (built on `@base-ui/react`):
`primitives.tsx` (Dialog/Tooltip/Collapsible), `field.tsx` (form fields +
ConfirmDialog), `toast.tsx` (`toast.success/error/info`, mounted in AppShell).
ConfirmDialog), `toast.tsx` (`toast.success/error/info`, mounted in AppShell;
the `onClick` option makes the whole card the action: a click outside its
buttons, links and expandable text, ignoring drags and text selection, or Enter
on the focused card. There is no separate action button).
Icons come from `lucide-react` — do not add new inline SVGs. The command
palette (`components/CommandPalette.tsx`, ⌘K/Ctrl+K) is built on `cmdk`.

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f

### Added

- Add **Settings → Notifications**: desktop and mobile notifications for a finished task (after the prompt and any background work it started are done; subagents never notify), a question or approval waiting for you, a run that failed after retries and fallbacks, and a model switch omp made on its own (for example Fable falling back to Opus after a refusal, or a usage-limit fallback). Each browser chooses its own events and whether to show an in-app toast or a system notification while you use omp-web in another tab. Nothing is shown for the session you are viewing, and clicking a notification opens its session; an in-app toast opens it from a click or tap anywhere on it, or Enter when it has keyboard focus. Over HTTPS (or on `localhost`) notifications arrive through Web Push even with no omp-web tab open, including the iPhone/iPad app added to the Home Screen; the push keys and device list live in `~/.omp/agent/omp-web/notifications.json`. Notifications are off until you turn them on. This replaces the previous completion notification for the open session, and the agent's `notify` tool now also works on Android.
- Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114).
- Show conflicting skill names and deduplicated installations when an OMP session starts, with resolution details, a **×** button that dismisses the notice for the session until the diagnostic report changes, and a persisted **Turn off** action that disables startup notices. Inspect a running session manually in **Settings → Extensions & Tools → Skills**, even when notices are off; inspection never resumes a stopped session. Requires an OMP runtime with the skill-diagnostics RPC commands; older runtimes report live diagnostics as unavailable.
- Ask a side question with `/btw <question>`: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model.
Expand All @@ -32,6 +33,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f

### Fixes & Improvements

- The completion notification omp-web used to show for the open session when its tab was in the background is gone, along with its permission prompt on the first finished run. Turn on **Settings → Notifications** to get notifications again; they now cover every session, not only the open one.
- Balance the composer icons on phones and narrow toolbars with a slightly smaller effort-level icon and a larger Slow-mode snail, while keeping menu icon sizes unchanged.
- Keep the model picker on the left and reasoning effort, Fast, Slow, microphone, and Send/Stop controls in a right-aligned composer group, including wrapped mobile rows.
- The Provider Usage panel now shows each account's email instead of omp's redacted form (`an*`); accounts without an email show as **Account N**. Only the email, plan and limits reach the browser.
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,31 @@ available for a selected running session when notices are off. Inspection does
not resume stopped sessions. Missing support or no running session is unavailable,
not a clean result.

## Notifications

Turn on notifications in **Settings → Notifications**. Each browser keeps its own
settings: which events notify (task finished, waiting for input, run failed,
model switched automatically) and what happens while you use omp-web in another
tab (an in-app toast, or always a system notification). Nothing is shown for the
session you are viewing. Clicking a notification opens its session.

- **Push** reaches the browser even when no omp-web tab is open. It needs a
secure context (HTTPS through your reverse proxy, or `localhost`), and the
omp-web server needs outbound HTTPS access to the browser vendors' push
services. omp-web generates its push (VAPID) keys on first use and stores them
with the device list in `~/.omp/agent/omp-web/notifications.json` (mode 0600).
- On **iPhone and iPad** (iOS 16.4 or later), push works only in the app added
to the Home Screen.
- In a secure context without push support, system notifications appear while
an omp-web tab is open. Over plain HTTP on a non-localhost address, browsers
allow no system notifications: only the in-app toasts shown while you use
omp-web remain.
- Desktop browsers show notifications through the operating system's
notification center. Push delivery needs the browser process running.

Completion notifications use omp's `prompt_result` and `session_settled` RPC
frames (omp 18.3.1 or later).

## Quick Start

**Run directly without installing:**
Expand Down Expand Up @@ -241,7 +266,8 @@ host (KDE Plasma, and most Wayland/X11 desktops).
- **Windows System Tray & Service**: Background service, tray icon, logon autostart, and Desktop/Start Menu shortcuts (Windows).
- **macOS launchd Service**: LaunchAgent that starts at login, restarts on crash, and logs under `~/Library/Logs/ompweb`.
- **Linux systemd Service & Tray**: User service that starts at login and restarts on crash, plus a StatusNotifierItem tray icon with service controls (KDE Plasma and compatible desktops).
- **Web-based Settings** (8 tabs): Interface & Behavior, Safety & Approvals, AI Model Defaults, API Keys & Providers, Usage, Agent & Intelligence (advisor, memory, compaction), Agents, Extensions & Tools (MCP, skills, plugins), System & Updates.
- **Notifications**: Desktop and mobile (PWA) notifications for finished tasks, questions waiting for an answer, failed runs, and automatic model switches, with Web Push when served over HTTPS. See [Notifications](#notifications).
- **Web-based Settings** (10 tabs): Interface & Behavior, Safety & Approvals, AI Model Defaults, API Keys & Providers, Usage, Agent & Intelligence (advisor, memory, compaction), Agents, Extensions & Tools (MCP, skills, plugins), Notifications, System & Updates.
- **Slash Commands & Shortcuts**: Quick prompts (`/plan`, `/review`, `/fix`, `/test`, etc.), `⌘K` / `Ctrl+K` palette, and model/reasoning cycling.
- **UI Themes & Localization**: Warm paper light/dark themes plus an omp.sh-inspired midnight (`omp`) theme, chat font size & interface scale, with full English, Chinese (简体中文), and Japanese (日本語) translations.

Expand Down
14 changes: 13 additions & 1 deletion app/api/agent/running/events/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { getExitedRpcSessions, getRunningRpcSessions, subscribeRunningSessions } from "@/lib/rpc-manager";
import { isValidNotificationId } from "@/lib/notification-events";
import { attachNotificationClient } from "@/lib/notification-hub";
import { subscribeSessionFileChanges } from "@/lib/session-watcher";

export const dynamic = "force-dynamic";

// GET /api/agent/running/events - SSE stream of the set of currently-running
// session ids. Also carries refresh hints when a live session's file metadata
// changes, so the sidebar can show a newly-started session immediately.
// changes, so the sidebar can show a newly-started session immediately, and
// this tab's notifications when `clientId` is given (one stream per tab:
// browsers allow only six HTTP/1.1 connections per host).
export async function GET(req: Request) {
const clientId = new URL(req.url).searchParams.get("clientId");
// Hoisted so the stream's cancel() (half-open disconnects that never fire
// the abort signal) can release the heartbeat and the subscriber.
let streamCleanup: (() => void) | null = null;
Expand All @@ -18,6 +23,7 @@ export async function GET(req: Request) {
let cleaned = false;
let unsubscribeRunning: (() => void) | null = null;
let unsubscribeFiles: (() => void) | null = null;
let detachNotifications: (() => void) | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;

const cleanup = () => {
Expand All @@ -36,6 +42,8 @@ export async function GET(req: Request) {
try { unsubscribeFiles(); } catch {}
unsubscribeFiles = null;
}
detachNotifications?.();
detachNotifications = null;
req.signal?.removeEventListener("abort", cleanup);
try {
controller.close();
Expand Down Expand Up @@ -77,6 +85,10 @@ export async function GET(req: Request) {
encode({ type: "sessions-changed", sessionIds, refreshSessionList: true });
});

if (isValidNotificationId(clientId)) {
detachNotifications = attachNotificationClient(clientId, (message) => encode({ type: "notification", ...message }));
}

// Initial snapshot so the client renders the correct state immediately.
const initialRunning = getRunningRpcSessions();
encode({
Expand Down
25 changes: 25 additions & 0 deletions app/api/notifications/devices/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { isValidNotificationId, parseNotificationPrefs } from "@/lib/notification-events";
import { getNotificationDevice, getVapidPublicKey, parsePushSubscription, saveNotificationDevice } from "@/lib/notification-hub";
import { isRecord } from "@/lib/type-guards";

export const dynamic = "force-dynamic";

/** GET /api/notifications/devices?deviceId= — VAPID public key and whether this device has a push subscription. */
export async function GET(req: Request) {
const deviceId = new URL(req.url).searchParams.get("deviceId");
const device = isValidNotificationId(deviceId) ? getNotificationDevice(deviceId) : undefined;
return NextResponse.json({ publicKey: getVapidPublicKey(), subscribed: Boolean(device?.subscription) });
}

/** PUT /api/notifications/devices { deviceId, prefs, subscription? } — an omitted subscription keeps the stored one. */
export async function PUT(req: Request) {
const body: unknown = await req.json().catch(() => null);
if (!isRecord(body) || !isValidNotificationId(body.deviceId)) {
return NextResponse.json({ error: "deviceId is required", code: "invalid_device" }, { status: 400 });
}
const subscription = body.subscription === undefined ? undefined : parsePushSubscription(body.subscription);
if (subscription === null) return NextResponse.json({ error: "Invalid push subscription", code: "invalid_subscription" }, { status: 400 });
const device = saveNotificationDevice(body.deviceId, parseNotificationPrefs(body.prefs), subscription);
return NextResponse.json({ subscribed: Boolean(device.subscription) });
}
20 changes: 20 additions & 0 deletions app/api/notifications/presence/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { isValidNotificationId } from "@/lib/notification-events";
import { reportPresence } from "@/lib/notification-hub";
import { isRecord } from "@/lib/type-guards";

export const dynamic = "force-dynamic";

/** POST /api/notifications/presence { clientId, deviceId, visible, sessionId, seq } — which session this tab shows. */
export async function POST(req: Request) {
const body: unknown = await req.json().catch(() => null);
if (
!isRecord(body) || !isValidNotificationId(body.clientId) || !isValidNotificationId(body.deviceId)
|| typeof body.visible !== "boolean" || !Number.isSafeInteger(body.seq)
) {
return NextResponse.json({ error: "Invalid presence report", code: "invalid_presence" }, { status: 400 });
}
const sessionId = typeof body.sessionId === "string" && body.sessionId ? body.sessionId : null;
reportPresence({ clientId: body.clientId, deviceId: body.deviceId, visible: body.visible, sessionId, seq: Number(body.seq) });
return new NextResponse(null, { status: 204 });
}
Loading