@shawnowen/comet-mcp 2.3.0 → 2.4.1

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