@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.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/cdp-client.d.ts +118 -0
- package/dist/cdp-client.d.ts.map +1 -0
- package/dist/cdp-client.js +867 -0
- package/dist/cdp-client.js.map +1 -0
- package/dist/comet-ai.d.ts +35 -0
- package/dist/comet-ai.d.ts.map +1 -0
- package/dist/comet-ai.js +396 -0
- package/dist/comet-ai.js.map +1 -0
- package/dist/http-server.d.ts +3 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +463 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1174 -0
- package/dist/index.js.map +1 -0
- package/dist/tab-group-archive.d.ts +13 -0
- package/dist/tab-group-archive.d.ts.map +1 -0
- package/dist/tab-group-archive.js +128 -0
- package/dist/tab-group-archive.js.map +1 -0
- package/dist/tab-groups.d.ts +86 -0
- package/dist/tab-groups.d.ts.map +1 -0
- package/dist/tab-groups.js +250 -0
- package/dist/tab-groups.js.map +1 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/extension/background.js +1094 -0
- package/extension/manifest.json +22 -0
- package/extension/sidepanel.css +1068 -0
- package/extension/sidepanel.html +145 -0
- package/extension/sidepanel.js +1186 -0
- package/package.json +59 -0
|
@@ -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
|
+
});
|