@shawnowen/comet-mcp 2.3.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,1094 @@
1
+ // Comet Tab Groups Bridge — service worker
2
+ // Marker for CDP discovery: comet-mcp scans service workers for this flag
3
+ self.__COMET_TAB_GROUPS_BRIDGE__ = true;
4
+ self.__COMET_TAB_GROUPS_VERSION__ = "1.4.0";
5
+
6
+ // ─── Robust MV3 Keepalive ────────────────────────────────────────────────────
7
+ //
8
+ // MV3 service workers are killed after ~30s of idle. setInterval does NOT
9
+ // prevent this — it's just a JS timer, not "pending work" in Chromium's eyes.
10
+ //
11
+ // Strategy (triple-layered):
12
+ // 1. Self-port: chrome.runtime.connect() to self keeps worker alive.
13
+ // Chromium won't kill a worker with an active Port. Ports auto-disconnect
14
+ // after ~5 minutes; onDisconnect immediately reconnects.
15
+ // 2. chrome.alarms: fires every 25s as a safety net. If the worker was
16
+ // somehow killed, the alarm event restarts it.
17
+ // 3. onMessage: responds to external pings (CDP health checks, MCP server).
18
+
19
+ let keepAlivePort = null;
20
+
21
+ function connectKeepAlive() {
22
+ try {
23
+ keepAlivePort = chrome.runtime.connect({ name: 'keepalive' });
24
+ keepAlivePort.onDisconnect.addListener(() => {
25
+ // Reading lastError clears it and prevents the "Unchecked runtime.lastError" warning
26
+ void chrome.runtime.lastError;
27
+ keepAlivePort = null;
28
+ // Small delay to avoid tight reconnect loop on extension unload/reload
29
+ setTimeout(connectKeepAlive, 250);
30
+ });
31
+ } catch {
32
+ keepAlivePort = null;
33
+ // Extension context may be invalidated; retry via alarm
34
+ }
35
+ }
36
+
37
+ chrome.runtime.onConnect.addListener((port) => {
38
+ if (port.name === 'keepalive') {
39
+ // Accept the self-connection — its mere existence keeps us alive
40
+ return;
41
+ }
42
+ });
43
+
44
+ // Start self-port keepalive after a brief delay so onConnect listener registers first
45
+ setTimeout(connectKeepAlive, 50);
46
+
47
+ // Alarm fallback — restarts worker if self-port somehow fails
48
+ chrome.alarms.create('keepalive', { periodInMinutes: 0.4167 }); // clamped to 30s minimum
49
+
50
+ chrome.alarms.onAlarm.addListener((alarm) => {
51
+ if (alarm.name === 'keepalive') {
52
+ // Real async chrome API call extends worker lifetime
53
+ chrome.tabs.query({ active: true, currentWindow: true }).catch(() => { });
54
+ // Ensure self-port is still connected
55
+ if (!keepAlivePort) connectKeepAlive();
56
+ }
57
+ });
58
+
59
+ // ─── Sidebar Message Dispatcher (T004) ────────────────────────────────────────
60
+ //
61
+ // All sidebar ↔ background communication follows the contract:
62
+ // Request: { type: string, payload?: object }
63
+ // Response: { ok: boolean, data?: any, error?: string }
64
+ //
65
+ // Message handlers are registered in the `messageHandlers` map below.
66
+ // Each handler is an async function: (payload) => data
67
+
68
+ const messageHandlers = {};
69
+
70
+ function registerHandler(type, handler) {
71
+ messageHandlers[type] = handler;
72
+ }
73
+
74
+ // Central dispatcher — routes messages to registered handlers
75
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
76
+ // Legacy ping support (for CDP health checks)
77
+ if (msg?.type === 'ping') {
78
+ sendResponse({ pong: true, ts: Date.now(), version: self.__COMET_TAB_GROUPS_VERSION__ });
79
+ return true;
80
+ }
81
+
82
+ const handler = messageHandlers[msg?.type];
83
+ if (!handler) {
84
+ // Unknown message type — ignore silently
85
+ return false;
86
+ }
87
+
88
+ // Execute handler asynchronously, respond with contract format
89
+ (async () => {
90
+ try {
91
+ const data = await handler(msg.payload || {});
92
+ sendResponse({ ok: true, data });
93
+ } catch (err) {
94
+ console.error(`[Comet] Handler error for "${msg.type}":`, err);
95
+ sendResponse({ ok: false, error: err.message || String(err) });
96
+ }
97
+ })();
98
+
99
+ return true; // keep sendResponse channel open for async
100
+ });
101
+
102
+ // Respond to messages from external extensions or CDP-injected scripts
103
+ chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
104
+ if (msg?.type === 'ping') {
105
+ sendResponse({ pong: true, ts: Date.now(), version: self.__COMET_TAB_GROUPS_VERSION__ });
106
+ return true;
107
+ }
108
+ });
109
+
110
+ // ─── Message Handlers ─────────────────────────────────────────────────────────
111
+
112
+ // T005: getGroups — returns all live tab groups
113
+ registerHandler('getGroups', async () => {
114
+ const groups = await chrome.tabGroups.query({});
115
+ return groups.map(g => ({
116
+ id: g.id,
117
+ title: g.title || '',
118
+ color: g.color,
119
+ collapsed: g.collapsed,
120
+ windowId: g.windowId,
121
+ }));
122
+ });
123
+
124
+ // T006: getTabs — returns all tabs with group/window info
125
+ registerHandler('getTabs', async () => {
126
+ const tabs = await chrome.tabs.query({});
127
+ return tabs.map(t => ({
128
+ id: t.id,
129
+ groupId: t.groupId,
130
+ windowId: t.windowId,
131
+ index: t.index,
132
+ title: t.title || '',
133
+ url: t.url || '',
134
+ active: t.active,
135
+ favIconUrl: t.favIconUrl || '',
136
+ }));
137
+ });
138
+
139
+ // T007: healthCheck — checks CDP bridge presence and returns version
140
+ registerHandler('healthCheck', async () => {
141
+ // The bridge is running if we're here (service worker is alive)
142
+ // Check if CDP connection is available by testing if we can query tabs
143
+ let cdpConnected = false;
144
+ try {
145
+ await chrome.tabs.query({ active: true, currentWindow: true });
146
+ cdpConnected = true;
147
+ } catch {
148
+ cdpConnected = false;
149
+ }
150
+ return {
151
+ cdpConnected,
152
+ version: self.__COMET_TAB_GROUPS_VERSION__,
153
+ uptime: Date.now(),
154
+ };
155
+ });
156
+
157
+ // ─── Archive Storage Helpers ──────────────────────────────────────────────────
158
+
159
+ const ARCHIVE_KEY = 'archivedGroups';
160
+
161
+ async function loadArchive() {
162
+ const result = await chrome.storage.local.get(ARCHIVE_KEY);
163
+ return result[ARCHIVE_KEY] || [];
164
+ }
165
+
166
+ async function saveArchive(entries) {
167
+ await chrome.storage.local.set({ [ARCHIVE_KEY]: entries });
168
+ }
169
+
170
+ function generateId() {
171
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
172
+ }
173
+
174
+ // T013: getArchivedGroups — returns all archived groups from storage
175
+ registerHandler('getArchivedGroups', async () => {
176
+ return await loadArchive();
177
+ });
178
+
179
+ // T014: archiveGroup — save tabs from a live group, then close them
180
+ registerHandler('archiveGroup', async ({ groupId, title }) => {
181
+ // Get all tabs in this group
182
+ const tabs = await chrome.tabs.query({ groupId });
183
+ if (!tabs.length) throw new Error(`No tabs found in group ${groupId}`);
184
+
185
+ // Get group info for color
186
+ const groups = await chrome.tabGroups.query({});
187
+ const group = groups.find(g => g.id === groupId);
188
+ const color = group ? group.color : 'grey';
189
+
190
+ // Create archive entry
191
+ const entry = {
192
+ taskThreadId: generateId(),
193
+ title: title || group?.title || 'Untitled',
194
+ color,
195
+ collapsed: group?.collapsed || false,
196
+ urls: tabs.map(t => ({ url: t.url || '', title: t.title || '' })),
197
+ archivedAt: new Date().toISOString(),
198
+ restoredAt: null,
199
+ status: 'archived',
200
+ };
201
+
202
+ // Save to archive
203
+ const archive = await loadArchive();
204
+ archive.unshift(entry); // newest first
205
+ await saveArchive(archive);
206
+
207
+ // Close the tabs
208
+ const tabIds = tabs.map(t => t.id);
209
+ await chrome.tabs.remove(tabIds);
210
+
211
+ pushEvent('archive.created', { taskThreadId: entry.taskThreadId, title: entry.title, tabCount: tabs.length });
212
+ return { taskThreadId: entry.taskThreadId };
213
+ });
214
+
215
+ // T015: restoreGroup — reopen all tabs from an archive entry
216
+ registerHandler('restoreGroup', async ({ taskThreadId }) => {
217
+ const archive = await loadArchive();
218
+ const idx = archive.findIndex(e => e.taskThreadId === taskThreadId);
219
+ if (idx === -1) throw new Error(`Archive entry not found: ${taskThreadId}`);
220
+
221
+ const entry = archive[idx];
222
+ const urls = entry.urls || [];
223
+ if (!urls.length) throw new Error('Archive entry has no URLs');
224
+
225
+ // Open tabs
226
+ const tabPromises = urls.map(u => chrome.tabs.create({ url: u.url, active: false }));
227
+ const tabs = await Promise.all(tabPromises);
228
+
229
+ // Group the tabs
230
+ const tabIds = tabs.map(t => t.id);
231
+ const groupId = await chrome.tabs.group({ tabIds });
232
+
233
+ // Set group properties
234
+ await chrome.tabGroups.update(groupId, {
235
+ title: entry.title || 'Restored',
236
+ color: entry.color || 'grey',
237
+ collapsed: false,
238
+ });
239
+
240
+ // Update archive entry status
241
+ archive[idx] = {
242
+ ...entry,
243
+ status: 'saved',
244
+ restoredAt: new Date().toISOString(),
245
+ };
246
+ await saveArchive(archive);
247
+
248
+ pushEvent('archive.restored', { taskThreadId, title: entry.title, tabCount: urls.length, groupId });
249
+ return { groupId };
250
+ });
251
+
252
+ // T016: deleteArchived — remove an archive entry
253
+ registerHandler('deleteArchived', async ({ taskThreadId }) => {
254
+ const archive = await loadArchive();
255
+ const filtered = archive.filter(e => e.taskThreadId !== taskThreadId);
256
+ const deleted = filtered.length < archive.length;
257
+ await saveArchive(filtered);
258
+
259
+ if (deleted) {
260
+ pushEvent('archive.deleted', { taskThreadId });
261
+ }
262
+ return { deleted };
263
+ });
264
+
265
+ // ─── OneTab Parity Handlers ──────────────────────────────────────────────────
266
+
267
+ // saveAllTabs — archive ALL open tab groups in one click (like OneTab's "Store all")
268
+ registerHandler('saveAllTabs', async () => {
269
+ const groups = await chrome.tabGroups.query({});
270
+ const allTabs = await chrome.tabs.query({});
271
+
272
+ if (!groups.length && !allTabs.length) {
273
+ return { archived: 0, totalTabs: 0 };
274
+ }
275
+
276
+ const archive = await loadArchive();
277
+ let archivedCount = 0;
278
+ let totalTabs = 0;
279
+ const tabIdsToClose = [];
280
+
281
+ // Archive each tab group
282
+ for (const group of groups) {
283
+ const groupTabs = allTabs.filter(t => t.groupId === group.id);
284
+ if (!groupTabs.length) continue;
285
+
286
+ const entry = {
287
+ taskThreadId: generateId(),
288
+ title: group.title || 'Untitled',
289
+ color: group.color || 'grey',
290
+ collapsed: false,
291
+ urls: groupTabs.map(t => ({ url: t.url || '', title: t.title || '' })),
292
+ archivedAt: new Date().toISOString(),
293
+ restoredAt: null,
294
+ status: 'archived',
295
+ };
296
+ archive.unshift(entry);
297
+ archivedCount++;
298
+ totalTabs += groupTabs.length;
299
+ tabIdsToClose.push(...groupTabs.map(t => t.id));
300
+ }
301
+
302
+ // Archive ungrouped tabs as a separate group
303
+ const ungroupedTabs = allTabs.filter(t => t.groupId === -1 && !t.pinned);
304
+ if (ungroupedTabs.length > 0) {
305
+ const entry = {
306
+ taskThreadId: generateId(),
307
+ title: 'Ungrouped Tabs',
308
+ color: 'grey',
309
+ collapsed: false,
310
+ urls: ungroupedTabs.map(t => ({ url: t.url || '', title: t.title || '' })),
311
+ archivedAt: new Date().toISOString(),
312
+ restoredAt: null,
313
+ status: 'archived',
314
+ };
315
+ archive.unshift(entry);
316
+ archivedCount++;
317
+ totalTabs += ungroupedTabs.length;
318
+ tabIdsToClose.push(...ungroupedTabs.map(t => t.id));
319
+ }
320
+
321
+ await saveArchive(archive);
322
+
323
+ // Close all archived tabs (but keep at least one tab open to prevent browser from closing)
324
+ if (tabIdsToClose.length > 0) {
325
+ // Create a blank tab first so the browser doesn't close
326
+ await chrome.tabs.create({ url: 'chrome://newtab', active: true });
327
+ await chrome.tabs.remove(tabIdsToClose);
328
+ }
329
+
330
+ pushEvent('archive.savedAll', { groupCount: archivedCount, tabCount: totalTabs });
331
+ return { archived: archivedCount, totalTabs };
332
+ });
333
+
334
+ // restoreSingleTab — open a single URL from an archived group
335
+ registerHandler('restoreSingleTab', async ({ taskThreadId, url }) => {
336
+ if (!url) throw new Error('URL is required');
337
+ const tab = await chrome.tabs.create({ url, active: true });
338
+ return { tabId: tab.id };
339
+ });
340
+
341
+ // renameArchived — rename an archived group's title
342
+ registerHandler('renameArchived', async ({ taskThreadId, newTitle }) => {
343
+ if (!taskThreadId) throw new Error('taskThreadId is required');
344
+ if (!newTitle || !newTitle.trim()) throw new Error('New title is required');
345
+
346
+ const archive = await loadArchive();
347
+ const entry = archive.find(e => e.taskThreadId === taskThreadId);
348
+ if (!entry) throw new Error(`Archive entry not found: ${taskThreadId}`);
349
+
350
+ entry.title = newTitle.trim();
351
+ await saveArchive(archive);
352
+
353
+ pushEvent('archive.renamed', { taskThreadId, newTitle: entry.title });
354
+ return { renamed: true, title: entry.title };
355
+ });
356
+
357
+ // T020: getRecentlyClosed — returns recently closed tabs/windows
358
+ registerHandler('getRecentlyClosed', async ({ maxResults = 25 } = {}) => {
359
+ const sessions = await chrome.sessions.getRecentlyClosed({ maxResults });
360
+ return sessions.map(s => ({
361
+ sessionId: s.tab?.sessionId || s.window?.sessionId || '',
362
+ lastModified: s.lastModified,
363
+ tab: s.tab ? {
364
+ title: s.tab.title || '',
365
+ url: s.tab.url || '',
366
+ favIconUrl: s.tab.favIconUrl || '',
367
+ } : null,
368
+ window: s.window ? {
369
+ tabs: (s.window.tabs || []).map(t => ({
370
+ title: t.title || '',
371
+ url: t.url || '',
372
+ favIconUrl: t.favIconUrl || '',
373
+ })),
374
+ } : null,
375
+ }));
376
+ });
377
+
378
+ // T021: restoreClosed — restore a recently closed tab or window
379
+ registerHandler('restoreClosed', async ({ sessionId }) => {
380
+ await chrome.sessions.restore(sessionId);
381
+ return {};
382
+ });
383
+
384
+ // T028: importUrls — import URL groups to archive
385
+ registerHandler('importUrls', async ({ groups }) => {
386
+ if (!groups || !groups.length) throw new Error('No groups to import');
387
+
388
+ const archive = await loadArchive();
389
+ let imported = 0;
390
+
391
+ for (const group of groups) {
392
+ const entry = {
393
+ taskThreadId: generateId(),
394
+ title: group.name || 'Imported',
395
+ color: 'grey',
396
+ collapsed: false,
397
+ urls: (group.urls || []).map(u => ({ url: u.url || '', title: u.title || '' })),
398
+ archivedAt: new Date().toISOString(),
399
+ restoredAt: null,
400
+ status: 'archived',
401
+ };
402
+ archive.unshift(entry);
403
+ imported += entry.urls.length;
404
+ }
405
+
406
+ await saveArchive(archive);
407
+ pushEvent('archive.imported', { groupCount: groups.length, urlCount: imported });
408
+ return { imported };
409
+ });
410
+
411
+ // T032: focusTab — switch browser focus to a specific tab
412
+ registerHandler('focusTab', async ({ tabId, windowId }) => {
413
+ await chrome.tabs.update(tabId, { active: true });
414
+ if (windowId) {
415
+ await chrome.windows.update(windowId, { focused: true });
416
+ }
417
+ return {};
418
+ });
419
+
420
+ // T033: closeTab — close a single tab
421
+ registerHandler('closeTab', async ({ tabId }) => {
422
+ await chrome.tabs.remove(tabId);
423
+ return {};
424
+ });
425
+
426
+ // T034: closeGroup — close all tabs in a group
427
+ registerHandler('closeGroup', async ({ groupId }) => {
428
+ const tabs = await chrome.tabs.query({ groupId });
429
+ if (tabs.length) {
430
+ await chrome.tabs.remove(tabs.map(t => t.id));
431
+ }
432
+ return {};
433
+ });
434
+
435
+ // T036: exportAll — export all live and archived session data
436
+ registerHandler('exportAll', async () => {
437
+ const [groups, tabs, archived] = await Promise.all([
438
+ chrome.tabGroups.query({}),
439
+ chrome.tabs.query({}),
440
+ loadArchive(),
441
+ ]);
442
+
443
+ return {
444
+ exportedAt: new Date().toISOString(),
445
+ version: self.__COMET_TAB_GROUPS_VERSION__,
446
+ live: {
447
+ groups: groups.map(g => ({
448
+ id: g.id, title: g.title || '', color: g.color,
449
+ collapsed: g.collapsed, windowId: g.windowId,
450
+ tabs: tabs.filter(t => t.groupId === g.id).map(t => ({
451
+ title: t.title || '', url: t.url || '',
452
+ })),
453
+ })),
454
+ },
455
+ archived,
456
+ };
457
+ });
458
+
459
+ // T017: One-time archive import from JSON file on install/startup
460
+ // Resolves archive path via $COMET_DATA_DIR env variable (fallback: ~/.equabot)
461
+ // and imports entries into chrome.storage.local, avoiding duplicates by taskThreadId.
462
+ async function importArchiveFromJsonFile() {
463
+ try {
464
+ const existing = await loadArchive();
465
+ if (existing.length > 0) {
466
+ console.log('[Comet] Archive already has data, skipping file import.');
467
+ return; // Already has data, skip import
468
+ }
469
+
470
+ // Check if we've already attempted import
471
+ const flags = await chrome.storage.local.get('archiveImportAttempted');
472
+ if (flags.archiveImportAttempted) return;
473
+ await chrome.storage.local.set({ archiveImportAttempted: true });
474
+
475
+ // Attempt to read archive file paths
476
+ // In Chrome extensions, direct file:// access requires the "file" permission
477
+ // which isn't available in MV3 side panel context. The recommended path is:
478
+ // 1. Pre-import via CLI: node scripts/onetab-import.mjs --input <file>
479
+ // 2. Or use the sidebar Import UI to paste URLs directly
480
+ //
481
+ // We still attempt a fetch as a best-effort approach:
482
+ const archivePaths = [
483
+ 'file://' + (globalThis.__COMET_DATA_DIR || '') + '/browser/tab-groups-archive.json',
484
+ ];
485
+
486
+ for (const path of archivePaths) {
487
+ if (!path || path === 'file:///browser/tab-groups-archive.json') continue;
488
+ try {
489
+ const resp = await fetch(path);
490
+ if (resp.ok) {
491
+ const data = await resp.json();
492
+ if (Array.isArray(data) && data.length > 0) {
493
+ // Merge entries, avoiding duplicates
494
+ const ids = new Set(existing.map(e => e.taskThreadId));
495
+ const newEntries = data.filter(e => e.taskThreadId && !ids.has(e.taskThreadId));
496
+ if (newEntries.length > 0) {
497
+ await saveArchive([...newEntries, ...existing]);
498
+ pushEvent('archive.fileImported', { count: newEntries.length });
499
+ console.log(`[Comet] Imported ${newEntries.length} entries from archive file.`);
500
+ }
501
+ return;
502
+ }
503
+ }
504
+ } catch {
505
+ // fetch failed for this path, try next
506
+ }
507
+ }
508
+
509
+ console.log('[Comet] Archive file import: no file accessible. Use CLI or UI import instead.');
510
+ } catch (err) {
511
+ console.log('[Comet] Archive file import skipped:', err.message);
512
+ }
513
+ }
514
+
515
+ chrome.runtime.onInstalled.addListener(() => {
516
+ chrome.alarms.create('keepalive', { periodInMinutes: 0.4167 });
517
+ if (!keepAlivePort) connectKeepAlive();
518
+ // Attempt one-time archive import
519
+ importArchiveFromJsonFile();
520
+ // Create context menus
521
+ createContextMenus();
522
+ });
523
+
524
+ // ─── Domain Exclusion Storage ─────────────────────────────────────────────────
525
+
526
+ const EXCLUSIONS_KEY = 'excludedDomains';
527
+
528
+ async function loadExclusions() {
529
+ const result = await chrome.storage.local.get(EXCLUSIONS_KEY);
530
+ return result[EXCLUSIONS_KEY] || [];
531
+ }
532
+
533
+ async function saveExclusions(domains) {
534
+ await chrome.storage.local.set({ [EXCLUSIONS_KEY]: domains });
535
+ }
536
+
537
+ function getDomain(url) {
538
+ try {
539
+ return new URL(url).hostname.replace(/^www\./, '');
540
+ } catch {
541
+ return '';
542
+ }
543
+ }
544
+
545
+ function isExcluded(url, exclusions) {
546
+ const domain = getDomain(url);
547
+ return exclusions.some(d => domain === d || domain.endsWith('.' + d));
548
+ }
549
+
550
+ // Message handlers for exclusions
551
+ registerHandler('getExclusions', async () => {
552
+ return await loadExclusions();
553
+ });
554
+
555
+ registerHandler('addExclusion', async ({ domain }) => {
556
+ if (!domain) throw new Error('Domain required');
557
+ const exclusions = await loadExclusions();
558
+ const clean = domain.replace(/^www\./, '').toLowerCase();
559
+ if (!exclusions.includes(clean)) {
560
+ exclusions.push(clean);
561
+ await saveExclusions(exclusions);
562
+ // Update context menu to reflect current tab's domain
563
+ pushEvent('exclusion.added', { domain: clean });
564
+ }
565
+ return { exclusions };
566
+ });
567
+
568
+ registerHandler('removeExclusion', async ({ domain }) => {
569
+ if (!domain) throw new Error('Domain required');
570
+ const exclusions = await loadExclusions();
571
+ const clean = domain.replace(/^www\./, '').toLowerCase();
572
+ const filtered = exclusions.filter(d => d !== clean);
573
+ await saveExclusions(filtered);
574
+ pushEvent('exclusion.removed', { domain: clean });
575
+ return { exclusions: filtered };
576
+ });
577
+
578
+ // ─── Context Menu System ──────────────────────────────────────────────────────
579
+
580
+ function createContextMenus() {
581
+ // Remove existing menus first (in case of update)
582
+ chrome.contextMenus.removeAll(() => {
583
+ // Parent menu
584
+ chrome.contextMenus.create({
585
+ id: 'comet-parent',
586
+ title: 'Comet Session Manager',
587
+ contexts: ['all'],
588
+ });
589
+
590
+ // Open side panel
591
+ chrome.contextMenus.create({
592
+ id: 'comet-open-sidebar',
593
+ parentId: 'comet-parent',
594
+ title: 'Open Comet Sidebar',
595
+ contexts: ['all'],
596
+ });
597
+
598
+ chrome.contextMenus.create({
599
+ id: 'comet-sep-1',
600
+ parentId: 'comet-parent',
601
+ type: 'separator',
602
+ contexts: ['all'],
603
+ });
604
+
605
+ // Send this tab
606
+ chrome.contextMenus.create({
607
+ id: 'comet-send-tab',
608
+ parentId: 'comet-parent',
609
+ title: 'Send this tab to Comet',
610
+ contexts: ['all'],
611
+ });
612
+
613
+ // Send all tabs in this window
614
+ chrome.contextMenus.create({
615
+ id: 'comet-send-window',
616
+ parentId: 'comet-parent',
617
+ title: 'Send all tabs in this window to Comet',
618
+ contexts: ['all'],
619
+ });
620
+
621
+ // Send tabs in this tab group
622
+ chrome.contextMenus.create({
623
+ id: 'comet-send-group',
624
+ parentId: 'comet-parent',
625
+ title: 'Send all tabs in this tab group to Comet',
626
+ contexts: ['all'],
627
+ });
628
+
629
+ // Send selected tabs
630
+ chrome.contextMenus.create({
631
+ id: 'comet-send-selected',
632
+ parentId: 'comet-parent',
633
+ title: 'Send selected tabs to Comet',
634
+ contexts: ['all'],
635
+ });
636
+
637
+ chrome.contextMenus.create({
638
+ id: 'comet-sep-2',
639
+ parentId: 'comet-parent',
640
+ type: 'separator',
641
+ contexts: ['all'],
642
+ });
643
+
644
+ // Send all except this tab
645
+ chrome.contextMenus.create({
646
+ id: 'comet-send-except',
647
+ parentId: 'comet-parent',
648
+ title: 'Send all tabs except this tab to Comet',
649
+ contexts: ['all'],
650
+ });
651
+
652
+ // Send tabs to the left
653
+ chrome.contextMenus.create({
654
+ id: 'comet-send-left',
655
+ parentId: 'comet-parent',
656
+ title: 'Send tabs on the left to Comet',
657
+ contexts: ['all'],
658
+ });
659
+
660
+ // Send tabs to the right
661
+ chrome.contextMenus.create({
662
+ id: 'comet-send-right',
663
+ parentId: 'comet-parent',
664
+ title: 'Send tabs on the right to Comet',
665
+ contexts: ['all'],
666
+ });
667
+
668
+ chrome.contextMenus.create({
669
+ id: 'comet-sep-3',
670
+ parentId: 'comet-parent',
671
+ type: 'separator',
672
+ contexts: ['all'],
673
+ });
674
+
675
+ // Send all from all windows
676
+ chrome.contextMenus.create({
677
+ id: 'comet-send-all',
678
+ parentId: 'comet-parent',
679
+ title: 'Send all tabs from all windows to Comet',
680
+ contexts: ['all'],
681
+ });
682
+
683
+ chrome.contextMenus.create({
684
+ id: 'comet-sep-4',
685
+ parentId: 'comet-parent',
686
+ type: 'separator',
687
+ contexts: ['all'],
688
+ });
689
+
690
+ // Exclude domain (dynamic — updated on tab change)
691
+ chrome.contextMenus.create({
692
+ id: 'comet-exclude-domain',
693
+ parentId: 'comet-parent',
694
+ title: 'Exclude this domain from Comet',
695
+ contexts: ['all'],
696
+ });
697
+
698
+ console.log('[Comet] Context menus created.');
699
+ });
700
+ }
701
+
702
+ // Update the "Exclude" menu item to show the current domain
703
+ chrome.tabs.onActivated.addListener(async ({ tabId }) => {
704
+ try {
705
+ const tab = await chrome.tabs.get(tabId);
706
+ if (tab.url) {
707
+ const domain = getDomain(tab.url);
708
+ const exclusions = await loadExclusions();
709
+ const isCurrentlyExcluded = exclusions.some(d => domain === d || domain.endsWith('.' + d));
710
+ chrome.contextMenus.update('comet-exclude-domain', {
711
+ title: isCurrentlyExcluded
712
+ ? `Include ${domain} in Comet (currently excluded)`
713
+ : `Exclude ${domain} from Comet`,
714
+ });
715
+ }
716
+ } catch {
717
+ // Tab may not exist, ignore
718
+ }
719
+ });
720
+
721
+ // ─── Context Menu Click Handler ───────────────────────────────────────────────
722
+
723
+ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
724
+ try {
725
+ switch (info.menuItemId) {
726
+
727
+ case 'comet-open-sidebar': {
728
+ // Open the side panel for this window
729
+ if (tab?.windowId) {
730
+ await chrome.sidePanel.open({ windowId: tab.windowId });
731
+ }
732
+ break;
733
+ }
734
+
735
+ case 'comet-send-tab': {
736
+ // Archive just this one tab
737
+ if (!tab) break;
738
+ const exclusions = await loadExclusions();
739
+ if (isExcluded(tab.url, exclusions)) {
740
+ console.log(`[Comet] Skipped excluded domain: ${getDomain(tab.url)}`);
741
+ break;
742
+ }
743
+ const entry = {
744
+ taskThreadId: generateId(),
745
+ title: tab.title || 'Single Tab',
746
+ color: 'grey',
747
+ collapsed: false,
748
+ urls: [{ url: tab.url || '', title: tab.title || '' }],
749
+ archivedAt: new Date().toISOString(),
750
+ restoredAt: null,
751
+ status: 'archived',
752
+ };
753
+ // If tab is in a group, use the group name
754
+ if (tab.groupId && tab.groupId !== -1) {
755
+ try {
756
+ const group = await chrome.tabGroups.get(tab.groupId);
757
+ entry.title = group.title || entry.title;
758
+ entry.color = group.color || 'grey';
759
+ } catch { /* group may not exist */ }
760
+ }
761
+ const archive = await loadArchive();
762
+ archive.unshift(entry);
763
+ await saveArchive(archive);
764
+ await chrome.tabs.remove(tab.id);
765
+ pushEvent('context.sendTab', { title: entry.title, url: tab.url });
766
+ break;
767
+ }
768
+
769
+ case 'comet-send-window': {
770
+ // Archive all tabs in the current window
771
+ if (!tab) break;
772
+ const windowTabs = await chrome.tabs.query({ windowId: tab.windowId });
773
+ const exclusions = await loadExclusions();
774
+ const eligible = windowTabs.filter(t => !t.pinned && !isExcluded(t.url, exclusions));
775
+ if (!eligible.length) break;
776
+
777
+ // Group tabs by their tab group
778
+ const grouped = new Map(); // groupId → tabs[]
779
+ for (const t of eligible) {
780
+ const key = t.groupId > 0 ? t.groupId : -1;
781
+ if (!grouped.has(key)) grouped.set(key, []);
782
+ grouped.get(key).push(t);
783
+ }
784
+
785
+ const archive = await loadArchive();
786
+ const groups = await chrome.tabGroups.query({ windowId: tab.windowId });
787
+ const groupMap = new Map(groups.map(g => [g.id, g]));
788
+
789
+ for (const [groupId, tabs] of grouped) {
790
+ const group = groupMap.get(groupId);
791
+ const entry = {
792
+ taskThreadId: generateId(),
793
+ title: group?.title || (groupId === -1 ? 'Ungrouped Tabs' : 'Untitled'),
794
+ color: group?.color || 'grey',
795
+ collapsed: false,
796
+ urls: tabs.map(t => ({ url: t.url || '', title: t.title || '' })),
797
+ archivedAt: new Date().toISOString(),
798
+ restoredAt: null,
799
+ status: 'archived',
800
+ };
801
+ archive.unshift(entry);
802
+ }
803
+
804
+ await saveArchive(archive);
805
+ // Create a blank tab before closing so window doesn't close
806
+ await chrome.tabs.create({ windowId: tab.windowId, url: 'chrome://newtab', active: true });
807
+ await chrome.tabs.remove(eligible.map(t => t.id));
808
+ pushEvent('context.sendWindow', { windowId: tab.windowId, tabCount: eligible.length });
809
+ break;
810
+ }
811
+
812
+ case 'comet-send-group': {
813
+ // Archive this tab's group
814
+ if (!tab || !tab.groupId || tab.groupId === -1) break;
815
+ const groupTabs = await chrome.tabs.query({ groupId: tab.groupId });
816
+ const exclusions = await loadExclusions();
817
+ const eligible = groupTabs.filter(t => !isExcluded(t.url, exclusions));
818
+ if (!eligible.length) break;
819
+
820
+ let groupTitle = 'Untitled';
821
+ let groupColor = 'grey';
822
+ try {
823
+ const group = await chrome.tabGroups.get(tab.groupId);
824
+ groupTitle = group.title || 'Untitled';
825
+ groupColor = group.color || 'grey';
826
+ } catch { /* ignore */ }
827
+
828
+ const entry = {
829
+ taskThreadId: generateId(),
830
+ title: groupTitle,
831
+ color: groupColor,
832
+ collapsed: false,
833
+ urls: eligible.map(t => ({ url: t.url || '', title: t.title || '' })),
834
+ archivedAt: new Date().toISOString(),
835
+ restoredAt: null,
836
+ status: 'archived',
837
+ };
838
+
839
+ const archive = await loadArchive();
840
+ archive.unshift(entry);
841
+ await saveArchive(archive);
842
+ await chrome.tabs.remove(eligible.map(t => t.id));
843
+ pushEvent('context.sendGroup', { title: groupTitle, tabCount: eligible.length });
844
+ break;
845
+ }
846
+
847
+ case 'comet-send-selected': {
848
+ // Archive highlighted (selected) tabs
849
+ if (!tab) break;
850
+ const highlighted = await chrome.tabs.query({ windowId: tab.windowId, highlighted: true });
851
+ const exclusions = await loadExclusions();
852
+ const eligible = highlighted.filter(t => !t.pinned && !isExcluded(t.url, exclusions));
853
+ if (!eligible.length) break;
854
+
855
+ const entry = {
856
+ taskThreadId: generateId(),
857
+ title: `Selected Tabs (${eligible.length})`,
858
+ color: 'grey',
859
+ collapsed: false,
860
+ urls: eligible.map(t => ({ url: t.url || '', title: t.title || '' })),
861
+ archivedAt: new Date().toISOString(),
862
+ restoredAt: null,
863
+ status: 'archived',
864
+ };
865
+
866
+ const archive = await loadArchive();
867
+ archive.unshift(entry);
868
+ await saveArchive(archive);
869
+ // Keep at least one tab open
870
+ const remaining = await chrome.tabs.query({ windowId: tab.windowId });
871
+ if (remaining.length <= eligible.length) {
872
+ await chrome.tabs.create({ windowId: tab.windowId, url: 'chrome://newtab', active: true });
873
+ }
874
+ await chrome.tabs.remove(eligible.map(t => t.id));
875
+ pushEvent('context.sendSelected', { tabCount: eligible.length });
876
+ break;
877
+ }
878
+
879
+ case 'comet-send-except': {
880
+ // Archive all tabs in window except this one
881
+ if (!tab) break;
882
+ const allInWindow = await chrome.tabs.query({ windowId: tab.windowId });
883
+ const exclusions = await loadExclusions();
884
+ const toArchive = allInWindow.filter(t => t.id !== tab.id && !t.pinned && !isExcluded(t.url, exclusions));
885
+ if (!toArchive.length) break;
886
+
887
+ const entry = {
888
+ taskThreadId: generateId(),
889
+ title: `All except: ${tab.title || 'current tab'}`,
890
+ color: 'grey',
891
+ collapsed: false,
892
+ urls: toArchive.map(t => ({ url: t.url || '', title: t.title || '' })),
893
+ archivedAt: new Date().toISOString(),
894
+ restoredAt: null,
895
+ status: 'archived',
896
+ };
897
+
898
+ const archive = await loadArchive();
899
+ archive.unshift(entry);
900
+ await saveArchive(archive);
901
+ await chrome.tabs.remove(toArchive.map(t => t.id));
902
+ pushEvent('context.sendExcept', { keptTab: tab.title, archivedCount: toArchive.length });
903
+ break;
904
+ }
905
+
906
+ case 'comet-send-left': {
907
+ // Archive tabs to the left of current tab
908
+ if (!tab) break;
909
+ const allInWindow = await chrome.tabs.query({ windowId: tab.windowId });
910
+ const exclusions = await loadExclusions();
911
+ const toArchive = allInWindow.filter(t => t.index < tab.index && !t.pinned && !isExcluded(t.url, exclusions));
912
+ if (!toArchive.length) break;
913
+
914
+ const entry = {
915
+ taskThreadId: generateId(),
916
+ title: `Tabs left of: ${tab.title || 'current'}`,
917
+ color: 'grey',
918
+ collapsed: false,
919
+ urls: toArchive.map(t => ({ url: t.url || '', title: t.title || '' })),
920
+ archivedAt: new Date().toISOString(),
921
+ restoredAt: null,
922
+ status: 'archived',
923
+ };
924
+
925
+ const archive = await loadArchive();
926
+ archive.unshift(entry);
927
+ await saveArchive(archive);
928
+ await chrome.tabs.remove(toArchive.map(t => t.id));
929
+ pushEvent('context.sendLeft', { count: toArchive.length });
930
+ break;
931
+ }
932
+
933
+ case 'comet-send-right': {
934
+ // Archive tabs to the right of current tab
935
+ if (!tab) break;
936
+ const allInWindow = await chrome.tabs.query({ windowId: tab.windowId });
937
+ const exclusions = await loadExclusions();
938
+ const toArchive = allInWindow.filter(t => t.index > tab.index && !t.pinned && !isExcluded(t.url, exclusions));
939
+ if (!toArchive.length) break;
940
+
941
+ const entry = {
942
+ taskThreadId: generateId(),
943
+ title: `Tabs right of: ${tab.title || 'current'}`,
944
+ color: 'grey',
945
+ collapsed: false,
946
+ urls: toArchive.map(t => ({ url: t.url || '', title: t.title || '' })),
947
+ archivedAt: new Date().toISOString(),
948
+ restoredAt: null,
949
+ status: 'archived',
950
+ };
951
+
952
+ const archive = await loadArchive();
953
+ archive.unshift(entry);
954
+ await saveArchive(archive);
955
+ await chrome.tabs.remove(toArchive.map(t => t.id));
956
+ pushEvent('context.sendRight', { count: toArchive.length });
957
+ break;
958
+ }
959
+
960
+ case 'comet-send-all': {
961
+ // Reuse the existing saveAllTabs handler
962
+ const handler = messageHandlers['saveAllTabs'];
963
+ if (handler) await handler({});
964
+ break;
965
+ }
966
+
967
+ case 'comet-exclude-domain': {
968
+ // Toggle exclusion for current tab's domain
969
+ if (!tab?.url) break;
970
+ const domain = getDomain(tab.url);
971
+ if (!domain) break;
972
+
973
+ const exclusions = await loadExclusions();
974
+ const idx = exclusions.indexOf(domain);
975
+ if (idx >= 0) {
976
+ // Currently excluded — remove exclusion
977
+ exclusions.splice(idx, 1);
978
+ await saveExclusions(exclusions);
979
+ chrome.contextMenus.update('comet-exclude-domain', {
980
+ title: `Exclude ${domain} from Comet`,
981
+ });
982
+ pushEvent('exclusion.removed', { domain });
983
+ } else {
984
+ // Not excluded — add exclusion
985
+ exclusions.push(domain);
986
+ await saveExclusions(exclusions);
987
+ chrome.contextMenus.update('comet-exclude-domain', {
988
+ title: `Include ${domain} in Comet (currently excluded)`,
989
+ });
990
+ pushEvent('exclusion.added', { domain });
991
+ }
992
+ break;
993
+ }
994
+ }
995
+ } catch (err) {
996
+ console.error('[Comet] Context menu action failed:', err);
997
+ }
998
+ });
999
+
1000
+ // ─── Event Ring Buffer (Phase 1: Sub-Agent Control) ───────────────────────
1001
+
1002
+ const MAX_EVENTS = 100;
1003
+ const eventBuffer = [];
1004
+
1005
+ function pushEvent(type, detail) {
1006
+ const entry = {
1007
+ type,
1008
+ ts: Date.now(),
1009
+ isoTime: new Date().toISOString(),
1010
+ ...detail,
1011
+ };
1012
+ eventBuffer.push(entry);
1013
+ if (eventBuffer.length > MAX_EVENTS) {
1014
+ eventBuffer.shift(); // FIFO eviction
1015
+ }
1016
+ }
1017
+
1018
+ // CDP-pollable globals
1019
+ self.__COMET_EVENTS__ = () => JSON.stringify(eventBuffer);
1020
+ self.__COMET_EVENTS_SINCE__ = (ts) =>
1021
+ JSON.stringify(eventBuffer.filter(e => e.ts > ts));
1022
+ self.__COMET_EVENTS_COUNT__ = () => eventBuffer.length;
1023
+
1024
+ // ─── Tab Group Listeners ──────────────────────────────────────────────────
1025
+
1026
+ chrome.tabGroups.onCreated.addListener((group) => {
1027
+ pushEvent('tabGroup.created', {
1028
+ groupId: group.id,
1029
+ title: group.title || '',
1030
+ color: group.color,
1031
+ windowId: group.windowId,
1032
+ collapsed: group.collapsed,
1033
+ });
1034
+ });
1035
+
1036
+ chrome.tabGroups.onRemoved.addListener((group) => {
1037
+ pushEvent('tabGroup.removed', {
1038
+ groupId: group.id,
1039
+ title: group.title || '',
1040
+ color: group.color,
1041
+ });
1042
+ });
1043
+
1044
+ chrome.tabGroups.onUpdated.addListener((group) => {
1045
+ pushEvent('tabGroup.updated', {
1046
+ groupId: group.id,
1047
+ title: group.title || '',
1048
+ color: group.color,
1049
+ collapsed: group.collapsed,
1050
+ windowId: group.windowId,
1051
+ });
1052
+ });
1053
+
1054
+ // ─── Tab Listeners ────────────────────────────────────────────────────────
1055
+
1056
+ chrome.tabs.onCreated.addListener((tab) => {
1057
+ pushEvent('tab.created', {
1058
+ tabId: tab.id,
1059
+ groupId: tab.groupId,
1060
+ windowId: tab.windowId,
1061
+ url: tab.pendingUrl || tab.url || '',
1062
+ });
1063
+ });
1064
+
1065
+ chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
1066
+ pushEvent('tab.removed', {
1067
+ tabId,
1068
+ windowId: removeInfo.windowId,
1069
+ isWindowClosing: removeInfo.isWindowClosing,
1070
+ });
1071
+ });
1072
+
1073
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
1074
+ // Only fire on meaningful changes: URL, title, or group assignment
1075
+ const dominated = changeInfo.url || changeInfo.title || ('groupId' in changeInfo);
1076
+ if (!dominated) return;
1077
+
1078
+ pushEvent('tab.updated', {
1079
+ tabId,
1080
+ groupId: tab.groupId,
1081
+ changes: Object.keys(changeInfo),
1082
+ url: tab.url || '',
1083
+ title: tab.title || '',
1084
+ });
1085
+ });
1086
+
1087
+ chrome.tabs.onMoved.addListener((tabId, moveInfo) => {
1088
+ pushEvent('tab.moved', {
1089
+ tabId,
1090
+ windowId: moveInfo.windowId,
1091
+ fromIndex: moveInfo.fromIndex,
1092
+ toIndex: moveInfo.toIndex,
1093
+ });
1094
+ });