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