ouroboros/web/modules/notifications.js
Ouroboros 224f33f81b
feat: add honest desktop notification attention cue (#1158)
* fix(v7.2.2): preserve host message authorship through chat and replay

Host-composed command replies, runtime diagnostics, admission and lifecycle
notices now carry role="system" and a descriptive system_type at their
producer; send_with_budget persists both on progress records and history
replays the recorded voice, so a reload no longer re-narrates host text as
Ouroboros. Model narration, proactive replies, final answers and question
pointers stay model-authored, selected by the existing narration fact rather
than by reading text.

The Host Service named-operation view accepted only direction="out" terminal
rows, so a typed command reply would have stayed pending forever; it now
recognizes either output voice while keeping the exact-origin ownership check.

tests/test_host_message_voice.py scans runtime send calls and literal chat
envelopes with counted, reasoned model/transport exceptions, so a new
unstamped producer or a stale exception fails; computed aliases remain a
documented review duty. The complete personal census of 92 classified sites is
retained as task artifact host-message-census.json.

DESIGN defines authorship, the System row and the markdown asymmetry;
CHECKLISTS item 30 now covers backend UI-message producers, which closes the
stale pointer in the design-system chapter. Pre-existing unkeyed live-header
clearing and the untyped terminal-host-notice contract are unchanged.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>

* feat(v7.4.0): add honest desktop notification attention cue

Add an optional launcher request_attention bridge that raises the live desktop window and requests one platform system sound, preserving browser and in-app fallbacks. Document the capability boundary, bump release carriers, and add focused tests.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>

---------

Co-authored-by: Ouroboros <ouroboros@local.mac>
Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-20 20:03:32 +03:00

638 lines
29 KiB
JavaScript

/* Client-local notifications: pull the owner back to a question or a finished
task while this client is running.
Two halves, on purpose. The POLICY half below is pure over one frame plus the
stored preferences, so the rules a reviewer must trust are testable without a
DOM, a socket or a provider. The SHELL half owns the browser surfaces
(permission, banner, tone, click) and holds no rules of its own.
The subscription is CLIENT-level, not per chat instance. A chat instance dies
with its room — closing a Project panel destroys it — so wiring notifications
inside one would go silent in the very case they exist for: the owner left
the window and the room is closed. `attach()` therefore subscribes once, on
the shared socket, and decides from the frame plus the client's own room set.
Replay safety is STRUCTURAL, not a persisted ledger: only live socket frames
reach `handleFrame`, while history rendering, reconnect backfill and reload
go through the chat instances' own readers, which never call it. So a reload
cannot re-notify even though nothing about past notifications is stored. The
bounded in-memory key set below collapses duplicates inside one live session
— one finished task has several wire shapes, and a quiz frame can be
delivered more than once.
Preferences are client-local like the appearance choice (web/theme.js): the
desktop window and each browser keep their own, nothing is sent to the
server, and the controls carry no `s-` id, so the Settings collector never
posts them.
Not claimed: no OS permission prompt, Do Not Disturb setting or platform
limit is bypassed. Where the Notification API is missing or denied, delivery
degrades to the in-app toast and says so in Settings. */
import { TERMINAL_TASK_STATUSES } from './chat_activity.js';
import { getLogTaskGroupId } from './log_events.js';
import { shellBridgeApi } from './ui_helpers.js';
export const NOTIFY_PREFS_KEY = 'ouroboros.notifications';
/* Categories, in owner-facing order. The first two are the agreed REQUIRED
signals (confirmed lifecycle state); `important` is the LLM-first one and
`main_reply` is the separate, quieter toggle. */
export const NOTIFY_CATEGORIES = ['needs_answer', 'task_done', 'important', 'main_reply'];
/* Ordinary Main replies start OFF and message text starts HIDDEN. Both are my
recommendation rather than an owner decision, and both are one click away. */
export const DEFAULT_NOTIFY_PREFS = Object.freeze({
enabled: false,
needs_answer: true,
task_done: true,
important: true,
main_reply: false,
sound: true,
show_text: false,
});
export const NOTIFY_TITLES = Object.freeze({
needs_answer: 'Ouroboros is waiting for your answer',
task_done: 'Task finished',
important: 'Message from Ouroboros',
main_reply: 'Ouroboros replied',
});
const BODY_CHARS = 140;
/* Bounded only for hygiene in a page that may live for days. A key evicted
after this many distinct events could ring again if its frame reappeared much
later; that is the disclosed tradeoff against unbounded growth. */
const SEEN_LIMIT = 2000;
const LINEAGE_LIMIT = 2000;
/* The web owner thread. A frame with no chat id at all is legacy Main traffic,
exactly as the chat log gate reads it. */
const MAIN_CHAT_ID = 1;
const asBool = (value, fallback) => (typeof value === 'boolean' ? value : fallback);
/** Tolerant read of a stored preference object: unknown keys are dropped and a
* missing or malformed value keeps the shipped default for that key alone. */
export function normalizeNotifyPrefs(raw) {
const source = raw && typeof raw === 'object' ? raw : {};
const prefs = { ...DEFAULT_NOTIFY_PREFS };
for (const key of Object.keys(DEFAULT_NOTIFY_PREFS)) {
prefs[key] = asBool(source[key], DEFAULT_NOTIFY_PREFS[key]);
}
return prefs;
}
export function readNotifyPrefs(storage) {
try {
const raw = storage?.getItem?.(NOTIFY_PREFS_KEY);
if (!raw) return { ...DEFAULT_NOTIFY_PREFS };
return normalizeNotifyPrefs(JSON.parse(raw));
} catch {
// Unavailable or malformed storage is a gap, not a reason to notify
// more than the owner asked for: fall back to the shipped defaults.
return { ...DEFAULT_NOTIFY_PREFS };
}
}
export function writeNotifyPrefs(storage, prefs) {
const clean = normalizeNotifyPrefs(prefs);
try {
storage?.setItem?.(NOTIFY_PREFS_KEY, JSON.stringify(clean));
return true;
} catch {
return false;
}
}
const text = (value) => String(value == null ? '' : value);
/* A quiz frame carries its fields either flat or nested under `quiz`
(chat_media.appendQuizMessage reads both shapes); use the same source. */
const quizSource = (frame) => (frame?.quiz && typeof frame.quiz === 'object' ? frame.quiz : frame);
function trimBody(value) {
const body = text(value).replace(/\s+/g, ' ').trim();
if (body.length <= BODY_CHARS) return body;
return `${body.slice(0, BODY_CHARS - 1)}…`;
}
/**
* Classify ONE live frame into a notification candidate, or null.
*
* Pure. `kind` is the ws frame type as chat.js observed it ('chat', 'quiz' or
* 'log'), `isMain` says whether the receiving instance is the Main thread, and
* `isRoot`/`taskId` are the facts only the client holds: task lineage lives in
* chat.js's own subagent map, and a log frame's task identity is resolved by
* `getLogTaskGroupId`. `isRoot === false` is a positive statement that this is
* a child; `undefined` means the caller could not tell.
*
* A child task never notifies the owner directly, so a child terminal is
* dropped here on either of its two shapes: only root terminals and the frames
* a parent itself emits reach a banner.
*
* A finished ROOT arrives in more than one live shape — the managed-queue
* conclusion is a `log` frame with `type='task_done'`
* (supervisor/events_task_done.py pushes it), while the authored summary
* arrives as a chat row — and a turn's ordinary reply is the same ending. All
* of them map to ONE key, `conclusion:<task_id>`, so whichever shape reaches
* this client first rings and the others collapse.
*/
export function classifyLiveFrame(frame, { kind = 'chat', isMain = false, isRoot, taskId: taskIdHint = '' } = {}) {
if (!frame || typeof frame !== 'object') return null;
const taskId = text(taskIdHint) || text(frame.task_id);
/* `isRoot === false` is a positive statement of child-ness; `undefined` only
means the caller could not tell, and it is treated as eligible ON PURPOSE.
Nothing on the wire ever declares a task to BE a root, so requiring proof
of root-ness would silence every root terminal — the required category —
to avoid a rare stray child banner. The disclosed residual (DESIGN §9) is
the narrow case of a child whose very first observed frame is its own
terminal AND which carries no delegation fact. */
const notChild = isRoot !== false;
const rootEligible = (id) => Boolean(id) && notChild;
if (kind === 'quiz') {
const source = quizSource(frame);
const quizId = text(source.quiz_id || frame.quiz_id);
if (!quizId || !notChild) return null;
// An already answered/closed question must not ring on a late frame.
const state = text(source.state || frame.quiz_state).toLowerCase();
if (state && state !== 'open') return null;
// `wait_for_answer === true` is the positive fact that the task is
// actually blocked on the owner; anything else is an optional ask,
// which belongs to the model-chosen `important` category.
const waiting = source.wait_for_answer === true || frame.wait_for_answer === true;
return {
category: waiting ? 'needs_answer' : 'important',
key: `quiz:${taskId}:${quizId}`,
title: waiting ? NOTIFY_TITLES.needs_answer : 'Ouroboros asks a question',
body: trimBody(source.question || frame.question || frame.content),
target: { chatId: frame.chat_id, taskId, quizId },
};
}
if (kind === 'log') {
// Log frames carry runtime diagnostics, not owner-facing prose, so this
// one stays title-only even when message text is switched on.
if (text(frame.type || frame.event) !== 'task_done') return null;
if (!rootEligible(taskId)) return null;
// A task_done frame is not always an ending: an update or restart
// teardown pushes a NON-settled `interrupted` terminal and requeues the
// same task id afterwards. Ringing there would both lie and burn this
// task's key, so its real completion would stay silent.
if (!TERMINAL_TASK_STATUSES.has(text(frame.status).toLowerCase())) return null;
// A DIRECT turn ends with this same frame: an ordinary Main reply, a
// consciousness wake-up, any conversation turn. Calling that "a task
// finished" would ring on every answer while the owner's ordinary-reply
// toggle is off, which is the opposite of what the toggle promises. A
// conversation's ending belongs to `main_reply` and nowhere else.
if (frame._is_direct_chat === true) return null;
return {
category: 'task_done',
key: `conclusion:${taskId}`,
title: NOTIFY_TITLES.task_done,
body: '',
target: { chatId: frame.chat_id, taskId },
};
}
if (kind !== 'chat') return null;
if (frame.role !== 'assistant' && frame.role !== 'system') return null;
if (frame.is_progress) return null;
const systemType = text(frame.system_type);
const rowKey = text(frame.card_row_id) || text(frame.client_message_id)
|| `${taskId}:${text(frame.ts)}`;
// The model's own in-turn decision to interrupt the owner. The
// discriminator already exists (tools/control_runtime._send_user_message
// stamps it), so importance needs no new host field and no second model
// call.
if (systemType === 'proactive_message') {
if (!notChild) return null;
return {
category: 'important',
key: `important:${rowKey}`,
title: NOTIFY_TITLES.important,
body: trimBody(frame.content),
target: { chatId: frame.chat_id, taskId },
};
}
// A child's row never reaches the owner, whatever else it carries.
if (text(frame.delegation_role).toLowerCase() === 'subagent') return null;
// Only the AUTHORED summary is a task's ending in chat form. A bare
// `task_terminal_status` row is set exclusively for a DIRECT turn
// (ouroboros/task_finalization.py), so reading it as a terminal would call
// every stopped or failed conversation "a task finished" — it falls through
// to the ordinary-reply category below instead.
if (systemType === 'task_summary') {
if (!rootEligible(taskId)) return null;
return {
category: 'task_done',
key: `conclusion:${taskId}`,
title: NOTIFY_TITLES.task_done,
body: trimBody(frame.content),
target: { chatId: frame.chat_id, taskId },
};
}
// An ordinary finished reply in Main: no typed system row, no lifecycle
// fact. Deliberately Main-only, as agreed.
if (!systemType && frame.role === 'assistant' && isMain) {
return {
category: 'main_reply',
// One turn ending is ONE event: an ordinary reply and the terminal
// of the same task share this key, so the owner is pulled back once
// rather than twice for the same conclusion.
key: taskId ? `conclusion:${taskId}` : `main_reply:${rowKey}`,
title: NOTIFY_TITLES.main_reply,
body: trimBody(frame.content),
target: { chatId: frame.chat_id, taskId },
};
}
return null;
}
/**
* Decide whether a classified candidate should actually be delivered.
* Pure over (candidate, prefs, seenKeys) so the whole gate is testable.
*/
export function decideNotification(candidate, prefs, seenKeys) {
if (!candidate) return { deliver: false, reason: 'not_notifiable' };
const clean = normalizeNotifyPrefs(prefs);
if (!clean.enabled) return { deliver: false, reason: 'disabled' };
if (!NOTIFY_CATEGORIES.includes(candidate.category)) {
return { deliver: false, reason: 'unknown_category' };
}
if (!clean[candidate.category]) return { deliver: false, reason: 'category_off' };
if (seenKeys && typeof seenKeys.has === 'function' && seenKeys.has(candidate.key)) {
return { deliver: false, reason: 'duplicate' };
}
return {
deliver: true,
reason: 'deliver',
title: candidate.title,
body: clean.show_text ? candidate.body : '',
sound: clean.sound,
category: candidate.category,
key: candidate.key,
target: candidate.target,
};
}
/** Owner-facing one-line status for the Settings block. Pure. */
export function notifyStatusText({ enabled, supported, permission, storageAvailable = true } = {}) {
if (!storageAvailable) {
return 'This device blocks storage, so these choices apply until the window reloads.';
}
if (!enabled) return 'Notifications are off; nothing is requested from this system.';
if (!supported) {
return 'This client exposes no system notifications, so alerts appear in the app instead.';
}
if (permission === 'denied') {
return 'This system denied notifications for this client, so alerts appear in the app instead. '
+ 'Allow them in the OS or browser settings to get banners.';
}
if (permission !== 'granted') {
return 'Permission has not been granted yet; alerts appear in the app until it is.';
}
return 'System banners are enabled for this client.';
}
/** Explain the separate native-attention capability without calling it a banner. */
export function attentionStatusText({ enabled = true, nativeAttention = false, supported = false, status = '', bridge = false } = {}) {
if (!enabled) return 'Notifications are off; no attention is requested from this system.';
if (status === 'window_only') return 'Desktop attention can raise this window, but its system sound is unavailable; the app tone is used when needed.';
if (status === 'unsupported' || status === 'unavailable') return 'Desktop attention is unavailable in this launcher; browser or in-app delivery remains available.';
if (status === 'native_sound' || nativeAttention) return 'Desktop attention is available; the launcher may raise this window and use the system sound.';
if (bridge) return 'This desktop client exposes an attention bridge; its sound capability will be confirmed on the next alert.';
if (supported) return 'Browser notifications are available; desktop attention depends on the client.';
return 'This client has no native attention bridge; alerts stay inside the app.';
}
/* ---------------------------------------------------------------- shell ---- */
export function createNotifier({
storage = globalThis.localStorage,
notificationCtor = globalThis.Notification,
audioContextCtor = globalThis.AudioContext || globalThis.webkitAudioContext,
showToast = null,
onActivate = null,
focusWindow = () => globalThis.focus?.(),
documentRef = globalThis.document,
hostApi = null,
} = {}) {
let prefs = readNotifyPrefs(storage);
let storageAvailable = true;
let destroyed = false;
let audioCtx = null;
let activate = onActivate;
let toast = showToast;
let nativeAttention = false;
let attentionStatus = '';
const seen = new Set();
/* Task lineage as the WIRE states it. A finished child and a finished root
share one log shape with no lineage field
(supervisor/events_task_done.py), so the only honest source is the
delegation truth carried by the frames a subagent's own traffic brings.
Learned client-wide, which is strictly more than any one room sees.
Disclosed residual: a child whose FIRST observed frame is its own
terminal cannot be recognised, and would notify once. */
const childTasks = new Set();
const attachDisposers = [];
const boundedAdd = (set, value, limit) => {
set.add(value);
if (set.size > limit) set.delete(set.values().next().value);
};
function noteLineage(frame) {
if (!frame || typeof frame !== 'object') return;
const parent = text(frame.parent_task_id).trim();
const child = text(frame.subagent_task_id).trim() || text(frame.task_id).trim();
if (!child) return;
const declared = text(frame.delegation_role).toLowerCase() === 'subagent';
// A subagent's terminal log frame carries delegation-truth enrichment
// (supervisor/subagent_task_truth.py stamps executor/substrate facts on
// the pushed event); an ordinary root terminal does not. It is a second,
// independent way to recognise a child whose earlier traffic this client
// never saw.
const delegated = Boolean(frame.executor_route || frame.actual_substrate
|| (frame.execution_evidence && typeof frame.execution_evidence === 'object'));
if (!declared && !delegated && (!parent || parent === child)) return;
boundedAdd(childTasks, child, LINEAGE_LIMIT);
}
const supported = () => typeof notificationCtor === 'function';
const permission = () => (supported() ? text(notificationCtor.permission) || 'default' : 'unsupported');
// Insertion-ordered: drop the oldest key, never the newest.
const remember = (key) => boundedAdd(seen, key, SEEN_LIMIT);
function tone() {
if (!audioContextCtor) return;
try {
audioCtx = audioCtx || new audioContextCtor();
audioCtx.resume?.();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = 660;
gain.gain.value = 0.05;
osc.connect(gain);
gain.connect(audioCtx.destination);
const now = audioCtx.currentTime || 0;
osc.start(now);
osc.stop(now + 0.12);
} catch {
// A device that refuses audio is not a delivery failure.
}
}
function deliver(decision) {
const banner = supported() && permission() === 'granted';
if (banner) {
try {
// The OS owns the banner's sound; `silent` honours the toggle
// so the owner never hears two sounds for one event.
const note = new notificationCtor(decision.title, {
body: decision.body || undefined,
tag: decision.key,
silent: !decision.sound,
});
note.onclick = () => {
try { focusWindow(); } catch { /* a blocked focus is not fatal */ }
try { activate?.(decision.target, decision); } catch { /* navigation is best-effort */ }
try { note.close?.(); } catch { /* already closed */ }
};
return 'banner';
} catch {
// Fall through to the in-app path below.
}
}
const api = hostApi || shellBridgeApi(globalThis);
const nativeCue = typeof api?.request_attention === 'function';
if (nativeCue) {
try {
void Promise.resolve(api.request_attention(Boolean(decision.sound))).then((result) => {
if (destroyed) return;
nativeAttention = Boolean(result?.ok);
attentionStatus = String(result?.status || 'unavailable');
if (decision.sound && result?.sound_played !== true) tone();
syncSettings();
}).catch(() => { if (!destroyed && decision.sound) tone(); });
} catch { if (decision.sound) tone(); }
} else if (decision.sound) tone();
const line = decision.body ? `${decision.title}: ${decision.body}` : decision.title;
try {
// The in-app surface must reach the source too, so the returned node
// gets its own listener; the stack's own handler still dismisses it.
const node = toast?.(line, 'info');
node?.addEventListener?.('click', () => {
try { activate?.(decision.target, decision); } catch { /* best-effort */ }
});
} catch { /* the toast stack owns its own errors */ }
return 'in_app';
}
function handleFrame(frame, context) {
if (destroyed) return null;
// Lineage is learned even while notifications are off, so switching them
// on mid-session does not start from an empty map.
noteLineage(frame);
if (!prefs.enabled) return null;
const resolved = { ...context };
if (resolved.isRoot === undefined) {
const id = text(resolved.taskId) || text(frame?.task_id);
if (id && childTasks.has(id)) resolved.isRoot = false;
}
const candidate = classifyLiveFrame(frame, resolved);
const decision = decideNotification(candidate, prefs, seen);
if (!decision.deliver) return null;
remember(decision.key);
const surface = deliver(decision);
return { ...decision, surface };
}
function syncSettings(root = documentRef) {
if (!root?.querySelectorAll) return;
for (const input of root.querySelectorAll('[data-notify-pref]')) {
const key = input.getAttribute('data-notify-pref');
if (!(key in DEFAULT_NOTIFY_PREFS)) continue;
input.checked = Boolean(prefs[key]);
// Categories are meaningless while notifications are off, but the
// owner's choices stay visible rather than being reset.
if (key !== 'enabled') input.disabled = !prefs.enabled;
}
for (const node of root.querySelectorAll('[data-notify-status]')) {
const next = notifyStatusText({
enabled: prefs.enabled,
supported: supported(),
permission: permission(),
storageAvailable,
});
if (node.textContent !== next) node.textContent = next;
}
for (const node of root.querySelectorAll('[data-notify-attention-status]')) {
const api = hostApi || shellBridgeApi(globalThis);
const next = attentionStatusText({
enabled: prefs.enabled,
nativeAttention,
supported: supported(),
status: attentionStatus,
bridge: typeof api?.request_attention === 'function',
});
if (node.textContent !== next) node.textContent = next;
}
for (const button of root.querySelectorAll('[data-notify-test]')) {
button.disabled = !prefs.enabled;
}
}
async function requestPermission() {
if (!supported() || permission() === 'granted' || permission() === 'denied') return permission();
try {
const result = await notificationCtor.requestPermission?.();
return text(result) || permission();
} catch {
return permission();
}
}
async function setPref(key, value) {
if (!(key in DEFAULT_NOTIFY_PREFS)) return prefs;
prefs = normalizeNotifyPrefs({ ...prefs, [key]: Boolean(value) });
storageAvailable = writeNotifyPrefs(storage, prefs);
// Enabling is a user gesture: the only moment a permission prompt and
// an audio context may legitimately be created.
if (key === 'enabled' && prefs.enabled) {
await requestPermission();
}
syncSettings();
return prefs;
}
function test() {
if (!prefs.enabled) return null;
const decision = decideNotification({
category: 'important',
key: `test:${Date.now()}`,
title: 'Ouroboros notifications are working',
body: 'This is a test notification.',
target: {},
}, { ...prefs, important: true, show_text: true }, null);
if (!decision.deliver) return null;
return { ...decision, surface: deliver(decision) };
}
const onChange = (event) => {
const input = event?.target?.closest?.('[data-notify-pref]');
if (!input) return;
void setPref(input.getAttribute('data-notify-pref'), input.checked);
};
const onClick = (event) => {
if (event?.target?.closest?.('[data-notify-test]')) test();
};
const onStorage = (event) => {
if (event?.key && event.key !== NOTIFY_PREFS_KEY) return;
prefs = readNotifyPrefs(storage);
syncSettings();
};
documentRef?.addEventListener?.('change', onChange);
documentRef?.addEventListener?.('click', onClick);
globalThis.addEventListener?.('storage', onStorage);
/* ONE subscription per client, on the shared socket. Rooms come and go; the
socket and this notifier do not. `ownerVisibleChat` keeps machine traffic
out: the hidden partition (chat 0) and A2A (negative) never notify, and
an unknown positive chat id is not assumed to be the owner's. */
function attach({ ws, ownerVisibleChat, mainChatId = MAIN_CHAT_ID } = {}) {
if (!ws?.on) return () => {};
const disposers = [];
const known = (id) => Boolean(ownerVisibleChat ? ownerVisibleChat(id) : id === mainChatId);
// A room the client knows, or — exactly as the Main thread itself reads
// it (chat_activity.mainThreadAccepts) — any positive chat not stamped as
// another Project's, which is how an external owner transport arrives.
// The hidden partition and A2A ids are machine traffic and never notify.
const projectStamped = (frame, envelope) =>
Boolean(frame?.project_thread || envelope?.project_thread);
const visible = (raw, frame, envelope) => {
if (raw === undefined || raw === null || raw === '') return true;
const id = Number(raw);
if (!Number.isFinite(id) || id <= 0) return false;
return known(id) || !projectStamped(frame, envelope);
};
const route = (frame, kind, envelope) => {
if (destroyed || !frame || typeof frame !== 'object') return;
try {
const raw = frame.chat_id !== undefined && frame.chat_id !== null
? frame.chat_id : envelope?.chat_id;
if (!visible(raw, frame, envelope)) return;
const id = Number(raw);
const inProjectRoom = projectStamped(frame, envelope)
|| (Number.isFinite(id) && id > 0 && id !== mainChatId && known(id));
const context = { kind, isMain: !inProjectRoom };
if (kind === 'log') context.taskId = getLogTaskGroupId(frame) || '';
handleFrame(frame, context);
} catch {
/* This listener runs BEFORE the chat instances' own handlers and
ws.emit does not isolate them, so an error here would break
message rendering. A missed notification must never do that. */
}
};
const on = (event, fn) => {
const dispose = ws.on(event, fn);
if (typeof dispose === 'function') disposers.push(dispose);
};
on('chat', (msg) => route(msg, 'chat', msg));
on('quiz', (msg) => route(msg, 'quiz', msg));
on('log', (msg) => route(msg?.data, 'log', msg));
const release = () => { while (disposers.length) disposers.pop()?.(); };
attachDisposers.push(release);
return release;
}
return {
get prefs() { return { ...prefs }; },
attach,
supported,
permission,
setPref,
requestPermission,
handleFrame,
test,
mountSettings: syncSettings,
configure({ onActivate: nextActivate, showToast: nextToast } = {}) {
if (nextActivate) activate = nextActivate;
if (nextToast) toast = nextToast;
syncSettings();
},
destroy() {
destroyed = true;
while (attachDisposers.length) attachDisposers.pop()?.();
documentRef?.removeEventListener?.('change', onChange);
documentRef?.removeEventListener?.('click', onClick);
globalThis.removeEventListener?.('storage', onStorage);
try { audioCtx?.close?.(); } catch { /* nothing to close */ }
audioCtx = null;
seen.clear();
},
};
}
/* One notifier per client: the dedupe set must be shared by the Main instance
and every Project room, so a quiz delivered to both rings once. */
let singleton = null;
export function getNotifier(options) {
if (!singleton) singleton = createNotifier(options);
return singleton;
}
export function resetNotifier() {
singleton?.destroy?.();
singleton = null;
}