@ohmaseclaro/fleetwatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,396 @@
1
+ /**
2
+ * CursorProvider — surfaces Cursor IDE chat sessions in fleetwatch.
3
+ *
4
+ * Data lives in a single ~30 GB SQLite DB at
5
+ * ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
6
+ * with two key namespaces in the `cursorDiskKV` table:
7
+ *
8
+ * - composerData:<conversationId> — session envelope (title, timestamps, files)
9
+ * - bubbleId:<conversationId>:<msgId> — individual user/assistant messages
10
+ *
11
+ * We open the DB read-only (Cursor can be running concurrently) and use
12
+ * fs.watch on the WAL/main file as a cheap change signal — when WAL is
13
+ * rewritten on commit, we re-scan envelopes and (for subscribed sessions)
14
+ * fetch any new bubbles by rowid.
15
+ *
16
+ * To keep the session list manageable we cap surfaced composers to a small
17
+ * recent window (COMPOSER_LIMIT). Bubbles for a given session are only
18
+ * loaded when a client subscribes, mirroring the JSONL provider.
19
+ */
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+ import { existsSync, watch as fsWatch } from "node:fs";
23
+ import Database from "better-sqlite3";
24
+ import { BaseProvider } from "./base.js";
25
+ const HOME = os.homedir();
26
+ export const CURSOR_DB_PATH = path.join(HOME, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
27
+ /** How many composers to surface in the session list (most recent first). */
28
+ const COMPOSER_LIMIT = 200;
29
+ /** Wait this long after a WAL change before re-polling, to coalesce bursts. */
30
+ const WAL_DEBOUNCE_MS = 500;
31
+ export class CursorProvider extends BaseProvider {
32
+ info = {
33
+ id: "cursor",
34
+ displayName: "Cursor",
35
+ description: "Cursor IDE chat sessions from the local SQLite store.",
36
+ accentColor: "#5fb9ff",
37
+ };
38
+ db = null;
39
+ walWatcher = null;
40
+ dbWatcher = null;
41
+ /** Override from options — null means "use discovery to find it". */
42
+ dbPathOverride;
43
+ /** Resolved at start(). */
44
+ dbPath = null;
45
+ limit;
46
+ /** All composers we know about (whether subscribed or not). */
47
+ sessions = new Map();
48
+ /** sessionIds a client is currently watching → drives bubble polling. */
49
+ subscribed = new Set();
50
+ debounceTimer = null;
51
+ constructor(opts) {
52
+ super(opts);
53
+ this.dbPathOverride = opts.dbPath ?? null;
54
+ this.limit = opts.limit ?? COMPOSER_LIMIT;
55
+ }
56
+ async onStart() {
57
+ // Locate the state.vscdb. Two layers:
58
+ // 1. Explicit override (from CursorProviderOptions) — wins immediately.
59
+ // 2. Discovery: walk known per-OS paths, then bounded filesystem search.
60
+ //
61
+ // The `verify` predicate opens each candidate as SQLite and checks for the
62
+ // `cursorDiskKV` table — VSCode uses a `state.vscdb` too (different schema)
63
+ // so a substring match on the path alone would mis-identify it.
64
+ if (this.dbPathOverride) {
65
+ this.dbPath = this.dbPathOverride;
66
+ }
67
+ else {
68
+ this.dbPath = await this.discover({
69
+ label: "Cursor globalStorage DB",
70
+ candidates: [
71
+ process.env.CURSOR_DB_PATH,
72
+ CURSOR_DB_PATH,
73
+ // Linux (VSCode-fork convention)
74
+ path.join(HOME, ".config", "Cursor", "User", "globalStorage", "state.vscdb"),
75
+ // Windows
76
+ process.env.APPDATA && path.join(process.env.APPDATA, "Cursor", "User", "globalStorage", "state.vscdb"),
77
+ ],
78
+ searchRoots: [
79
+ // macOS app data
80
+ path.join(HOME, "Library", "Application Support"),
81
+ // Linux app data
82
+ path.join(HOME, ".config"),
83
+ ],
84
+ searchName: "state.vscdb",
85
+ // Prevent VSCode / VSCodium / Codium / etc state.vscdb files from
86
+ // matching when verify can't open them.
87
+ pathMustContain: "Cursor",
88
+ searchMaxDepth: 5,
89
+ verify: verifyCursorDb,
90
+ });
91
+ }
92
+ if (!this.dbPath) {
93
+ this.skipStartup("no Cursor SQLite DB found");
94
+ return;
95
+ }
96
+ if (!existsSync(this.dbPath)) {
97
+ this.skipStartup(`Cursor DB not at ${this.dbPath}`);
98
+ return;
99
+ }
100
+ try {
101
+ // readonly: true → opens the DB without acquiring a write lock; safe
102
+ // while Cursor is running (Cursor uses WAL mode so reads don't block).
103
+ this.db = new Database(this.dbPath, { readonly: true, fileMustExist: true });
104
+ }
105
+ catch (err) {
106
+ this.log(`failed to open db: ${err.message}`);
107
+ this.skipStartup("could not open Cursor DB");
108
+ return;
109
+ }
110
+ this.scanComposers();
111
+ this.watchForChanges();
112
+ this.log(`surfaced ${this.sessions.size} session(s) from ${this.dbPath}`);
113
+ }
114
+ async onStop() {
115
+ if (this.debounceTimer)
116
+ clearTimeout(this.debounceTimer);
117
+ this.debounceTimer = null;
118
+ try {
119
+ this.walWatcher?.close();
120
+ }
121
+ catch { }
122
+ try {
123
+ this.dbWatcher?.close();
124
+ }
125
+ catch { }
126
+ this.walWatcher = null;
127
+ this.dbWatcher = null;
128
+ try {
129
+ this.db?.close();
130
+ }
131
+ catch { }
132
+ this.db = null;
133
+ }
134
+ async backfillSession(sessionId) {
135
+ // No-op when this isn't our session — the ProviderManager fan-out pattern
136
+ // means every provider is asked for every subscription.
137
+ if (!this.sessions.has(sessionId) || !this.db)
138
+ return;
139
+ this.subscribed.add(sessionId);
140
+ this.loadBubbles(sessionId);
141
+ }
142
+ /** Read the most-recent composer envelopes and upsert them into the registry. */
143
+ scanComposers() {
144
+ if (!this.db)
145
+ return;
146
+ let rows;
147
+ try {
148
+ // Range scan: 'composerData:' ≤ key < 'composerData;' lets SQLite use the
149
+ // implicit index on `key` (UNIQUE → sqlite_autoindex_cursorDiskKV_1)
150
+ // regardless of LIKE's case sensitivity defaults. Massive speedup on a
151
+ // 1.9M-row table — turns a SCAN into a SEARCH.
152
+ //
153
+ // ORDER BY rowid DESC = newest first: ON CONFLICT REPLACE means every
154
+ // composer update inserts a new row with a higher rowid, so this gives
155
+ // us the most-recently-modified composers.
156
+ const stmt = this.db.prepare("SELECT key, value FROM cursorDiskKV WHERE key >= 'composerData:' AND key < 'composerData;' ORDER BY rowid DESC LIMIT ?");
157
+ rows = stmt.all(this.limit);
158
+ }
159
+ catch (err) {
160
+ this.log(`scanComposers failed: ${err.message}`);
161
+ return;
162
+ }
163
+ for (const row of rows) {
164
+ try {
165
+ this.upsertComposerFromJson(row.value);
166
+ }
167
+ catch (err) {
168
+ // Skip malformed entries; one bad row shouldn't kill the scan.
169
+ }
170
+ }
171
+ }
172
+ upsertComposerFromJson(json) {
173
+ const parsed = JSON.parse(json);
174
+ if (typeof parsed.composerId !== "string")
175
+ return;
176
+ const composerId = parsed.composerId;
177
+ const name = typeof parsed.name === "string" && parsed.name.trim().length > 0
178
+ ? parsed.name.trim()
179
+ : undefined;
180
+ const subtitle = typeof parsed.subtitle === "string" ? parsed.subtitle : undefined;
181
+ const createdAt = numericTs(parsed.createdAt);
182
+ const lastUpdatedAt = numericTs(parsed.conversationCheckpointLastUpdatedAt) ?? createdAt ?? 0;
183
+ const projectLabel = labelFromOriginalFileStates(parsed.originalFileStates) ?? "Cursor";
184
+ const projectPath = projectPathFromOriginalFileStates(parsed.originalFileStates) ?? "cursor";
185
+ const isSubagent = parsed.subagentInfo && typeof parsed.subagentInfo.subagentType === "string";
186
+ const parentSessionId = parsed.subagentInfo?.parentComposerId;
187
+ this.registry.upsertMeta(composerId, {
188
+ filePath: `cursor:${composerId}`,
189
+ projectPath,
190
+ projectLabel,
191
+ source: "cursor",
192
+ isSubagent: !!isSubagent,
193
+ parentSessionId: typeof parentSessionId === "string" ? parentSessionId : undefined,
194
+ });
195
+ if (name) {
196
+ this.registry.setTitle(composerId, { aiTitle: name });
197
+ }
198
+ // Seed sort-order metrics so the session appears in the list at the right
199
+ // place even before any bubbles are loaded. lastUserMessageAt drives the
200
+ // sort; lastEventAt drives status derivation (>IDLE_THRESHOLD → idle).
201
+ const headers = Array.isArray(parsed.fullConversationHeadersOnly)
202
+ ? parsed.fullConversationHeadersOnly
203
+ : [];
204
+ const eventCount = headers.length;
205
+ if (lastUpdatedAt > 0) {
206
+ this.registry.setUserMessageFromHistory(projectPath, composerId, lastUpdatedAt, subtitle ?? name ?? "Cursor conversation");
207
+ this.registry.setActivity(composerId, {
208
+ lastEventAt: lastUpdatedAt,
209
+ eventCount,
210
+ });
211
+ }
212
+ if (!this.sessions.has(composerId)) {
213
+ this.sessions.set(composerId, { composerId, lastSeenRowid: 0 });
214
+ }
215
+ }
216
+ /** Load (or top-up) bubbles for a single composer into the registry. */
217
+ loadBubbles(composerId) {
218
+ if (!this.db)
219
+ return;
220
+ const state = this.sessions.get(composerId);
221
+ if (!state)
222
+ return;
223
+ let rows;
224
+ try {
225
+ // Range scan on key: `bubbleId:<id>:` ≤ key < `bubbleId:<id>;`
226
+ // Uses the autoindex for an O(log n) seek instead of full SCAN.
227
+ const lo = `bubbleId:${composerId}:`;
228
+ const hi = `bubbleId:${composerId};`;
229
+ const stmt = this.db.prepare("SELECT rowid, value FROM cursorDiskKV WHERE key >= ? AND key < ? AND rowid > ? ORDER BY rowid");
230
+ rows = stmt.all(lo, hi, state.lastSeenRowid);
231
+ }
232
+ catch (err) {
233
+ this.log(`loadBubbles failed: ${err.message}`);
234
+ return;
235
+ }
236
+ for (const row of rows) {
237
+ try {
238
+ const ev = bubbleToEvent(row.value, composerId, row.rowid);
239
+ if (ev)
240
+ this.registry.appendEvent(composerId, ev);
241
+ }
242
+ catch { }
243
+ if (row.rowid > state.lastSeenRowid)
244
+ state.lastSeenRowid = row.rowid;
245
+ }
246
+ }
247
+ /** Watch WAL / DB for changes; on change, re-poll subscribed sessions. */
248
+ watchForChanges() {
249
+ if (!this.dbPath)
250
+ return;
251
+ const dbPath = this.dbPath;
252
+ const walPath = `${dbPath}-wal`;
253
+ const trigger = () => {
254
+ if (this.debounceTimer)
255
+ clearTimeout(this.debounceTimer);
256
+ this.debounceTimer = setTimeout(() => {
257
+ this.debounceTimer = null;
258
+ this.poll();
259
+ }, WAL_DEBOUNCE_MS);
260
+ };
261
+ try {
262
+ // WAL gets rewritten on every commit while Cursor is running.
263
+ if (existsSync(walPath)) {
264
+ this.walWatcher = fsWatch(walPath, () => trigger());
265
+ }
266
+ // Fallback: also watch the main file in case WAL is checkpointed away.
267
+ this.dbWatcher = fsWatch(dbPath, () => trigger());
268
+ }
269
+ catch (err) {
270
+ this.log(`watch failed: ${err.message}`);
271
+ }
272
+ }
273
+ poll() {
274
+ if (!this.db)
275
+ return;
276
+ // Re-scan envelopes — picks up new composers + updated activity timestamps.
277
+ this.scanComposers();
278
+ // For subscribed sessions, ingest any new bubbles.
279
+ for (const id of this.subscribed) {
280
+ this.loadBubbles(id);
281
+ }
282
+ }
283
+ }
284
+ // ─── helpers ─────────────────────────────────────────────────────────────
285
+ /**
286
+ * Disambiguate Cursor's state.vscdb from VSCode's (which has the same
287
+ * filename but a different schema). Opens read-only, checks for both:
288
+ * - the `cursorDiskKV` table (unique to Cursor)
289
+ * - at least one `composerData:` or `bubbleId:` key inside it
290
+ *
291
+ * Returns false on any failure — never throws.
292
+ */
293
+ async function verifyCursorDb(file) {
294
+ let db = null;
295
+ try {
296
+ db = new Database(file, { readonly: true, fileMustExist: true });
297
+ const row = db
298
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
299
+ .get();
300
+ if (!row?.name)
301
+ return false;
302
+ // One narrow probe — confirms the table is populated with the Cursor key shape.
303
+ const probe = db
304
+ .prepare("SELECT 1 FROM cursorDiskKV WHERE key >= 'composerData:' AND key < 'composerData;' LIMIT 1")
305
+ .get();
306
+ return !!probe;
307
+ }
308
+ catch {
309
+ return false;
310
+ }
311
+ finally {
312
+ try {
313
+ db?.close();
314
+ }
315
+ catch { }
316
+ }
317
+ }
318
+ function bubbleToEvent(json, sessionId, rowid) {
319
+ const parsed = JSON.parse(json);
320
+ const type = parsed.type === 1 ? "user" :
321
+ parsed.type === 2 ? "assistant" :
322
+ null;
323
+ if (!type)
324
+ return null;
325
+ // Cursor occasionally synthesizes user-side notifications; skip pure noise.
326
+ const text = typeof parsed.text === "string" && parsed.text.length > 0
327
+ ? parsed.text
328
+ : typeof parsed.richText === "string" && parsed.richText.length > 0
329
+ ? parsed.richText
330
+ : undefined;
331
+ const thinking = typeof parsed.thinking === "string" ? parsed.thinking : undefined;
332
+ if (!text && !thinking)
333
+ return null;
334
+ const ts = numericTs(parsed.createdAt) ?? Date.now();
335
+ return {
336
+ sessionId,
337
+ ts,
338
+ type,
339
+ text,
340
+ thinking,
341
+ // Use bubbleId for stable de-dup on the client.
342
+ uuid: typeof parsed.bubbleId === "string" ? parsed.bubbleId : `cursor:${sessionId}:${rowid}`,
343
+ };
344
+ }
345
+ function numericTs(v) {
346
+ if (typeof v === "number")
347
+ return v > 1e12 ? v : v * 1000;
348
+ if (typeof v === "string") {
349
+ const p = Date.parse(v);
350
+ return Number.isFinite(p) ? p : undefined;
351
+ }
352
+ return undefined;
353
+ }
354
+ /**
355
+ * Derive a short "project / folder" label from the set of file URIs that
356
+ * Cursor recorded for this conversation. We take the longest common
357
+ * directory prefix and use its trailing one or two segments.
358
+ */
359
+ function labelFromOriginalFileStates(states) {
360
+ const prefix = commonPathPrefix(states);
361
+ if (!prefix)
362
+ return undefined;
363
+ const segs = prefix.split("/").filter(Boolean);
364
+ if (segs.length >= 2)
365
+ return `${segs[segs.length - 2]} / ${segs[segs.length - 1]}`;
366
+ return segs[segs.length - 1];
367
+ }
368
+ function projectPathFromOriginalFileStates(states) {
369
+ return commonPathPrefix(states) || undefined;
370
+ }
371
+ function commonPathPrefix(states) {
372
+ if (!states || typeof states !== "object")
373
+ return "";
374
+ const keys = Object.keys(states);
375
+ if (keys.length === 0)
376
+ return "";
377
+ const stripped = keys.map((k) => k.replace(/^file:\/\//, ""));
378
+ if (stripped.length === 1) {
379
+ // For a single file, return its directory.
380
+ const idx = stripped[0].lastIndexOf("/");
381
+ return idx >= 0 ? stripped[0].slice(0, idx) : stripped[0];
382
+ }
383
+ // Find longest common prefix, then truncate at last '/'.
384
+ let prefix = stripped[0];
385
+ for (let i = 1; i < stripped.length; i++) {
386
+ while (stripped[i].indexOf(prefix) !== 0) {
387
+ prefix = prefix.slice(0, -1);
388
+ if (!prefix)
389
+ return "";
390
+ }
391
+ }
392
+ // Make sure prefix is a directory boundary.
393
+ const idx = prefix.lastIndexOf("/");
394
+ return idx > 0 ? prefix.slice(0, idx) : prefix;
395
+ }
396
+ //# sourceMappingURL=cursor.js.map
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Provider data-location discovery.
3
+ *
4
+ * Every provider declares a `DiscoverySpec` describing where its data MIGHT
5
+ * live, and the abstraction handles the rest:
6
+ *
7
+ * 1. Try each `candidates[]` path in order. If exists AND `verify()`
8
+ * confirms it's our data, return it.
9
+ * 2. If no candidate matched, optionally do a bounded filesystem search
10
+ * under `searchRoots[]` looking for `searchFilename` matches. Every
11
+ * match still has to pass `verify()`.
12
+ * 3. Return null if nothing was found — the provider should call
13
+ * `skipStartup(...)` and exit gracefully.
14
+ *
15
+ * The mandatory `verify()` predicate is what keeps us from mistaking one
16
+ * provider's data for another's: VSCode and Cursor both have a `state.vscdb`,
17
+ * but only Cursor's contains a `cursorDiskKV` table. Claude Code and Cowork
18
+ * both use JSONL, but live in distinctively-named parent directories.
19
+ *
20
+ * Search is intentionally bounded (depth-capped, common heavy dirs ignored)
21
+ * so that probing a misconfigured machine doesn't cost the user 30 seconds
22
+ * of `find /`. Default depth is 6 — enough to reach `~/Library/Application
23
+ * Support/<App>/User/globalStorage/state.vscdb` but not enough to walk a
24
+ * monorepo's node_modules.
25
+ */
26
+ import { existsSync, promises as fs } from "node:fs";
27
+ import path from "node:path";
28
+ /**
29
+ * Common large/uninteresting directories we never recurse into during search.
30
+ * Keeps `find`-style scans fast and avoids reading user-content trees.
31
+ */
32
+ const IGNORE_DIRS = new Set([
33
+ "node_modules",
34
+ ".git",
35
+ ".svn",
36
+ ".hg",
37
+ ".cache",
38
+ "Caches",
39
+ "Trash",
40
+ ".Trash",
41
+ "iCloud~",
42
+ "target",
43
+ "build",
44
+ "out",
45
+ "dist",
46
+ ".npm",
47
+ ".nvm",
48
+ ".pnpm",
49
+ ".pyenv",
50
+ ".rbenv",
51
+ "venv",
52
+ ".venv",
53
+ "__pycache__",
54
+ // macOS — avoid scanning huge user-content libraries
55
+ "Photos Library.photoslibrary",
56
+ "Music",
57
+ "Movies",
58
+ "Pictures",
59
+ "Downloads",
60
+ // Common backup tools
61
+ "Backups.backupdb",
62
+ ]);
63
+ /**
64
+ * Locate the data path for a provider using its DiscoverySpec.
65
+ * Returns the resolved path or null if nothing matched.
66
+ */
67
+ export async function discover(spec, log = () => { }) {
68
+ // 1) Walk the explicit candidate list — fastest path, no I/O beyond stat.
69
+ for (const raw of spec.candidates) {
70
+ if (!raw)
71
+ continue;
72
+ if (!existsSync(raw))
73
+ continue;
74
+ try {
75
+ if (await spec.verify(raw)) {
76
+ log(`discovery: found ${spec.label} at ${raw}`);
77
+ return raw;
78
+ }
79
+ }
80
+ catch {
81
+ // verifier threw — treat as "not us" and keep going
82
+ }
83
+ }
84
+ // 2) Fall back to a bounded filesystem search.
85
+ if (!spec.searchName || !spec.searchRoots || spec.searchRoots.length === 0) {
86
+ log(`discovery: no candidates matched for ${spec.label}; no search roots configured`);
87
+ return null;
88
+ }
89
+ const maxDepth = spec.searchMaxDepth ?? 6;
90
+ const matches = [];
91
+ for (const root of spec.searchRoots) {
92
+ if (!existsSync(root))
93
+ continue;
94
+ await scan(root, spec.searchName, maxDepth, 0, matches);
95
+ }
96
+ if (matches.length === 0) {
97
+ log(`discovery: search found no ${spec.label} candidates`);
98
+ return null;
99
+ }
100
+ // 3) Apply optional pathMustContain filter then verify each hit.
101
+ const filtered = spec.pathMustContain
102
+ ? matches.filter((m) => m.includes(spec.pathMustContain))
103
+ : matches;
104
+ for (const hit of filtered) {
105
+ try {
106
+ if (await spec.verify(hit)) {
107
+ log(`discovery: found ${spec.label} via search at ${hit}`);
108
+ return hit;
109
+ }
110
+ }
111
+ catch {
112
+ // skip
113
+ }
114
+ }
115
+ log(`discovery: ${spec.label} candidates failed verification (tried ${filtered.length})`);
116
+ return null;
117
+ }
118
+ /**
119
+ * Bounded recursive directory walk. Pushes matching paths into `out`.
120
+ * Skips IGNORE_DIRS and hidden directories below the first level.
121
+ */
122
+ async function scan(dir, name, maxDepth, depth, out) {
123
+ if (depth > maxDepth)
124
+ return;
125
+ let entries;
126
+ try {
127
+ entries = (await fs.readdir(dir, { withFileTypes: true }));
128
+ }
129
+ catch {
130
+ return;
131
+ }
132
+ for (const entry of entries) {
133
+ if (IGNORE_DIRS.has(entry.name))
134
+ continue;
135
+ // Don't chase symlinks — they can create cycles or escape the search root.
136
+ if (entry.isSymbolicLink && entry.isSymbolicLink())
137
+ continue;
138
+ // We deliberately DON'T skip hidden dirs in general — many providers
139
+ // store data in dotted config dirs (~/.claude, ~/.config, ~/.cursor).
140
+ // The IGNORE_DIRS set above carves out the genuinely noisy ones
141
+ // (.git, .cache, .npm, .Trash, etc.).
142
+ const full = path.join(dir, entry.name);
143
+ const isMatch = typeof name === "string" ? entry.name === name : name.test(entry.name);
144
+ if (isMatch)
145
+ out.push(full);
146
+ if (entry.isDirectory()) {
147
+ await scan(full, name, maxDepth, depth + 1, out);
148
+ }
149
+ }
150
+ }
151
+ //# sourceMappingURL=discovery.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Coordinates a collection of providers. Lets the rest of the app interact
3
+ * with "all providers" without knowing the concrete types.
4
+ */
5
+ export class ProviderManager {
6
+ providers = [];
7
+ add(provider) {
8
+ this.providers.push(provider);
9
+ }
10
+ list() {
11
+ return this.providers.slice();
12
+ }
13
+ /** Snapshot of every provider's info + current lifecycle state. */
14
+ status() {
15
+ return this.providers.map((p) => ({
16
+ id: p.id,
17
+ state: p.state ?? "unknown",
18
+ info: p.info,
19
+ }));
20
+ }
21
+ async startAll() {
22
+ for (const p of this.providers) {
23
+ try {
24
+ await p.start();
25
+ }
26
+ catch (err) {
27
+ // One provider failing should not stop others.
28
+ // eslint-disable-next-line no-console
29
+ console.error(`[providers] ${p.id} failed to start:`, err.message);
30
+ }
31
+ }
32
+ }
33
+ async stopAll() {
34
+ await Promise.all(this.providers.map((p) => p.stop().catch(() => { })));
35
+ }
36
+ /**
37
+ * Fan out a backfill request to every provider — each is a no-op when it
38
+ * doesn't own the session, so this is cheap and lets us avoid maintaining
39
+ * a sessionId → provider mapping at this layer.
40
+ */
41
+ async backfillSession(sessionId) {
42
+ await Promise.all(this.providers.map((p) => p.backfillSession(sessionId).catch(() => { })));
43
+ }
44
+ }
45
+ //# sourceMappingURL=types.js.map