@shawnowen/comet-mcp 2.3.1 → 2.4.2

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