feat(agent): add opt-in push notifications

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent 80748f764f
commit a1c0264d36
51 changed files with 4815 additions and 121 deletions
+3
View File
@@ -27,6 +27,7 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { AgentConversation } from "@/components/agent/agent-conversation";
import { AgentNotifications } from "@/components/agent/agent-notifications";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { SearchMatchLabel } from "@/components/search/search-match-label";
import { SybilCharacter } from "@/components/sybil-character";
@@ -965,6 +966,7 @@ export default function App() {
isCheckingSession,
isSigningIn,
isAuthenticated,
authMode,
authError,
handleAuthFailure: baseHandleAuthFailure,
handleSignIn,
@@ -3721,6 +3723,7 @@ export default function App() {
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
) : null}
</div>
{authMode === "token" ? <AgentNotifications onLogout={logout} /> : null}
</aside>
<main
@@ -0,0 +1,22 @@
import { useEffect, useState } from "preact/hooks";
import { Bell } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { disableAgentBrowserPush, enableAgentBrowserPush, getAgentPushState, observeAgentPush, refreshAgentBrowserPush } from "@/lib/agent-push-browser";
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
useEffect(() => observeAgentPush(setState), []);
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
<div className="space-y-2 pb-1 pt-2">
<p role="status">{state.message}</p>
{state.error ? <p role="alert" className="text-rose-300">{state.error}</p> : null}
<div className="flex flex-wrap gap-2">
{state.enabled ? <Button className="h-8 text-xs" variant="secondary" disabled={state.busy} onClick={() => void disableAgentBrowserPush()}>Turn off notifications</Button>
: <Button className="h-8 text-xs" variant="secondary" disabled={!state.canEnable} onClick={() => void enableAgentBrowserPush()}>Enable notifications</Button>}
<Button className="h-8 text-xs" variant="ghost" disabled={state.busy} onClick={() => void refreshAgentBrowserPush()}>Refresh</Button>
<Button className="h-8 text-xs" variant="ghost" disabled={signingOut} onClick={() => { setSigningOut(true); void onLogout().finally(() => setSigningOut(false)); }}>Sign out</Button>
</div>
</div>
</details>;
}
+44 -32
View File
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
import {
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
applyAgentEvent, applyAgentReadState,
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
type AgentActivity, type AgentRoomState,
} from "@/lib/agent-room";
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
import { getAgentPresenceSessionId, syncAgentNotificationRead } from "@/lib/agent-push-browser";
import { AGENT_ATTACHMENT_DRAFT_KEY, createAgentUploadQueue, type AgentAttachment, type AgentAttachmentCapability } from "@/lib/agent-attachments";
@@ -47,7 +48,8 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
},
});
useEffect(() => () => uploadQueue.current?.dispose(), []);
const mark = useRef<((cursor: string) => void) | null>(null);
const mark = useRef<((cursor: string, sourceId: string | null | undefined) => void) | null>(null);
const sourceMismatch = useRef(false);
const [outboxRevision, setOutboxRevision] = useState(0);
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
const next = change(current.current); current.current = next; setView(previous => ({ ...previous, room: next }));
@@ -80,11 +82,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
useEffect(() => {
if (!enabled) {
sourceMismatch.current = false;
client.current = null; mark.current = null; setAttachmentCapability(undefined);
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
let active = true;
let sourceChanged = false;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let startupTimer: ReturnType<typeof setTimeout> | undefined;
@@ -100,7 +104,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
client.current = api;
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
publish: (next, isTyping) => {
if (!active) return;
if (!active || sourceChanged) return;
outbox.current?.reconcile(next);
current.current = next;
setView({ room: next, isTyping });
@@ -108,35 +112,36 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
});
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
// Read acknowledgments may be ahead of the batched UI for ~100ms. The
// writer must observe that acknowledged cursor immediately to avoid a
// repeated PUT loop while the presenter waits to publish its batch.
let acknowledgedReadCursor = current.current.readCursor;
const readWriter = createAgentReadWriter({
current: () => acknowledgedReadCursor,
save: cursor => api.markRead(cursor, controller.signal),
const readSession = createAgentReadSession({
initial: current.current,
save: (cursor, sourceId) => api.markRead(cursor, controller.signal, sourceId),
invalidated: failure => {
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
presenter.dispose(); controller.abort(); socket?.close();
setError(failure.message); setConnection("offline"); setLoading(false);
},
apply: read => {
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
void syncAgentNotificationRead(read);
if (active && !sourceChanged) presenter.update(state => applyAgentReadState(state, read));
},
});
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
mark.current = (cursor, sourceId) => { void readSession.request(cursor, sourceId).catch(reportError); };
const refreshRead = async () => {
const read = await api.readState(controller.signal);
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
if (active && !sourceChanged) readSession.receive(read);
};
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
if (active) presenter.push({ type: "messages", ...page }, { replay });
if (active && !sourceChanged) presenter.push({ type: "messages", ...page }, { replay });
}, controller.signal);
const refreshActivity = async () => {
const descriptor = await api.descriptor(controller.signal);
if (active) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
};
const connect = () => {
if (!active || authFailed) return;
if (!active || authFailed || sourceChanged) return;
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
socketAuthenticated = false;
@@ -144,12 +149,12 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
socket = next;
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
next.onopen = () => {
if (!active || socket !== next) { next.close(); return; }
if (!active || sourceChanged || socket !== next) { next.close(); return; }
lastPacket = Date.now();
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
next.send(agentAuthFrame(getAuthToken(), current.current.cursor, getAgentPresenceSessionId()));
};
next.onmessage = ({ data }) => {
if (!active || socket !== next) return;
if (!active || sourceChanged || socket !== next) return;
lastPacket = Date.now();
try {
const event = parseAgentRoomEvent(String(data));
@@ -159,6 +164,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
setConnection("connected"); setError(null); return;
}
if (event.type === "read_state") {
readSession.receive(event); return;
}
presenter.push(event);
} catch (failure) {
reportError(failure);
@@ -167,9 +175,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
next.close();
}
};
next.onerror = () => { if (active) setConnection("reconnecting"); };
next.onerror = () => { if (active && !sourceChanged) setConnection("reconnecting"); };
next.onclose = event => {
if (!active || socket !== next) return;
if (!active || sourceChanged || socket !== next) return;
clearTimeout(socketTimeout); socketAuthenticated = false;
if (event.code === 4401) {
authFailed = true; setConnection("offline"); setError("Agent authentication failed. Sign in again or retry."); return;
@@ -186,21 +194,21 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
// The first start begins at zero, not the latest page. Unread counts
// therefore include older public replies even after a long absence.
await catchUp(!started);
if (!active) return;
if (!active || sourceChanged) return;
presenter.flush();
started = true; setLoading(false); connect();
} catch (failure) {
if (!active) return;
if (!active || sourceChanged) return;
reportError(failure); setLoading(false); setConnection("offline");
startupTimer = setTimeout(() => { void start(); }, 10_000);
}
};
const maintain = async (foreground = false) => {
if (!active || !started || maintaining || authFailed) return;
if (!active || sourceChanged || !started || maintaining || authFailed) return;
maintaining = true;
try {
await refreshRead();
await readWriter.flush();
await readSession.flush();
if (!socketAuthenticated) {
await Promise.all([catchUp(), refreshActivity()]);
} else if (foreground) await catchUp();
@@ -217,7 +225,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
document.addEventListener("visibilitychange", focus);
void start();
return () => {
active = false; controller.abort();
active = false; readSession.cancel(); controller.abort();
presenter.dispose();
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
@@ -228,12 +236,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
}, [enabled, revision, update]);
const renderedCursor = room.cursor;
const renderedSourceId = room.sourceId;
const markRead = useCallback(() => {
if (!enabled || isLoading) return;
// This callback belongs to this rendered snapshot. Reading current.current
// here would incorrectly mark a socket message that has not rendered yet.
mark.current?.(renderedCursor);
}, [enabled, isLoading, renderedCursor]);
mark.current?.(renderedCursor, renderedSourceId);
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
@@ -250,7 +259,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.retry(messageId);
}, [enabled]);
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
const retry = useCallback(() => {
if (sourceMismatch.current) { window.location.reload(); return; }
setRevision(value => value + 1);
}, []);
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
+16 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "preact/hooks";
import { getConfiguredToken, setAuthToken, verifySession } from "@/lib/api";
import { revokeAgentBrowserPush, startAgentBrowserSession } from "@/lib/agent-push-browser";
const TOKEN_STORAGE_KEY = "sybil_admin_token";
@@ -47,6 +48,7 @@ export function useSessionAuth() {
};
const handleAuthFailure = (message: string | null) => {
void revokeAgentBrowserPush();
setIsAuthenticated(false);
setAuthMode(null);
setAuthError(message ? normalizeAuthError(message) : null);
@@ -69,7 +71,8 @@ export function useSessionAuth() {
}
};
const logout = () => {
const logout = async () => {
await revokeAgentBrowserPush();
setAuthToken(null);
persistToken(null);
setIsAuthenticated(false);
@@ -77,6 +80,18 @@ export function useSessionAuth() {
setAuthError(null);
};
useEffect(() => {
if (!isAuthenticated || authMode !== "token") return;
return startAgentBrowserSession();
}, [isAuthenticated, authMode]);
useEffect(() => {
// Signing out in one tab retires the others instead of using a stale token.
const signedOut = (event: StorageEvent) => { if (event.key === TOKEN_STORAGE_KEY && !event.newValue) handleAuthFailure(null); };
window.addEventListener("storage", signedOut);
return () => window.removeEventListener("storage", signedOut);
}, []);
useEffect(() => {
const token = readStoredToken() ?? getConfiguredToken();
void (async () => {
+158
View File
@@ -0,0 +1,158 @@
import { getApiBaseUrl, getAuthToken } from "./api";
import { ensureServiceWorker } from "../pwa";
import {
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
} from "./agent-push.ts";
let sessionId: string | undefined;
function browserUuid() {
if (typeof globalThis.crypto?.randomUUID === "function") return crypto.randomUUID();
if (typeof globalThis.crypto?.getRandomValues !== "function") throw new Error("Secure browser identifiers are unavailable.");
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = bytes[6] & 15 | 64; bytes[8] = bytes[8] & 63 | 128;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/** A document UUID avoids duplicated sessionStorage IDs when the user duplicates a tab. */
export function getAgentPresenceSessionId() { try { return sessionId ||= browserUuid(); } catch { return undefined; } }
const api = new AgentPushClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
async function consentScope() {
const token = getAuthToken(), base = new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href;
const digest = await agentPushAuthorityScope(base, token);
if (token !== getAuthToken() || base !== new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href) return null;
return digest;
}
const listeners = new Set<(state: AgentPushView) => void>();
function bounded<T>(promise: Promise<T>, timeoutMs = 10000): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Browser notification operation timed out.")), timeoutMs);
promise.then(value => { clearTimeout(timeout); resolve(value); }, () => { clearTimeout(timeout); reject(new Error("Browser notification operation failed.")); });
});
}
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
});
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
const support = () => {
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
if (ios && !standalone) return "install" as const;
return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && !!navigator.locks
? "supported" as const : "unsupported" as const;
};
async function postWorker(message: unknown) {
if (!("serviceWorker" in navigator)) return;
// No registration from background sync. A page worker can safely receive only sanitized IDs.
const registration = await navigator.serviceWorker.getRegistration("/");
const worker = registration?.active;
if (!worker) return;
await new Promise<void>((resolve) => {
const channel = new MessageChannel(), timeout = setTimeout(() => { channel.port1.close(); resolve(); }, 1500);
channel.port1.onmessage = () => { clearTimeout(timeout); channel.port1.close(); resolve(); };
worker.postMessage(message, [channel.port2]);
});
}
export async function syncAgentNotificationRead(read: { sourceId?: string | null; cursor: string }) {
if (!getAuthToken() || !agentPushSourceId(read.sourceId) || !agentPushCursor(read.cursor)) return;
await postWorker({ type: "sybil-agent-read", version: 1, sourceId: read.sourceId, cursor: BigInt(read.cursor).toString() }).catch(() => undefined);
}
function decodePublicKey(value: string): Uint8Array<ArrayBuffer> {
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4));
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
const controller = createAgentPushController({
api, read: readConsent, scope: consentScope,
write: value => { localStorage.setItem(AGENT_PUSH_STORAGE_KEY, JSON.stringify(value)); },
remove: () => localStorage.removeItem(AGENT_PUSH_STORAGE_KEY),
uuid: browserUuid,
lock: async operation => {
if (!navigator.locks) return await operation(); // Cleanup only; support() disables opt-in.
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 12000);
try { return await navigator.locks.request("sybil-agent-push-installation-v1", { signal: controller.signal }, operation); }
finally { clearTimeout(timeout); }
},
changed: state => { for (const listener of listeners) listener(state); },
platform: {
support, permission: () => "Notification" in window ? Notification.permission : "denied",
requestPermission: () => Notification.requestPermission(),
subscription: async () => {
if (!("serviceWorker" in navigator)) return null;
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
return subscription ? subscriptionHandle(subscription) : null;
},
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
},
});
export const getAgentPushState = () => controller.state();
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
export const enableAgentBrowserPush = () => controller.enable();
export const disableAgentBrowserPush = () => controller.disable();
export const refreshAgentBrowserPush = () => controller.refresh();
const presence = createAgentPresenceReporter({
snapshot: () => ({ visibility: document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
send: (value, keepalive) => { const id = getAgentPresenceSessionId(); return id ? api.presence(id, value, keepalive) : Promise.reject(new Error("Attention session unavailable.")); },
});
let stopSession: (() => void) | undefined;
export async function revokeAgentBrowserPush() {
stopSession?.();
// Revoke/mark the durable tombstone before the caller clears the bearer token.
const id = getAgentPresenceSessionId();
await Promise.allSettled([readConsent() ? controller.disable() : postWorker({ type: "sybil-agent-logout", version: 1 }), ...(id ? [api.retirePresence(id)] : [])]);
}
/** Mounted from shared authentication so /search, every chat and every tab participate. */
export function startAgentBrowserSession() {
stopSession?.();
let active = true, refreshing = false, interactionTimer: ReturnType<typeof setTimeout> | undefined;
const report = (keepalive = false, hidden = false) => { if (active) void presence.report(keepalive, hidden).catch(() => undefined); };
const refresh = async () => {
if (!active || refreshing) return;
refreshing = true;
try {
await controller.refresh();
if (active && document.visibilityState === "visible") {
const read = await api.readState();
if (active && read) await syncAgentNotificationRead(read);
}
} catch { /* Absence of an acknowledged source/read never closes notifications. */ }
finally { refreshing = false; }
};
const attention = () => { report(); if (document.visibilityState === "visible") void refresh(); };
const interaction = (event: Event) => {
if (!event.isTrusted) return;
presence.interact();
if (!interactionTimer) {
report();
interactionTimer = setTimeout(() => { interactionTimer = undefined; report(); }, 1000);
}
};
const hide = () => report(true, true);
const storage = (event: StorageEvent) => { if (event.key === AGENT_PUSH_STORAGE_KEY) void refresh(); };
const workerMessage = (event: MessageEvent) => { if (["sybil-push-subscription-changed", "sybil-push-shown"].includes(event.data?.type)) void refresh(); };
const heartbeat = setInterval(() => { report(); void refresh(); }, 15000);
window.addEventListener("focus", attention); window.addEventListener("blur", attention);
window.addEventListener("pageshow", attention); window.addEventListener("online", attention);
window.addEventListener("pagehide", hide); window.addEventListener("storage", storage);
document.addEventListener("visibilitychange", attention);
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.addEventListener(type, interaction, { passive: true, capture: true });
navigator.serviceWorker?.addEventListener("message", workerMessage);
report(); void refresh();
const stop = () => {
if (!active) return;
active = false; clearInterval(heartbeat); clearTimeout(interactionTimer);
window.removeEventListener("focus", attention); window.removeEventListener("blur", attention);
window.removeEventListener("pageshow", attention); window.removeEventListener("online", attention);
window.removeEventListener("pagehide", hide); window.removeEventListener("storage", storage);
document.removeEventListener("visibilitychange", attention);
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.removeEventListener(type, interaction, true);
navigator.serviceWorker?.removeEventListener("message", workerMessage);
if (stopSession === stop) stopSession = undefined;
// On unmount preserve the final lease until expiry; a late PUT cannot undo a DELETE.
};
stopSession = stop;
return stop;
}
+320
View File
@@ -0,0 +1,320 @@
/** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */
export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1";
export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed";
export const agentPushCursor = (value: unknown): value is string => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
export const agentPushUuid = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
export const agentPushSourceId = agentPushUuid;
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
export type AgentPushConfig = {
version: 1; enabled: boolean; webPushAvailable: boolean; apnsAvailable: boolean;
sourceId: string | null; quarantined: boolean; vapidPublicKey?: string;
policy: { presenceHeartbeatMs: number; presenceLeaseMs: number };
};
export function parseAgentPushConfig(value: unknown): AgentPushConfig {
const config = object(value), policy = object(config?.policy);
if (!config || config.version !== 1 || typeof config.enabled !== "boolean" || typeof config.webPushAvailable !== "boolean"
|| typeof config.apnsAvailable !== "boolean" || (config.sourceId !== null && !agentPushSourceId(config.sourceId))
|| (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy
|| typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000
|| typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000
|| (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
throw new Error("Notification configuration is unavailable.");
}
return { ...config, quarantined: config.quarantined === true } as AgentPushConfig;
}
export type AgentPresence = { sequence: number; visibility: "visible" | "hidden"; focused: boolean; interacted: boolean };
/** Nonsecret identity only; the bearer is never retained with the digest. */
export async function agentPushAuthorityScope(baseUrl: string, token: string | null, origin = globalThis.location?.href): Promise<string | null> {
if (!token || !globalThis.crypto?.subtle) return null;
const base = new URL(baseUrl.replace(/\/+$/, ""), origin).href;
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([base, token])));
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
}
export class AgentPushClient {
private options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch };
constructor(options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }) { this.options = options; }
private async request(path: string, method = "GET", body?: unknown, keepalive = false, expectedScope?: string): Promise<unknown> {
const token = this.options.token();
if (!token) throw new Error("Sign in to manage notifications.");
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000);
try {
// Capture the bearer before hashing, then use that exact bearer for the
// request. A concurrent sign-in must not turn old-authority cleanup into
// a new-authority DELETE whose204 falsely clears a durable tombstone.
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new Error("Notification authority changed.");
const response = await (this.options.fetch || globalThis.fetch)(`${this.options.baseUrl.replace(/\/+$/, "")}/v1/agent${path}`, {
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", redirect: "error", credentials: "omit", signal: controller.signal, keepalive,
});
// Never echo server/provider bodies: they may contain endpoint or key material.
if (!response.ok) throw new Error(response.status === 401 ? "Sign in to manage notifications." : "Notification settings could not be saved. Try again.");
return response.status === 204 ? undefined : await response.json();
} catch { throw new Error("Notification service is unavailable. Try again when connected."); }
finally { clearTimeout(timeout); }
}
async config() { return parseAgentPushConfig(await this.request("/push/config")); }
async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) {
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
const value = object(await this.request(`/push/installations/${id}`, "PUT", { transport: "web", enabled: true, subscription }, false, expectedScope));
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new Error("Notification registration was not acknowledged.");
}
async revoke(id: string, expectedScope?: string) {
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
await this.request(`/push/installations/${id}`, "DELETE", undefined, false, expectedScope);
}
async presence(id: string, presence: AgentPresence, keepalive = false) {
if (!agentPushUuid(id)) throw new Error("Invalid attention session.");
await this.request(`/presence/${id}`, "PUT", presence, keepalive);
}
async retirePresence(id: string) { if (agentPushUuid(id)) await this.request(`/presence/${id}`, "DELETE", undefined, true); }
async readState() {
const value = object(await this.request("/read-state"));
if (agentPushSourceId(value?.sourceId) && agentPushCursor(value?.cursor)) return { sourceId: value.sourceId, cursor: BigInt(value.cursor).toString() };
return null;
}
}
export const MAX_AGENT_PUSH_REVOCATIONS = 16;
export type AgentPushRevocation = { installationId: string; scope: string };
export type AgentPushConsent = { version: 1; installationId: string; revision: string; scope?: string; sourceId?: string; enabled: boolean; revocationPending: boolean; pendingRevocations?: AgentPushRevocation[] };
export function parseAgentPushConsent(raw: string | null): AgentPushConsent | null {
try {
const value = object(JSON.parse(raw || "null"));
if (!(value?.version === 1 && agentPushUuid(value.installationId) && agentPushUuid(value.revision)
&& (value.scope === undefined || (typeof value.scope === "string" && /^[a-f0-9]{64}$/.test(value.scope)))
&& (value.pendingRevocations === undefined || (Array.isArray(value.pendingRevocations) && value.pendingRevocations.length <= MAX_AGENT_PUSH_REVOCATIONS
&& value.pendingRevocations.every(item => agentPushUuid(object(item)?.installationId) && typeof object(item)?.scope === "string" && /^[a-f0-9]{64}$/.test(String(object(item)?.scope)))))
&& typeof value.enabled === "boolean" && typeof value.revocationPending === "boolean")) return null;
// Keep the installation identity for cleanup when migrating old/unbound
// consent. An absent or malformed source can never authorize registration.
return { ...value, sourceId: agentPushSourceId(value.sourceId) ? value.sourceId : undefined } as AgentPushConsent;
} catch { return null; }
}
export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null };
export type PushSubscriptionHandle = Pick<PushSubscription, "toJSON" | "unsubscribe">;
type PushPlatform = {
support: () => "supported" | "unsupported" | "install";
permission: () => NotificationPermission;
requestPermission: () => Promise<NotificationPermission>;
subscription: () => Promise<PushSubscriptionHandle | null>;
subscribe: (publicKey: string) => Promise<PushSubscriptionHandle>;
clearNotifications: () => Promise<void>;
};
/** Explicit consent survives restarts; a revoke tombstone always wins over an older enable. */
export function createAgentPushController(options: {
api: Pick<AgentPushClient, "config" | "register" | "revoke">;
platform: PushPlatform; read: () => AgentPushConsent | null; write: (consent: AgentPushConsent) => void; remove?: () => void;
scope: () => Promise<string | null>;
uuid: () => string; lock: <T>(operation: () => Promise<T>) => Promise<T>; changed: (view: AgentPushView) => void;
}) {
let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | null = null;
let scope: string | null = null;
let registeredSubscription: string | null = null;
const currentConsent = () => localRevocation || options.read();
const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope
&& agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId;
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !config.quarantined && agentPushSourceId(config.sourceId);
const refreshContext = async () => {
const nextScope = await options.scope(), nextConfig = await options.api.config();
if (nextScope !== await options.scope()) { scope = null; config = null; throw new Error("Notification authority changed."); }
scope = nextScope; config = nextConfig;
};
const recheckContext = async (record: AgentPushConsent) => {
await refreshContext();
return matchesContext(record) && transportAvailable();
};
const state = (): AgentPushView => {
const consent = currentConsent(), support = options.platform.support(), permission = options.platform.permission();
const available = !!scope && transportAvailable();
const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS;
const enabled = consent?.enabled === true && !consent.revocationPending && permission === "granted";
const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there."
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
: permission === "denied" ? "Notifications are blocked. Allow them in browser settings, then try again."
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
: !available ? "Notifications are not configured on this server yet."
: enabled ? "Notifications are on for this browser. Alerts use a private, generic preview."
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
};
const publish = () => options.changed(state());
const write = (consent: AgentPushConsent) => { options.write(consent); publish(); };
const consent = (): AgentPushConsent => currentConsent() || { version: 1, installationId: options.uuid(), revision: options.uuid(), ...(scope ? { scope } : {}), enabled: false, revocationPending: false };
const retireAtOriginalAuthority = async (record: Pick<AgentPushConsent, "installationId" | "scope">) => {
if (!record.scope || record.scope !== await options.scope()) return false;
await options.api.revoke(record.installationId, record.scope);
return true;
};
const retryRevocations = async () => {
const original = currentConsent();
if (!original) return;
for (const pending of original.pendingRevocations || []) {
if (currentConsent()?.revision !== original.revision) return;
const removed = await retireAtOriginalAuthority(pending).catch(() => false);
const latest = currentConsent();
if (removed && latest?.revision === original.revision) {
write({ ...latest, pendingRevocations: (latest.pendingRevocations || []).filter(item => item.installationId !== pending.installationId || item.scope !== pending.scope) });
}
}
};
const revoke = async (record: AgentPushConsent) => {
let serverRemoved = false, subscriptionRemoved = false;
await Promise.all([
retireAtOriginalAuthority(record).then(removed => { serverRemoved = removed; }).catch(() => undefined),
options.platform.subscription().then(async subscription => { subscriptionRemoved = !subscription || await subscription.unsubscribe(); }).catch(() => undefined),
options.platform.clearNotifications().catch(() => undefined),
]);
const authority = await options.scope().catch(() => null);
const latest = currentConsent();
if (latest?.revision === record.revision && subscriptionRemoved) {
const queue = latest.pendingRevocations || [];
const archived = !!record.scope && queue.some(item => item.installationId === record.installationId && item.scope === record.scope);
const differentAuthority = record.scope && record.scope !== authority;
if (serverRemoved || archived) {
const removed = { ...latest, revocationPending: false };
try { write(removed); localRevocation = null; } catch { localRevocation = removed; }
} else if (differentAuthority && queue.length < MAX_AGENT_PUSH_REVOCATIONS) {
// Keep only the old nonsecret authority digest and ID. A current-server
// DELETE/204 is never evidence that a different server retired this ID.
const archivedRecord = { ...latest, revocationPending: false,
pendingRevocations: [...queue, { installationId: record.installationId, scope: record.scope! }] };
try { write(archivedRecord); localRevocation = null; } catch { localRevocation = latest; }
}
}
};
const invalidate = async (record: AgentPushConsent) => {
if (currentConsent()?.revision !== record.revision) {
await retireAtOriginalAuthority(record).catch(() => undefined);
return;
}
const pending = { ...record, enabled: false, revocationPending: true, revision: options.uuid() };
registeredSubscription = null;
try { write(pending); } catch { localRevocation = pending; }
await revoke(pending);
};
const disable = async () => {
error = null;
const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() };
// Synchronous durable intent is necessary before logout clears page credentials.
registeredSubscription = null;
try { write(record); }
catch {
localRevocation = record;
// Some browsers reject setItem when full but still permit removal.
try { options.remove?.(); } catch { /* Unsubscribe/revoke below still run. */ }
error = "Browser storage is unavailable. Check that notifications are off in browser settings.";
}
busy = true; publish();
try { await options.lock(() => revoke(record)); }
catch {
// A stuck tab must not prevent logout cleanup. The server permanently
// retires this UUID, so cleanup remains safe without acquiring that lock.
await revoke(record).catch(() => undefined);
error = "Notifications are off here. Removal will retry when connected.";
}
finally { busy = false; publish(); }
};
const refresh = async () => {
if (busy) return;
try {
await refreshContext();
await options.lock(async () => {
await retryRevocations();
let record = currentConsent();
if (!record) return;
if (record.revocationPending) { await revoke(record); return; }
if (!record.enabled) return;
if (!matchesContext(record)) { await invalidate(record); return; }
const subscription = await options.platform.subscription();
if (currentConsent()?.revision !== record.revision) return;
if (options.platform.permission() !== "granted" || !subscription) {
record = { ...record, enabled: false, revocationPending: true, revision: options.uuid() }; write(record); await revoke(record); return;
}
if (transportAvailable()) {
const serialized = JSON.stringify(subscription.toJSON());
if (registeredSubscription !== serialized) {
const matching = await recheckContext(record);
if (currentConsent()?.revision !== record.revision) return;
if (!matching) { await invalidate(record); return; }
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
// A source replacement may complete while the PUT is in flight.
// Never retain that registration without a fresh source check.
try {
const stillMatching = await recheckContext(record);
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
if (!stillMatching) { await invalidate(record); return; }
} catch { await invalidate(record); throw new Error("Notification service is unavailable."); }
registeredSubscription = serialized;
}
}
});
error = null;
} catch { error = "Notification service is unavailable. Try again when connected."; }
publish();
};
const enable = async () => {
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
error = null;
// Explicit revocation permanently retires the old UUID on the server. Each
// new opt-in has a new identity, so a delayed old PUT cannot resurrect it.
const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId,
enabled: false, revocationPending: false, pendingRevocations: currentConsent()?.pendingRevocations || [] };
const publicKey = config.vapidPublicKey;
try { options.write(record); localRevocation = null; } catch { error = "Allow browser storage before enabling notifications."; publish(); return; }
// Must execute in the original click stack, before locks, worker startup or fetches.
let permission: Promise<NotificationPermission>;
try { permission = options.platform.requestPermission(); }
catch { error = "Notifications could not be requested. Check browser settings and try again."; publish(); return; }
busy = true; publish();
try {
if (await permission !== "granted") { error = "Notifications were not allowed. You can change this in browser settings."; return; }
await options.lock(async () => {
const unchanged = () => options.read()?.revision === record.revision;
if (!unchanged()) return;
const matching = await recheckContext(record);
if (!unchanged()) return;
if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; }
const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey);
if (!unchanged()) { await subscription.unsubscribe(); return; }
// A tombstone also covers a lost registration response or failed local storage write.
const pending = { ...record, enabled: false, revocationPending: true }; write(pending);
try {
const matchingBeforeRegister = await recheckContext(record);
if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; }
const matchingAfterRegister = await recheckContext(record);
if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
write({ ...record, enabled: true, revocationPending: false });
registeredSubscription = JSON.stringify(subscription.toJSON());
} catch {
await revoke(pending);
throw new Error("Notifications could not be enabled. Try again when connected.");
}
});
} catch { error = "Notifications could not be enabled. Try again when connected."; }
finally { busy = false; publish(); }
};
return { state, refresh, enable, disable };
}
/** Independent ordered lease per document, including focused-but-idle and recent input. */
export function createAgentPresenceReporter(options: {
snapshot: () => { visibility: "visible" | "hidden"; focused: boolean };
send: (presence: AgentPresence, keepalive: boolean) => Promise<void>;
}) {
let sequence = 0, interaction = 0, acknowledgedInteraction = 0;
return {
interact() { interaction++; },
async report(keepalive = false, hidden = false) {
const snapshot = hidden ? { visibility: "hidden" as const, focused: false } : options.snapshot();
const capturedInteraction = interaction;
const presence = { ...snapshot, focused: snapshot.visibility === "visible" && snapshot.focused, sequence: ++sequence, interacted: capturedInteraction > acknowledgedInteraction };
await options.send(presence, keepalive);
acknowledgedInteraction = Math.max(acknowledgedInteraction, capturedInteraction);
},
};
}
+91 -13
View File
@@ -1,5 +1,6 @@
import type { Message } from "./api";
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
export type AgentActivity = {
state: "idle" | "thinking" | "tool" | "waiting" | "error";
@@ -19,15 +20,18 @@ export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
};
export type AgentReadState = { cursor: string; updatedAt: string | null };
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
export type AgentRoomState = {
conversationId: string | null; messages: Message[]; cursor: string;
readCursor: string; activity: AgentActivity | null;
/** undefined before the first read snapshot; null pins a legacy source-less session. */
sourceId?: string | null;
};
export type AgentRoomEvent =
| ({ type: "messages" } & AgentMessagesPage)
| { type: "activity"; conversationId: string; activity: AgentActivity }
| { type: "ready"; conversationId?: string }
| ({ type: "read_state" } & AgentReadState)
| { type: "pong" | "heartbeat" }
| { type: "error"; error?: string; message?: string };
@@ -81,6 +85,17 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
export const createAgentRoomState = (): AgentRoomState => ({
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
});
export class AgentReadSourceChangedError extends Error {
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
}
function readSource(state: Pick<AgentRoomState, "sourceId" | "cursor" | "readCursor" | "messages">, read: AgentReadState): string | null {
if (read.sourceId != null && !agentPushSourceId(read.sourceId)) throw new AgentReadSourceChangedError();
const incoming = read.sourceId ?? null;
if (state.sourceId !== undefined && state.sourceId !== incoming) throw new AgentReadSourceChangedError();
// A source cannot be attached retroactively to already rendered unknown history.
if (state.sourceId === undefined && incoming !== null && (state.cursor !== "0" || state.readCursor !== "0" || state.messages.length > 0)) throw new AgentReadSourceChangedError();
return incoming;
}
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
@@ -88,10 +103,12 @@ export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage):
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
}
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
const sourceId = readSource(state, read);
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
}
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
if (event.type === "messages") return applyAgentPage(state, event);
if (event.type === "read_state") return applyAgentReadState(state, event);
if (event.type !== "activity") return state;
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
return { ...state, conversationId: event.conversationId, activity: event.activity };
@@ -110,7 +127,10 @@ export function agentDisplayMessages(state: AgentRoomState): Message[] {
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
const event = object(JSON.parse(raw));
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
if (!["messages", "activity", "ready", "pong", "heartbeat", "error", "read_state"].includes(event.type)) return null;
if (event.type === "read_state" && (!agentPushCursor(event.cursor)
|| (event.updatedAt !== null && typeof event.updatedAt !== "string")
|| (event.sourceId !== undefined && event.sourceId !== null && !agentPushSourceId(event.sourceId)))) throw new Error("Invalid Agent read frame.");
if (event.type === "messages") {
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
@@ -123,8 +143,9 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
return event as AgentRoomEvent;
}
export function agentAuthFrame(token: string | null, cursor: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor),
...(agentPushUuid(presenceSessionId) ? { clientKind: "web", presenceSessionId } : {}) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
@@ -146,10 +167,10 @@ export class AgentRoomClient {
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
let value: unknown;
try { value = await response.json(); }
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
catch { throw Object.assign(new Error(`Agent API returned ${response.status} without a JSON response.`), { status: response.status }); }
if (!response.ok) {
const error = object(value);
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
throw Object.assign(new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`), { status: response.status });
}
return value as T;
}
@@ -158,8 +179,8 @@ export class AgentRoomClient {
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
}
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
}
async submit(content: string, requestId: string, attachments: AgentAttachment[] = []): Promise<AgentSubmission> {
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId, ...(attachments.length ? { attachments: attachments.map(({ id }) => ({ id })) } : {}) }) }));
@@ -215,18 +236,75 @@ export function createAgentReadWriter(options: {
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
}) {
let desired = "0";
let stopped = false;
let running: Promise<void> | undefined;
const cancel = () => { stopped = true; desired = "0"; };
const flush = (): Promise<void> => {
if (stopped) return Promise.resolve();
if (running) return running;
running = (async () => {
while (BigInt(desired) > BigInt(options.current())) {
while (!stopped && BigInt(desired) > BigInt(options.current())) {
const captured = desired;
const saved = await options.save(captured);
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
options.apply(saved);
if (stopped) return;
try {
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
options.apply(saved);
// A rejected/source-mismatched acknowledgment must never become a PUT loop.
if (BigInt(options.current()) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
} catch (error) { cancel(); throw error; }
}
})().finally(() => { running = undefined; });
return running;
};
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
return { request(cursor: string) { if (!stopped) desired = advanceAgentCursor(desired, cursor); return flush(); }, flush, cancel };
}
/** Pin reads to the source that owns this rendered room, including queued writes. */
export function createAgentReadSession(options: {
initial: AgentRoomState;
save: (cursor: string, sourceId: string | null) => Promise<AgentReadState>;
apply: (read: AgentReadState) => void;
invalidated?: (error: AgentReadSourceChangedError) => void;
}) {
let binding = options.initial.sourceId, acknowledged = options.initial.readCursor;
let failure: AgentReadSourceChangedError | undefined;
const invalidate = () => {
if (!failure) { failure = new AgentReadSourceChangedError(); writer.cancel(); options.invalidated?.(failure); }
return failure;
};
const receive = (read: AgentReadState) => {
if (failure) throw failure;
try {
const nextSource = readSource({ ...options.initial, sourceId: binding, readCursor: acknowledged }, read);
const nextCursor = advanceAgentCursor(acknowledged, read.cursor);
binding = nextSource; acknowledged = nextCursor;
options.apply({ ...read, sourceId: binding });
} catch (error) { if (error instanceof AgentReadSourceChangedError) throw invalidate(); throw error; }
};
const writer = createAgentReadWriter({
current: () => acknowledged,
save: async cursor => {
if (failure || binding === undefined) throw invalidate();
const source = binding;
try { return await options.save(cursor, source); }
catch (error) {
if (error instanceof AgentReadSourceChangedError || object(error)?.status === 409) throw invalidate();
throw error;
}
},
apply: receive,
});
return {
receive,
request(cursor: string, renderedSourceId: string | null | undefined) {
if (failure) return Promise.reject(failure);
if (binding === undefined || renderedSourceId !== binding) return Promise.reject(invalidate());
return writer.request(cursor);
},
flush: writer.flush,
cancel: writer.cancel,
sourceId: () => binding,
readCursor: () => acknowledged,
};
}
+13 -6
View File
@@ -1,12 +1,19 @@
export async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
const registration = await navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
// ready can otherwise remain pending forever after an install failure.
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("The notification worker did not become ready. Reload and try again.")), 15000);
void navigator.serviceWorker.ready.then(ready => { clearTimeout(timeout); resolve(ready); }, () => {
clearTimeout(timeout); reject(new Error("The notification worker is unavailable."));
});
void registration.update().catch(() => undefined);
});
}
export function registerServiceWorker() {
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
window.addEventListener("load", () => {
void navigator.serviceWorker
.register("/sw.js", { updateViaCache: "none" })
.then((registration) => registration.update())
.catch((error: unknown) => {
console.warn("Sybil service worker registration failed", error);
});
void ensureServiceWorker().catch(() => undefined);
});
}