diff --git a/clients/chrome-extension/background/worker.ts b/clients/chrome-extension/background/worker.ts index 5ba89b21d3c..966d3eebb03 100644 --- a/clients/chrome-extension/background/worker.ts +++ b/clients/chrome-extension/background/worker.ts @@ -619,6 +619,10 @@ function createSseConnection(mode: SseMode): SseConnection { ); if (authError) { shouldConnect = false; + // Auth-required is a hard stop: no automatic reconnect will + // succeed until the user re-signs-in, so let the worker idle + // out instead of waking every 30 s. + void clearKeepaliveAlarm(); setConnectionHealth("auth_required", { lastErrorMessage: authError, }); @@ -896,6 +900,54 @@ function disconnect(): void { } } +// ── Keep-alive (MV3 service-worker liveness) ───────────────────────── + +const KEEPALIVE_ALARM_NAME = "vellum-relay-keepalive"; +const KEEPALIVE_PERIOD_MIN = 0.5; + +async function ensureKeepaliveAlarm(): Promise { + const existing = await chrome.alarms.get(KEEPALIVE_ALARM_NAME); + if (existing) return; + await chrome.alarms.create(KEEPALIVE_ALARM_NAME, { + periodInMinutes: KEEPALIVE_PERIOD_MIN, + }); +} + +async function clearKeepaliveAlarm(): Promise { + await chrome.alarms.clear(KEEPALIVE_ALARM_NAME); +} + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name !== KEEPALIVE_ALARM_NAME) return; + if (shouldConnect && !(sseConnection?.isOpen() ?? false)) { + void connect({ interactive: false }).catch((err) => { + const detail = err instanceof Error ? err.message : String(err); + console.warn(`[vellum-relay] Keepalive reconnect failed: ${detail}`); + }); + } +}); + +// On install/update, only register the alarm if we already have an +// active auto-connect intent (e.g. an update installing over a +// connected install). For a fresh install with no prior connect, +// the alarm is created when the user first presses Connect — that +// avoids burning a wake-up every 30 s on installs that never connect. +chrome.runtime.onInstalled.addListener(() => { + void chrome.storage.local.get(AUTO_CONNECT_KEY).then((result) => { + if (result[AUTO_CONNECT_KEY] === true) { + void ensureKeepaliveAlarm(); + } + }); +}); + +chrome.runtime.onStartup.addListener(() => { + void chrome.storage.local.get(AUTO_CONNECT_KEY).then((result) => { + if (result[AUTO_CONNECT_KEY] === true) { + void ensureKeepaliveAlarm(); + } + }); +}); + // ── Extension message listener (from popup) ───────────────────────── chrome.runtime.onMessage.addListener((message, _sender, sendResponseFn) => { @@ -910,6 +962,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponseFn) => { // was in-flight — their pause intent takes precedence. if (shouldConnect) { await setAutoConnect(true); + await ensureKeepaliveAlarm(); } sendResponseFn({ ok: true }); }) @@ -922,6 +975,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponseFn) => { // must not leave the flag set, otherwise the next bootstrap // would retry a doomed connect. await setAutoConnect(false); + await clearKeepaliveAlarm(); const serializedError = serializeWorkerError(err); const errorMessage = serializedError.error; // Classify the failure: auth-related errors (MissingTokenError) @@ -947,6 +1001,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponseFn) => { if (message.type === "pause" || message.type === "disconnect") { shouldConnect = false; setConnectionHealth("paused"); + void clearKeepaliveAlarm(); // Await the storage write so MV3 can't terminate the worker before // the autoConnect flag is persisted to false. setAutoConnect(false) @@ -1238,6 +1293,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponseFn) => { disconnect(); setConnectionHealth("paused"); clearEventLog(); + await clearKeepaliveAlarm(); await setAutoConnect(false); await clearSession(); await clearSelectedAssistant(); @@ -1253,6 +1309,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponseFn) => { disconnect(); setConnectionHealth("paused"); clearEventLog(); + await clearKeepaliveAlarm(); await setAutoConnect(false); await clearStoredUserMode(); sendResponseFn({ ok: true }); @@ -1325,6 +1382,7 @@ async function bootstrap(): Promise { const result = await chrome.storage.local.get(AUTO_CONNECT_KEY); if (result[AUTO_CONNECT_KEY] !== true) return; shouldConnect = true; + await ensureKeepaliveAlarm(); try { await connect({ interactive: false }); } catch (err) { @@ -1333,6 +1391,7 @@ async function bootstrap(): Promise { // sign in / pair to try again. Persist the error detail exactly // once so the popup can surface it, then stop retrying. shouldConnect = false; + void clearKeepaliveAlarm(); if (err instanceof MissingTokenError) { console.warn(`[vellum-relay] Skipping auto-connect: ${err.message}`); setConnectionHealth("auth_required", { diff --git a/clients/chrome-extension/manifest.json b/clients/chrome-extension/manifest.json index 5927d78425a..f7d663a1c6e 100644 --- a/clients/chrome-extension/manifest.json +++ b/clients/chrome-extension/manifest.json @@ -2,10 +2,11 @@ "manifest_version": 3, "name": "Vellum Assistant", "version": "0.8.0", - "minimum_chrome_version": "116", + "minimum_chrome_version": "120", "description": "Bridges the Vellum assistant to your live browser session via Chrome DevTools Protocol (CDP) through chrome.debugger.", "homepage_url": "https://www.vellum.ai", "permissions": [ + "alarms", "debugger", "identity", "storage", diff --git a/clients/chrome-extension/types/chrome-globals.d.ts b/clients/chrome-extension/types/chrome-globals.d.ts index 5918c7f4a1b..5f867cf52ae 100644 --- a/clients/chrome-extension/types/chrome-globals.d.ts +++ b/clients/chrome-extension/types/chrome-globals.d.ts @@ -115,12 +115,34 @@ interface ChromeRuntimeManifest { [key: string]: unknown; } +interface ChromeRuntimeOnInstalledDetails { + reason: "install" | "update" | "chrome_update" | "shared_module_update"; + previousVersion?: string; + id?: string; +} + +interface ChromeRuntimeOnInstalledEvent { + addListener( + listener: (details: ChromeRuntimeOnInstalledDetails) => void, + ): void; + removeListener( + listener: (details: ChromeRuntimeOnInstalledDetails) => void, + ): void; +} + +interface ChromeRuntimeOnStartupEvent { + addListener(listener: () => void): void; + removeListener(listener: () => void): void; +} + interface ChromeRuntimeNamespace { /** The ID of the extension. */ readonly id: string; readonly lastError: ChromeRuntimeLastError | undefined; connectNative(application: string): ChromeRuntimePort; onMessage: ChromeRuntimeOnMessageEvent; + onInstalled: ChromeRuntimeOnInstalledEvent; + onStartup: ChromeRuntimeOnStartupEvent; // Generic over the response type so callers can narrow the callback // argument without casting. Matches the de-facto shape used by the // official @types/chrome package. @@ -133,6 +155,30 @@ interface ChromeRuntimeNamespace { getURL(path: string): string; } +interface ChromeAlarm { + name: string; + scheduledTime: number; + periodInMinutes?: number; +} + +interface ChromeAlarmCreateInfo { + when?: number; + delayInMinutes?: number; + periodInMinutes?: number; +} + +interface ChromeAlarmsOnAlarmEvent { + addListener(listener: (alarm: ChromeAlarm) => void): void; + removeListener(listener: (alarm: ChromeAlarm) => void): void; +} + +interface ChromeAlarmsNamespace { + create(name: string, alarmInfo: ChromeAlarmCreateInfo): Promise; + get(name: string): Promise; + clear(name: string): Promise; + onAlarm: ChromeAlarmsOnAlarmEvent; +} + interface ChromeTab { id?: number; windowId?: number; @@ -276,6 +322,7 @@ interface ChromeActionNamespace { interface ChromeGlobal { action: ChromeActionNamespace; + alarms: ChromeAlarmsNamespace; storage: ChromeStorageNamespace; identity: ChromeIdentityNamespace; runtime: ChromeRuntimeNamespace;