@shawnowen/comet-mcp 2.3.1 → 2.4.2
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/README.md +97 -19
- package/dist/alert-dispatcher.d.ts +23 -0
- package/dist/alert-dispatcher.js +101 -0
- package/dist/binding-reaper.d.ts +46 -0
- package/dist/binding-reaper.js +73 -0
- package/dist/bound-session.d.ts +23 -0
- package/dist/bound-session.js +119 -0
- package/dist/bridge-config.d.ts +6 -0
- package/dist/bridge-config.js +78 -0
- package/dist/cdp-client.d.ts +40 -4
- package/dist/cdp-client.js +502 -155
- package/dist/comet-ai.d.ts +15 -0
- package/dist/comet-ai.js +114 -38
- package/dist/delegate-binding.d.ts +19 -0
- package/dist/delegate-binding.js +73 -0
- package/dist/http-server.js +2188 -47
- package/dist/index.js +3545 -788
- package/dist/observer.d.ts +47 -0
- package/dist/observer.js +516 -0
- package/dist/project-config.d.ts +46 -0
- package/dist/project-config.js +166 -0
- package/dist/session-registry.d.ts +57 -0
- package/dist/session-registry.js +500 -0
- package/dist/sidecar-artifacts.d.ts +49 -0
- package/dist/sidecar-artifacts.js +146 -0
- package/dist/snapshot-capture.d.ts +3 -0
- package/dist/snapshot-capture.js +91 -0
- package/dist/tab-group-archive.js +3 -1
- package/dist/tab-groups.d.ts +28 -1
- package/dist/tab-groups.js +205 -3
- package/dist/types.d.ts +237 -0
- package/dist/window-bindings.d.ts +160 -0
- package/dist/window-bindings.js +561 -0
- package/extension/background.js +1577 -300
- package/extension/icons/icon.svg +9 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +34 -4
- package/extension/perplexity-capability-manifest.json +1181 -0
- package/extension/perplexity-capability-manifest.schema.json +142 -0
- package/extension/session-logic.js +3054 -0
- package/extension/session-manager.html +311 -0
- package/extension/sidepanel.css +5338 -528
- package/extension/sidepanel.html +282 -2
- package/extension/sidepanel.js +10604 -950
- package/extension/window-policy.js +162 -0
- package/package.json +10 -7
- package/vendor/lifecycle-mcp-adapter.mjs +103 -0
- package/vendor/lifecycle-metadata.mjs +252 -0
- package/vendor/readiness-report.mjs +742 -0
- package/dist/cdp-client.d.ts.map +0 -1
- package/dist/cdp-client.js.map +0 -1
- package/dist/comet-ai.d.ts.map +0 -1
- package/dist/comet-ai.js.map +0 -1
- package/dist/http-server.d.ts.map +0 -1
- package/dist/http-server.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/tab-group-archive.d.ts.map +0 -1
- package/dist/tab-group-archive.js.map +0 -1
- package/dist/tab-groups.d.ts.map +0 -1
- package/dist/tab-groups.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
package/extension/background.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Comet Tab Groups Bridge — service worker
|
|
2
2
|
// Marker for CDP discovery: comet-mcp scans service workers for this flag
|
|
3
3
|
self.__COMET_TAB_GROUPS_BRIDGE__ = true;
|
|
4
|
-
self.__COMET_TAB_GROUPS_VERSION__ = "
|
|
4
|
+
self.__COMET_TAB_GROUPS_VERSION__ = "2.0.0";
|
|
5
|
+
|
|
6
|
+
// Load shared pure logic module (Spec 036)
|
|
7
|
+
importScripts("session-logic.js");
|
|
8
|
+
importScripts("window-policy.js");
|
|
5
9
|
|
|
6
10
|
// ─── Robust MV3 Keepalive ────────────────────────────────────────────────────
|
|
7
11
|
//
|
|
@@ -20,7 +24,7 @@ let keepAlivePort = null;
|
|
|
20
24
|
|
|
21
25
|
function connectKeepAlive() {
|
|
22
26
|
try {
|
|
23
|
-
keepAlivePort = chrome.runtime.connect({ name:
|
|
27
|
+
keepAlivePort = chrome.runtime.connect({ name: "keepalive" });
|
|
24
28
|
keepAlivePort.onDisconnect.addListener(() => {
|
|
25
29
|
// Reading lastError clears it and prevents the "Unchecked runtime.lastError" warning
|
|
26
30
|
void chrome.runtime.lastError;
|
|
@@ -35,7 +39,7 @@ function connectKeepAlive() {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
chrome.runtime.onConnect.addListener((port) => {
|
|
38
|
-
if (port.name ===
|
|
42
|
+
if (port.name === "keepalive") {
|
|
39
43
|
// Accept the self-connection — its mere existence keeps us alive
|
|
40
44
|
return;
|
|
41
45
|
}
|
|
@@ -45,12 +49,12 @@ chrome.runtime.onConnect.addListener((port) => {
|
|
|
45
49
|
setTimeout(connectKeepAlive, 50);
|
|
46
50
|
|
|
47
51
|
// Alarm fallback — restarts worker if self-port somehow fails
|
|
48
|
-
chrome.alarms.create(
|
|
52
|
+
chrome.alarms.create("keepalive", { periodInMinutes: 0.4167 }); // clamped to 30s minimum
|
|
49
53
|
|
|
50
54
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
51
|
-
if (alarm.name ===
|
|
55
|
+
if (alarm.name === "keepalive") {
|
|
52
56
|
// Real async chrome API call extends worker lifetime
|
|
53
|
-
chrome.tabs.query({ active: true, currentWindow: true }).catch(() => {
|
|
57
|
+
chrome.tabs.query({ active: true, currentWindow: true }).catch(() => {});
|
|
54
58
|
// Ensure self-port is still connected
|
|
55
59
|
if (!keepAlivePort) connectKeepAlive();
|
|
56
60
|
}
|
|
@@ -66,15 +70,77 @@ chrome.alarms.onAlarm.addListener((alarm) => {
|
|
|
66
70
|
// Each handler is an async function: (payload) => data
|
|
67
71
|
|
|
68
72
|
const messageHandlers = {};
|
|
73
|
+
const {
|
|
74
|
+
TOP_DISPLAY_FULLSCREEN_WINDOW,
|
|
75
|
+
createTopDisplayFullscreenWindow,
|
|
76
|
+
ensureOneGroupPerWindow,
|
|
77
|
+
enforceTopDisplayFullscreenWindow,
|
|
78
|
+
isManagedWindow,
|
|
79
|
+
LOCKED_GROUPS_PER_WINDOW_POLICY,
|
|
80
|
+
} = globalThis.CometWindowPolicy;
|
|
81
|
+
|
|
82
|
+
function normalizeSettings(settings = {}) {
|
|
83
|
+
return {
|
|
84
|
+
...settings,
|
|
85
|
+
groupsPerWindow: { ...LOCKED_GROUPS_PER_WINDOW_POLICY },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const SAFETY_REMINDER_ALLOWLIST_KEY = "safetyReminderHostAllowlist";
|
|
90
|
+
const DEFAULT_SAFETY_REMINDER_HOST_ALLOWLIST =
|
|
91
|
+
globalThis.SessionLogic?.DEFAULT_SAFETY_REMINDER_HOST_ALLOWLIST || [];
|
|
92
|
+
|
|
93
|
+
async function getSafetyReminderHostAllowlist() {
|
|
94
|
+
const stored = await chrome.storage.local.get(SAFETY_REMINDER_ALLOWLIST_KEY);
|
|
95
|
+
const allowlist = stored[SAFETY_REMINDER_ALLOWLIST_KEY];
|
|
96
|
+
return Array.isArray(allowlist) ? allowlist : [...DEFAULT_SAFETY_REMINDER_HOST_ALLOWLIST];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function shouldInjectSafetyReminderForUrl(url) {
|
|
100
|
+
const allowlist = await getSafetyReminderHostAllowlist();
|
|
101
|
+
return globalThis.SessionLogic?.shouldInjectSafetyReminder
|
|
102
|
+
? globalThis.SessionLogic.shouldInjectSafetyReminder(url, allowlist)
|
|
103
|
+
: true;
|
|
104
|
+
}
|
|
69
105
|
|
|
70
106
|
function registerHandler(type, handler) {
|
|
71
107
|
messageHandlers[type] = handler;
|
|
72
108
|
}
|
|
73
109
|
|
|
110
|
+
let windowPolicyTimer = null;
|
|
111
|
+
let windowPolicyRunning = false;
|
|
112
|
+
let windowPolicyPending = false;
|
|
113
|
+
let windowPolicyPendingGroupId = null;
|
|
114
|
+
|
|
115
|
+
function scheduleWindowPolicyEnforcement(groupId = null, delayMs = 250) {
|
|
116
|
+
if (windowPolicyTimer) clearTimeout(windowPolicyTimer);
|
|
117
|
+
windowPolicyTimer = setTimeout(async () => {
|
|
118
|
+
if (windowPolicyRunning) {
|
|
119
|
+
windowPolicyPending = true;
|
|
120
|
+
windowPolicyPendingGroupId = groupId || windowPolicyPendingGroupId;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
windowPolicyRunning = true;
|
|
124
|
+
try {
|
|
125
|
+
await ensureOneGroupPerWindow(groupId);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.warn("[Comet] Window policy enforcement failed:", err);
|
|
128
|
+
} finally {
|
|
129
|
+
windowPolicyRunning = false;
|
|
130
|
+
if (windowPolicyPending) {
|
|
131
|
+
const pendingGroupId = windowPolicyPendingGroupId;
|
|
132
|
+
windowPolicyPending = false;
|
|
133
|
+
windowPolicyPendingGroupId = null;
|
|
134
|
+
scheduleWindowPolicyEnforcement(pendingGroupId, 0);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}, delayMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
74
140
|
// Central dispatcher — routes messages to registered handlers
|
|
75
141
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
76
142
|
// Legacy ping support (for CDP health checks)
|
|
77
|
-
if (msg?.type ===
|
|
143
|
+
if (msg?.type === "ping") {
|
|
78
144
|
sendResponse({ pong: true, ts: Date.now(), version: self.__COMET_TAB_GROUPS_VERSION__ });
|
|
79
145
|
return true;
|
|
80
146
|
}
|
|
@@ -88,7 +154,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
88
154
|
// Execute handler asynchronously, respond with contract format
|
|
89
155
|
(async () => {
|
|
90
156
|
try {
|
|
91
|
-
const data = await handler(msg.payload || {});
|
|
157
|
+
const data = await handler(msg.payload || {}, sender);
|
|
92
158
|
sendResponse({ ok: true, data });
|
|
93
159
|
} catch (err) {
|
|
94
160
|
console.error(`[Comet] Handler error for "${msg.type}":`, err);
|
|
@@ -101,7 +167,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
101
167
|
|
|
102
168
|
// Respond to messages from external extensions or CDP-injected scripts
|
|
103
169
|
chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
|
|
104
|
-
if (msg?.type ===
|
|
170
|
+
if (msg?.type === "ping") {
|
|
105
171
|
sendResponse({ pong: true, ts: Date.now(), version: self.__COMET_TAB_GROUPS_VERSION__ });
|
|
106
172
|
return true;
|
|
107
173
|
}
|
|
@@ -110,11 +176,11 @@ chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
|
|
|
110
176
|
// ─── Message Handlers ─────────────────────────────────────────────────────────
|
|
111
177
|
|
|
112
178
|
// T005: getGroups — returns all live tab groups
|
|
113
|
-
registerHandler(
|
|
179
|
+
registerHandler("getGroups", async () => {
|
|
114
180
|
const groups = await chrome.tabGroups.query({});
|
|
115
|
-
return groups.map(g => ({
|
|
181
|
+
return groups.map((g) => ({
|
|
116
182
|
id: g.id,
|
|
117
|
-
title: g.title ||
|
|
183
|
+
title: g.title || "",
|
|
118
184
|
color: g.color,
|
|
119
185
|
collapsed: g.collapsed,
|
|
120
186
|
windowId: g.windowId,
|
|
@@ -122,22 +188,66 @@ registerHandler('getGroups', async () => {
|
|
|
122
188
|
});
|
|
123
189
|
|
|
124
190
|
// T006: getTabs — returns all tabs with group/window info
|
|
125
|
-
registerHandler(
|
|
191
|
+
registerHandler("getTabs", async () => {
|
|
126
192
|
const tabs = await chrome.tabs.query({});
|
|
127
|
-
return tabs.map(t => ({
|
|
193
|
+
return tabs.map((t) => ({
|
|
128
194
|
id: t.id,
|
|
129
195
|
groupId: t.groupId,
|
|
130
196
|
windowId: t.windowId,
|
|
131
197
|
index: t.index,
|
|
132
|
-
title: t.title ||
|
|
133
|
-
url: t.url ||
|
|
198
|
+
title: t.title || "",
|
|
199
|
+
url: t.url || "",
|
|
134
200
|
active: t.active,
|
|
135
|
-
favIconUrl: t.favIconUrl ||
|
|
201
|
+
favIconUrl: t.favIconUrl || "",
|
|
136
202
|
}));
|
|
137
203
|
});
|
|
138
204
|
|
|
205
|
+
// Equanaut Input Gateway — telemetry only. The actual LLM fetch happens in
|
|
206
|
+
// the side-panel context (sidepanel.js) because MV3 service workers need
|
|
207
|
+
// host_permissions for cross-origin fetch and we want to avoid that upgrade.
|
|
208
|
+
registerHandler(
|
|
209
|
+
"equanautGatewaySubmit",
|
|
210
|
+
async ({
|
|
211
|
+
text,
|
|
212
|
+
contextLabel,
|
|
213
|
+
attachments,
|
|
214
|
+
windowId,
|
|
215
|
+
scopedGroupCount,
|
|
216
|
+
scopedTabCount,
|
|
217
|
+
slashCommandId,
|
|
218
|
+
slashCommandResolved,
|
|
219
|
+
}) => {
|
|
220
|
+
pushEvent("equanaut.gateway.submit", {
|
|
221
|
+
textLength: typeof text === "string" ? text.length : 0,
|
|
222
|
+
hasContext: Boolean(contextLabel && String(contextLabel).trim()),
|
|
223
|
+
attachmentCount: Array.isArray(attachments) ? attachments.length : 0,
|
|
224
|
+
windowId: windowId ? String(windowId) : "",
|
|
225
|
+
scopedGroupCount: Number(scopedGroupCount) || 0,
|
|
226
|
+
scopedTabCount: Number(scopedTabCount) || 0,
|
|
227
|
+
slashCommandId: slashCommandId ? String(slashCommandId) : null,
|
|
228
|
+
slashCommandResolved: Boolean(slashCommandResolved),
|
|
229
|
+
});
|
|
230
|
+
return { accepted: true };
|
|
231
|
+
}
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
registerHandler("equanautGatewayAttach", async () => {
|
|
235
|
+
pushEvent("equanaut.gateway.attach", {});
|
|
236
|
+
return { acknowledged: true };
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
registerHandler("equanautGatewaySparkle", async () => {
|
|
240
|
+
pushEvent("equanaut.gateway.sparkle", {});
|
|
241
|
+
return { acknowledged: true };
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
registerHandler("equanautGatewayMic", async ({ action }) => {
|
|
245
|
+
pushEvent("equanaut.gateway.mic", { action: action || "click" });
|
|
246
|
+
return { acknowledged: true };
|
|
247
|
+
});
|
|
248
|
+
|
|
139
249
|
// T007: healthCheck — checks CDP bridge presence and returns version
|
|
140
|
-
registerHandler(
|
|
250
|
+
registerHandler("healthCheck", async () => {
|
|
141
251
|
// The bridge is running if we're here (service worker is alive)
|
|
142
252
|
// Check if CDP connection is available by testing if we can query tabs
|
|
143
253
|
let cdpConnected = false;
|
|
@@ -154,50 +264,528 @@ registerHandler('healthCheck', async () => {
|
|
|
154
264
|
};
|
|
155
265
|
});
|
|
156
266
|
|
|
267
|
+
registerHandler("getSafetyReminderHostAllowlist", async () => ({
|
|
268
|
+
allowlist: await getSafetyReminderHostAllowlist(),
|
|
269
|
+
}));
|
|
270
|
+
|
|
271
|
+
registerHandler("updateSafetyReminderHostAllowlist", async ({ allowlist }) => {
|
|
272
|
+
if (!Array.isArray(allowlist)) throw new Error("allowlist must be an array");
|
|
273
|
+
const normalized = allowlist.map((value) => String(value).trim()).filter(Boolean);
|
|
274
|
+
await chrome.storage.local.set({ [SAFETY_REMINDER_ALLOWLIST_KEY]: normalized });
|
|
275
|
+
return { allowlist: normalized };
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
registerHandler("shouldInjectSafetyReminder", async ({ url }) => ({
|
|
279
|
+
inject: await shouldInjectSafetyReminderForUrl(url),
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
// ─── Display & Window Handlers (T101, T102) ─────────────────────────────────
|
|
283
|
+
|
|
284
|
+
// T101: getDisplays — returns all connected displays with bounds
|
|
285
|
+
registerHandler("getDisplays", async () => {
|
|
286
|
+
try {
|
|
287
|
+
const displays = await chrome.system.display.getInfo();
|
|
288
|
+
return displays.map((d) => ({
|
|
289
|
+
id: d.id,
|
|
290
|
+
name: d.name || `Display ${d.id}`,
|
|
291
|
+
bounds: d.bounds, // { left, top, width, height }
|
|
292
|
+
isPrimary: d.isPrimary || false,
|
|
293
|
+
}));
|
|
294
|
+
} catch {
|
|
295
|
+
// Fallback: chrome.system.display may not be available in all Chromium forks
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// T102: getWindowDisplayMapping — maps each window to its display by position
|
|
301
|
+
registerHandler("getWindowDisplayMapping", async () => {
|
|
302
|
+
const [displays, windows] = await Promise.all([
|
|
303
|
+
chrome.system.display.getInfo().catch(() => []),
|
|
304
|
+
chrome.windows.getAll({ populate: false }),
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
const mapping = {};
|
|
308
|
+
|
|
309
|
+
for (const win of windows) {
|
|
310
|
+
if (win.type !== "normal") continue;
|
|
311
|
+
|
|
312
|
+
// Minimized windows have no useful position — assign to primary display
|
|
313
|
+
if (win.state === "minimized" || win.top == null || win.left == null) {
|
|
314
|
+
const primary = displays.find((d) => d.isPrimary) || displays[0];
|
|
315
|
+
mapping[win.id] = primary ? primary.id : "unknown";
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Find the display whose bounds contain the window's top-left corner
|
|
320
|
+
// If window spans multiple displays, pick the one with most overlap
|
|
321
|
+
let bestDisplay = null;
|
|
322
|
+
let bestOverlap = -1;
|
|
323
|
+
|
|
324
|
+
for (const d of displays) {
|
|
325
|
+
const b = d.bounds;
|
|
326
|
+
const overlapLeft = Math.max(win.left, b.left);
|
|
327
|
+
const overlapTop = Math.max(win.top, b.top);
|
|
328
|
+
const overlapRight = Math.min(win.left + win.width, b.left + b.width);
|
|
329
|
+
const overlapBottom = Math.min(win.top + win.height, b.top + b.height);
|
|
330
|
+
const overlap =
|
|
331
|
+
Math.max(0, overlapRight - overlapLeft) * Math.max(0, overlapBottom - overlapTop);
|
|
332
|
+
|
|
333
|
+
if (overlap > bestOverlap) {
|
|
334
|
+
bestOverlap = overlap;
|
|
335
|
+
bestDisplay = d;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (bestDisplay) {
|
|
340
|
+
mapping[win.id] = bestDisplay.id;
|
|
341
|
+
} else if (displays.length === 0) {
|
|
342
|
+
// No display API — fall back to y-position heuristic
|
|
343
|
+
mapping[win.id] = win.top < 0 ? "top" : "bottom";
|
|
344
|
+
} else {
|
|
345
|
+
mapping[win.id] = "unknown";
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return mapping;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ─── Fullscreen/Side Panel Handlers (T009-T013, T063) ───────────────────────
|
|
353
|
+
|
|
354
|
+
const SESSION_MANAGER_URL = chrome.runtime.getURL("session-manager.html");
|
|
355
|
+
|
|
356
|
+
registerHandler("openSidePanel", async () => {
|
|
357
|
+
try {
|
|
358
|
+
const win = await chrome.windows.getLastFocused();
|
|
359
|
+
await chrome.sidePanel.open({ windowId: win.id });
|
|
360
|
+
return { ok: true };
|
|
361
|
+
} catch (err) {
|
|
362
|
+
return { ok: false, error: err.message };
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// T009: expandToFullTab — close sidepanel, open full-tab
|
|
367
|
+
registerHandler("expandToFullTab", async () => {
|
|
368
|
+
// Disable sidepanel to force-close it
|
|
369
|
+
await chrome.sidePanel.setOptions({ enabled: false });
|
|
370
|
+
// Open session-manager.html as a new tab
|
|
371
|
+
await chrome.tabs.create({ url: SESSION_MANAGER_URL });
|
|
372
|
+
// Re-enable sidepanel after a delay (so it's available again later)
|
|
373
|
+
setTimeout(() => {
|
|
374
|
+
chrome.sidePanel.setOptions({ enabled: true });
|
|
375
|
+
}, 500);
|
|
376
|
+
return { ok: true };
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// T010: minimizeToSidepanel — close full-tab, reopen sidepanel
|
|
380
|
+
registerHandler("minimizeToSidepanel", async (payload, sender) => {
|
|
381
|
+
// Open sidepanel first
|
|
382
|
+
const win = await chrome.windows.getLastFocused();
|
|
383
|
+
await chrome.sidePanel.open({ windowId: win.id });
|
|
384
|
+
// Close the requesting tab (the session-manager.html full-tab)
|
|
385
|
+
// The sender tab is passed via the message dispatcher
|
|
386
|
+
if (sender?.tab?.id) {
|
|
387
|
+
await chrome.tabs.remove(sender.tab.id).catch(() => {});
|
|
388
|
+
}
|
|
389
|
+
return { ok: true };
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// T011: Conflict detection — if session-manager.html is opened while sidepanel is active
|
|
393
|
+
chrome.tabs.onCreated.addListener(async (tab) => {
|
|
394
|
+
// Check after a short delay for the URL to be set
|
|
395
|
+
setTimeout(async () => {
|
|
396
|
+
try {
|
|
397
|
+
const updated = await chrome.tabs.get(tab.id);
|
|
398
|
+
if (updated.url && updated.url.startsWith(SESSION_MANAGER_URL)) {
|
|
399
|
+
// Full-tab opened — disable sidepanel to prevent dual view
|
|
400
|
+
await chrome.sidePanel.setOptions({ enabled: false });
|
|
401
|
+
setTimeout(() => chrome.sidePanel.setOptions({ enabled: true }), 500);
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
// Tab may have been closed
|
|
405
|
+
}
|
|
406
|
+
}, 200);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// T011: If session-manager.html closes unexpectedly, re-enable sidepanel
|
|
410
|
+
chrome.tabs.onRemoved.addListener(async (tabId, removeInfo) => {
|
|
411
|
+
// Check if any remaining session-manager.html tabs exist
|
|
412
|
+
try {
|
|
413
|
+
const tabs = await chrome.tabs.query({});
|
|
414
|
+
const hasFullTab = tabs.some((t) => t.url && t.url.startsWith(SESSION_MANAGER_URL));
|
|
415
|
+
if (!hasFullTab) {
|
|
416
|
+
// No full-tab remains — ensure sidepanel is re-enabled
|
|
417
|
+
await chrome.sidePanel.setOptions({ enabled: true });
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
// ignore
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ─── Extension Icon Click — Save & Close (T015-T017) ────────────────────────
|
|
425
|
+
|
|
426
|
+
chrome.action.onClicked.addListener(async (tab) => {
|
|
427
|
+
const currentWindowId = tab.windowId;
|
|
428
|
+
|
|
429
|
+
// Query all tabs in the current window
|
|
430
|
+
const allTabs = await chrome.tabs.query({ windowId: currentWindowId });
|
|
431
|
+
const saveTabs = allTabs.filter(
|
|
432
|
+
(t) => !t.url?.startsWith("chrome-extension://") && !t.url?.startsWith("chrome://")
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// T017: Edge case — no saveable tabs
|
|
436
|
+
if (!saveTabs.length) {
|
|
437
|
+
// Just open the sidepanel
|
|
438
|
+
try {
|
|
439
|
+
await chrome.sidePanel.open({ windowId: currentWindowId });
|
|
440
|
+
} catch {
|
|
441
|
+
// ignore
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Get group info for enrichment
|
|
447
|
+
const groups = await chrome.tabGroups.query({ windowId: currentWindowId });
|
|
448
|
+
const groupMap = new Map(groups.map((g) => [g.id, g]));
|
|
449
|
+
|
|
450
|
+
// Group tabs by their groupId
|
|
451
|
+
const byGroup = new Map();
|
|
452
|
+
for (const t of saveTabs) {
|
|
453
|
+
const gid = t.groupId !== -1 ? t.groupId : "ungrouped";
|
|
454
|
+
if (!byGroup.has(gid)) byGroup.set(gid, []);
|
|
455
|
+
byGroup.get(gid).push(t);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Build RecentlyClosedGroup entries and save to archive
|
|
459
|
+
const archive = await loadArchive();
|
|
460
|
+
|
|
461
|
+
for (const [gid, tabs] of byGroup) {
|
|
462
|
+
const groupInfo = gid !== "ungrouped" ? groupMap.get(gid) : null;
|
|
463
|
+
// Spec 037: Fetch session data from HTTP bridge for orchestrator URL
|
|
464
|
+
let sessionData = null;
|
|
465
|
+
const threadId = generateId();
|
|
466
|
+
try {
|
|
467
|
+
const groupTitle = groupInfo?.title || "";
|
|
468
|
+
const sessionRes = await fetch(
|
|
469
|
+
`http://localhost:3456/api/session/${encodeURIComponent(groupTitle)}`
|
|
470
|
+
);
|
|
471
|
+
if (sessionRes.ok) sessionData = await sessionRes.json();
|
|
472
|
+
} catch {
|
|
473
|
+
/* HTTP bridge unavailable — proceed without session data */
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const entry = {
|
|
477
|
+
taskThreadId: threadId,
|
|
478
|
+
// T016: Handle ungrouped tabs as "Unnamed" group
|
|
479
|
+
title: groupInfo?.title || (gid === "ungrouped" ? "Ungrouped Tabs" : "Untitled"),
|
|
480
|
+
color: groupInfo?.color || "grey",
|
|
481
|
+
collapsed: false,
|
|
482
|
+
urls: tabs.map((t) => ({
|
|
483
|
+
url: t.url || "",
|
|
484
|
+
title: t.title || "",
|
|
485
|
+
favIconUrl: t.favIconUrl || "",
|
|
486
|
+
})),
|
|
487
|
+
archivedAt: new Date().toISOString(),
|
|
488
|
+
restoredAt: null,
|
|
489
|
+
status: "pending",
|
|
490
|
+
// Spec 037: Orchestrator session data
|
|
491
|
+
orchestratorUrl: sessionData?.orchestratorUrl || null,
|
|
492
|
+
taskGoal: sessionData?.taskGoal || null,
|
|
493
|
+
sessionName: sessionData?.sessionName || null,
|
|
494
|
+
};
|
|
495
|
+
archive.unshift(entry);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
await saveArchive(archive);
|
|
499
|
+
|
|
500
|
+
// Close all saved tabs and open full-screen session manager
|
|
501
|
+
const tabIds = saveTabs.map((t) => t.id);
|
|
502
|
+
const fullUrl = chrome.runtime.getURL("session-manager.html");
|
|
503
|
+
await chrome.tabs.create({ windowId: currentWindowId, url: fullUrl, active: true });
|
|
504
|
+
await chrome.tabs.remove(tabIds);
|
|
505
|
+
|
|
506
|
+
pushEvent("icon.saveAndClose", { groupCount: byGroup.size, tabCount: saveTabs.length });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ─── Settings Handlers (T058-T059) ───────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
registerHandler("getSettings", async () => {
|
|
512
|
+
const stored = await chrome.storage.local.get("sidebarSettings");
|
|
513
|
+
return normalizeSettings(stored.sidebarSettings || {});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
registerHandler("updateSettings", async (settings) => {
|
|
517
|
+
await chrome.storage.local.set({ sidebarSettings: normalizeSettings(settings) });
|
|
518
|
+
return { ok: true };
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// T058: Hard limit enforcement — best-effort, closes violating tabs/groups
|
|
522
|
+
let hardLimitSettings = null;
|
|
523
|
+
|
|
524
|
+
// Load settings on startup
|
|
525
|
+
chrome.storage.local.get("sidebarSettings").then((stored) => {
|
|
526
|
+
hardLimitSettings = normalizeSettings(stored.sidebarSettings || {});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Watch for settings changes
|
|
530
|
+
chrome.storage.onChanged.addListener((changes) => {
|
|
531
|
+
if (changes.sidebarSettings) {
|
|
532
|
+
hardLimitSettings = normalizeSettings(changes.sidebarSettings.newValue || {});
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// Clarification Q1 Session 2: Hard limits degrade to soft warnings for browser-native
|
|
537
|
+
// tab creation. Only extension-initiated actions (archive restore, import) are truly blocked.
|
|
538
|
+
// Browser-native tabs are never auto-closed; instead we push a warning event to the sidebar.
|
|
539
|
+
chrome.tabs.onCreated.addListener(async (tab) => {
|
|
540
|
+
if (!hardLimitSettings) return;
|
|
541
|
+
const tpg = hardLimitSettings.tabsPerGroup;
|
|
542
|
+
if (!tpg || tpg.mode !== "hard") return;
|
|
543
|
+
|
|
544
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
const updated = await chrome.tabs.get(tab.id);
|
|
548
|
+
if (updated.groupId === -1) return;
|
|
549
|
+
|
|
550
|
+
const groupTabs = await chrome.tabs.query({ groupId: updated.groupId });
|
|
551
|
+
if (groupTabs.length > tpg.value) {
|
|
552
|
+
// Don't close — degrade to warning for browser-native actions
|
|
553
|
+
console.log(
|
|
554
|
+
`[Settings] Hard limit exceeded: group ${updated.groupId} has ${groupTabs.length}/${tpg.value} tabs (warning only — native action)`
|
|
555
|
+
);
|
|
556
|
+
// Store warning for sidebar to pick up
|
|
557
|
+
const warnings = (await chrome.storage.local.get("limitWarnings")).limitWarnings || [];
|
|
558
|
+
warnings.push({
|
|
559
|
+
type: "tabsPerGroup",
|
|
560
|
+
groupId: updated.groupId,
|
|
561
|
+
actual: groupTabs.length,
|
|
562
|
+
limit: tpg.value,
|
|
563
|
+
timestamp: Date.now(),
|
|
564
|
+
});
|
|
565
|
+
// Keep only last 20 warnings
|
|
566
|
+
await chrome.storage.local.set({ limitWarnings: warnings.slice(-20) });
|
|
567
|
+
}
|
|
568
|
+
} catch {
|
|
569
|
+
// Tab may have been closed already
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Handler to check hard limits before extension-initiated actions (restore, import)
|
|
574
|
+
registerHandler("checkHardLimit", async ({ type, count }) => {
|
|
575
|
+
if (!hardLimitSettings) return { allowed: true };
|
|
576
|
+
const setting = hardLimitSettings[type];
|
|
577
|
+
if (!setting || setting.mode !== "hard") return { allowed: true };
|
|
578
|
+
if (count >= setting.value) {
|
|
579
|
+
return { allowed: false, limit: setting.value, actual: count, type };
|
|
580
|
+
}
|
|
581
|
+
return { allowed: true };
|
|
582
|
+
});
|
|
583
|
+
|
|
157
584
|
// ─── Archive Storage Helpers ──────────────────────────────────────────────────
|
|
158
585
|
|
|
159
|
-
const ARCHIVE_KEY =
|
|
586
|
+
const ARCHIVE_KEY = "archivedGroups";
|
|
587
|
+
const SESSION_ENTITY_DESCRIPTIONS_KEY = "sessionEntityDescriptions";
|
|
588
|
+
|
|
589
|
+
function normalizeDescriptionText(value) {
|
|
590
|
+
return value === undefined || value === null ? "" : String(value);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function normalizeInstructionText(value) {
|
|
594
|
+
return value === undefined || value === null ? "" : String(value).trim();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function normalizeArchiveWorkflowStatus(status) {
|
|
598
|
+
if (globalThis.SessionLogic?.normalizeArchiveWorkflowStatus) {
|
|
599
|
+
return globalThis.SessionLogic.normalizeArchiveWorkflowStatus(status);
|
|
600
|
+
}
|
|
601
|
+
const raw = String(status || "")
|
|
602
|
+
.trim()
|
|
603
|
+
.toLowerCase();
|
|
604
|
+
if (raw === "done") return "done";
|
|
605
|
+
if (raw === "trashed") return "trashed";
|
|
606
|
+
return "pending";
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function coerceArchiveWorkflowStatusForWrite(status) {
|
|
610
|
+
const raw = String(status || "")
|
|
611
|
+
.trim()
|
|
612
|
+
.toLowerCase();
|
|
613
|
+
if (raw === "saved" || raw === "archived") return "pending";
|
|
614
|
+
if (raw === "pending" || raw === "done" || raw === "trashed") return raw;
|
|
615
|
+
throw new Error(`Invalid status: ${status}`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function hasOwn(record, key) {
|
|
619
|
+
return !!record && Object.prototype.hasOwnProperty.call(record, key);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function normalizeRecordDescription(record) {
|
|
623
|
+
if (globalThis.SessionLogic?.normalizeEntityDescription) {
|
|
624
|
+
return globalThis.SessionLogic.normalizeEntityDescription(record);
|
|
625
|
+
}
|
|
626
|
+
if (!record || typeof record !== "object") return "";
|
|
627
|
+
if (hasOwn(record, "description")) return normalizeDescriptionText(record.description);
|
|
628
|
+
if (hasOwn(record, "note")) return normalizeDescriptionText(record.note);
|
|
629
|
+
if (hasOwn(record, "notes")) {
|
|
630
|
+
const notes = record.notes;
|
|
631
|
+
return Array.isArray(notes)
|
|
632
|
+
? notes.map((note) => normalizeDescriptionText(note)).join("\n")
|
|
633
|
+
: normalizeDescriptionText(notes);
|
|
634
|
+
}
|
|
635
|
+
return "";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function entityDescriptionKey(entityType, entityId) {
|
|
639
|
+
return `${String(entityType || "").trim()}:${String(entityId || "").trim()}`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function loadEntityDescriptions() {
|
|
643
|
+
const result = await chrome.storage.local.get(SESSION_ENTITY_DESCRIPTIONS_KEY);
|
|
644
|
+
const stored = result[SESSION_ENTITY_DESCRIPTIONS_KEY];
|
|
645
|
+
return stored && typeof stored === "object" ? stored : {};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function saveEntityDescriptions(descriptions) {
|
|
649
|
+
await chrome.storage.local.set({ [SESSION_ENTITY_DESCRIPTIONS_KEY]: descriptions || {} });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function loadSidebarSettings() {
|
|
653
|
+
const result = await chrome.storage.local.get("sidebarSettings");
|
|
654
|
+
return result.sidebarSettings && typeof result.sidebarSettings === "object"
|
|
655
|
+
? result.sidebarSettings
|
|
656
|
+
: {};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function getStoredDescription(descriptions, entityType, entityId) {
|
|
660
|
+
const key = entityDescriptionKey(entityType, entityId);
|
|
661
|
+
const entry = descriptions?.[key];
|
|
662
|
+
if (typeof entry === "string") return entry;
|
|
663
|
+
return normalizeDescriptionText(entry?.description);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function getStoredGroupInstructions(settings, groupId) {
|
|
667
|
+
const entry = settings?.equanautGroupInstructions?.[String(groupId)];
|
|
668
|
+
if (typeof entry === "string") return entry.trim();
|
|
669
|
+
return normalizeInstructionText(entry?.instructions);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function normalizeArchiveTab(tab, parentDescription = "") {
|
|
673
|
+
const normalized = typeof tab === "object" && tab ? { ...tab } : { url: String(tab || "") };
|
|
674
|
+
const explicitDescription = normalizeRecordDescription(normalized);
|
|
675
|
+
normalized.description = explicitDescription;
|
|
676
|
+
normalized.inheritedDescription = explicitDescription
|
|
677
|
+
? ""
|
|
678
|
+
: normalizeDescriptionText(normalized.inheritedDescription || parentDescription);
|
|
679
|
+
return normalized;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function normalizeArchiveEntry(entry) {
|
|
683
|
+
if (!entry || typeof entry !== "object") return entry;
|
|
684
|
+
const normalized = { ...entry };
|
|
685
|
+
normalized.description = normalizeRecordDescription(normalized);
|
|
686
|
+
normalized.agentInstructions = normalizeInstructionText(normalized.agentInstructions);
|
|
687
|
+
normalized.urls = Array.isArray(normalized.urls)
|
|
688
|
+
? normalized.urls.map((tab) => normalizeArchiveTab(tab, normalized.description))
|
|
689
|
+
: [];
|
|
690
|
+
normalized.status = normalizeArchiveWorkflowStatus(normalized.status);
|
|
691
|
+
return normalized;
|
|
692
|
+
}
|
|
160
693
|
|
|
161
694
|
async function loadArchive() {
|
|
162
695
|
const result = await chrome.storage.local.get(ARCHIVE_KEY);
|
|
163
|
-
|
|
696
|
+
const archive = result[ARCHIVE_KEY] || [];
|
|
697
|
+
return Array.isArray(archive) ? archive.map(normalizeArchiveEntry) : [];
|
|
164
698
|
}
|
|
165
699
|
|
|
166
700
|
async function saveArchive(entries) {
|
|
167
|
-
await chrome.storage.local.set({
|
|
701
|
+
await chrome.storage.local.set({
|
|
702
|
+
[ARCHIVE_KEY]: Array.isArray(entries) ? entries.map(normalizeArchiveEntry) : [],
|
|
703
|
+
});
|
|
168
704
|
}
|
|
169
705
|
|
|
706
|
+
registerHandler("getEntityDescriptions", async () => {
|
|
707
|
+
return await loadEntityDescriptions();
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
registerHandler("updateEntityDescription", async ({ entityType, entityId, description }) => {
|
|
711
|
+
if (!entityType || entityId === undefined || entityId === null) {
|
|
712
|
+
throw new Error("entityType and entityId are required");
|
|
713
|
+
}
|
|
714
|
+
const descriptions = await loadEntityDescriptions();
|
|
715
|
+
const key = entityDescriptionKey(entityType, entityId);
|
|
716
|
+
descriptions[key] = {
|
|
717
|
+
entityType: String(entityType),
|
|
718
|
+
entityId: String(entityId),
|
|
719
|
+
description: normalizeDescriptionText(description),
|
|
720
|
+
updatedAt: new Date().toISOString(),
|
|
721
|
+
};
|
|
722
|
+
await saveEntityDescriptions(descriptions);
|
|
723
|
+
return descriptions[key];
|
|
724
|
+
});
|
|
725
|
+
|
|
170
726
|
function generateId() {
|
|
171
727
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
172
728
|
}
|
|
173
729
|
|
|
174
|
-
|
|
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
|
|
730
|
+
async function buildArchiveEntryFromLiveGroup(groupId, title, snapshotOnly = false) {
|
|
182
731
|
const tabs = await chrome.tabs.query({ groupId });
|
|
183
732
|
if (!tabs.length) throw new Error(`No tabs found in group ${groupId}`);
|
|
184
733
|
|
|
185
|
-
// Get group info for color
|
|
186
734
|
const groups = await chrome.tabGroups.query({});
|
|
187
|
-
const group = groups.find(g => g.id === groupId);
|
|
188
|
-
const color = group ? group.color :
|
|
735
|
+
const group = groups.find((g) => g.id === groupId);
|
|
736
|
+
const color = group ? group.color : "grey";
|
|
737
|
+
const windowId = group ? group.windowId : tabs[0] ? tabs[0].windowId : null;
|
|
738
|
+
let sourceDisplayId = null;
|
|
739
|
+
try {
|
|
740
|
+
const mapping = await messageHandlers["getWindowDisplayMapping"]({});
|
|
741
|
+
sourceDisplayId = mapping[windowId] || null;
|
|
742
|
+
} catch {
|
|
743
|
+
/* ignore */
|
|
744
|
+
}
|
|
745
|
+
const [descriptions, settings] = await Promise.all([
|
|
746
|
+
loadEntityDescriptions(),
|
|
747
|
+
loadSidebarSettings(),
|
|
748
|
+
]);
|
|
749
|
+
const groupDescription = getStoredDescription(descriptions, "group", groupId);
|
|
750
|
+
const agentInstructions = getStoredGroupInstructions(settings, groupId);
|
|
189
751
|
|
|
190
|
-
|
|
191
|
-
const entry = {
|
|
752
|
+
return {
|
|
192
753
|
taskThreadId: generateId(),
|
|
193
|
-
title: title || group?.title ||
|
|
754
|
+
title: title || group?.title || "Untitled",
|
|
755
|
+
description: groupDescription,
|
|
756
|
+
agentInstructions,
|
|
194
757
|
color,
|
|
195
758
|
collapsed: group?.collapsed || false,
|
|
196
|
-
urls: tabs.map(t => ({
|
|
759
|
+
urls: tabs.map((t) => ({
|
|
760
|
+
url: t.url || "",
|
|
761
|
+
title: t.title || "",
|
|
762
|
+
favIconUrl: t.favIconUrl || "",
|
|
763
|
+
description: getStoredDescription(descriptions, "tab", t.id),
|
|
764
|
+
inheritedDescription: getStoredDescription(descriptions, "tab", t.id) ? "" : groupDescription,
|
|
765
|
+
})),
|
|
197
766
|
archivedAt: new Date().toISOString(),
|
|
198
767
|
restoredAt: null,
|
|
199
|
-
status:
|
|
768
|
+
status: "pending",
|
|
769
|
+
sourceWindowId: windowId,
|
|
770
|
+
sourceDisplayId,
|
|
771
|
+
starred: false,
|
|
772
|
+
locked: false,
|
|
773
|
+
pinned: false,
|
|
774
|
+
note: "",
|
|
775
|
+
folderId: null,
|
|
776
|
+
snapshotOnly,
|
|
777
|
+
source: snapshotOnly ? "snapshot" : "archive",
|
|
200
778
|
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// T013: getArchivedGroups — returns all archived groups from storage
|
|
782
|
+
registerHandler("getArchivedGroups", async () => {
|
|
783
|
+
return await loadArchive();
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// T014: archiveGroup — save tabs from a live group, then close them
|
|
787
|
+
registerHandler("archiveGroup", async ({ groupId, title }) => {
|
|
788
|
+
const entry = await buildArchiveEntryFromLiveGroup(groupId, title, false);
|
|
201
789
|
|
|
202
790
|
// Save to archive
|
|
203
791
|
const archive = await loadArchive();
|
|
@@ -205,69 +793,416 @@ registerHandler('archiveGroup', async ({ groupId, title }) => {
|
|
|
205
793
|
await saveArchive(archive);
|
|
206
794
|
|
|
207
795
|
// Close the tabs
|
|
208
|
-
const
|
|
796
|
+
const tabs = await chrome.tabs.query({ groupId });
|
|
797
|
+
const tabIds = tabs.map((t) => t.id);
|
|
209
798
|
await chrome.tabs.remove(tabIds);
|
|
210
799
|
|
|
211
|
-
pushEvent(
|
|
800
|
+
pushEvent("archive.created", {
|
|
801
|
+
taskThreadId: entry.taskThreadId,
|
|
802
|
+
title: entry.title,
|
|
803
|
+
tabCount: tabs.length,
|
|
804
|
+
});
|
|
212
805
|
return { taskThreadId: entry.taskThreadId };
|
|
213
806
|
});
|
|
214
807
|
|
|
215
|
-
|
|
216
|
-
|
|
808
|
+
const snapshotSavesInFlight = new Set();
|
|
809
|
+
|
|
810
|
+
// saveGroupSnapshot — save tabs from a live group without closing or moving anything
|
|
811
|
+
registerHandler("saveGroupSnapshot", async ({ groupId, title }) => {
|
|
812
|
+
const tabs = await chrome.tabs.query({ groupId });
|
|
813
|
+
if (!tabs.length) throw new Error(`No tabs found in group ${groupId}`);
|
|
814
|
+
|
|
815
|
+
const fingerprint = `${groupId}:${tabs.map((t) => `${t.id}:${t.url || ""}`).join("|")}`;
|
|
816
|
+
if (snapshotSavesInFlight.has(fingerprint)) {
|
|
817
|
+
return { duplicateIgnored: true, totalTabs: tabs.length };
|
|
818
|
+
}
|
|
819
|
+
snapshotSavesInFlight.add(fingerprint);
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
const entry = await buildArchiveEntryFromLiveGroup(groupId, title, true);
|
|
823
|
+
const archive = await loadArchive();
|
|
824
|
+
archive.unshift(entry);
|
|
825
|
+
await saveArchive(archive);
|
|
826
|
+
|
|
827
|
+
pushEvent("archive.snapshotSaved", {
|
|
828
|
+
taskThreadId: entry.taskThreadId,
|
|
829
|
+
title: entry.title,
|
|
830
|
+
tabCount: entry.urls.length,
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
taskThreadId: entry.taskThreadId,
|
|
835
|
+
totalTabs: entry.urls.length,
|
|
836
|
+
snapshotOnly: true,
|
|
837
|
+
};
|
|
838
|
+
} finally {
|
|
839
|
+
setTimeout(() => snapshotSavesInFlight.delete(fingerprint), 1500);
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
async function resolveRestoreTargetWindowId(sourceWindowId) {
|
|
844
|
+
const numericSourceWindowId = Number(sourceWindowId);
|
|
845
|
+
if (Number.isFinite(numericSourceWindowId)) {
|
|
846
|
+
const sourceWindow = await chrome.windows
|
|
847
|
+
.get(numericSourceWindowId, { populate: false })
|
|
848
|
+
.catch(() => null);
|
|
849
|
+
if (sourceWindow?.id) return sourceWindow.id;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const currentWindow = await chrome.windows.getCurrent({ populate: false }).catch(() => null);
|
|
853
|
+
if (currentWindow?.id) return currentWindow.id;
|
|
854
|
+
|
|
855
|
+
const focusedWindow = await chrome.windows.getLastFocused({ populate: false }).catch(() => null);
|
|
856
|
+
if (focusedWindow?.id) return focusedWindow.id;
|
|
857
|
+
|
|
858
|
+
throw new Error("Cannot resolve source window for Restore in this window");
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// T015/T049: restoreGroup — reopen all tabs from an archive entry
|
|
862
|
+
// T049: Accepts target parameter: 'newWindow' (default), 'thisWindow', 'incognito'
|
|
863
|
+
registerHandler("restoreGroup", async ({ taskThreadId, target = "newWindow", sourceWindowId }) => {
|
|
217
864
|
const archive = await loadArchive();
|
|
218
|
-
const idx = archive.findIndex(e => e.taskThreadId === taskThreadId);
|
|
865
|
+
const idx = archive.findIndex((e) => e.taskThreadId === taskThreadId);
|
|
219
866
|
if (idx === -1) throw new Error(`Archive entry not found: ${taskThreadId}`);
|
|
220
867
|
|
|
221
868
|
const entry = archive[idx];
|
|
869
|
+
if (entry.locked) throw new Error("Cannot restore a locked archive entry");
|
|
222
870
|
const urls = entry.urls || [];
|
|
223
|
-
if (!urls.length) throw new Error(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
871
|
+
if (!urls.length) throw new Error("Archive entry has no URLs");
|
|
872
|
+
|
|
873
|
+
let windowId;
|
|
874
|
+
let tabs;
|
|
875
|
+
|
|
876
|
+
if (target === "incognito") {
|
|
877
|
+
// Open in a top-display fullscreen incognito window.
|
|
878
|
+
const win = await createTopDisplayFullscreenWindow({ incognito: true });
|
|
879
|
+
windowId = win.id;
|
|
880
|
+
// Remove the default new tab in the incognito window
|
|
881
|
+
if (win.tabs && win.tabs.length === 1) {
|
|
882
|
+
const defaultTabId = win.tabs[0].id;
|
|
883
|
+
tabs = await Promise.all(
|
|
884
|
+
urls.map((u) => chrome.tabs.create({ url: u.url, windowId, active: false }))
|
|
885
|
+
);
|
|
886
|
+
await chrome.tabs.remove(defaultTabId).catch(() => {});
|
|
887
|
+
} else {
|
|
888
|
+
tabs = await Promise.all(
|
|
889
|
+
urls.map((u) => chrome.tabs.create({ url: u.url, windowId, active: false }))
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
} else if (target === "thisWindow") {
|
|
893
|
+
windowId = await resolveRestoreTargetWindowId(sourceWindowId);
|
|
894
|
+
tabs = await Promise.all(
|
|
895
|
+
urls.map((u) => chrome.tabs.create({ url: u.url, windowId, active: false }))
|
|
896
|
+
);
|
|
897
|
+
} else {
|
|
898
|
+
// 'newWindow' — default behavior: open in a top-display fullscreen window.
|
|
899
|
+
const win = await createTopDisplayFullscreenWindow({ url: urls[0].url });
|
|
900
|
+
windowId = win.id;
|
|
901
|
+
tabs = [win.tabs?.[0]].filter(Boolean);
|
|
902
|
+
for (let i = 1; i < urls.length; i++) {
|
|
903
|
+
tabs.push(await chrome.tabs.create({ url: urls[i].url, windowId, active: false }));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
232
906
|
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
907
|
+
// Group the tabs (skip for incognito — grouping may not be supported)
|
|
908
|
+
let groupId = null;
|
|
909
|
+
if (target !== "incognito") {
|
|
910
|
+
try {
|
|
911
|
+
const tabIds = tabs.map((t) => t.id);
|
|
912
|
+
groupId = await chrome.tabs.group({ tabIds });
|
|
913
|
+
await chrome.tabGroups.update(groupId, {
|
|
914
|
+
title: entry.title || "Restored",
|
|
915
|
+
color: entry.color || "grey",
|
|
916
|
+
collapsed: false,
|
|
917
|
+
});
|
|
918
|
+
if (target === "thisWindow") {
|
|
919
|
+
const enforced = await ensureOneGroupPerWindow(groupId, {
|
|
920
|
+
markChangedWindowManaged: true,
|
|
921
|
+
}).catch(() => null);
|
|
922
|
+
if (enforced?.group?.windowId) {
|
|
923
|
+
windowId = enforced.group.windowId;
|
|
924
|
+
} else {
|
|
925
|
+
const restoredGroup = await chrome.tabGroups.get(groupId).catch(() => null);
|
|
926
|
+
if (restoredGroup?.windowId) windowId = restoredGroup.windowId;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
} catch {
|
|
930
|
+
// Grouping failed (e.g., incognito mode), tabs are still open
|
|
931
|
+
}
|
|
932
|
+
}
|
|
239
933
|
|
|
240
934
|
// Update archive entry status
|
|
241
935
|
archive[idx] = {
|
|
242
936
|
...entry,
|
|
243
|
-
status:
|
|
937
|
+
status: "pending",
|
|
244
938
|
restoredAt: new Date().toISOString(),
|
|
245
939
|
};
|
|
246
940
|
await saveArchive(archive);
|
|
247
941
|
|
|
248
|
-
pushEvent(
|
|
249
|
-
|
|
942
|
+
pushEvent("archive.restored", {
|
|
943
|
+
taskThreadId,
|
|
944
|
+
title: entry.title,
|
|
945
|
+
tabCount: urls.length,
|
|
946
|
+
groupId,
|
|
947
|
+
target,
|
|
948
|
+
});
|
|
949
|
+
return { groupId: groupId || null, windowId };
|
|
250
950
|
});
|
|
251
951
|
|
|
252
952
|
// T016: deleteArchived — remove an archive entry
|
|
253
|
-
registerHandler(
|
|
953
|
+
registerHandler("deleteArchived", async ({ taskThreadId }) => {
|
|
254
954
|
const archive = await loadArchive();
|
|
255
|
-
const
|
|
955
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
956
|
+
if (entry?.locked) throw new Error("Cannot delete a locked archive entry");
|
|
957
|
+
const filtered = archive.filter((e) => e.taskThreadId !== taskThreadId);
|
|
256
958
|
const deleted = filtered.length < archive.length;
|
|
257
959
|
await saveArchive(filtered);
|
|
258
960
|
|
|
259
961
|
if (deleted) {
|
|
260
|
-
pushEvent(
|
|
962
|
+
pushEvent("archive.deleted", { taskThreadId });
|
|
261
963
|
}
|
|
262
964
|
return { deleted };
|
|
263
965
|
});
|
|
264
966
|
|
|
967
|
+
// ─── T045: Pre-archive agent state check ──────────────────────────────────────
|
|
968
|
+
|
|
969
|
+
registerHandler("getGroupAgentStates", async ({ groupId }) => {
|
|
970
|
+
const tabs = await chrome.tabs.query({ groupId });
|
|
971
|
+
const results = [];
|
|
972
|
+
for (const tab of tabs) {
|
|
973
|
+
const isPerplexity = (tab.url || "").includes("perplexity.ai");
|
|
974
|
+
let agentState = "idle";
|
|
975
|
+
if (isPerplexity) {
|
|
976
|
+
// Check for Perplexity's visual activity indicators via scripting
|
|
977
|
+
try {
|
|
978
|
+
const [result] = await chrome.scripting.executeScript({
|
|
979
|
+
target: { tabId: tab.id },
|
|
980
|
+
func: () => {
|
|
981
|
+
// Check for Perplexity's active indicators
|
|
982
|
+
const hasProgress = !!document.querySelector(
|
|
983
|
+
'[class*="progress"], [class*="loading"], [class*="typing"]'
|
|
984
|
+
);
|
|
985
|
+
const hasAnimation = !!document.querySelector(
|
|
986
|
+
'[class*="animate"], [class*="pulse"], [class*="spin"]'
|
|
987
|
+
);
|
|
988
|
+
const hasStopButton = !!document.querySelector(
|
|
989
|
+
'button[aria-label="Stop"], button[aria-label="Cancel"]'
|
|
990
|
+
);
|
|
991
|
+
return hasProgress || hasAnimation || hasStopButton;
|
|
992
|
+
},
|
|
993
|
+
});
|
|
994
|
+
if (result?.result) agentState = "active";
|
|
995
|
+
} catch {
|
|
996
|
+
// Script injection failed (e.g., chrome:// page), assume idle
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
results.push({ tabId: tab.id, title: tab.title || "", state: agentState, url: tab.url || "" });
|
|
1000
|
+
}
|
|
1001
|
+
return results;
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// T012: Check Perplexity activity via DOM observation for a single tab
|
|
1005
|
+
registerHandler("checkPerplexityActivity", async ({ tabId }) => {
|
|
1006
|
+
try {
|
|
1007
|
+
const tab = await chrome.tabs.get(tabId);
|
|
1008
|
+
if (!(tab.url || "").includes("perplexity.ai")) return { active: false };
|
|
1009
|
+
const [result] = await chrome.scripting.executeScript({
|
|
1010
|
+
target: { tabId },
|
|
1011
|
+
func: () => {
|
|
1012
|
+
const hasProgress = !!document.querySelector(
|
|
1013
|
+
'[class*="progress"], [class*="loading"], [class*="typing"]'
|
|
1014
|
+
);
|
|
1015
|
+
const hasAnimation = !!document.querySelector(
|
|
1016
|
+
'[class*="animate"], [class*="pulse"], [class*="spin"]'
|
|
1017
|
+
);
|
|
1018
|
+
const hasStopButton = !!document.querySelector(
|
|
1019
|
+
'button[aria-label="Stop"], button[aria-label="Cancel"]'
|
|
1020
|
+
);
|
|
1021
|
+
return hasProgress || hasAnimation || hasStopButton;
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
return { active: !!result?.result };
|
|
1025
|
+
} catch {
|
|
1026
|
+
return { active: false };
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// ─── T054: Archive Management Action Handlers ─────────────────────────────────
|
|
1031
|
+
|
|
1032
|
+
// T051: Star/unstar an archived group
|
|
1033
|
+
registerHandler("starArchived", async ({ taskThreadId }) => {
|
|
1034
|
+
const archive = await loadArchive();
|
|
1035
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1036
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1037
|
+
entry.starred = !entry.starred;
|
|
1038
|
+
await saveArchive(archive);
|
|
1039
|
+
return { starred: entry.starred };
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
// T051: Lock/unlock an archived group
|
|
1043
|
+
registerHandler("lockArchived", async ({ taskThreadId }) => {
|
|
1044
|
+
const archive = await loadArchive();
|
|
1045
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1046
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1047
|
+
entry.locked = !entry.locked;
|
|
1048
|
+
await saveArchive(archive);
|
|
1049
|
+
return { locked: entry.locked };
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// T051: Pin/unpin an archived group
|
|
1053
|
+
registerHandler("pinArchived", async ({ taskThreadId }) => {
|
|
1054
|
+
const archive = await loadArchive();
|
|
1055
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1056
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1057
|
+
entry.pinned = !entry.pinned;
|
|
1058
|
+
await saveArchive(archive);
|
|
1059
|
+
return { pinned: entry.pinned };
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
// T051: Update note on archived group
|
|
1063
|
+
registerHandler("updateArchiveNote", async ({ taskThreadId, note }) => {
|
|
1064
|
+
const archive = await loadArchive();
|
|
1065
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1066
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1067
|
+
entry.description = normalizeDescriptionText(note);
|
|
1068
|
+
await saveArchive(archive);
|
|
1069
|
+
return { description: entry.description, note: entry.description };
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
registerHandler("updateArchiveDescription", async ({ taskThreadId, description }) => {
|
|
1073
|
+
const archive = await loadArchive();
|
|
1074
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1075
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1076
|
+
entry.description = normalizeDescriptionText(description);
|
|
1077
|
+
await saveArchive(archive);
|
|
1078
|
+
return { description: entry.description };
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// T052: Update workflow status (pending, done, trashed; legacy aliases saved/archived)
|
|
1082
|
+
registerHandler("updateArchiveStatus", async ({ taskThreadId, status }) => {
|
|
1083
|
+
const nextStatus = coerceArchiveWorkflowStatusForWrite(status);
|
|
1084
|
+
const archive = await loadArchive();
|
|
1085
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1086
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1087
|
+
if (entry.locked) throw new Error("Cannot change status of a locked entry");
|
|
1088
|
+
entry.status = nextStatus;
|
|
1089
|
+
await saveArchive(archive);
|
|
1090
|
+
return { status: entry.status };
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// T026: Update folder assignment for an archived entry
|
|
1094
|
+
registerHandler("updateArchiveFolder", async ({ taskThreadId, folder }) => {
|
|
1095
|
+
const archive = await loadArchive();
|
|
1096
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1097
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1098
|
+
if (folder === null || folder === undefined) {
|
|
1099
|
+
delete entry.folder;
|
|
1100
|
+
} else {
|
|
1101
|
+
entry.folder = String(folder).trim();
|
|
1102
|
+
}
|
|
1103
|
+
await saveArchive(archive);
|
|
1104
|
+
pushEvent("archive.updated", { taskThreadId, field: "folder", value: entry.folder || null });
|
|
1105
|
+
return { folder: entry.folder || null };
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// T053: Remove duplicate URLs within an archived group
|
|
1109
|
+
registerHandler("removeDuplicates", async ({ taskThreadId }) => {
|
|
1110
|
+
const archive = await loadArchive();
|
|
1111
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1112
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1113
|
+
if (entry.locked) throw new Error("Cannot modify a locked entry");
|
|
1114
|
+
const seen = new Set();
|
|
1115
|
+
const original = entry.urls.length;
|
|
1116
|
+
entry.urls = entry.urls.filter((u) => {
|
|
1117
|
+
if (seen.has(u.url)) return false;
|
|
1118
|
+
seen.add(u.url);
|
|
1119
|
+
return true;
|
|
1120
|
+
});
|
|
1121
|
+
await saveArchive(archive);
|
|
1122
|
+
return { removed: original - entry.urls.length, remaining: entry.urls.length };
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// T053: Sort archived groups
|
|
1126
|
+
registerHandler("sortArchive", async ({ sortBy = "date" }) => {
|
|
1127
|
+
const archive = await loadArchive();
|
|
1128
|
+
// Pinned items stay at top
|
|
1129
|
+
const pinned = archive.filter((e) => e.pinned);
|
|
1130
|
+
const unpinned = archive.filter((e) => !e.pinned);
|
|
1131
|
+
if (sortBy === "name") {
|
|
1132
|
+
unpinned.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
|
|
1133
|
+
} else if (sortBy === "tabs") {
|
|
1134
|
+
unpinned.sort((a, b) => (b.urls?.length || 0) - (a.urls?.length || 0));
|
|
1135
|
+
} else {
|
|
1136
|
+
// date (default) — newest first
|
|
1137
|
+
unpinned.sort((a, b) => new Date(b.archivedAt) - new Date(a.archivedAt));
|
|
1138
|
+
}
|
|
1139
|
+
await saveArchive([...pinned, ...unpinned]);
|
|
1140
|
+
return { sorted: true, sortBy };
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
// Reorder archive entries by ID list
|
|
1144
|
+
registerHandler("reorderArchive", async ({ order }) => {
|
|
1145
|
+
const archive = await loadArchive();
|
|
1146
|
+
const byId = new Map(archive.map((e) => [e.taskThreadId || e.id, e]));
|
|
1147
|
+
const reordered = [];
|
|
1148
|
+
for (const id of order) {
|
|
1149
|
+
const entry = byId.get(id);
|
|
1150
|
+
if (entry) {
|
|
1151
|
+
reordered.push(entry);
|
|
1152
|
+
byId.delete(id);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// Append any entries not in the order list (safety)
|
|
1156
|
+
for (const entry of byId.values()) reordered.push(entry);
|
|
1157
|
+
await saveArchive(reordered);
|
|
1158
|
+
return { ok: true };
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
// Update arbitrary fields on an archive entry (color, description, notes, etc.)
|
|
1162
|
+
registerHandler("updateArchiveEntry", async ({ taskThreadId, updates }) => {
|
|
1163
|
+
const archive = await loadArchive();
|
|
1164
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1165
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1166
|
+
// Only allow safe fields
|
|
1167
|
+
const allowed = [
|
|
1168
|
+
"color",
|
|
1169
|
+
"title",
|
|
1170
|
+
"description",
|
|
1171
|
+
"agentInstructions",
|
|
1172
|
+
"notes",
|
|
1173
|
+
"folder",
|
|
1174
|
+
"status",
|
|
1175
|
+
"urls",
|
|
1176
|
+
];
|
|
1177
|
+
for (const key of Object.keys(updates)) {
|
|
1178
|
+
if (allowed.includes(key)) entry[key] = updates[key];
|
|
1179
|
+
}
|
|
1180
|
+
if (!hasOwn(updates, "description") && hasOwn(updates, "notes")) {
|
|
1181
|
+
entry.description = normalizeRecordDescription({ notes: updates.notes });
|
|
1182
|
+
}
|
|
1183
|
+
await saveArchive(archive);
|
|
1184
|
+
return { ok: true };
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// T051: Copy archived group URLs to clipboard (returns URL list as text)
|
|
1188
|
+
registerHandler("getArchiveUrls", async ({ taskThreadId }) => {
|
|
1189
|
+
const archive = await loadArchive();
|
|
1190
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
1191
|
+
if (!entry) throw new Error(`Not found: ${taskThreadId}`);
|
|
1192
|
+
const text = entry.urls.map((u) => `${u.url} | ${u.title}`).join("\n");
|
|
1193
|
+
return { text, urlCount: entry.urls.length };
|
|
1194
|
+
});
|
|
1195
|
+
|
|
265
1196
|
// ─── OneTab Parity Handlers ──────────────────────────────────────────────────
|
|
266
1197
|
|
|
267
1198
|
// saveAllTabs — archive ALL open tab groups in one click (like OneTab's "Store all")
|
|
268
|
-
registerHandler(
|
|
1199
|
+
registerHandler("saveAllTabs", async () => {
|
|
269
1200
|
const groups = await chrome.tabGroups.query({});
|
|
270
1201
|
const allTabs = await chrome.tabs.query({});
|
|
1202
|
+
const [descriptions, settings] = await Promise.all([
|
|
1203
|
+
loadEntityDescriptions(),
|
|
1204
|
+
loadSidebarSettings(),
|
|
1205
|
+
]);
|
|
271
1206
|
|
|
272
1207
|
if (!groups.length && !allTabs.length) {
|
|
273
1208
|
return { archived: 0, totalTabs: 0 };
|
|
@@ -280,42 +1215,61 @@ registerHandler('saveAllTabs', async () => {
|
|
|
280
1215
|
|
|
281
1216
|
// Archive each tab group
|
|
282
1217
|
for (const group of groups) {
|
|
283
|
-
const groupTabs = allTabs.filter(t => t.groupId === group.id);
|
|
1218
|
+
const groupTabs = allTabs.filter((t) => t.groupId === group.id);
|
|
284
1219
|
if (!groupTabs.length) continue;
|
|
1220
|
+
const groupDescription = getStoredDescription(descriptions, "group", group.id);
|
|
1221
|
+
const agentInstructions = getStoredGroupInstructions(settings, group.id);
|
|
285
1222
|
|
|
286
1223
|
const entry = {
|
|
287
1224
|
taskThreadId: generateId(),
|
|
288
|
-
title: group.title ||
|
|
289
|
-
|
|
1225
|
+
title: group.title || "Untitled",
|
|
1226
|
+
description: groupDescription,
|
|
1227
|
+
agentInstructions,
|
|
1228
|
+
color: group.color || "grey",
|
|
290
1229
|
collapsed: false,
|
|
291
|
-
urls: groupTabs.map(t =>
|
|
1230
|
+
urls: groupTabs.map((t) => {
|
|
1231
|
+
const tabDescription = getStoredDescription(descriptions, "tab", t.id);
|
|
1232
|
+
return {
|
|
1233
|
+
url: t.url || "",
|
|
1234
|
+
title: t.title || "",
|
|
1235
|
+
favIconUrl: t.favIconUrl || "",
|
|
1236
|
+
description: tabDescription,
|
|
1237
|
+
inheritedDescription: tabDescription ? "" : groupDescription,
|
|
1238
|
+
};
|
|
1239
|
+
}),
|
|
292
1240
|
archivedAt: new Date().toISOString(),
|
|
293
1241
|
restoredAt: null,
|
|
294
|
-
status:
|
|
1242
|
+
status: "pending",
|
|
295
1243
|
};
|
|
296
1244
|
archive.unshift(entry);
|
|
297
1245
|
archivedCount++;
|
|
298
1246
|
totalTabs += groupTabs.length;
|
|
299
|
-
tabIdsToClose.push(...groupTabs.map(t => t.id));
|
|
1247
|
+
tabIdsToClose.push(...groupTabs.map((t) => t.id));
|
|
300
1248
|
}
|
|
301
1249
|
|
|
302
1250
|
// Archive ungrouped tabs as a separate group
|
|
303
|
-
const ungroupedTabs = allTabs.filter(t => t.groupId === -1 && !t.pinned);
|
|
1251
|
+
const ungroupedTabs = allTabs.filter((t) => t.groupId === -1 && !t.pinned);
|
|
304
1252
|
if (ungroupedTabs.length > 0) {
|
|
305
1253
|
const entry = {
|
|
306
1254
|
taskThreadId: generateId(),
|
|
307
|
-
title:
|
|
308
|
-
|
|
1255
|
+
title: "Ungrouped Tabs",
|
|
1256
|
+
description: "",
|
|
1257
|
+
color: "grey",
|
|
309
1258
|
collapsed: false,
|
|
310
|
-
urls: ungroupedTabs.map(t => ({
|
|
1259
|
+
urls: ungroupedTabs.map((t) => ({
|
|
1260
|
+
url: t.url || "",
|
|
1261
|
+
title: t.title || "",
|
|
1262
|
+
favIconUrl: t.favIconUrl || "",
|
|
1263
|
+
description: getStoredDescription(descriptions, "tab", t.id),
|
|
1264
|
+
})),
|
|
311
1265
|
archivedAt: new Date().toISOString(),
|
|
312
1266
|
restoredAt: null,
|
|
313
|
-
status:
|
|
1267
|
+
status: "pending",
|
|
314
1268
|
};
|
|
315
1269
|
archive.unshift(entry);
|
|
316
1270
|
archivedCount++;
|
|
317
1271
|
totalTabs += ungroupedTabs.length;
|
|
318
|
-
tabIdsToClose.push(...ungroupedTabs.map(t => t.id));
|
|
1272
|
+
tabIdsToClose.push(...ungroupedTabs.map((t) => t.id));
|
|
319
1273
|
}
|
|
320
1274
|
|
|
321
1275
|
await saveArchive(archive);
|
|
@@ -323,133 +1277,205 @@ registerHandler('saveAllTabs', async () => {
|
|
|
323
1277
|
// Close all archived tabs (but keep at least one tab open to prevent browser from closing)
|
|
324
1278
|
if (tabIdsToClose.length > 0) {
|
|
325
1279
|
// Create a blank tab first so the browser doesn't close
|
|
326
|
-
await chrome.tabs.create({ url:
|
|
1280
|
+
await chrome.tabs.create({ url: "chrome://newtab", active: true });
|
|
327
1281
|
await chrome.tabs.remove(tabIdsToClose);
|
|
328
1282
|
}
|
|
329
1283
|
|
|
330
|
-
pushEvent(
|
|
1284
|
+
pushEvent("archive.savedAll", { groupCount: archivedCount, tabCount: totalTabs });
|
|
331
1285
|
return { archived: archivedCount, totalTabs };
|
|
332
1286
|
});
|
|
333
1287
|
|
|
334
1288
|
// restoreSingleTab — open a single URL from an archived group
|
|
335
|
-
registerHandler(
|
|
336
|
-
if (!url) throw new Error(
|
|
1289
|
+
registerHandler("restoreSingleTab", async ({ taskThreadId, url }) => {
|
|
1290
|
+
if (!url) throw new Error("URL is required");
|
|
337
1291
|
const tab = await chrome.tabs.create({ url, active: true });
|
|
338
1292
|
return { tabId: tab.id };
|
|
339
1293
|
});
|
|
340
1294
|
|
|
341
1295
|
// renameArchived — rename an archived group's title
|
|
342
|
-
registerHandler(
|
|
343
|
-
if (!taskThreadId) throw new Error(
|
|
344
|
-
if (!newTitle || !newTitle.trim()) throw new Error(
|
|
1296
|
+
registerHandler("renameArchived", async ({ taskThreadId, newTitle }) => {
|
|
1297
|
+
if (!taskThreadId) throw new Error("taskThreadId is required");
|
|
1298
|
+
if (!newTitle || !newTitle.trim()) throw new Error("New title is required");
|
|
345
1299
|
|
|
346
1300
|
const archive = await loadArchive();
|
|
347
|
-
const entry = archive.find(e => e.taskThreadId === taskThreadId);
|
|
1301
|
+
const entry = archive.find((e) => e.taskThreadId === taskThreadId);
|
|
348
1302
|
if (!entry) throw new Error(`Archive entry not found: ${taskThreadId}`);
|
|
349
1303
|
|
|
350
1304
|
entry.title = newTitle.trim();
|
|
351
1305
|
await saveArchive(archive);
|
|
352
1306
|
|
|
353
|
-
pushEvent(
|
|
1307
|
+
pushEvent("archive.renamed", { taskThreadId, newTitle: entry.title });
|
|
354
1308
|
return { renamed: true, title: entry.title };
|
|
355
1309
|
});
|
|
356
1310
|
|
|
357
|
-
// T020: getRecentlyClosed — returns recently closed tabs/windows
|
|
358
|
-
registerHandler(
|
|
359
|
-
const sessions = await
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
1311
|
+
// T020: getRecentlyClosed — returns recently closed tabs/windows + group metadata
|
|
1312
|
+
registerHandler("getRecentlyClosed", async ({ maxResults = 25 } = {}) => {
|
|
1313
|
+
const [sessions, recentGroups, descriptions] = await Promise.all([
|
|
1314
|
+
chrome.sessions.getRecentlyClosed({ maxResults }),
|
|
1315
|
+
loadRecentGroups(),
|
|
1316
|
+
loadEntityDescriptions(),
|
|
1317
|
+
]);
|
|
1318
|
+
|
|
1319
|
+
// Build a URL→group lookup from our enriched storage for individual tabs
|
|
1320
|
+
const urlGroupMap = new Map(); // url → { groupTitle, groupColor, groupId }
|
|
1321
|
+
for (const g of recentGroups) {
|
|
1322
|
+
for (const t of g.tabs) {
|
|
1323
|
+
// Use URL as key — if same URL appears in multiple groups, newest wins
|
|
1324
|
+
if (!urlGroupMap.has(t.url)) {
|
|
1325
|
+
urlGroupMap.set(t.url, {
|
|
1326
|
+
groupTitle: g.groupTitle,
|
|
1327
|
+
groupColor: g.groupColor,
|
|
1328
|
+
groupId: g.id,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
return {
|
|
1335
|
+
sessions: sessions.map((s) => ({
|
|
1336
|
+
sessionId: s.tab?.sessionId || s.window?.sessionId || "",
|
|
1337
|
+
lastModified: s.lastModified,
|
|
1338
|
+
tab: s.tab
|
|
1339
|
+
? {
|
|
1340
|
+
title: s.tab.title || "",
|
|
1341
|
+
url: s.tab.url || "",
|
|
1342
|
+
favIconUrl: s.tab.favIconUrl || "",
|
|
1343
|
+
description: getStoredDescription(
|
|
1344
|
+
descriptions,
|
|
1345
|
+
"recent",
|
|
1346
|
+
s.tab?.sessionId || s.window?.sessionId || ""
|
|
1347
|
+
),
|
|
1348
|
+
// Enriched group metadata from our snapshot
|
|
1349
|
+
groupTitle: urlGroupMap.get(s.tab.url || "")?.groupTitle || "",
|
|
1350
|
+
groupColor: urlGroupMap.get(s.tab.url || "")?.groupColor || "",
|
|
1351
|
+
groupId: urlGroupMap.get(s.tab.url || "")?.groupId || "",
|
|
1352
|
+
}
|
|
1353
|
+
: null,
|
|
1354
|
+
window: s.window
|
|
1355
|
+
? {
|
|
1356
|
+
tabs: (s.window.tabs || []).map((t) => ({
|
|
1357
|
+
title: t.title || "",
|
|
1358
|
+
url: t.url || "",
|
|
1359
|
+
favIconUrl: t.favIconUrl || "",
|
|
1360
|
+
description: normalizeRecordDescription(t),
|
|
1361
|
+
groupTitle: urlGroupMap.get(t.url || "")?.groupTitle || "",
|
|
1362
|
+
groupColor: urlGroupMap.get(t.url || "")?.groupColor || "",
|
|
1363
|
+
groupId: urlGroupMap.get(t.url || "")?.groupId || "",
|
|
1364
|
+
})),
|
|
1365
|
+
}
|
|
1366
|
+
: null,
|
|
1367
|
+
})),
|
|
1368
|
+
// Also return the full group entries for folder rendering
|
|
1369
|
+
recentGroups: recentGroups.map((g) => ({
|
|
1370
|
+
id: g.id,
|
|
1371
|
+
groupTitle: g.groupTitle,
|
|
1372
|
+
groupColor: g.groupColor,
|
|
1373
|
+
description:
|
|
1374
|
+
getStoredDescription(descriptions, "recent", g.id) || normalizeRecordDescription(g),
|
|
1375
|
+
closedAt: g.closedAt,
|
|
1376
|
+
tabs: (g.tabs || []).map((tab) => normalizeArchiveTab(tab, normalizeRecordDescription(g))),
|
|
1377
|
+
})),
|
|
1378
|
+
};
|
|
376
1379
|
});
|
|
377
1380
|
|
|
378
1381
|
// T021: restoreClosed — restore a recently closed tab or window
|
|
379
|
-
registerHandler(
|
|
1382
|
+
registerHandler("restoreClosed", async ({ sessionId }) => {
|
|
380
1383
|
await chrome.sessions.restore(sessionId);
|
|
381
1384
|
return {};
|
|
382
1385
|
});
|
|
383
1386
|
|
|
384
1387
|
// T028: importUrls — import URL groups to archive
|
|
385
|
-
registerHandler(
|
|
386
|
-
if (!groups || !groups.length) throw new Error(
|
|
1388
|
+
registerHandler("importUrls", async ({ groups }) => {
|
|
1389
|
+
if (!groups || !groups.length) throw new Error("No groups to import");
|
|
387
1390
|
|
|
388
1391
|
const archive = await loadArchive();
|
|
389
1392
|
let imported = 0;
|
|
390
1393
|
|
|
391
1394
|
for (const group of groups) {
|
|
1395
|
+
const groupDescription = normalizeRecordDescription(group);
|
|
392
1396
|
const entry = {
|
|
393
1397
|
taskThreadId: generateId(),
|
|
394
|
-
title: group.name ||
|
|
395
|
-
|
|
1398
|
+
title: group.name || "Imported",
|
|
1399
|
+
description: groupDescription,
|
|
1400
|
+
color: "grey",
|
|
396
1401
|
collapsed: false,
|
|
397
|
-
urls: (group.urls || []).map(u => ({
|
|
1402
|
+
urls: (group.urls || []).map((u) => ({
|
|
1403
|
+
url: u.url || "",
|
|
1404
|
+
title: u.title || "",
|
|
1405
|
+
description: normalizeRecordDescription(u),
|
|
1406
|
+
inheritedDescription: normalizeRecordDescription(u) ? "" : groupDescription,
|
|
1407
|
+
})),
|
|
398
1408
|
archivedAt: new Date().toISOString(),
|
|
399
1409
|
restoredAt: null,
|
|
400
|
-
status:
|
|
1410
|
+
status: "pending",
|
|
401
1411
|
};
|
|
402
1412
|
archive.unshift(entry);
|
|
403
1413
|
imported += entry.urls.length;
|
|
404
1414
|
}
|
|
405
1415
|
|
|
406
1416
|
await saveArchive(archive);
|
|
407
|
-
pushEvent(
|
|
1417
|
+
pushEvent("archive.imported", { groupCount: groups.length, urlCount: imported });
|
|
408
1418
|
return { imported };
|
|
409
1419
|
});
|
|
410
1420
|
|
|
411
|
-
// T032: focusTab —
|
|
412
|
-
registerHandler(
|
|
1421
|
+
// T032: focusTab — activate a tab without taking OS window focus
|
|
1422
|
+
registerHandler("focusTab", async ({ tabId, windowId }) => {
|
|
413
1423
|
await chrome.tabs.update(tabId, { active: true });
|
|
414
1424
|
if (windowId) {
|
|
415
|
-
await chrome.windows.update(windowId, { focused:
|
|
1425
|
+
await chrome.windows.update(windowId, { focused: false });
|
|
416
1426
|
}
|
|
417
1427
|
return {};
|
|
418
1428
|
});
|
|
419
1429
|
|
|
420
1430
|
// T033: closeTab — close a single tab
|
|
421
|
-
registerHandler(
|
|
1431
|
+
registerHandler("closeTab", async ({ tabId }) => {
|
|
422
1432
|
await chrome.tabs.remove(tabId);
|
|
423
1433
|
return {};
|
|
424
1434
|
});
|
|
425
1435
|
|
|
426
1436
|
// T034: closeGroup — close all tabs in a group
|
|
427
|
-
registerHandler(
|
|
1437
|
+
registerHandler("closeGroup", async ({ groupId }) => {
|
|
428
1438
|
const tabs = await chrome.tabs.query({ groupId });
|
|
429
1439
|
if (tabs.length) {
|
|
430
|
-
await chrome.tabs.remove(tabs.map(t => t.id));
|
|
1440
|
+
await chrome.tabs.remove(tabs.map((t) => t.id));
|
|
431
1441
|
}
|
|
432
1442
|
return {};
|
|
433
1443
|
});
|
|
434
1444
|
|
|
435
1445
|
// T036: exportAll — export all live and archived session data
|
|
436
|
-
registerHandler(
|
|
437
|
-
const [groups, tabs, archived] = await Promise.all([
|
|
1446
|
+
registerHandler("exportAll", async () => {
|
|
1447
|
+
const [groups, tabs, archived, descriptions] = await Promise.all([
|
|
438
1448
|
chrome.tabGroups.query({}),
|
|
439
1449
|
chrome.tabs.query({}),
|
|
440
1450
|
loadArchive(),
|
|
1451
|
+
loadEntityDescriptions(),
|
|
441
1452
|
]);
|
|
442
1453
|
|
|
443
1454
|
return {
|
|
444
1455
|
exportedAt: new Date().toISOString(),
|
|
445
1456
|
version: self.__COMET_TAB_GROUPS_VERSION__,
|
|
446
1457
|
live: {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1458
|
+
windows: [...new Set(tabs.map((t) => t.windowId).filter((id) => id !== undefined))].map(
|
|
1459
|
+
(windowId) => ({
|
|
1460
|
+
id: windowId,
|
|
1461
|
+
description: getStoredDescription(descriptions, "window", windowId),
|
|
1462
|
+
})
|
|
1463
|
+
),
|
|
1464
|
+
groups: groups.map((g) => ({
|
|
1465
|
+
id: g.id,
|
|
1466
|
+
title: g.title || "",
|
|
1467
|
+
description: getStoredDescription(descriptions, "group", g.id),
|
|
1468
|
+
color: g.color,
|
|
1469
|
+
collapsed: g.collapsed,
|
|
1470
|
+
windowId: g.windowId,
|
|
1471
|
+
tabs: tabs
|
|
1472
|
+
.filter((t) => t.groupId === g.id)
|
|
1473
|
+
.map((t) => ({
|
|
1474
|
+
id: t.id,
|
|
1475
|
+
title: t.title || "",
|
|
1476
|
+
url: t.url || "",
|
|
1477
|
+
description: getStoredDescription(descriptions, "tab", t.id),
|
|
1478
|
+
})),
|
|
453
1479
|
})),
|
|
454
1480
|
},
|
|
455
1481
|
archived,
|
|
@@ -463,12 +1489,12 @@ async function importArchiveFromJsonFile() {
|
|
|
463
1489
|
try {
|
|
464
1490
|
const existing = await loadArchive();
|
|
465
1491
|
if (existing.length > 0) {
|
|
466
|
-
console.log(
|
|
1492
|
+
console.log("[Comet] Archive already has data, skipping file import.");
|
|
467
1493
|
return; // Already has data, skip import
|
|
468
1494
|
}
|
|
469
1495
|
|
|
470
1496
|
// Check if we've already attempted import
|
|
471
|
-
const flags = await chrome.storage.local.get(
|
|
1497
|
+
const flags = await chrome.storage.local.get("archiveImportAttempted");
|
|
472
1498
|
if (flags.archiveImportAttempted) return;
|
|
473
1499
|
await chrome.storage.local.set({ archiveImportAttempted: true });
|
|
474
1500
|
|
|
@@ -480,22 +1506,22 @@ async function importArchiveFromJsonFile() {
|
|
|
480
1506
|
//
|
|
481
1507
|
// We still attempt a fetch as a best-effort approach:
|
|
482
1508
|
const archivePaths = [
|
|
483
|
-
|
|
1509
|
+
"file://" + (globalThis.__COMET_DATA_DIR || "") + "/browser/tab-groups-archive.json",
|
|
484
1510
|
];
|
|
485
1511
|
|
|
486
1512
|
for (const path of archivePaths) {
|
|
487
|
-
if (!path || path ===
|
|
1513
|
+
if (!path || path === "file:///browser/tab-groups-archive.json") continue;
|
|
488
1514
|
try {
|
|
489
1515
|
const resp = await fetch(path);
|
|
490
1516
|
if (resp.ok) {
|
|
491
1517
|
const data = await resp.json();
|
|
492
1518
|
if (Array.isArray(data) && data.length > 0) {
|
|
493
1519
|
// 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));
|
|
1520
|
+
const ids = new Set(existing.map((e) => e.taskThreadId));
|
|
1521
|
+
const newEntries = data.filter((e) => e.taskThreadId && !ids.has(e.taskThreadId));
|
|
496
1522
|
if (newEntries.length > 0) {
|
|
497
1523
|
await saveArchive([...newEntries, ...existing]);
|
|
498
|
-
pushEvent(
|
|
1524
|
+
pushEvent("archive.fileImported", { count: newEntries.length });
|
|
499
1525
|
console.log(`[Comet] Imported ${newEntries.length} entries from archive file.`);
|
|
500
1526
|
}
|
|
501
1527
|
return;
|
|
@@ -506,14 +1532,14 @@ async function importArchiveFromJsonFile() {
|
|
|
506
1532
|
}
|
|
507
1533
|
}
|
|
508
1534
|
|
|
509
|
-
console.log(
|
|
1535
|
+
console.log("[Comet] Archive file import: no file accessible. Use CLI or UI import instead.");
|
|
510
1536
|
} catch (err) {
|
|
511
|
-
console.log(
|
|
1537
|
+
console.log("[Comet] Archive file import skipped:", err.message);
|
|
512
1538
|
}
|
|
513
1539
|
}
|
|
514
1540
|
|
|
515
1541
|
chrome.runtime.onInstalled.addListener(() => {
|
|
516
|
-
chrome.alarms.create(
|
|
1542
|
+
chrome.alarms.create("keepalive", { periodInMinutes: 0.4167 });
|
|
517
1543
|
if (!keepAlivePort) connectKeepAlive();
|
|
518
1544
|
// Attempt one-time archive import
|
|
519
1545
|
importArchiveFromJsonFile();
|
|
@@ -523,7 +1549,7 @@ chrome.runtime.onInstalled.addListener(() => {
|
|
|
523
1549
|
|
|
524
1550
|
// ─── Domain Exclusion Storage ─────────────────────────────────────────────────
|
|
525
1551
|
|
|
526
|
-
const EXCLUSIONS_KEY =
|
|
1552
|
+
const EXCLUSIONS_KEY = "excludedDomains";
|
|
527
1553
|
|
|
528
1554
|
async function loadExclusions() {
|
|
529
1555
|
const result = await chrome.storage.local.get(EXCLUSIONS_KEY);
|
|
@@ -536,42 +1562,42 @@ async function saveExclusions(domains) {
|
|
|
536
1562
|
|
|
537
1563
|
function getDomain(url) {
|
|
538
1564
|
try {
|
|
539
|
-
return new URL(url).hostname.replace(/^www\./,
|
|
1565
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
540
1566
|
} catch {
|
|
541
|
-
return
|
|
1567
|
+
return "";
|
|
542
1568
|
}
|
|
543
1569
|
}
|
|
544
1570
|
|
|
545
1571
|
function isExcluded(url, exclusions) {
|
|
546
1572
|
const domain = getDomain(url);
|
|
547
|
-
return exclusions.some(d => domain === d || domain.endsWith(
|
|
1573
|
+
return exclusions.some((d) => domain === d || domain.endsWith("." + d));
|
|
548
1574
|
}
|
|
549
1575
|
|
|
550
1576
|
// Message handlers for exclusions
|
|
551
|
-
registerHandler(
|
|
1577
|
+
registerHandler("getExclusions", async () => {
|
|
552
1578
|
return await loadExclusions();
|
|
553
1579
|
});
|
|
554
1580
|
|
|
555
|
-
registerHandler(
|
|
556
|
-
if (!domain) throw new Error(
|
|
1581
|
+
registerHandler("addExclusion", async ({ domain }) => {
|
|
1582
|
+
if (!domain) throw new Error("Domain required");
|
|
557
1583
|
const exclusions = await loadExclusions();
|
|
558
|
-
const clean = domain.replace(/^www\./,
|
|
1584
|
+
const clean = domain.replace(/^www\./, "").toLowerCase();
|
|
559
1585
|
if (!exclusions.includes(clean)) {
|
|
560
1586
|
exclusions.push(clean);
|
|
561
1587
|
await saveExclusions(exclusions);
|
|
562
1588
|
// Update context menu to reflect current tab's domain
|
|
563
|
-
pushEvent(
|
|
1589
|
+
pushEvent("exclusion.added", { domain: clean });
|
|
564
1590
|
}
|
|
565
1591
|
return { exclusions };
|
|
566
1592
|
});
|
|
567
1593
|
|
|
568
|
-
registerHandler(
|
|
569
|
-
if (!domain) throw new Error(
|
|
1594
|
+
registerHandler("removeExclusion", async ({ domain }) => {
|
|
1595
|
+
if (!domain) throw new Error("Domain required");
|
|
570
1596
|
const exclusions = await loadExclusions();
|
|
571
|
-
const clean = domain.replace(/^www\./,
|
|
572
|
-
const filtered = exclusions.filter(d => d !== clean);
|
|
1597
|
+
const clean = domain.replace(/^www\./, "").toLowerCase();
|
|
1598
|
+
const filtered = exclusions.filter((d) => d !== clean);
|
|
573
1599
|
await saveExclusions(filtered);
|
|
574
|
-
pushEvent(
|
|
1600
|
+
pushEvent("exclusion.removed", { domain: clean });
|
|
575
1601
|
return { exclusions: filtered };
|
|
576
1602
|
});
|
|
577
1603
|
|
|
@@ -582,120 +1608,128 @@ function createContextMenus() {
|
|
|
582
1608
|
chrome.contextMenus.removeAll(() => {
|
|
583
1609
|
// Parent menu
|
|
584
1610
|
chrome.contextMenus.create({
|
|
585
|
-
id:
|
|
586
|
-
title:
|
|
587
|
-
contexts: [
|
|
1611
|
+
id: "comet-parent",
|
|
1612
|
+
title: "Equabotz Browser Agents",
|
|
1613
|
+
contexts: ["all"],
|
|
588
1614
|
});
|
|
589
1615
|
|
|
590
1616
|
// Open side panel
|
|
591
1617
|
chrome.contextMenus.create({
|
|
592
|
-
id:
|
|
593
|
-
parentId:
|
|
594
|
-
title:
|
|
595
|
-
contexts: [
|
|
1618
|
+
id: "comet-open-sidebar",
|
|
1619
|
+
parentId: "comet-parent",
|
|
1620
|
+
title: "Open Comet Sidebar",
|
|
1621
|
+
contexts: ["all"],
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
// Open full screen session manager
|
|
1625
|
+
chrome.contextMenus.create({
|
|
1626
|
+
id: "comet-open-fullscreen",
|
|
1627
|
+
parentId: "comet-parent",
|
|
1628
|
+
title: "Open Full Screen Session Manager",
|
|
1629
|
+
contexts: ["all"],
|
|
596
1630
|
});
|
|
597
1631
|
|
|
598
1632
|
chrome.contextMenus.create({
|
|
599
|
-
id:
|
|
600
|
-
parentId:
|
|
601
|
-
type:
|
|
602
|
-
contexts: [
|
|
1633
|
+
id: "comet-sep-1",
|
|
1634
|
+
parentId: "comet-parent",
|
|
1635
|
+
type: "separator",
|
|
1636
|
+
contexts: ["all"],
|
|
603
1637
|
});
|
|
604
1638
|
|
|
605
1639
|
// Send this tab
|
|
606
1640
|
chrome.contextMenus.create({
|
|
607
|
-
id:
|
|
608
|
-
parentId:
|
|
609
|
-
title:
|
|
610
|
-
contexts: [
|
|
1641
|
+
id: "comet-send-tab",
|
|
1642
|
+
parentId: "comet-parent",
|
|
1643
|
+
title: "Send this tab to Comet",
|
|
1644
|
+
contexts: ["all"],
|
|
611
1645
|
});
|
|
612
1646
|
|
|
613
1647
|
// Send all tabs in this window
|
|
614
1648
|
chrome.contextMenus.create({
|
|
615
|
-
id:
|
|
616
|
-
parentId:
|
|
617
|
-
title:
|
|
618
|
-
contexts: [
|
|
1649
|
+
id: "comet-send-window",
|
|
1650
|
+
parentId: "comet-parent",
|
|
1651
|
+
title: "Send all tabs in this window to Comet",
|
|
1652
|
+
contexts: ["all"],
|
|
619
1653
|
});
|
|
620
1654
|
|
|
621
1655
|
// Send tabs in this tab group
|
|
622
1656
|
chrome.contextMenus.create({
|
|
623
|
-
id:
|
|
624
|
-
parentId:
|
|
625
|
-
title:
|
|
626
|
-
contexts: [
|
|
1657
|
+
id: "comet-send-group",
|
|
1658
|
+
parentId: "comet-parent",
|
|
1659
|
+
title: "Send all tabs in this tab group to Comet",
|
|
1660
|
+
contexts: ["all"],
|
|
627
1661
|
});
|
|
628
1662
|
|
|
629
1663
|
// Send selected tabs
|
|
630
1664
|
chrome.contextMenus.create({
|
|
631
|
-
id:
|
|
632
|
-
parentId:
|
|
633
|
-
title:
|
|
634
|
-
contexts: [
|
|
1665
|
+
id: "comet-send-selected",
|
|
1666
|
+
parentId: "comet-parent",
|
|
1667
|
+
title: "Send selected tabs to Comet",
|
|
1668
|
+
contexts: ["all"],
|
|
635
1669
|
});
|
|
636
1670
|
|
|
637
1671
|
chrome.contextMenus.create({
|
|
638
|
-
id:
|
|
639
|
-
parentId:
|
|
640
|
-
type:
|
|
641
|
-
contexts: [
|
|
1672
|
+
id: "comet-sep-2",
|
|
1673
|
+
parentId: "comet-parent",
|
|
1674
|
+
type: "separator",
|
|
1675
|
+
contexts: ["all"],
|
|
642
1676
|
});
|
|
643
1677
|
|
|
644
1678
|
// Send all except this tab
|
|
645
1679
|
chrome.contextMenus.create({
|
|
646
|
-
id:
|
|
647
|
-
parentId:
|
|
648
|
-
title:
|
|
649
|
-
contexts: [
|
|
1680
|
+
id: "comet-send-except",
|
|
1681
|
+
parentId: "comet-parent",
|
|
1682
|
+
title: "Send all tabs except this tab to Comet",
|
|
1683
|
+
contexts: ["all"],
|
|
650
1684
|
});
|
|
651
1685
|
|
|
652
1686
|
// Send tabs to the left
|
|
653
1687
|
chrome.contextMenus.create({
|
|
654
|
-
id:
|
|
655
|
-
parentId:
|
|
656
|
-
title:
|
|
657
|
-
contexts: [
|
|
1688
|
+
id: "comet-send-left",
|
|
1689
|
+
parentId: "comet-parent",
|
|
1690
|
+
title: "Send tabs on the left to Comet",
|
|
1691
|
+
contexts: ["all"],
|
|
658
1692
|
});
|
|
659
1693
|
|
|
660
1694
|
// Send tabs to the right
|
|
661
1695
|
chrome.contextMenus.create({
|
|
662
|
-
id:
|
|
663
|
-
parentId:
|
|
664
|
-
title:
|
|
665
|
-
contexts: [
|
|
1696
|
+
id: "comet-send-right",
|
|
1697
|
+
parentId: "comet-parent",
|
|
1698
|
+
title: "Send tabs on the right to Comet",
|
|
1699
|
+
contexts: ["all"],
|
|
666
1700
|
});
|
|
667
1701
|
|
|
668
1702
|
chrome.contextMenus.create({
|
|
669
|
-
id:
|
|
670
|
-
parentId:
|
|
671
|
-
type:
|
|
672
|
-
contexts: [
|
|
1703
|
+
id: "comet-sep-3",
|
|
1704
|
+
parentId: "comet-parent",
|
|
1705
|
+
type: "separator",
|
|
1706
|
+
contexts: ["all"],
|
|
673
1707
|
});
|
|
674
1708
|
|
|
675
1709
|
// Send all from all windows
|
|
676
1710
|
chrome.contextMenus.create({
|
|
677
|
-
id:
|
|
678
|
-
parentId:
|
|
679
|
-
title:
|
|
680
|
-
contexts: [
|
|
1711
|
+
id: "comet-send-all",
|
|
1712
|
+
parentId: "comet-parent",
|
|
1713
|
+
title: "Send all tabs from all windows to Comet",
|
|
1714
|
+
contexts: ["all"],
|
|
681
1715
|
});
|
|
682
1716
|
|
|
683
1717
|
chrome.contextMenus.create({
|
|
684
|
-
id:
|
|
685
|
-
parentId:
|
|
686
|
-
type:
|
|
687
|
-
contexts: [
|
|
1718
|
+
id: "comet-sep-4",
|
|
1719
|
+
parentId: "comet-parent",
|
|
1720
|
+
type: "separator",
|
|
1721
|
+
contexts: ["all"],
|
|
688
1722
|
});
|
|
689
1723
|
|
|
690
1724
|
// Exclude domain (dynamic — updated on tab change)
|
|
691
1725
|
chrome.contextMenus.create({
|
|
692
|
-
id:
|
|
693
|
-
parentId:
|
|
694
|
-
title:
|
|
695
|
-
contexts: [
|
|
1726
|
+
id: "comet-exclude-domain",
|
|
1727
|
+
parentId: "comet-parent",
|
|
1728
|
+
title: "Exclude this domain from Comet",
|
|
1729
|
+
contexts: ["all"],
|
|
696
1730
|
});
|
|
697
1731
|
|
|
698
|
-
console.log(
|
|
1732
|
+
console.log("[Comet] Context menus created.");
|
|
699
1733
|
});
|
|
700
1734
|
}
|
|
701
1735
|
|
|
@@ -706,8 +1740,8 @@ chrome.tabs.onActivated.addListener(async ({ tabId }) => {
|
|
|
706
1740
|
if (tab.url) {
|
|
707
1741
|
const domain = getDomain(tab.url);
|
|
708
1742
|
const exclusions = await loadExclusions();
|
|
709
|
-
const isCurrentlyExcluded = exclusions.some(d => domain === d || domain.endsWith(
|
|
710
|
-
chrome.contextMenus.update(
|
|
1743
|
+
const isCurrentlyExcluded = exclusions.some((d) => domain === d || domain.endsWith("." + d));
|
|
1744
|
+
chrome.contextMenus.update("comet-exclude-domain", {
|
|
711
1745
|
title: isCurrentlyExcluded
|
|
712
1746
|
? `Include ${domain} in Comet (currently excluded)`
|
|
713
1747
|
: `Exclude ${domain} from Comet`,
|
|
@@ -723,8 +1757,7 @@ chrome.tabs.onActivated.addListener(async ({ tabId }) => {
|
|
|
723
1757
|
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
724
1758
|
try {
|
|
725
1759
|
switch (info.menuItemId) {
|
|
726
|
-
|
|
727
|
-
case 'comet-open-sidebar': {
|
|
1760
|
+
case "comet-open-sidebar": {
|
|
728
1761
|
// Open the side panel for this window
|
|
729
1762
|
if (tab?.windowId) {
|
|
730
1763
|
await chrome.sidePanel.open({ windowId: tab.windowId });
|
|
@@ -732,7 +1765,14 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
|
732
1765
|
break;
|
|
733
1766
|
}
|
|
734
1767
|
|
|
735
|
-
case
|
|
1768
|
+
case "comet-open-fullscreen": {
|
|
1769
|
+
// Open the full screen session manager in a new tab
|
|
1770
|
+
const url = chrome.runtime.getURL("session-manager.html");
|
|
1771
|
+
await chrome.tabs.create({ url });
|
|
1772
|
+
break;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
case "comet-send-tab": {
|
|
736
1776
|
// Archive just this one tab
|
|
737
1777
|
if (!tab) break;
|
|
738
1778
|
const exclusions = await loadExclusions();
|
|
@@ -742,36 +1782,38 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
|
742
1782
|
}
|
|
743
1783
|
const entry = {
|
|
744
1784
|
taskThreadId: generateId(),
|
|
745
|
-
title: tab.title ||
|
|
746
|
-
color:
|
|
1785
|
+
title: tab.title || "Single Tab",
|
|
1786
|
+
color: "grey",
|
|
747
1787
|
collapsed: false,
|
|
748
|
-
urls: [{ url: tab.url ||
|
|
1788
|
+
urls: [{ url: tab.url || "", title: tab.title || "" }],
|
|
749
1789
|
archivedAt: new Date().toISOString(),
|
|
750
1790
|
restoredAt: null,
|
|
751
|
-
status:
|
|
1791
|
+
status: "pending",
|
|
752
1792
|
};
|
|
753
1793
|
// If tab is in a group, use the group name
|
|
754
1794
|
if (tab.groupId && tab.groupId !== -1) {
|
|
755
1795
|
try {
|
|
756
1796
|
const group = await chrome.tabGroups.get(tab.groupId);
|
|
757
1797
|
entry.title = group.title || entry.title;
|
|
758
|
-
entry.color = group.color ||
|
|
759
|
-
} catch {
|
|
1798
|
+
entry.color = group.color || "grey";
|
|
1799
|
+
} catch {
|
|
1800
|
+
/* group may not exist */
|
|
1801
|
+
}
|
|
760
1802
|
}
|
|
761
1803
|
const archive = await loadArchive();
|
|
762
1804
|
archive.unshift(entry);
|
|
763
1805
|
await saveArchive(archive);
|
|
764
1806
|
await chrome.tabs.remove(tab.id);
|
|
765
|
-
pushEvent(
|
|
1807
|
+
pushEvent("context.sendTab", { title: entry.title, url: tab.url });
|
|
766
1808
|
break;
|
|
767
1809
|
}
|
|
768
1810
|
|
|
769
|
-
case
|
|
1811
|
+
case "comet-send-window": {
|
|
770
1812
|
// Archive all tabs in the current window
|
|
771
1813
|
if (!tab) break;
|
|
772
1814
|
const windowTabs = await chrome.tabs.query({ windowId: tab.windowId });
|
|
773
1815
|
const exclusions = await loadExclusions();
|
|
774
|
-
const eligible = windowTabs.filter(t => !t.pinned && !isExcluded(t.url, exclusions));
|
|
1816
|
+
const eligible = windowTabs.filter((t) => !t.pinned && !isExcluded(t.url, exclusions));
|
|
775
1817
|
if (!eligible.length) break;
|
|
776
1818
|
|
|
777
1819
|
// Group tabs by their tab group
|
|
@@ -784,83 +1826,85 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
|
784
1826
|
|
|
785
1827
|
const archive = await loadArchive();
|
|
786
1828
|
const groups = await chrome.tabGroups.query({ windowId: tab.windowId });
|
|
787
|
-
const groupMap = new Map(groups.map(g => [g.id, g]));
|
|
1829
|
+
const groupMap = new Map(groups.map((g) => [g.id, g]));
|
|
788
1830
|
|
|
789
1831
|
for (const [groupId, tabs] of grouped) {
|
|
790
1832
|
const group = groupMap.get(groupId);
|
|
791
1833
|
const entry = {
|
|
792
1834
|
taskThreadId: generateId(),
|
|
793
|
-
title: group?.title || (groupId === -1 ?
|
|
794
|
-
color: group?.color ||
|
|
1835
|
+
title: group?.title || (groupId === -1 ? "Ungrouped Tabs" : "Untitled"),
|
|
1836
|
+
color: group?.color || "grey",
|
|
795
1837
|
collapsed: false,
|
|
796
|
-
urls: tabs.map(t => ({ url: t.url ||
|
|
1838
|
+
urls: tabs.map((t) => ({ url: t.url || "", title: t.title || "" })),
|
|
797
1839
|
archivedAt: new Date().toISOString(),
|
|
798
1840
|
restoredAt: null,
|
|
799
|
-
status:
|
|
1841
|
+
status: "pending",
|
|
800
1842
|
};
|
|
801
1843
|
archive.unshift(entry);
|
|
802
1844
|
}
|
|
803
1845
|
|
|
804
1846
|
await saveArchive(archive);
|
|
805
1847
|
// Create a blank tab before closing so window doesn't close
|
|
806
|
-
await chrome.tabs.create({ windowId: tab.windowId, url:
|
|
807
|
-
await chrome.tabs.remove(eligible.map(t => t.id));
|
|
808
|
-
pushEvent(
|
|
1848
|
+
await chrome.tabs.create({ windowId: tab.windowId, url: "chrome://newtab", active: true });
|
|
1849
|
+
await chrome.tabs.remove(eligible.map((t) => t.id));
|
|
1850
|
+
pushEvent("context.sendWindow", { windowId: tab.windowId, tabCount: eligible.length });
|
|
809
1851
|
break;
|
|
810
1852
|
}
|
|
811
1853
|
|
|
812
|
-
case
|
|
1854
|
+
case "comet-send-group": {
|
|
813
1855
|
// Archive this tab's group
|
|
814
1856
|
if (!tab || !tab.groupId || tab.groupId === -1) break;
|
|
815
1857
|
const groupTabs = await chrome.tabs.query({ groupId: tab.groupId });
|
|
816
1858
|
const exclusions = await loadExclusions();
|
|
817
|
-
const eligible = groupTabs.filter(t => !isExcluded(t.url, exclusions));
|
|
1859
|
+
const eligible = groupTabs.filter((t) => !isExcluded(t.url, exclusions));
|
|
818
1860
|
if (!eligible.length) break;
|
|
819
1861
|
|
|
820
|
-
let groupTitle =
|
|
821
|
-
let groupColor =
|
|
1862
|
+
let groupTitle = "Untitled";
|
|
1863
|
+
let groupColor = "grey";
|
|
822
1864
|
try {
|
|
823
1865
|
const group = await chrome.tabGroups.get(tab.groupId);
|
|
824
|
-
groupTitle = group.title ||
|
|
825
|
-
groupColor = group.color ||
|
|
826
|
-
} catch {
|
|
1866
|
+
groupTitle = group.title || "Untitled";
|
|
1867
|
+
groupColor = group.color || "grey";
|
|
1868
|
+
} catch {
|
|
1869
|
+
/* ignore */
|
|
1870
|
+
}
|
|
827
1871
|
|
|
828
1872
|
const entry = {
|
|
829
1873
|
taskThreadId: generateId(),
|
|
830
1874
|
title: groupTitle,
|
|
831
1875
|
color: groupColor,
|
|
832
1876
|
collapsed: false,
|
|
833
|
-
urls: eligible.map(t => ({ url: t.url ||
|
|
1877
|
+
urls: eligible.map((t) => ({ url: t.url || "", title: t.title || "" })),
|
|
834
1878
|
archivedAt: new Date().toISOString(),
|
|
835
1879
|
restoredAt: null,
|
|
836
|
-
status:
|
|
1880
|
+
status: "pending",
|
|
837
1881
|
};
|
|
838
1882
|
|
|
839
1883
|
const archive = await loadArchive();
|
|
840
1884
|
archive.unshift(entry);
|
|
841
1885
|
await saveArchive(archive);
|
|
842
|
-
await chrome.tabs.remove(eligible.map(t => t.id));
|
|
843
|
-
pushEvent(
|
|
1886
|
+
await chrome.tabs.remove(eligible.map((t) => t.id));
|
|
1887
|
+
pushEvent("context.sendGroup", { title: groupTitle, tabCount: eligible.length });
|
|
844
1888
|
break;
|
|
845
1889
|
}
|
|
846
1890
|
|
|
847
|
-
case
|
|
1891
|
+
case "comet-send-selected": {
|
|
848
1892
|
// Archive highlighted (selected) tabs
|
|
849
1893
|
if (!tab) break;
|
|
850
1894
|
const highlighted = await chrome.tabs.query({ windowId: tab.windowId, highlighted: true });
|
|
851
1895
|
const exclusions = await loadExclusions();
|
|
852
|
-
const eligible = highlighted.filter(t => !t.pinned && !isExcluded(t.url, exclusions));
|
|
1896
|
+
const eligible = highlighted.filter((t) => !t.pinned && !isExcluded(t.url, exclusions));
|
|
853
1897
|
if (!eligible.length) break;
|
|
854
1898
|
|
|
855
1899
|
const entry = {
|
|
856
1900
|
taskThreadId: generateId(),
|
|
857
1901
|
title: `Selected Tabs (${eligible.length})`,
|
|
858
|
-
color:
|
|
1902
|
+
color: "grey",
|
|
859
1903
|
collapsed: false,
|
|
860
|
-
urls: eligible.map(t => ({ url: t.url ||
|
|
1904
|
+
urls: eligible.map((t) => ({ url: t.url || "", title: t.title || "" })),
|
|
861
1905
|
archivedAt: new Date().toISOString(),
|
|
862
1906
|
restoredAt: null,
|
|
863
|
-
status:
|
|
1907
|
+
status: "pending",
|
|
864
1908
|
};
|
|
865
1909
|
|
|
866
1910
|
const archive = await loadArchive();
|
|
@@ -869,102 +1913,112 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
|
869
1913
|
// Keep at least one tab open
|
|
870
1914
|
const remaining = await chrome.tabs.query({ windowId: tab.windowId });
|
|
871
1915
|
if (remaining.length <= eligible.length) {
|
|
872
|
-
await chrome.tabs.create({
|
|
1916
|
+
await chrome.tabs.create({
|
|
1917
|
+
windowId: tab.windowId,
|
|
1918
|
+
url: "chrome://newtab",
|
|
1919
|
+
active: true,
|
|
1920
|
+
});
|
|
873
1921
|
}
|
|
874
|
-
await chrome.tabs.remove(eligible.map(t => t.id));
|
|
875
|
-
pushEvent(
|
|
1922
|
+
await chrome.tabs.remove(eligible.map((t) => t.id));
|
|
1923
|
+
pushEvent("context.sendSelected", { tabCount: eligible.length });
|
|
876
1924
|
break;
|
|
877
1925
|
}
|
|
878
1926
|
|
|
879
|
-
case
|
|
1927
|
+
case "comet-send-except": {
|
|
880
1928
|
// Archive all tabs in window except this one
|
|
881
1929
|
if (!tab) break;
|
|
882
1930
|
const allInWindow = await chrome.tabs.query({ windowId: tab.windowId });
|
|
883
1931
|
const exclusions = await loadExclusions();
|
|
884
|
-
const toArchive = allInWindow.filter(
|
|
1932
|
+
const toArchive = allInWindow.filter(
|
|
1933
|
+
(t) => t.id !== tab.id && !t.pinned && !isExcluded(t.url, exclusions)
|
|
1934
|
+
);
|
|
885
1935
|
if (!toArchive.length) break;
|
|
886
1936
|
|
|
887
1937
|
const entry = {
|
|
888
1938
|
taskThreadId: generateId(),
|
|
889
|
-
title: `All except: ${tab.title ||
|
|
890
|
-
color:
|
|
1939
|
+
title: `All except: ${tab.title || "current tab"}`,
|
|
1940
|
+
color: "grey",
|
|
891
1941
|
collapsed: false,
|
|
892
|
-
urls: toArchive.map(t => ({ url: t.url ||
|
|
1942
|
+
urls: toArchive.map((t) => ({ url: t.url || "", title: t.title || "" })),
|
|
893
1943
|
archivedAt: new Date().toISOString(),
|
|
894
1944
|
restoredAt: null,
|
|
895
|
-
status:
|
|
1945
|
+
status: "pending",
|
|
896
1946
|
};
|
|
897
1947
|
|
|
898
1948
|
const archive = await loadArchive();
|
|
899
1949
|
archive.unshift(entry);
|
|
900
1950
|
await saveArchive(archive);
|
|
901
|
-
await chrome.tabs.remove(toArchive.map(t => t.id));
|
|
902
|
-
pushEvent(
|
|
1951
|
+
await chrome.tabs.remove(toArchive.map((t) => t.id));
|
|
1952
|
+
pushEvent("context.sendExcept", { keptTab: tab.title, archivedCount: toArchive.length });
|
|
903
1953
|
break;
|
|
904
1954
|
}
|
|
905
1955
|
|
|
906
|
-
case
|
|
1956
|
+
case "comet-send-left": {
|
|
907
1957
|
// Archive tabs to the left of current tab
|
|
908
1958
|
if (!tab) break;
|
|
909
1959
|
const allInWindow = await chrome.tabs.query({ windowId: tab.windowId });
|
|
910
1960
|
const exclusions = await loadExclusions();
|
|
911
|
-
const toArchive = allInWindow.filter(
|
|
1961
|
+
const toArchive = allInWindow.filter(
|
|
1962
|
+
(t) => t.index < tab.index && !t.pinned && !isExcluded(t.url, exclusions)
|
|
1963
|
+
);
|
|
912
1964
|
if (!toArchive.length) break;
|
|
913
1965
|
|
|
914
1966
|
const entry = {
|
|
915
1967
|
taskThreadId: generateId(),
|
|
916
|
-
title: `Tabs left of: ${tab.title ||
|
|
917
|
-
color:
|
|
1968
|
+
title: `Tabs left of: ${tab.title || "current"}`,
|
|
1969
|
+
color: "grey",
|
|
918
1970
|
collapsed: false,
|
|
919
|
-
urls: toArchive.map(t => ({ url: t.url ||
|
|
1971
|
+
urls: toArchive.map((t) => ({ url: t.url || "", title: t.title || "" })),
|
|
920
1972
|
archivedAt: new Date().toISOString(),
|
|
921
1973
|
restoredAt: null,
|
|
922
|
-
status:
|
|
1974
|
+
status: "pending",
|
|
923
1975
|
};
|
|
924
1976
|
|
|
925
1977
|
const archive = await loadArchive();
|
|
926
1978
|
archive.unshift(entry);
|
|
927
1979
|
await saveArchive(archive);
|
|
928
|
-
await chrome.tabs.remove(toArchive.map(t => t.id));
|
|
929
|
-
pushEvent(
|
|
1980
|
+
await chrome.tabs.remove(toArchive.map((t) => t.id));
|
|
1981
|
+
pushEvent("context.sendLeft", { count: toArchive.length });
|
|
930
1982
|
break;
|
|
931
1983
|
}
|
|
932
1984
|
|
|
933
|
-
case
|
|
1985
|
+
case "comet-send-right": {
|
|
934
1986
|
// Archive tabs to the right of current tab
|
|
935
1987
|
if (!tab) break;
|
|
936
1988
|
const allInWindow = await chrome.tabs.query({ windowId: tab.windowId });
|
|
937
1989
|
const exclusions = await loadExclusions();
|
|
938
|
-
const toArchive = allInWindow.filter(
|
|
1990
|
+
const toArchive = allInWindow.filter(
|
|
1991
|
+
(t) => t.index > tab.index && !t.pinned && !isExcluded(t.url, exclusions)
|
|
1992
|
+
);
|
|
939
1993
|
if (!toArchive.length) break;
|
|
940
1994
|
|
|
941
1995
|
const entry = {
|
|
942
1996
|
taskThreadId: generateId(),
|
|
943
|
-
title: `Tabs right of: ${tab.title ||
|
|
944
|
-
color:
|
|
1997
|
+
title: `Tabs right of: ${tab.title || "current"}`,
|
|
1998
|
+
color: "grey",
|
|
945
1999
|
collapsed: false,
|
|
946
|
-
urls: toArchive.map(t => ({ url: t.url ||
|
|
2000
|
+
urls: toArchive.map((t) => ({ url: t.url || "", title: t.title || "" })),
|
|
947
2001
|
archivedAt: new Date().toISOString(),
|
|
948
2002
|
restoredAt: null,
|
|
949
|
-
status:
|
|
2003
|
+
status: "pending",
|
|
950
2004
|
};
|
|
951
2005
|
|
|
952
2006
|
const archive = await loadArchive();
|
|
953
2007
|
archive.unshift(entry);
|
|
954
2008
|
await saveArchive(archive);
|
|
955
|
-
await chrome.tabs.remove(toArchive.map(t => t.id));
|
|
956
|
-
pushEvent(
|
|
2009
|
+
await chrome.tabs.remove(toArchive.map((t) => t.id));
|
|
2010
|
+
pushEvent("context.sendRight", { count: toArchive.length });
|
|
957
2011
|
break;
|
|
958
2012
|
}
|
|
959
2013
|
|
|
960
|
-
case
|
|
2014
|
+
case "comet-send-all": {
|
|
961
2015
|
// Reuse the existing saveAllTabs handler
|
|
962
|
-
const handler = messageHandlers[
|
|
2016
|
+
const handler = messageHandlers["saveAllTabs"];
|
|
963
2017
|
if (handler) await handler({});
|
|
964
2018
|
break;
|
|
965
2019
|
}
|
|
966
2020
|
|
|
967
|
-
case
|
|
2021
|
+
case "comet-exclude-domain": {
|
|
968
2022
|
// Toggle exclusion for current tab's domain
|
|
969
2023
|
if (!tab?.url) break;
|
|
970
2024
|
const domain = getDomain(tab.url);
|
|
@@ -976,27 +2030,137 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
|
976
2030
|
// Currently excluded — remove exclusion
|
|
977
2031
|
exclusions.splice(idx, 1);
|
|
978
2032
|
await saveExclusions(exclusions);
|
|
979
|
-
chrome.contextMenus.update(
|
|
2033
|
+
chrome.contextMenus.update("comet-exclude-domain", {
|
|
980
2034
|
title: `Exclude ${domain} from Comet`,
|
|
981
2035
|
});
|
|
982
|
-
pushEvent(
|
|
2036
|
+
pushEvent("exclusion.removed", { domain });
|
|
983
2037
|
} else {
|
|
984
2038
|
// Not excluded — add exclusion
|
|
985
2039
|
exclusions.push(domain);
|
|
986
2040
|
await saveExclusions(exclusions);
|
|
987
|
-
chrome.contextMenus.update(
|
|
2041
|
+
chrome.contextMenus.update("comet-exclude-domain", {
|
|
988
2042
|
title: `Include ${domain} in Comet (currently excluded)`,
|
|
989
2043
|
});
|
|
990
|
-
pushEvent(
|
|
2044
|
+
pushEvent("exclusion.added", { domain });
|
|
991
2045
|
}
|
|
992
2046
|
break;
|
|
993
2047
|
}
|
|
994
2048
|
}
|
|
995
2049
|
} catch (err) {
|
|
996
|
-
console.error(
|
|
2050
|
+
console.error("[Comet] Context menu action failed:", err);
|
|
997
2051
|
}
|
|
998
2052
|
});
|
|
999
2053
|
|
|
2054
|
+
// ─── Tab-Group Snapshot (Recently Closed Group Tracking) ──────────────────
|
|
2055
|
+
//
|
|
2056
|
+
// chrome.sessions.getRecentlyClosed() does NOT preserve tab group metadata.
|
|
2057
|
+
// We maintain a live snapshot of tab→group mappings so that when a tab is
|
|
2058
|
+
// removed, we can record which group it belonged to. This enables the
|
|
2059
|
+
// Recently Closed section to render tabs grouped by their original tab group
|
|
2060
|
+
// folders with colors and titles — matching Live Sessions behavior.
|
|
2061
|
+
|
|
2062
|
+
const RECENT_GROUPS_KEY = "recentlyClosedGroups";
|
|
2063
|
+
const MAX_RECENT_GROUPS = 50; // max group entries to retain
|
|
2064
|
+
|
|
2065
|
+
// Live snapshot: tabId → { tabId, groupId, groupTitle, groupColor, url, title, favIconUrl }
|
|
2066
|
+
const tabSnapshot = new Map();
|
|
2067
|
+
// Live group info: groupId → { title, color }
|
|
2068
|
+
const groupSnapshot = new Map();
|
|
2069
|
+
|
|
2070
|
+
// Initialize snapshots from current state
|
|
2071
|
+
async function initTabSnapshot() {
|
|
2072
|
+
try {
|
|
2073
|
+
const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
|
|
2074
|
+
for (const g of groups) {
|
|
2075
|
+
groupSnapshot.set(g.id, { title: g.title || "", color: g.color || "grey" });
|
|
2076
|
+
}
|
|
2077
|
+
for (const t of tabs) {
|
|
2078
|
+
const gInfo = t.groupId !== -1 ? groupSnapshot.get(t.groupId) : null;
|
|
2079
|
+
tabSnapshot.set(t.id, {
|
|
2080
|
+
tabId: t.id,
|
|
2081
|
+
groupId: t.groupId,
|
|
2082
|
+
groupTitle: gInfo?.title || "",
|
|
2083
|
+
groupColor: gInfo?.color || "grey",
|
|
2084
|
+
url: t.url || "",
|
|
2085
|
+
title: t.title || "",
|
|
2086
|
+
favIconUrl: t.favIconUrl || "",
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
} catch (err) {
|
|
2090
|
+
console.error("[Comet] Failed to init tab snapshot:", err);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
initTabSnapshot();
|
|
2094
|
+
|
|
2095
|
+
// Load persisted recently closed groups
|
|
2096
|
+
async function loadRecentGroups() {
|
|
2097
|
+
const result = await chrome.storage.local.get(RECENT_GROUPS_KEY);
|
|
2098
|
+
return result[RECENT_GROUPS_KEY] || [];
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
async function saveRecentGroups(entries) {
|
|
2102
|
+
await chrome.storage.local.set({ [RECENT_GROUPS_KEY]: entries });
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// Record a closed tab's group membership into recently closed groups
|
|
2106
|
+
async function recordClosedTab(tabInfo) {
|
|
2107
|
+
if (!tabInfo || tabInfo.groupId === -1 || !tabInfo.groupTitle) return;
|
|
2108
|
+
|
|
2109
|
+
const [entries, descriptions] = await Promise.all([loadRecentGroups(), loadEntityDescriptions()]);
|
|
2110
|
+
const closedAt = Date.now();
|
|
2111
|
+
const groupDescription = getStoredDescription(descriptions, "group", tabInfo.groupId);
|
|
2112
|
+
const tabDescription = getStoredDescription(descriptions, "tab", tabInfo.tabId);
|
|
2113
|
+
|
|
2114
|
+
// Find or create a group entry for this tab's group
|
|
2115
|
+
// Merge into existing entry if same group was closed within last 5 seconds
|
|
2116
|
+
// (batch closure of a tab group sends individual onRemoved events rapidly)
|
|
2117
|
+
const MERGE_WINDOW_MS = 5000;
|
|
2118
|
+
let groupEntry = entries.find(
|
|
2119
|
+
(e) =>
|
|
2120
|
+
e.groupTitle === tabInfo.groupTitle &&
|
|
2121
|
+
e.groupColor === tabInfo.groupColor &&
|
|
2122
|
+
closedAt - e.closedAt < MERGE_WINDOW_MS
|
|
2123
|
+
);
|
|
2124
|
+
|
|
2125
|
+
if (groupEntry) {
|
|
2126
|
+
if (!normalizeRecordDescription(groupEntry) && groupDescription) {
|
|
2127
|
+
groupEntry.description = groupDescription;
|
|
2128
|
+
}
|
|
2129
|
+
// Add tab to existing group entry
|
|
2130
|
+
groupEntry.tabs.push({
|
|
2131
|
+
url: tabInfo.url,
|
|
2132
|
+
title: tabInfo.title,
|
|
2133
|
+
favIconUrl: tabInfo.favIconUrl,
|
|
2134
|
+
description: tabDescription,
|
|
2135
|
+
inheritedDescription: tabDescription ? "" : groupDescription,
|
|
2136
|
+
});
|
|
2137
|
+
groupEntry.closedAt = closedAt; // update to latest
|
|
2138
|
+
} else {
|
|
2139
|
+
// Create new group entry
|
|
2140
|
+
groupEntry = {
|
|
2141
|
+
id: `rcg-${generateId()}`,
|
|
2142
|
+
groupTitle: tabInfo.groupTitle,
|
|
2143
|
+
groupColor: tabInfo.groupColor,
|
|
2144
|
+
description: groupDescription,
|
|
2145
|
+
closedAt,
|
|
2146
|
+
tabs: [
|
|
2147
|
+
{
|
|
2148
|
+
url: tabInfo.url,
|
|
2149
|
+
title: tabInfo.title,
|
|
2150
|
+
favIconUrl: tabInfo.favIconUrl,
|
|
2151
|
+
description: tabDescription,
|
|
2152
|
+
inheritedDescription: tabDescription ? "" : groupDescription,
|
|
2153
|
+
},
|
|
2154
|
+
],
|
|
2155
|
+
};
|
|
2156
|
+
entries.unshift(groupEntry);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// Trim to max
|
|
2160
|
+
while (entries.length > MAX_RECENT_GROUPS) entries.pop();
|
|
2161
|
+
await saveRecentGroups(entries);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
1000
2164
|
// ─── Event Ring Buffer (Phase 1: Sub-Agent Control) ───────────────────────
|
|
1001
2165
|
|
|
1002
2166
|
const MAX_EVENTS = 100;
|
|
@@ -1017,16 +2181,17 @@ function pushEvent(type, detail) {
|
|
|
1017
2181
|
|
|
1018
2182
|
// CDP-pollable globals
|
|
1019
2183
|
self.__COMET_EVENTS__ = () => JSON.stringify(eventBuffer);
|
|
1020
|
-
self.__COMET_EVENTS_SINCE__ = (ts) =>
|
|
1021
|
-
JSON.stringify(eventBuffer.filter(e => e.ts > ts));
|
|
2184
|
+
self.__COMET_EVENTS_SINCE__ = (ts) => JSON.stringify(eventBuffer.filter((e) => e.ts > ts));
|
|
1022
2185
|
self.__COMET_EVENTS_COUNT__ = () => eventBuffer.length;
|
|
1023
2186
|
|
|
1024
2187
|
// ─── Tab Group Listeners ──────────────────────────────────────────────────
|
|
1025
2188
|
|
|
1026
2189
|
chrome.tabGroups.onCreated.addListener((group) => {
|
|
1027
|
-
|
|
2190
|
+
groupSnapshot.set(group.id, { title: group.title || "", color: group.color || "grey" });
|
|
2191
|
+
scheduleWindowPolicyEnforcement(group.id);
|
|
2192
|
+
pushEvent("tabGroup.created", {
|
|
1028
2193
|
groupId: group.id,
|
|
1029
|
-
title: group.title ||
|
|
2194
|
+
title: group.title || "",
|
|
1030
2195
|
color: group.color,
|
|
1031
2196
|
windowId: group.windowId,
|
|
1032
2197
|
collapsed: group.collapsed,
|
|
@@ -1034,36 +2199,77 @@ chrome.tabGroups.onCreated.addListener((group) => {
|
|
|
1034
2199
|
});
|
|
1035
2200
|
|
|
1036
2201
|
chrome.tabGroups.onRemoved.addListener((group) => {
|
|
1037
|
-
|
|
2202
|
+
groupSnapshot.delete(group.id);
|
|
2203
|
+
pushEvent("tabGroup.removed", {
|
|
1038
2204
|
groupId: group.id,
|
|
1039
|
-
title: group.title ||
|
|
2205
|
+
title: group.title || "",
|
|
1040
2206
|
color: group.color,
|
|
1041
2207
|
});
|
|
1042
2208
|
});
|
|
1043
2209
|
|
|
1044
2210
|
chrome.tabGroups.onUpdated.addListener((group) => {
|
|
1045
|
-
|
|
2211
|
+
groupSnapshot.set(group.id, { title: group.title || "", color: group.color || "grey" });
|
|
2212
|
+
scheduleWindowPolicyEnforcement(group.id);
|
|
2213
|
+
// Update all tabs in this group with new title/color
|
|
2214
|
+
for (const [tabId, info] of tabSnapshot) {
|
|
2215
|
+
if (info.groupId === group.id) {
|
|
2216
|
+
info.groupTitle = group.title || "";
|
|
2217
|
+
info.groupColor = group.color || "grey";
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
pushEvent("tabGroup.updated", {
|
|
1046
2221
|
groupId: group.id,
|
|
1047
|
-
title: group.title ||
|
|
2222
|
+
title: group.title || "",
|
|
1048
2223
|
color: group.color,
|
|
1049
2224
|
collapsed: group.collapsed,
|
|
1050
2225
|
windowId: group.windowId,
|
|
1051
2226
|
});
|
|
1052
2227
|
});
|
|
1053
2228
|
|
|
2229
|
+
chrome.windows.onFocusChanged.addListener((windowId) => {
|
|
2230
|
+
if (windowId === chrome.windows.WINDOW_ID_NONE) return;
|
|
2231
|
+
if (isManagedWindow(windowId)) enforceTopDisplayFullscreenWindow(windowId).catch(() => {});
|
|
2232
|
+
scheduleWindowPolicyEnforcement(null, 500);
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
chrome.windows.onCreated.addListener((window) => {
|
|
2236
|
+
if (!window?.id) return;
|
|
2237
|
+
if (isManagedWindow(window.id)) enforceTopDisplayFullscreenWindow(window.id).catch(() => {});
|
|
2238
|
+
scheduleWindowPolicyEnforcement(null, 500);
|
|
2239
|
+
});
|
|
2240
|
+
|
|
1054
2241
|
// ─── Tab Listeners ────────────────────────────────────────────────────────
|
|
1055
2242
|
|
|
1056
2243
|
chrome.tabs.onCreated.addListener((tab) => {
|
|
1057
|
-
|
|
2244
|
+
const gInfo = tab.groupId !== -1 ? groupSnapshot.get(tab.groupId) : null;
|
|
2245
|
+
tabSnapshot.set(tab.id, {
|
|
2246
|
+
tabId: tab.id,
|
|
2247
|
+
groupId: tab.groupId,
|
|
2248
|
+
groupTitle: gInfo?.title || "",
|
|
2249
|
+
groupColor: gInfo?.color || "grey",
|
|
2250
|
+
url: tab.pendingUrl || tab.url || "",
|
|
2251
|
+
title: tab.title || "",
|
|
2252
|
+
favIconUrl: tab.favIconUrl || "",
|
|
2253
|
+
});
|
|
2254
|
+
pushEvent("tab.created", {
|
|
1058
2255
|
tabId: tab.id,
|
|
1059
2256
|
groupId: tab.groupId,
|
|
1060
2257
|
windowId: tab.windowId,
|
|
1061
|
-
url: tab.pendingUrl || tab.url ||
|
|
2258
|
+
url: tab.pendingUrl || tab.url || "",
|
|
1062
2259
|
});
|
|
1063
2260
|
});
|
|
1064
2261
|
|
|
1065
2262
|
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
|
1066
|
-
|
|
2263
|
+
// Capture group info BEFORE removing from snapshot
|
|
2264
|
+
const info = tabSnapshot.get(tabId);
|
|
2265
|
+
tabSnapshot.delete(tabId);
|
|
2266
|
+
|
|
2267
|
+
// Record to recently closed groups if tab was in a named group
|
|
2268
|
+
if (info && info.groupId !== -1 && info.groupTitle) {
|
|
2269
|
+
recordClosedTab(info);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
pushEvent("tab.removed", {
|
|
1067
2273
|
tabId,
|
|
1068
2274
|
windowId: removeInfo.windowId,
|
|
1069
2275
|
isWindowClosing: removeInfo.isWindowClosing,
|
|
@@ -1071,24 +2277,95 @@ chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
|
|
1071
2277
|
});
|
|
1072
2278
|
|
|
1073
2279
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
1074
|
-
//
|
|
1075
|
-
const
|
|
2280
|
+
// Update snapshot with latest tab info
|
|
2281
|
+
const gInfo = tab.groupId !== -1 ? groupSnapshot.get(tab.groupId) : null;
|
|
2282
|
+
const existing = tabSnapshot.get(tabId) || {};
|
|
2283
|
+
tabSnapshot.set(tabId, {
|
|
2284
|
+
tabId,
|
|
2285
|
+
groupId: tab.groupId,
|
|
2286
|
+
groupTitle: gInfo?.title || existing.groupTitle || "",
|
|
2287
|
+
groupColor: gInfo?.color || existing.groupColor || "grey",
|
|
2288
|
+
url: tab.url || existing.url || "",
|
|
2289
|
+
title: tab.title || existing.title || "",
|
|
2290
|
+
favIconUrl: tab.favIconUrl || existing.favIconUrl || "",
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
// Only fire event on meaningful changes: URL, title, or group assignment
|
|
2294
|
+
const dominated = changeInfo.url || changeInfo.title || "groupId" in changeInfo;
|
|
1076
2295
|
if (!dominated) return;
|
|
1077
2296
|
|
|
1078
|
-
pushEvent(
|
|
2297
|
+
pushEvent("tab.updated", {
|
|
1079
2298
|
tabId,
|
|
1080
2299
|
groupId: tab.groupId,
|
|
1081
2300
|
changes: Object.keys(changeInfo),
|
|
1082
|
-
url: tab.url ||
|
|
1083
|
-
title: tab.title ||
|
|
2301
|
+
url: tab.url || "",
|
|
2302
|
+
title: tab.title || "",
|
|
1084
2303
|
});
|
|
1085
2304
|
});
|
|
1086
2305
|
|
|
1087
2306
|
chrome.tabs.onMoved.addListener((tabId, moveInfo) => {
|
|
1088
|
-
pushEvent(
|
|
2307
|
+
pushEvent("tab.moved", {
|
|
1089
2308
|
tabId,
|
|
1090
2309
|
windowId: moveInfo.windowId,
|
|
1091
2310
|
fromIndex: moveInfo.fromIndex,
|
|
1092
2311
|
toIndex: moveInfo.toIndex,
|
|
1093
2312
|
});
|
|
1094
2313
|
});
|
|
2314
|
+
|
|
2315
|
+
// ─── Extension Icon Badge — Agent Count (T069-T070) ─────────────────────────
|
|
2316
|
+
|
|
2317
|
+
const BADGE_STALE_THRESHOLD = 300000; // 5 minutes
|
|
2318
|
+
|
|
2319
|
+
async function updateAgentBadge() {
|
|
2320
|
+
try {
|
|
2321
|
+
let agents = [];
|
|
2322
|
+
try {
|
|
2323
|
+
const res = await fetch("http://localhost:3456/api/agents");
|
|
2324
|
+
if (res.ok) agents = await res.json();
|
|
2325
|
+
} catch {
|
|
2326
|
+
const stored = await chrome.storage.local.get("agentRegistry");
|
|
2327
|
+
agents = stored.agentRegistry || [];
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
const active = agents.filter((a) => a.status === "working" || a.status === "active");
|
|
2331
|
+
const stale = agents.filter((a) => {
|
|
2332
|
+
const elapsed = Date.now() - (a.lastHeartbeat || a.lastActivity || 0);
|
|
2333
|
+
return elapsed > BADGE_STALE_THRESHOLD && (a.lastHeartbeat || a.lastActivity);
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
if (agents.length === 0) {
|
|
2337
|
+
await chrome.action.setBadgeText({ text: "" });
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
await chrome.action.setBadgeText({ text: String(active.length || agents.length) });
|
|
2342
|
+
if (stale.length > 0) {
|
|
2343
|
+
await chrome.action.setBadgeBackgroundColor({ color: "#e74c3c" });
|
|
2344
|
+
} else if (active.length > 0) {
|
|
2345
|
+
await chrome.action.setBadgeBackgroundColor({ color: "#4caf50" });
|
|
2346
|
+
} else {
|
|
2347
|
+
await chrome.action.setBadgeBackgroundColor({ color: "#4a9eff" });
|
|
2348
|
+
}
|
|
2349
|
+
} catch {
|
|
2350
|
+
// ignore
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// Poll every 10 seconds
|
|
2355
|
+
setInterval(updateAgentBadge, 10000);
|
|
2356
|
+
updateAgentBadge();
|
|
2357
|
+
|
|
2358
|
+
// ─── Auto-Categorize Ungrouped Tabs (T096-T098) ─────────────────────────────
|
|
2359
|
+
|
|
2360
|
+
// getDomainDisplayName is provided by session-logic.js via importScripts (Spec 036)
|
|
2361
|
+
|
|
2362
|
+
registerHandler("autoCategorize", async ({ tabs }) => {
|
|
2363
|
+
if (!tabs || !tabs.length) return { groups: [] };
|
|
2364
|
+
const byDomain = new Map();
|
|
2365
|
+
for (const tab of tabs) {
|
|
2366
|
+
const domain = getDomainDisplayName(tab.url || "");
|
|
2367
|
+
if (!byDomain.has(domain)) byDomain.set(domain, []);
|
|
2368
|
+
byDomain.get(domain).push(tab);
|
|
2369
|
+
}
|
|
2370
|
+
return { groups: [...byDomain.entries()].map(([name, tabs]) => ({ name, tabs })) };
|
|
2371
|
+
});
|