tabctl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3372 @@
1
+ const HOST_NAME = "com.erwinkroon.tabctl";
2
+ const manifest = chrome.runtime.getManifest();
3
+ const MANIFEST_VERSION = manifest.version || "0.0.0";
4
+ const MANIFEST_VERSION_NAME = manifest.version_name || MANIFEST_VERSION;
5
+ function parseVersionName(versionName) {
6
+ const match = versionName.match(/-dev\.([0-9a-f]+)(\.dirty)?$/i);
7
+ if (!match) {
8
+ return { gitSha: null, dirty: false };
9
+ }
10
+ return { gitSha: match[1] || null, dirty: Boolean(match[2]) };
11
+ }
12
+ const parsed = parseVersionName(MANIFEST_VERSION_NAME);
13
+ const VERSION_INFO = {
14
+ version: MANIFEST_VERSION_NAME,
15
+ baseVersion: MANIFEST_VERSION,
16
+ gitSha: parsed.gitSha,
17
+ dirty: parsed.dirty,
18
+ };
19
+ const KEEPALIVE_ALARM = "tabctl-keepalive";
20
+ const KEEPALIVE_INTERVAL_MINUTES = 1;
21
+ const DEFAULT_STALE_DAYS = 30;
22
+ const DESCRIPTION_MAX_LENGTH = 250;
23
+ const SELECTOR_VALUE_MAX_LENGTH = 500;
24
+ const SCREENSHOT_TILE_MAX_DIM = 2000;
25
+ const SCREENSHOT_MAX_BYTES = 2000000;
26
+ const SCREENSHOT_QUALITY = 80;
27
+ const SCREENSHOT_SCROLL_DELAY_MS = 150;
28
+ const SCREENSHOT_CAPTURE_DELAY_MS = 350;
29
+ const SCREENSHOT_PROCESS_TIMEOUT_MS = 8000;
30
+ const SETTLE_STABILITY_MS = 500;
31
+ const SETTLE_POLL_INTERVAL_MS = 50;
32
+ const state = {
33
+ port: null,
34
+ archiveWindowId: null,
35
+ archiveWindowIdLoaded: false,
36
+ lastFocused: {},
37
+ lastFocusedLoaded: false,
38
+ };
39
+ function log(...args) {
40
+ console.log("[tabctl]", ...args);
41
+ }
42
+ function sendResponse(id, ok, payload) {
43
+ if (!state.port) {
44
+ return;
45
+ }
46
+ if (ok) {
47
+ const data = typeof payload === "object" && payload !== null
48
+ ? { ...payload, component: "extension", version: VERSION_INFO.version, baseVersion: VERSION_INFO.baseVersion }
49
+ : { payload, component: "extension", version: VERSION_INFO.version, baseVersion: VERSION_INFO.baseVersion };
50
+ state.port.postMessage({ id, ok: true, data });
51
+ return;
52
+ }
53
+ const error = payload instanceof Error
54
+ ? { message: payload.message, stack: payload.stack }
55
+ : payload;
56
+ state.port.postMessage({ id, ok: false, error });
57
+ }
58
+ function connectNative() {
59
+ if (state.port) {
60
+ return;
61
+ }
62
+ try {
63
+ const port = chrome.runtime.connectNative(HOST_NAME);
64
+ state.port = port;
65
+ port.onMessage.addListener(handleNativeMessage);
66
+ port.onDisconnect.addListener(() => {
67
+ const lastError = chrome.runtime.lastError;
68
+ if (lastError) {
69
+ log("Native host disconnected:", lastError.message);
70
+ }
71
+ else {
72
+ log("Native host disconnected");
73
+ }
74
+ state.port = null;
75
+ });
76
+ log("Native host connected");
77
+ }
78
+ catch (error) {
79
+ log("Native host connection failed", error);
80
+ }
81
+ }
82
+ chrome.runtime.onInstalled.addListener(() => {
83
+ connectNative();
84
+ chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: KEEPALIVE_INTERVAL_MINUTES });
85
+ });
86
+ chrome.runtime.onStartup.addListener(() => {
87
+ connectNative();
88
+ chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: KEEPALIVE_INTERVAL_MINUTES });
89
+ });
90
+ chrome.alarms.onAlarm.addListener((alarm) => {
91
+ if (alarm.name === KEEPALIVE_ALARM) {
92
+ connectNative();
93
+ }
94
+ });
95
+ async function ensureLastFocusedLoaded() {
96
+ if (state.lastFocusedLoaded) {
97
+ return;
98
+ }
99
+ const stored = await chrome.storage.local.get("lastFocused");
100
+ state.lastFocused = stored.lastFocused || {};
101
+ state.lastFocusedLoaded = true;
102
+ }
103
+ async function ensureArchiveWindowIdLoaded() {
104
+ if (state.archiveWindowIdLoaded) {
105
+ return;
106
+ }
107
+ const stored = await chrome.storage.local.get("archiveWindowId");
108
+ state.archiveWindowId = stored.archiveWindowId || null;
109
+ state.archiveWindowIdLoaded = true;
110
+ }
111
+ async function setLastFocused(tabId) {
112
+ await ensureLastFocusedLoaded();
113
+ state.lastFocused[String(tabId)] = Date.now();
114
+ await chrome.storage.local.set({ lastFocused: state.lastFocused });
115
+ }
116
+ chrome.tabs.onActivated.addListener((info) => {
117
+ setLastFocused(info.tabId).catch((error) => log("Failed to set last focused", error));
118
+ });
119
+ chrome.tabs.onRemoved.addListener((tabId) => {
120
+ ensureLastFocusedLoaded().then(() => {
121
+ const key = String(tabId);
122
+ if (state.lastFocused[key]) {
123
+ delete state.lastFocused[key];
124
+ chrome.storage.local.set({ lastFocused: state.lastFocused });
125
+ }
126
+ }).catch((error) => log("Failed to prune last focused", error));
127
+ });
128
+ chrome.windows.onFocusChanged.addListener((windowId) => {
129
+ if (windowId === chrome.windows.WINDOW_ID_NONE) {
130
+ return;
131
+ }
132
+ chrome.tabs.query({ windowId, active: true }).then((tabs) => {
133
+ if (tabs[0] && tabs[0].id != null) {
134
+ setLastFocused(tabs[0].id).catch((error) => log("Failed to set last focused", error));
135
+ }
136
+ }).catch((error) => log("Failed to query active tab", error));
137
+ });
138
+ async function handleNativeMessage(message) {
139
+ if (!message || typeof message !== "object") {
140
+ return;
141
+ }
142
+ const { id, action, params } = message;
143
+ if (!id || !action) {
144
+ return;
145
+ }
146
+ try {
147
+ const data = await handleAction(action, params || {}, id);
148
+ sendResponse(id, true, data);
149
+ }
150
+ catch (error) {
151
+ sendResponse(id, false, error);
152
+ }
153
+ }
154
+ function sendProgress(id, payload) {
155
+ if (!state.port) {
156
+ return;
157
+ }
158
+ state.port.postMessage({ id, progress: true, data: payload });
159
+ }
160
+ async function handleAction(action, params, requestId) {
161
+ switch (action) {
162
+ case "ping":
163
+ return {
164
+ now: Date.now(),
165
+ version: VERSION_INFO.version,
166
+ baseVersion: VERSION_INFO.baseVersion,
167
+ gitSha: VERSION_INFO.gitSha,
168
+ dirty: VERSION_INFO.dirty,
169
+ component: "extension",
170
+ };
171
+ case "version":
172
+ return {
173
+ version: VERSION_INFO.version,
174
+ baseVersion: VERSION_INFO.baseVersion,
175
+ gitSha: VERSION_INFO.gitSha,
176
+ dirty: VERSION_INFO.dirty,
177
+ component: "extension",
178
+ };
179
+ case "list":
180
+ return await getTabSnapshot();
181
+ case "analyze":
182
+ return await analyzeTabs(params, requestId);
183
+ case "inspect":
184
+ return await inspectTabs(params, requestId);
185
+ case "focus":
186
+ return await focusTab(params);
187
+ case "refresh":
188
+ return await refreshTabs(params);
189
+ case "open":
190
+ return await openTabs(params);
191
+ case "group-list":
192
+ return await listGroups(params);
193
+ case "group-update":
194
+ return await groupUpdate(params);
195
+ case "group-ungroup":
196
+ return await groupUngroup(params);
197
+ case "group-assign":
198
+ return await groupAssign(params);
199
+ case "move-tab":
200
+ return await moveTab(params);
201
+ case "move-group":
202
+ return await moveGroup(params);
203
+ case "merge-window":
204
+ return await mergeWindow(params);
205
+ case "archive":
206
+ return await archiveTabs(params);
207
+ case "close":
208
+ return await closeTabs(params);
209
+ case "report":
210
+ return await reportTabs(params);
211
+ case "screenshot":
212
+ return await screenshotTabs(params, requestId);
213
+ case "undo":
214
+ return await undoTransaction(params);
215
+ default:
216
+ throw new Error(`Unknown action: ${action}`);
217
+ }
218
+ }
219
+ function isScriptableUrl(url) {
220
+ return typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"));
221
+ }
222
+ function getMostRecentFocusedWindowId(windows) {
223
+ let bestWindowId = null;
224
+ let bestFocusedAt = -Infinity;
225
+ for (const win of windows) {
226
+ for (const tab of win.tabs) {
227
+ const focusedAt = Number(tab.lastFocusedAt);
228
+ if (!Number.isFinite(focusedAt)) {
229
+ continue;
230
+ }
231
+ if (focusedAt > bestFocusedAt) {
232
+ bestFocusedAt = focusedAt;
233
+ bestWindowId = win.windowId;
234
+ }
235
+ }
236
+ }
237
+ return bestWindowId;
238
+ }
239
+ function resolveWindowIdFromParams(snapshot, value) {
240
+ if (typeof value === "number" && Number.isFinite(value)) {
241
+ return value;
242
+ }
243
+ if (typeof value === "string") {
244
+ const normalized = value.trim().toLowerCase();
245
+ if (normalized === "active") {
246
+ const focused = snapshot.windows.find((win) => win.focused);
247
+ return focused ? focused.windowId : null;
248
+ }
249
+ if (normalized === "last-focused") {
250
+ return getMostRecentFocusedWindowId(snapshot.windows);
251
+ }
252
+ const parsed = Number(normalized);
253
+ return Number.isFinite(parsed) ? parsed : null;
254
+ }
255
+ const parsed = Number(value);
256
+ return Number.isFinite(parsed) ? parsed : null;
257
+ }
258
+ function normalizeUrl(rawUrl) {
259
+ if (!rawUrl || typeof rawUrl !== "string") {
260
+ return null;
261
+ }
262
+ let url;
263
+ try {
264
+ url = new URL(rawUrl);
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
270
+ return null;
271
+ }
272
+ url.hash = "";
273
+ const dropKeys = new Set([
274
+ "fbclid",
275
+ "gclid",
276
+ "igshid",
277
+ "mc_cid",
278
+ "mc_eid",
279
+ "ref",
280
+ "ref_src",
281
+ "ref_url",
282
+ "utm_campaign",
283
+ "utm_content",
284
+ "utm_medium",
285
+ "utm_source",
286
+ "utm_term",
287
+ "utm_name",
288
+ "si",
289
+ ]);
290
+ for (const key of Array.from(url.searchParams.keys())) {
291
+ if (key.startsWith("utm_") || dropKeys.has(key)) {
292
+ url.searchParams.delete(key);
293
+ }
294
+ }
295
+ const search = url.searchParams.toString();
296
+ url.search = search ? `?${search}` : "";
297
+ return url.toString();
298
+ }
299
+ async function getTabSnapshot() {
300
+ await ensureLastFocusedLoaded();
301
+ const windows = await chrome.windows.getAll({ populate: true, windowTypes: ["normal"] });
302
+ const groups = await chrome.tabGroups.query({});
303
+ const groupById = new Map(groups.map((group) => [group.id, group]));
304
+ const snapshot = windows.map((win) => {
305
+ const tabs = (win.tabs || []).map((tab) => {
306
+ const group = tab.groupId !== -1 ? groupById.get(tab.groupId) : null;
307
+ return {
308
+ tabId: tab.id,
309
+ windowId: win.id,
310
+ index: tab.index,
311
+ url: tab.url,
312
+ title: tab.title,
313
+ active: tab.active,
314
+ pinned: tab.pinned,
315
+ groupId: tab.groupId,
316
+ groupTitle: group ? group.title : null,
317
+ groupColor: group ? group.color : null,
318
+ groupCollapsed: group ? group.collapsed : null,
319
+ lastFocusedAt: state.lastFocused[String(tab.id)] || null,
320
+ };
321
+ });
322
+ const windowGroups = groups
323
+ .filter((group) => group.windowId === win.id)
324
+ .map((group) => ({
325
+ groupId: group.id,
326
+ title: group.title,
327
+ color: group.color,
328
+ collapsed: group.collapsed,
329
+ }));
330
+ return {
331
+ windowId: win.id,
332
+ focused: win.focused,
333
+ state: win.state,
334
+ tabs,
335
+ groups: windowGroups,
336
+ };
337
+ });
338
+ return {
339
+ generatedAt: Date.now(),
340
+ windows: snapshot,
341
+ };
342
+ }
343
+ function flattenTabs(snapshot) {
344
+ const result = [];
345
+ for (const win of snapshot.windows) {
346
+ for (const tab of win.tabs) {
347
+ result.push(tab);
348
+ }
349
+ }
350
+ return result;
351
+ }
352
+ function isGitHubIssueOrPr(url) {
353
+ if (!url) {
354
+ return false;
355
+ }
356
+ return /^https:\/\/github\.com\/[^/]+\/[^/]+\/(issues|pull)\/\d+/.test(url);
357
+ }
358
+ async function executeWithTimeout(tabId, timeoutMs, func, args = []) {
359
+ const execPromise = chrome.scripting.executeScript({
360
+ target: { tabId },
361
+ func,
362
+ args,
363
+ });
364
+ const timeoutPromise = new Promise((resolve) => {
365
+ const handle = setTimeout(() => {
366
+ clearTimeout(handle);
367
+ resolve(null);
368
+ }, timeoutMs);
369
+ });
370
+ try {
371
+ const result = await Promise.race([execPromise, timeoutPromise]);
372
+ if (!result || !Array.isArray(result)) {
373
+ return null;
374
+ }
375
+ const [{ result: value }] = result;
376
+ return value ?? null;
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ async function detectGitHubState(tabId, timeoutMs) {
383
+ const result = await executeWithTimeout(tabId, timeoutMs, () => {
384
+ const stateEl = document.querySelector(".gh-header-meta .State") ||
385
+ document.querySelector(".State") ||
386
+ document.querySelector(".js-issue-state");
387
+ if (!stateEl) {
388
+ return null;
389
+ }
390
+ const text = (stateEl.textContent || "").trim().toLowerCase();
391
+ if (text.includes("merged")) {
392
+ return "merged";
393
+ }
394
+ if (text.includes("closed")) {
395
+ return "closed";
396
+ }
397
+ if (text.includes("open")) {
398
+ return "open";
399
+ }
400
+ return null;
401
+ });
402
+ return typeof result === "string" ? result : null;
403
+ }
404
+ async function extractPageMeta(tabId, timeoutMs) {
405
+ const result = await executeWithTimeout(tabId, timeoutMs, () => {
406
+ const pickContent = (selector) => {
407
+ const el = document.querySelector(selector);
408
+ if (!el) {
409
+ return "";
410
+ }
411
+ const content = el.getAttribute("content") || el.textContent || "";
412
+ return content.trim();
413
+ };
414
+ const description = pickContent("meta[name='description']") ||
415
+ pickContent("meta[property='og:description']") ||
416
+ pickContent("meta[name='twitter:description']");
417
+ const h1 = document.querySelector("h1");
418
+ const h1Text = h1 ? h1.textContent?.trim() : "";
419
+ return {
420
+ description: description.replace(/\s+/g, " ").trim(),
421
+ h1: (h1Text || "").replace(/\s+/g, " ").trim(),
422
+ };
423
+ });
424
+ if (!result || typeof result !== "object") {
425
+ return null;
426
+ }
427
+ const meta = result;
428
+ return {
429
+ description: (meta.description || "").slice(0, DESCRIPTION_MAX_LENGTH),
430
+ h1: (meta.h1 || "").slice(0, DESCRIPTION_MAX_LENGTH),
431
+ };
432
+ }
433
+ async function extractSelectorSignal(tabId, specs, timeoutMs) {
434
+ if (!specs.length) {
435
+ return null;
436
+ }
437
+ const result = await executeWithTimeout(tabId, timeoutMs, (rawSpecs, maxLen) => {
438
+ const values = {};
439
+ const missing = [];
440
+ const errors = {};
441
+ const hints = {};
442
+ for (const raw of rawSpecs) {
443
+ const selector = typeof raw.selector === "string" ? raw.selector : "";
444
+ if (!selector) {
445
+ continue;
446
+ }
447
+ const name = typeof raw.name === "string" && raw.name ? raw.name : selector;
448
+ const attr = typeof raw.attr === "string" ? raw.attr : "text";
449
+ const all = Boolean(raw.all);
450
+ const text = typeof raw.text === "string" ? raw.text.trim() : "";
451
+ const textMode = typeof raw.textMode === "string" ? raw.textMode.trim().toLowerCase() : "";
452
+ const normalizedTextMode = textMode === "includes" ? "contains" : textMode;
453
+ const textModes = new Set(["", "contains", "exact", "starts-with"]);
454
+ if (!textModes.has(normalizedTextMode)) {
455
+ errors[name] = `Unsupported textMode: ${textMode || "unknown"}`;
456
+ hints[name] = "Use textMode: contains | exact | starts-with";
457
+ continue;
458
+ }
459
+ try {
460
+ const elements = Array.from(document.querySelectorAll(selector));
461
+ if (!elements.length) {
462
+ missing.push(name);
463
+ if (selector.includes(":contains(")) {
464
+ hints[name] = "CSS :contains() is not supported; use selector text filters or a different selector.";
465
+ }
466
+ else {
467
+ hints[name] = "No matches found; capture a screenshot for context or adjust the selector.";
468
+ }
469
+ continue;
470
+ }
471
+ const matchesText = (el) => {
472
+ if (!text) {
473
+ return true;
474
+ }
475
+ const content = (el.textContent || "").replace(/\s+/g, " ").trim();
476
+ if (normalizedTextMode === "exact") {
477
+ return content === text;
478
+ }
479
+ if (normalizedTextMode === "starts-with") {
480
+ return content.startsWith(text);
481
+ }
482
+ return content.includes(text);
483
+ };
484
+ const filtered = text ? elements.filter(matchesText) : elements;
485
+ if (!filtered.length) {
486
+ missing.push(name);
487
+ hints[name] = "Selector matched elements, but none matched the text filter; capture a screenshot for context or adjust text/textMode.";
488
+ continue;
489
+ }
490
+ const getValue = (el) => {
491
+ let value = "";
492
+ if (attr === "text") {
493
+ value = el.textContent || "";
494
+ }
495
+ else if (attr === "href-url" || attr === "src-url") {
496
+ const rawValue = el.getAttribute(attr === "href-url" ? "href" : "src") || "";
497
+ if (!rawValue) {
498
+ value = "";
499
+ }
500
+ else {
501
+ try {
502
+ const resolved = new URL(rawValue, document.baseURI);
503
+ if (resolved.protocol === "http:" || resolved.protocol === "https:") {
504
+ value = resolved.toString();
505
+ }
506
+ else {
507
+ value = "";
508
+ }
509
+ }
510
+ catch {
511
+ value = "";
512
+ }
513
+ }
514
+ }
515
+ else {
516
+ value = el.getAttribute(attr) || "";
517
+ }
518
+ return value.replace(/\s+/g, " ").trim().slice(0, maxLen);
519
+ };
520
+ if (all) {
521
+ values[name] = filtered.map(getValue).filter((val) => val.length > 0);
522
+ }
523
+ else {
524
+ values[name] = getValue(filtered[0]);
525
+ }
526
+ }
527
+ catch (err) {
528
+ const message = err instanceof Error ? err.message : "selector_error";
529
+ errors[name] = message;
530
+ if (selector.includes(":contains(")) {
531
+ hints[name] = "CSS :contains() is not supported; use selector text filters or a different selector.";
532
+ }
533
+ else {
534
+ hints[name] = "Selector failed to evaluate; capture a screenshot for context or adjust the selector.";
535
+ }
536
+ }
537
+ }
538
+ return { values, missing, errors, hints };
539
+ }, [specs, SELECTOR_VALUE_MAX_LENGTH]);
540
+ if (!result || typeof result !== "object") {
541
+ return null;
542
+ }
543
+ return result;
544
+ }
545
+ function delay(ms) {
546
+ return new Promise((resolve) => setTimeout(resolve, ms));
547
+ }
548
+ async function waitForTabReady(tabId, params, fallbackTimeoutMs) {
549
+ const waitFor = typeof params.waitFor === "string" ? params.waitFor.trim().toLowerCase() : "";
550
+ if (!waitFor || waitFor === "none") {
551
+ return;
552
+ }
553
+ const timeoutRaw = Number(params.waitTimeoutMs);
554
+ const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? Math.floor(timeoutRaw) : fallbackTimeoutMs;
555
+ // settle mode handles its own URL checking, so skip the early return
556
+ if (waitFor === "settle") {
557
+ await waitForSettle(tabId, timeoutMs);
558
+ return;
559
+ }
560
+ try {
561
+ const tab = await chrome.tabs.get(tabId);
562
+ if (!isScriptableUrl(tab.url)) {
563
+ return;
564
+ }
565
+ }
566
+ catch {
567
+ return;
568
+ }
569
+ if (waitFor === "load") {
570
+ await waitForTabLoad(tabId, timeoutMs);
571
+ return;
572
+ }
573
+ if (waitFor === "dom") {
574
+ await waitForDomReady(tabId, timeoutMs);
575
+ }
576
+ }
577
+ function waitForTabLoad(tabId, timeoutMs) {
578
+ return new Promise((resolve) => {
579
+ let settled = false;
580
+ const done = () => {
581
+ if (settled) {
582
+ return;
583
+ }
584
+ settled = true;
585
+ chrome.tabs.onUpdated.removeListener(onUpdated);
586
+ resolve();
587
+ };
588
+ const onUpdated = (updatedTabId, info) => {
589
+ if (updatedTabId === tabId && info.status === "complete") {
590
+ done();
591
+ }
592
+ };
593
+ chrome.tabs.onUpdated.addListener(onUpdated);
594
+ chrome.tabs.get(tabId).then((tab) => {
595
+ if (tab.status === "complete") {
596
+ done();
597
+ }
598
+ }).catch(() => {
599
+ done();
600
+ });
601
+ setTimeout(done, timeoutMs);
602
+ });
603
+ }
604
+ async function waitForDomReady(tabId, timeoutMs) {
605
+ const result = await executeWithTimeout(tabId, timeoutMs, () => {
606
+ if (document.readyState === "interactive" || document.readyState === "complete") {
607
+ return true;
608
+ }
609
+ return new Promise((resolve) => {
610
+ const onReady = () => {
611
+ document.removeEventListener("DOMContentLoaded", onReady);
612
+ resolve(true);
613
+ };
614
+ document.addEventListener("DOMContentLoaded", onReady, { once: true });
615
+ setTimeout(() => {
616
+ document.removeEventListener("DOMContentLoaded", onReady);
617
+ resolve(false);
618
+ }, Math.max(0, timeoutMs - 50));
619
+ });
620
+ });
621
+ if (result === null) {
622
+ await delay(Math.min(200, Math.max(50, Math.floor(timeoutMs / 10))));
623
+ }
624
+ }
625
+ async function waitForSettle(tabId, timeoutMs) {
626
+ const startTime = Date.now();
627
+ let lastUrl = "";
628
+ let lastTitle = "";
629
+ let stableStart = Date.now();
630
+ while (Date.now() - startTime < timeoutMs) {
631
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
632
+ if (!tab)
633
+ return;
634
+ const currentUrl = tab.url || "";
635
+ const currentTitle = tab.title || "";
636
+ // Reset stability timer if URL or title changed
637
+ if (currentUrl !== lastUrl || currentTitle !== lastTitle) {
638
+ lastUrl = currentUrl;
639
+ lastTitle = currentTitle;
640
+ stableStart = Date.now();
641
+ }
642
+ else if (isScriptableUrl(currentUrl) &&
643
+ tab.status === "complete" &&
644
+ Date.now() - stableStart >= SETTLE_STABILITY_MS) {
645
+ // Page is loaded, URL is valid, and stable for long enough
646
+ return;
647
+ }
648
+ await delay(SETTLE_POLL_INTERVAL_MS);
649
+ }
650
+ // Timeout reached, continue anyway
651
+ }
652
+ function estimateDataUrlBytes(dataUrl) {
653
+ const commaIndex = dataUrl.indexOf(",");
654
+ if (commaIndex < 0) {
655
+ return dataUrl.length;
656
+ }
657
+ const base64 = dataUrl.slice(commaIndex + 1);
658
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
659
+ return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
660
+ }
661
+ function arrayBufferToBase64(buffer) {
662
+ const bytes = new Uint8Array(buffer);
663
+ let binary = "";
664
+ for (let i = 0; i < bytes.length; i += 1) {
665
+ binary += String.fromCharCode(bytes[i]);
666
+ }
667
+ return btoa(binary);
668
+ }
669
+ async function resizeDataUrl(dataUrl, format, quality, scale) {
670
+ if (!globalThis.OffscreenCanvas || !globalThis.createImageBitmap) {
671
+ return null;
672
+ }
673
+ const response = await fetch(dataUrl);
674
+ const blob = await response.blob();
675
+ const bitmap = await createImageBitmap(blob);
676
+ const width = Math.max(1, Math.floor(bitmap.width * scale));
677
+ const height = Math.max(1, Math.floor(bitmap.height * scale));
678
+ const canvas = new OffscreenCanvas(width, height);
679
+ const ctx = canvas.getContext("2d");
680
+ if (!ctx) {
681
+ return null;
682
+ }
683
+ ctx.drawImage(bitmap, 0, 0, width, height);
684
+ const type = format === "jpeg" ? "image/jpeg" : "image/png";
685
+ const blobOut = await canvas.convertToBlob({ type, quality: format === "jpeg" ? quality / 100 : undefined });
686
+ const buffer = await blobOut.arrayBuffer();
687
+ const base64 = arrayBufferToBase64(buffer);
688
+ return {
689
+ dataUrl: `data:${type};base64,${base64}`,
690
+ bytes: buffer.byteLength,
691
+ };
692
+ }
693
+ async function resizeDataUrlToMaxDim(dataUrl, format, quality, maxDim) {
694
+ if (!globalThis.OffscreenCanvas || !globalThis.createImageBitmap) {
695
+ return null;
696
+ }
697
+ const response = await fetch(dataUrl);
698
+ const blob = await response.blob();
699
+ const bitmap = await createImageBitmap(blob);
700
+ const maxSize = Math.max(bitmap.width, bitmap.height);
701
+ if (!Number.isFinite(maxSize) || maxSize <= maxDim) {
702
+ return null;
703
+ }
704
+ const scale = maxDim / maxSize;
705
+ return resizeDataUrl(dataUrl, format, quality, scale);
706
+ }
707
+ async function cropDataUrl(dataUrl, format, quality, width, height, devicePixelRatio) {
708
+ if (!globalThis.OffscreenCanvas || !globalThis.createImageBitmap) {
709
+ return dataUrl;
710
+ }
711
+ const response = await fetch(dataUrl);
712
+ const blob = await response.blob();
713
+ const bitmap = await createImageBitmap(blob);
714
+ const targetWidth = Math.min(bitmap.width, Math.max(1, Math.round(width * devicePixelRatio)));
715
+ const targetHeight = Math.min(bitmap.height, Math.max(1, Math.round(height * devicePixelRatio)));
716
+ if (targetWidth === bitmap.width && targetHeight === bitmap.height) {
717
+ return dataUrl;
718
+ }
719
+ const canvas = new OffscreenCanvas(targetWidth, targetHeight);
720
+ const ctx = canvas.getContext("2d");
721
+ if (!ctx) {
722
+ return dataUrl;
723
+ }
724
+ ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight, 0, 0, targetWidth, targetHeight);
725
+ const type = format === "jpeg" ? "image/jpeg" : "image/png";
726
+ const blobOut = await canvas.convertToBlob({ type, quality: format === "jpeg" ? quality / 100 : undefined });
727
+ const buffer = await blobOut.arrayBuffer();
728
+ const base64 = arrayBufferToBase64(buffer);
729
+ return `data:${type};base64,${base64}`;
730
+ }
731
+ async function ensureMaxBytes(dataUrl, format, quality, maxBytes) {
732
+ let currentUrl = dataUrl;
733
+ let currentBytes = estimateDataUrlBytes(currentUrl);
734
+ if (currentBytes <= maxBytes) {
735
+ return { dataUrl: currentUrl, bytes: currentBytes, scaled: false, oversized: false };
736
+ }
737
+ let scaled = false;
738
+ let attempts = 0;
739
+ while (currentBytes > maxBytes && attempts < 3) {
740
+ const scale = Math.max(0.2, Math.sqrt(maxBytes / currentBytes) * 0.95);
741
+ if (scale >= 0.99) {
742
+ break;
743
+ }
744
+ const resized = await resizeDataUrl(currentUrl, format, quality, scale);
745
+ if (!resized) {
746
+ break;
747
+ }
748
+ currentUrl = resized.dataUrl;
749
+ currentBytes = resized.bytes;
750
+ scaled = true;
751
+ attempts += 1;
752
+ }
753
+ return {
754
+ dataUrl: currentUrl,
755
+ bytes: currentBytes,
756
+ scaled,
757
+ oversized: currentBytes > maxBytes,
758
+ };
759
+ }
760
+ async function constrainDataUrl(dataUrl, format, quality, maxDim, maxBytes) {
761
+ let currentUrl = dataUrl;
762
+ let currentBytes = estimateDataUrlBytes(currentUrl);
763
+ let scaled = false;
764
+ if (Number.isFinite(maxDim) && maxDim > 0) {
765
+ const resized = await resizeDataUrlToMaxDim(currentUrl, format, quality, maxDim);
766
+ if (resized) {
767
+ currentUrl = resized.dataUrl;
768
+ currentBytes = resized.bytes;
769
+ scaled = true;
770
+ }
771
+ }
772
+ if (currentBytes > maxBytes) {
773
+ const resized = await ensureMaxBytes(currentUrl, format, quality, maxBytes);
774
+ return {
775
+ dataUrl: resized.dataUrl,
776
+ bytes: resized.bytes,
777
+ scaled: scaled || resized.scaled,
778
+ oversized: resized.oversized,
779
+ };
780
+ }
781
+ return { dataUrl: currentUrl, bytes: currentBytes, scaled, oversized: false };
782
+ }
783
+ async function captureVisible(windowId, format, quality) {
784
+ const options = { format };
785
+ if (format === "jpeg") {
786
+ options.quality = quality;
787
+ }
788
+ return chrome.tabs.captureVisibleTab(windowId, options);
789
+ }
790
+ async function getPageMetrics(tabId, timeoutMs) {
791
+ const result = await executeWithTimeout(tabId, timeoutMs, () => {
792
+ const doc = document.documentElement;
793
+ const body = document.body;
794
+ const pageWidth = Math.max(doc.scrollWidth, doc.clientWidth, body ? body.scrollWidth : 0, body ? body.clientWidth : 0);
795
+ const pageHeight = Math.max(doc.scrollHeight, doc.clientHeight, body ? body.scrollHeight : 0, body ? body.clientHeight : 0);
796
+ return {
797
+ pageWidth,
798
+ pageHeight,
799
+ viewportWidth: window.innerWidth,
800
+ viewportHeight: window.innerHeight,
801
+ devicePixelRatio: window.devicePixelRatio || 1,
802
+ scrollX: window.scrollX || window.pageXOffset || 0,
803
+ scrollY: window.scrollY || window.pageYOffset || 0,
804
+ };
805
+ });
806
+ if (!result || typeof result !== "object") {
807
+ return null;
808
+ }
809
+ return result;
810
+ }
811
+ async function scrollToPosition(tabId, timeoutMs, x, y) {
812
+ const result = await executeWithTimeout(tabId, timeoutMs, (scrollX, scrollY) => {
813
+ window.scrollTo(scrollX, scrollY);
814
+ return {
815
+ scrollX: window.scrollX || window.pageXOffset || 0,
816
+ scrollY: window.scrollY || window.pageYOffset || 0,
817
+ };
818
+ }, [x, y]);
819
+ if (!result || typeof result !== "object") {
820
+ return null;
821
+ }
822
+ return result;
823
+ }
824
+ async function captureTabTiles(tab, options) {
825
+ const tabId = tab.tabId;
826
+ const windowId = tab.windowId;
827
+ if (!Number.isFinite(tabId) || !Number.isFinite(windowId)) {
828
+ throw new Error("Missing tab/window id");
829
+ }
830
+ const metrics = await getPageMetrics(tabId, SCREENSHOT_PROCESS_TIMEOUT_MS);
831
+ if (!metrics) {
832
+ throw new Error("Failed to read page metrics");
833
+ }
834
+ const pageWidth = Number(metrics.pageWidth);
835
+ const pageHeight = Number(metrics.pageHeight);
836
+ const viewportWidth = Number(metrics.viewportWidth);
837
+ const viewportHeight = Number(metrics.viewportHeight);
838
+ const devicePixelRatio = Number(metrics.devicePixelRatio) || 1;
839
+ const startScrollX = Number(metrics.scrollX) || 0;
840
+ const startScrollY = Number(metrics.scrollY) || 0;
841
+ if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) {
842
+ throw new Error("Viewport size unavailable");
843
+ }
844
+ const tiles = [];
845
+ let tileIndex = 0;
846
+ const captureTile = async (x, y, width, height, total) => {
847
+ if (tileIndex > 0) {
848
+ await delay(SCREENSHOT_CAPTURE_DELAY_MS);
849
+ }
850
+ const rawDataUrl = await captureVisible(windowId, options.format, options.quality);
851
+ if (!rawDataUrl) {
852
+ throw new Error("Capture failed");
853
+ }
854
+ const croppedUrl = await cropDataUrl(rawDataUrl, options.format, options.quality, width, height, devicePixelRatio);
855
+ const sizeResult = await constrainDataUrl(croppedUrl, options.format, options.quality, options.tileMaxDim, options.maxBytes);
856
+ tiles.push({
857
+ index: tileIndex,
858
+ total,
859
+ x,
860
+ y,
861
+ width,
862
+ height,
863
+ scale: devicePixelRatio,
864
+ format: options.format,
865
+ bytes: sizeResult.bytes,
866
+ scaled: sizeResult.scaled,
867
+ oversized: sizeResult.oversized,
868
+ dataUrl: sizeResult.dataUrl,
869
+ });
870
+ tileIndex += 1;
871
+ };
872
+ if (options.mode === "viewport") {
873
+ await captureTile(startScrollX, startScrollY, viewportWidth, viewportHeight, 1);
874
+ return tiles;
875
+ }
876
+ const stepX = viewportWidth;
877
+ const stepY = Math.min(viewportHeight, options.tileMaxDim);
878
+ const maxX = viewportWidth;
879
+ const maxY = Math.max(viewportHeight, pageHeight);
880
+ const tileCount = Math.ceil(maxX / stepX) * Math.ceil(maxY / stepY);
881
+ for (let y = 0; y < maxY; y += stepY) {
882
+ for (let x = 0; x < maxX; x += stepX) {
883
+ await scrollToPosition(tabId, SCREENSHOT_PROCESS_TIMEOUT_MS, x, y);
884
+ await delay(SCREENSHOT_SCROLL_DELAY_MS);
885
+ const width = Math.min(stepX, maxX - x);
886
+ const height = Math.min(stepY, maxY - y);
887
+ try {
888
+ await captureTile(x, y, width, height, tileCount);
889
+ }
890
+ catch (err) {
891
+ const message = err instanceof Error ? err.message : "capture_failed";
892
+ if (message.includes("MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND")) {
893
+ await delay(1000);
894
+ await captureTile(x, y, width, height, tileCount);
895
+ }
896
+ else {
897
+ throw err;
898
+ }
899
+ }
900
+ }
901
+ }
902
+ await scrollToPosition(tabId, SCREENSHOT_PROCESS_TIMEOUT_MS, startScrollX, startScrollY);
903
+ return tiles;
904
+ }
905
+ async function screenshotTabs(params, requestId) {
906
+ const snapshot = await getTabSnapshot();
907
+ const selection = selectTabsByScope(snapshot, params);
908
+ if (selection.error) {
909
+ throw selection.error;
910
+ }
911
+ const mode = params.mode === "full" ? "full" : "viewport";
912
+ const format = params.format === "jpeg" ? "jpeg" : "png";
913
+ const qualityRaw = Number(params.quality);
914
+ const quality = Number.isFinite(qualityRaw) ? Math.min(100, Math.max(0, Math.floor(qualityRaw))) : SCREENSHOT_QUALITY;
915
+ const tileMaxDimRaw = Number(params.tileMaxDim);
916
+ const tileMaxDim = Number.isFinite(tileMaxDimRaw) && tileMaxDimRaw > 0 ? Math.floor(tileMaxDimRaw) : SCREENSHOT_TILE_MAX_DIM;
917
+ const adjustedTileMaxDim = tileMaxDim < 50 ? 50 : tileMaxDim;
918
+ const maxBytesRaw = Number(params.maxBytes);
919
+ const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? Math.floor(maxBytesRaw) : SCREENSHOT_MAX_BYTES;
920
+ const adjustedMaxBytes = maxBytes < 50000 ? 50000 : maxBytes;
921
+ const progressEnabled = params.progress === true;
922
+ const tabs = selection.tabs;
923
+ const entries = [];
924
+ let totalTiles = 0;
925
+ const startedAt = Date.now();
926
+ for (let index = 0; index < tabs.length; index += 1) {
927
+ const tab = tabs[index];
928
+ const tabId = tab.tabId;
929
+ const url = tab.url;
930
+ if (!isScriptableUrl(url)) {
931
+ entries.push({
932
+ tabId,
933
+ windowId: tab.windowId,
934
+ groupId: tab.groupId,
935
+ url: tab.url,
936
+ title: tab.title,
937
+ error: { message: "unsupported_url" },
938
+ tiles: [],
939
+ });
940
+ if (progressEnabled) {
941
+ sendProgress(requestId, { phase: "screenshot", processed: index + 1, total: tabs.length, tabId });
942
+ }
943
+ continue;
944
+ }
945
+ let tiles = [];
946
+ let error = null;
947
+ try {
948
+ const windowId = tab.windowId;
949
+ const activeTabs = await chrome.tabs.query({ windowId, active: true });
950
+ const activeTabId = activeTabs[0]?.id ?? null;
951
+ if (activeTabId && activeTabId !== tabId) {
952
+ await chrome.tabs.update(tabId, { active: true });
953
+ await delay(SCREENSHOT_SCROLL_DELAY_MS);
954
+ }
955
+ try {
956
+ await waitForTabReady(tabId, params, SCREENSHOT_PROCESS_TIMEOUT_MS);
957
+ tiles = await captureTabTiles(tab, { mode, format, quality, tileMaxDim: adjustedTileMaxDim, maxBytes: adjustedMaxBytes });
958
+ }
959
+ finally {
960
+ if (activeTabId && activeTabId !== tabId) {
961
+ await chrome.tabs.update(activeTabId, { active: true });
962
+ }
963
+ }
964
+ }
965
+ catch (err) {
966
+ const message = err instanceof Error ? err.message : "capture_failed";
967
+ error = { message };
968
+ }
969
+ totalTiles += tiles.length;
970
+ entries.push({
971
+ tabId: tab.tabId,
972
+ windowId: tab.windowId,
973
+ groupId: tab.groupId,
974
+ url: tab.url,
975
+ title: tab.title,
976
+ tiles,
977
+ ...(error ? { error } : {}),
978
+ });
979
+ if (progressEnabled) {
980
+ sendProgress(requestId, { phase: "screenshot", processed: index + 1, total: tabs.length, tabId });
981
+ }
982
+ }
983
+ return {
984
+ generatedAt: Date.now(),
985
+ totals: { tabs: tabs.length, tiles: totalTiles },
986
+ meta: {
987
+ durationMs: Date.now() - startedAt,
988
+ mode,
989
+ format,
990
+ quality: format === "jpeg" ? quality : null,
991
+ tileMaxDim: adjustedTileMaxDim,
992
+ maxBytes: adjustedMaxBytes,
993
+ },
994
+ entries,
995
+ };
996
+ }
997
+ async function analyzeTabs(params, requestId) {
998
+ const staleDays = Number.isFinite(params.staleDays) ? params.staleDays : DEFAULT_STALE_DAYS;
999
+ const checkGitHub = params.checkGitHub === true;
1000
+ const githubConcurrencyRaw = Number(params.githubConcurrency);
1001
+ const githubConcurrency = Number.isFinite(githubConcurrencyRaw) && githubConcurrencyRaw > 0
1002
+ ? Math.min(10, Math.floor(githubConcurrencyRaw))
1003
+ : 4;
1004
+ const githubTimeoutRaw = Number(params.githubTimeoutMs);
1005
+ const githubTimeoutMs = Number.isFinite(githubTimeoutRaw) && githubTimeoutRaw > 0
1006
+ ? Math.floor(githubTimeoutRaw)
1007
+ : 4000;
1008
+ const progressEnabled = params.progress === true;
1009
+ const snapshot = await getTabSnapshot();
1010
+ const selection = selectTabsByScope(snapshot, params);
1011
+ if (selection.error) {
1012
+ throw selection.error;
1013
+ }
1014
+ const selectedTabs = selection.tabs;
1015
+ const scopeTabs = selectedTabs;
1016
+ const now = Date.now();
1017
+ const startedAt = Date.now();
1018
+ let githubChecked = 0;
1019
+ let githubTotal = 0;
1020
+ let githubMatched = 0;
1021
+ const normalizedMap = new Map();
1022
+ const duplicates = new Map();
1023
+ for (const tab of scopeTabs) {
1024
+ const normalized = normalizeUrl(tab.url);
1025
+ if (!normalized) {
1026
+ continue;
1027
+ }
1028
+ if (normalizedMap.has(normalized)) {
1029
+ const existing = normalizedMap.get(normalized);
1030
+ duplicates.set(tab.tabId, existing.tabId);
1031
+ }
1032
+ else {
1033
+ normalizedMap.set(normalized, tab);
1034
+ }
1035
+ }
1036
+ const candidateMap = new Map();
1037
+ const addReason = (tab, reason) => {
1038
+ const tabId = tab.tabId;
1039
+ const entry = candidateMap.get(tabId) || { tab, reasons: [] };
1040
+ entry.reasons.push(reason);
1041
+ candidateMap.set(tabId, entry);
1042
+ };
1043
+ for (const tab of selectedTabs) {
1044
+ if (duplicates.has(tab.tabId)) {
1045
+ addReason(tab, {
1046
+ type: "duplicate",
1047
+ detail: `Matches tab ${duplicates.get(tab.tabId)}`,
1048
+ });
1049
+ }
1050
+ if (tab.lastFocusedAt) {
1051
+ const ageDays = (now - tab.lastFocusedAt) / (24 * 60 * 60 * 1000);
1052
+ if (ageDays >= staleDays) {
1053
+ addReason(tab, {
1054
+ type: "stale",
1055
+ detail: `Last focused ${Math.floor(ageDays)} days ago`,
1056
+ });
1057
+ }
1058
+ }
1059
+ }
1060
+ const githubTabs = checkGitHub
1061
+ ? selectedTabs.filter((tab) => isGitHubIssueOrPr(tab.url) && isScriptableUrl(tab.url))
1062
+ : [];
1063
+ githubTotal = githubTabs.length;
1064
+ if (checkGitHub && githubTabs.length > 0) {
1065
+ let index = 0;
1066
+ const total = githubTabs.length;
1067
+ const workers = Array.from({ length: Math.min(githubConcurrency, total) }, async () => {
1068
+ while (true) {
1069
+ const currentIndex = index;
1070
+ if (currentIndex >= total) {
1071
+ return;
1072
+ }
1073
+ index += 1;
1074
+ const tab = githubTabs[currentIndex];
1075
+ const state = await detectGitHubState(tab.tabId, githubTimeoutMs);
1076
+ githubChecked += 1;
1077
+ if (state === "closed" || state === "merged") {
1078
+ githubMatched += 1;
1079
+ addReason(tab, {
1080
+ type: "closed_issue",
1081
+ detail: `GitHub state: ${state}`,
1082
+ });
1083
+ }
1084
+ if (progressEnabled) {
1085
+ sendProgress(requestId, {
1086
+ phase: "github",
1087
+ processed: githubChecked,
1088
+ total,
1089
+ matched: githubMatched,
1090
+ tabId: tab.tabId,
1091
+ timeoutMs: githubTimeoutMs,
1092
+ });
1093
+ }
1094
+ }
1095
+ });
1096
+ await Promise.all(workers);
1097
+ }
1098
+ const candidates = Array.from(candidateMap.values()).map((entry) => {
1099
+ const reasons = entry.reasons;
1100
+ const severity = reasons.some((reason) => reason.type === "duplicate" || reason.type === "closed_issue")
1101
+ ? "high"
1102
+ : "medium";
1103
+ return {
1104
+ tabId: entry.tab.tabId,
1105
+ windowId: entry.tab.windowId,
1106
+ groupId: entry.tab.groupId,
1107
+ url: entry.tab.url,
1108
+ title: entry.tab.title,
1109
+ lastFocusedAt: entry.tab.lastFocusedAt,
1110
+ reasons,
1111
+ severity,
1112
+ };
1113
+ });
1114
+ return {
1115
+ generatedAt: Date.now(),
1116
+ staleDays,
1117
+ totals: {
1118
+ tabs: scopeTabs.length,
1119
+ analyzed: selectedTabs.length,
1120
+ candidates: candidates.length,
1121
+ },
1122
+ meta: {
1123
+ durationMs: Date.now() - startedAt,
1124
+ githubChecked,
1125
+ githubTotal,
1126
+ githubMatched,
1127
+ githubTimeoutMs,
1128
+ },
1129
+ candidates,
1130
+ };
1131
+ }
1132
+ async function inspectTabs(params, requestId) {
1133
+ const signalList = Array.isArray(params.signals) && params.signals.length > 0
1134
+ ? params.signals.map(String)
1135
+ : ["page-meta"];
1136
+ const signalConcurrencyRaw = Number(params.signalConcurrency);
1137
+ const signalConcurrency = Number.isFinite(signalConcurrencyRaw) && signalConcurrencyRaw > 0
1138
+ ? Math.min(10, Math.floor(signalConcurrencyRaw))
1139
+ : 4;
1140
+ const signalTimeoutRaw = Number(params.signalTimeoutMs);
1141
+ const signalTimeoutMs = Number.isFinite(signalTimeoutRaw) && signalTimeoutRaw > 0
1142
+ ? Math.floor(signalTimeoutRaw)
1143
+ : 4000;
1144
+ const progressEnabled = params.progress === true;
1145
+ // For settle mode with specific tab IDs, wait BEFORE taking snapshot
1146
+ // This ensures the tab URL is available for isScriptableUrl() checks
1147
+ const waitFor = typeof params.waitFor === "string" ? params.waitFor.trim().toLowerCase() : "";
1148
+ if (waitFor === "settle" && Array.isArray(params.tabIds) && params.tabIds.length > 0) {
1149
+ const waitTimeoutRaw = Number(params.waitTimeoutMs);
1150
+ const waitTimeoutMs = Number.isFinite(waitTimeoutRaw) && waitTimeoutRaw > 0 ? Math.floor(waitTimeoutRaw) : signalTimeoutMs;
1151
+ const tabIds = params.tabIds.map(Number).filter(Number.isFinite);
1152
+ await Promise.all(tabIds.map((id) => waitForSettle(id, waitTimeoutMs)));
1153
+ }
1154
+ const snapshot = await getTabSnapshot();
1155
+ const selection = selectTabsByScope(snapshot, params);
1156
+ if (selection.error) {
1157
+ throw selection.error;
1158
+ }
1159
+ const tabs = selection.tabs;
1160
+ const startedAt = Date.now();
1161
+ const selectorSpecs = [];
1162
+ if (Array.isArray(params.selectorSpecs)) {
1163
+ selectorSpecs.push(...params.selectorSpecs);
1164
+ }
1165
+ if (params.signalConfig && typeof params.signalConfig === "object") {
1166
+ const config = params.signalConfig;
1167
+ if (Array.isArray(config.selectors)) {
1168
+ selectorSpecs.push(...config.selectors);
1169
+ }
1170
+ if (config.signals && typeof config.signals === "object") {
1171
+ const signals = config.signals;
1172
+ const selectorConfig = signals.selector;
1173
+ if (selectorConfig && Array.isArray(selectorConfig.selectors)) {
1174
+ selectorSpecs.push(...selectorConfig.selectors);
1175
+ }
1176
+ }
1177
+ }
1178
+ const normalizedSelectors = selectorSpecs
1179
+ .filter((spec) => spec && typeof spec.selector === "string" && spec.selector.length > 0)
1180
+ .map((spec) => ({
1181
+ name: typeof spec.name === "string" ? spec.name : undefined,
1182
+ selector: spec.selector,
1183
+ attr: typeof spec.attr === "string" ? spec.attr : "text",
1184
+ all: Boolean(spec.all),
1185
+ text: typeof spec.text === "string" && spec.text.trim() ? spec.text.trim() : undefined,
1186
+ textMode: typeof spec.textMode === "string" ? spec.textMode.trim().toLowerCase() : undefined,
1187
+ }));
1188
+ const selectorWarnings = normalizedSelectors
1189
+ .filter((spec) => typeof spec.selector === "string" && spec.selector.includes(":contains("))
1190
+ .map((spec) => ({
1191
+ name: spec.name || spec.selector,
1192
+ hint: "CSS :contains() is not supported; use selector text filters or a different selector.",
1193
+ }));
1194
+ const signalDefs = [];
1195
+ for (const signalId of signalList) {
1196
+ if (signalId === "github-state") {
1197
+ signalDefs.push({
1198
+ id: signalId,
1199
+ match: (tab) => isGitHubIssueOrPr(tab.url) && isScriptableUrl(tab.url),
1200
+ run: async (tabId) => {
1201
+ const state = await detectGitHubState(tabId, signalTimeoutMs);
1202
+ return state ? { state } : null;
1203
+ },
1204
+ });
1205
+ }
1206
+ else if (signalId === "page-meta") {
1207
+ signalDefs.push({
1208
+ id: signalId,
1209
+ match: (tab) => isScriptableUrl(tab.url),
1210
+ run: async (tabId) => extractPageMeta(tabId, signalTimeoutMs),
1211
+ });
1212
+ }
1213
+ else if (signalId === "selector") {
1214
+ signalDefs.push({
1215
+ id: signalId,
1216
+ match: (tab) => isScriptableUrl(tab.url),
1217
+ run: async (tabId) => extractSelectorSignal(tabId, normalizedSelectors, signalTimeoutMs),
1218
+ });
1219
+ }
1220
+ }
1221
+ const tasks = [];
1222
+ for (const tab of tabs) {
1223
+ for (const signal of signalDefs) {
1224
+ if (signal.match(tab)) {
1225
+ tasks.push({ tab, signal });
1226
+ }
1227
+ }
1228
+ }
1229
+ const totalTasks = tasks.length;
1230
+ let completedTasks = 0;
1231
+ const entryMap = new Map();
1232
+ const workerCount = Math.min(signalConcurrency, totalTasks || 1);
1233
+ let index = 0;
1234
+ const workers = Array.from({ length: workerCount }, async () => {
1235
+ while (true) {
1236
+ const currentIndex = index;
1237
+ if (currentIndex >= totalTasks) {
1238
+ return;
1239
+ }
1240
+ index += 1;
1241
+ const task = tasks[currentIndex];
1242
+ const tabId = task.tab.tabId;
1243
+ let result = null;
1244
+ let error = null;
1245
+ const started = Date.now();
1246
+ try {
1247
+ await waitForTabReady(tabId, params, signalTimeoutMs);
1248
+ result = await task.signal.run(tabId);
1249
+ }
1250
+ catch (err) {
1251
+ const message = err instanceof Error ? err.message : "signal_error";
1252
+ error = message;
1253
+ }
1254
+ const durationMs = Date.now() - started;
1255
+ const entry = entryMap.get(tabId) || { tab: task.tab, signals: {} };
1256
+ entry.signals[task.signal.id] = {
1257
+ ok: error === null,
1258
+ durationMs,
1259
+ data: result,
1260
+ error,
1261
+ };
1262
+ entryMap.set(tabId, entry);
1263
+ completedTasks += 1;
1264
+ if (progressEnabled) {
1265
+ sendProgress(requestId, {
1266
+ phase: "inspect",
1267
+ processed: completedTasks,
1268
+ total: totalTasks,
1269
+ signalId: task.signal.id,
1270
+ tabId,
1271
+ });
1272
+ }
1273
+ }
1274
+ });
1275
+ await Promise.all(workers);
1276
+ const entries = Array.from(entryMap.values()).map((entry) => ({
1277
+ tabId: entry.tab.tabId,
1278
+ windowId: entry.tab.windowId,
1279
+ groupId: entry.tab.groupId,
1280
+ url: entry.tab.url,
1281
+ title: entry.tab.title,
1282
+ signals: entry.signals,
1283
+ }));
1284
+ return {
1285
+ generatedAt: Date.now(),
1286
+ totals: {
1287
+ tabs: tabs.length,
1288
+ signals: signalDefs.length,
1289
+ tasks: totalTasks,
1290
+ },
1291
+ meta: {
1292
+ durationMs: Date.now() - startedAt,
1293
+ signalTimeoutMs,
1294
+ selectorCount: normalizedSelectors.length,
1295
+ selectorWarnings: selectorWarnings.length > 0 ? selectorWarnings : undefined,
1296
+ },
1297
+ entries,
1298
+ };
1299
+ }
1300
+ async function focusTab(params) {
1301
+ const tabIds = Array.isArray(params.tabIds) ? params.tabIds.map(Number) : [];
1302
+ const tabId = Number.isFinite(params.tabId)
1303
+ ? Number(params.tabId)
1304
+ : tabIds.length
1305
+ ? Number(tabIds[0])
1306
+ : null;
1307
+ if (!tabId) {
1308
+ throw new Error("Missing tabId");
1309
+ }
1310
+ const tab = await chrome.tabs.get(tabId);
1311
+ await chrome.windows.update(tab.windowId, { focused: true });
1312
+ await chrome.tabs.update(tabId, { active: true });
1313
+ return {
1314
+ tabId,
1315
+ windowId: tab.windowId,
1316
+ };
1317
+ }
1318
+ async function refreshTabs(params) {
1319
+ const tabId = Number.isFinite(params.tabId)
1320
+ ? Number(params.tabId)
1321
+ : null;
1322
+ if (!tabId) {
1323
+ throw new Error("Missing tabId");
1324
+ }
1325
+ await chrome.tabs.reload(tabId);
1326
+ return {
1327
+ tabId,
1328
+ summary: { refreshedTabs: 1 },
1329
+ };
1330
+ }
1331
+ function matchIncludes(value, needle) {
1332
+ if (!needle) {
1333
+ return false;
1334
+ }
1335
+ return typeof value === "string" && value.toLowerCase().includes(needle);
1336
+ }
1337
+ function resolveOpenWindow(snapshot, params) {
1338
+ const windows = snapshot.windows;
1339
+ if (!windows.length) {
1340
+ return { error: { message: "No windows available" } };
1341
+ }
1342
+ if (params.windowId != null) {
1343
+ if (typeof params.windowId === "string") {
1344
+ const normalized = params.windowId.trim().toLowerCase();
1345
+ if (normalized === "active") {
1346
+ const focused = windows.find((win) => win.focused);
1347
+ if (focused) {
1348
+ return { windowId: focused.windowId };
1349
+ }
1350
+ return { error: { message: "Active window not found" } };
1351
+ }
1352
+ if (normalized === "last-focused") {
1353
+ const lastFocused = getMostRecentFocusedWindowId(windows);
1354
+ if (lastFocused != null) {
1355
+ return { windowId: lastFocused };
1356
+ }
1357
+ return { error: { message: "Last focused window not found" } };
1358
+ }
1359
+ if (normalized === "new") {
1360
+ return { error: { message: "--window new is only supported by open" } };
1361
+ }
1362
+ }
1363
+ const windowId = Number(params.windowId);
1364
+ const found = windows.find((win) => win.windowId === windowId);
1365
+ if (!found) {
1366
+ return { error: { message: "Window not found" } };
1367
+ }
1368
+ return { windowId };
1369
+ }
1370
+ if (params.windowTabId != null) {
1371
+ const tabId = Number(params.windowTabId);
1372
+ const found = windows.find((win) => win.tabs.some((tab) => tab.tabId === tabId));
1373
+ if (!found) {
1374
+ return { error: { message: "Window not found for tab" } };
1375
+ }
1376
+ return { windowId: found.windowId };
1377
+ }
1378
+ let candidates = [...windows];
1379
+ let filtered = false;
1380
+ if (typeof params.afterGroupTitle === "string" && params.afterGroupTitle.trim()) {
1381
+ const groupTitle = params.afterGroupTitle.trim();
1382
+ candidates = candidates.filter((win) => win.groups.some((group) => group.title === groupTitle));
1383
+ filtered = true;
1384
+ }
1385
+ if (typeof params.windowGroupTitle === "string" && params.windowGroupTitle.trim()) {
1386
+ const groupTitle = params.windowGroupTitle.trim();
1387
+ candidates = candidates.filter((win) => win.groups.some((group) => group.title === groupTitle));
1388
+ filtered = true;
1389
+ }
1390
+ if (typeof params.windowUrl === "string" && params.windowUrl.trim()) {
1391
+ const needle = params.windowUrl.trim().toLowerCase();
1392
+ candidates = candidates.filter((win) => win.tabs.some((tab) => matchIncludes(tab.url, needle)));
1393
+ filtered = true;
1394
+ }
1395
+ if (filtered) {
1396
+ if (candidates.length === 1) {
1397
+ return { windowId: candidates[0].windowId };
1398
+ }
1399
+ if (candidates.length === 0) {
1400
+ return { error: { message: "No matching window found" } };
1401
+ }
1402
+ return { error: { message: "Multiple windows match selection. Provide --window to disambiguate." } };
1403
+ }
1404
+ const focused = windows.find((win) => win.focused);
1405
+ if (focused) {
1406
+ return { windowId: focused.windowId };
1407
+ }
1408
+ if (windows.length === 1) {
1409
+ return { windowId: windows[0].windowId };
1410
+ }
1411
+ const lastFocused = getMostRecentFocusedWindowId(windows);
1412
+ if (lastFocused != null) {
1413
+ return { windowId: lastFocused };
1414
+ }
1415
+ return { error: { message: "Multiple windows available. Provide --window to target one." } };
1416
+ }
1417
+ async function openTabs(params) {
1418
+ const urls = Array.isArray(params.urls)
1419
+ ? params.urls.map((url) => (typeof url === "string" ? url.trim() : "")).filter(Boolean)
1420
+ : [];
1421
+ const groupTitle = typeof params.groupTitle === "string" ? params.groupTitle.trim() : "";
1422
+ const groupColor = typeof params.color === "string" ? params.color.trim() : "";
1423
+ const afterGroupTitle = typeof params.afterGroupTitle === "string" ? params.afterGroupTitle.trim() : "";
1424
+ const beforeTabId = Number.isFinite(params.beforeTabId) ? Number(params.beforeTabId) : null;
1425
+ const afterTabId = Number.isFinite(params.afterTabId) ? Number(params.afterTabId) : null;
1426
+ if (beforeTabId != null && afterTabId != null) {
1427
+ throw new Error("Only one target position is allowed");
1428
+ }
1429
+ const newWindow = params.newWindow === true;
1430
+ if (!urls.length && !newWindow) {
1431
+ throw new Error("No URLs provided");
1432
+ }
1433
+ if (newWindow) {
1434
+ if (afterGroupTitle || beforeTabId || afterTabId) {
1435
+ throw new Error("Cannot use --before/--after with --new-window");
1436
+ }
1437
+ if (params.windowId != null || params.windowGroupTitle || params.windowTabId != null || params.windowUrl) {
1438
+ throw new Error("Cannot combine --new-window with window selectors");
1439
+ }
1440
+ const created = [];
1441
+ const skipped = [];
1442
+ const createdWindow = await chrome.windows.create({ focused: false });
1443
+ const windowId = createdWindow.id;
1444
+ let seedTabs = createdWindow.tabs;
1445
+ if (!seedTabs) {
1446
+ seedTabs = await chrome.tabs.query({ windowId });
1447
+ }
1448
+ const seedTabId = seedTabs.find((tab) => typeof tab.id === "number")?.id ?? null;
1449
+ for (const url of urls) {
1450
+ try {
1451
+ const tab = await chrome.tabs.create({ windowId, url, active: false });
1452
+ created.push({
1453
+ tabId: tab.id,
1454
+ windowId: tab.windowId,
1455
+ index: tab.index,
1456
+ url: tab.url,
1457
+ title: tab.title,
1458
+ });
1459
+ }
1460
+ catch (error) {
1461
+ skipped.push({ url, reason: "create_failed" });
1462
+ }
1463
+ }
1464
+ if (!urls.length && seedTabs.length) {
1465
+ const tab = seedTabs[0];
1466
+ created.push({
1467
+ tabId: tab.id,
1468
+ windowId: tab.windowId,
1469
+ index: tab.index,
1470
+ url: tab.url,
1471
+ title: tab.title,
1472
+ });
1473
+ }
1474
+ if (seedTabId && created.length > 0 && urls.length > 0) {
1475
+ try {
1476
+ await chrome.tabs.remove(seedTabId);
1477
+ }
1478
+ catch (error) {
1479
+ log("Failed to remove seed tab", error);
1480
+ }
1481
+ }
1482
+ let groupId = null;
1483
+ if (groupTitle && created.length > 0) {
1484
+ try {
1485
+ const tabIds = created.map((tab) => tab.tabId).filter((id) => typeof id === "number");
1486
+ if (tabIds.length > 0) {
1487
+ groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId } });
1488
+ const update = { title: groupTitle };
1489
+ if (groupColor) {
1490
+ update.color = groupColor;
1491
+ }
1492
+ await chrome.tabGroups.update(groupId, update);
1493
+ }
1494
+ }
1495
+ catch (error) {
1496
+ log("Failed to create group", error);
1497
+ groupId = null;
1498
+ }
1499
+ }
1500
+ return {
1501
+ windowId,
1502
+ groupId,
1503
+ groupTitle: groupTitle || null,
1504
+ afterGroupTitle: null,
1505
+ insertIndex: null,
1506
+ created,
1507
+ skipped,
1508
+ summary: {
1509
+ createdTabs: created.length,
1510
+ skippedUrls: skipped.length,
1511
+ grouped: Boolean(groupId),
1512
+ },
1513
+ };
1514
+ }
1515
+ const snapshot = await getTabSnapshot();
1516
+ let openParams = params;
1517
+ if (params.windowId == null && (beforeTabId != null || afterTabId != null)) {
1518
+ const anchorId = beforeTabId != null ? beforeTabId : afterTabId;
1519
+ const anchorWindow = snapshot.windows
1520
+ .find((win) => win.tabs.some((tab) => tab.tabId === anchorId));
1521
+ if (anchorWindow) {
1522
+ openParams = { ...params, windowId: anchorWindow.windowId };
1523
+ }
1524
+ }
1525
+ const selection = resolveOpenWindow(snapshot, openParams);
1526
+ if (selection.error) {
1527
+ throw selection.error;
1528
+ }
1529
+ const windowId = selection.windowId;
1530
+ const windowSnapshot = snapshot.windows.find((win) => win.windowId === windowId);
1531
+ if (!windowSnapshot) {
1532
+ throw new Error("Window snapshot unavailable");
1533
+ }
1534
+ const created = [];
1535
+ const skipped = [];
1536
+ let insertIndex = null;
1537
+ if (afterGroupTitle) {
1538
+ const targetGroup = windowSnapshot.groups.find((group) => group.title === afterGroupTitle);
1539
+ if (!targetGroup) {
1540
+ throw new Error("Group not found in target window");
1541
+ }
1542
+ const groupTabs = windowSnapshot.tabs.filter((tab) => tab.groupId === targetGroup.groupId);
1543
+ if (!groupTabs.length) {
1544
+ throw new Error("Group has no tabs to anchor insertion");
1545
+ }
1546
+ const indices = groupTabs
1547
+ .map((tab) => normalizeTabIndex(tab.index))
1548
+ .filter((value) => value != null);
1549
+ if (!indices.length) {
1550
+ throw new Error("Group tabs missing indices");
1551
+ }
1552
+ insertIndex = Math.max(...indices) + 1;
1553
+ }
1554
+ if (beforeTabId != null || afterTabId != null) {
1555
+ if (afterGroupTitle) {
1556
+ throw new Error("Only one target position is allowed");
1557
+ }
1558
+ const anchorId = beforeTabId != null ? beforeTabId : afterTabId;
1559
+ const anchorTab = windowSnapshot.tabs.find((tab) => tab.tabId === anchorId);
1560
+ if (!anchorTab) {
1561
+ throw new Error("Anchor tab not found in target window");
1562
+ }
1563
+ const anchorIndex = normalizeTabIndex(anchorTab.index);
1564
+ if (!Number.isFinite(anchorIndex)) {
1565
+ throw new Error("Anchor tab index unavailable");
1566
+ }
1567
+ insertIndex = beforeTabId != null ? anchorIndex : anchorIndex + 1;
1568
+ }
1569
+ let nextIndex = insertIndex;
1570
+ for (const url of urls) {
1571
+ try {
1572
+ const createOptions = { windowId, url, active: false };
1573
+ if (nextIndex != null) {
1574
+ createOptions.index = nextIndex;
1575
+ nextIndex += 1;
1576
+ }
1577
+ const tab = await chrome.tabs.create(createOptions);
1578
+ created.push({
1579
+ tabId: tab.id,
1580
+ windowId: tab.windowId,
1581
+ index: tab.index,
1582
+ url: tab.url,
1583
+ title: tab.title,
1584
+ });
1585
+ }
1586
+ catch (error) {
1587
+ skipped.push({ url, reason: "create_failed" });
1588
+ }
1589
+ }
1590
+ let groupId = null;
1591
+ if (groupTitle && created.length > 0) {
1592
+ try {
1593
+ const tabIds = created.map((tab) => tab.tabId).filter((id) => typeof id === "number");
1594
+ if (tabIds.length > 0) {
1595
+ groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId } });
1596
+ const update = { title: groupTitle };
1597
+ if (groupColor) {
1598
+ update.color = groupColor;
1599
+ }
1600
+ await chrome.tabGroups.update(groupId, update);
1601
+ }
1602
+ }
1603
+ catch (error) {
1604
+ log("Failed to create group", error);
1605
+ groupId = null;
1606
+ }
1607
+ }
1608
+ return {
1609
+ windowId,
1610
+ groupId,
1611
+ groupTitle: groupTitle || null,
1612
+ afterGroupTitle: afterGroupTitle || null,
1613
+ insertIndex,
1614
+ created,
1615
+ skipped,
1616
+ summary: {
1617
+ createdTabs: created.length,
1618
+ skippedUrls: skipped.length,
1619
+ grouped: Boolean(groupId),
1620
+ },
1621
+ };
1622
+ }
1623
+ function getGroupTabs(windowSnapshot, groupId) {
1624
+ return windowSnapshot.tabs
1625
+ .filter((tab) => tab.groupId === groupId)
1626
+ .sort((a, b) => (Number(a.index) || 0) - (Number(b.index) || 0));
1627
+ }
1628
+ function listGroupSummaries(snapshot, windowId) {
1629
+ const windowLabels = buildWindowLabels(snapshot);
1630
+ const summaries = [];
1631
+ const windows = snapshot.windows;
1632
+ for (const win of windows) {
1633
+ if (windowId && win.windowId !== windowId) {
1634
+ continue;
1635
+ }
1636
+ for (const group of win.groups) {
1637
+ summaries.push({
1638
+ windowId: win.windowId,
1639
+ windowLabel: windowLabels.get(win.windowId) ?? null,
1640
+ groupId: group.groupId,
1641
+ title: typeof group.title === "string" ? group.title : null,
1642
+ });
1643
+ }
1644
+ }
1645
+ return summaries;
1646
+ }
1647
+ function summarizeGroupMatch(match, windowLabels) {
1648
+ return {
1649
+ windowId: match.windowId,
1650
+ windowLabel: windowLabels.get(match.windowId) ?? null,
1651
+ groupId: match.group.groupId,
1652
+ title: typeof match.group.title === "string" ? match.group.title : null,
1653
+ };
1654
+ }
1655
+ function findGroupMatches(snapshot, groupTitle, windowId) {
1656
+ const matches = [];
1657
+ const windows = snapshot.windows;
1658
+ for (const win of windows) {
1659
+ if (windowId && win.windowId !== windowId) {
1660
+ continue;
1661
+ }
1662
+ for (const group of win.groups) {
1663
+ if (group.title === groupTitle) {
1664
+ matches.push({
1665
+ windowId: win.windowId,
1666
+ group,
1667
+ tabs: getGroupTabs(win, group.groupId),
1668
+ });
1669
+ }
1670
+ }
1671
+ }
1672
+ return matches;
1673
+ }
1674
+ function resolveGroupByTitle(snapshot, groupTitle, windowId) {
1675
+ const windowLabels = buildWindowLabels(snapshot);
1676
+ const allMatches = findGroupMatches(snapshot, groupTitle);
1677
+ const matches = windowId ? allMatches.filter((match) => match.windowId === windowId) : allMatches;
1678
+ const availableGroups = listGroupSummaries(snapshot);
1679
+ if (matches.length === 0) {
1680
+ const message = windowId && allMatches.length > 0
1681
+ ? "Group title not found in specified window"
1682
+ : "No matching group title found";
1683
+ return {
1684
+ error: {
1685
+ message,
1686
+ hint: "Use tabctl group-list to see existing groups.",
1687
+ matches: allMatches.map((match) => summarizeGroupMatch(match, windowLabels)),
1688
+ availableGroups,
1689
+ },
1690
+ };
1691
+ }
1692
+ if (matches.length > 1) {
1693
+ return {
1694
+ error: {
1695
+ message: "Group title is ambiguous. Provide a windowId.",
1696
+ hint: "Use --window to disambiguate group titles.",
1697
+ matches: matches.map((match) => summarizeGroupMatch(match, windowLabels)),
1698
+ availableGroups,
1699
+ },
1700
+ };
1701
+ }
1702
+ return { match: matches[0] };
1703
+ }
1704
+ function resolveGroupById(snapshot, groupId) {
1705
+ const windows = snapshot.windows;
1706
+ const matches = [];
1707
+ for (const win of windows) {
1708
+ const group = win.groups.find((entry) => entry.groupId === groupId);
1709
+ if (group) {
1710
+ matches.push({
1711
+ windowId: win.windowId,
1712
+ group,
1713
+ tabs: getGroupTabs(win, groupId),
1714
+ });
1715
+ }
1716
+ }
1717
+ if (matches.length === 0) {
1718
+ return {
1719
+ error: {
1720
+ message: "Group not found",
1721
+ hint: "Use tabctl group-list to see existing groups.",
1722
+ availableGroups: listGroupSummaries(snapshot),
1723
+ },
1724
+ };
1725
+ }
1726
+ if (matches.length > 1) {
1727
+ const windowLabels = buildWindowLabels(snapshot);
1728
+ return {
1729
+ error: {
1730
+ message: "Group id is ambiguous. Provide a windowId.",
1731
+ hint: "Use --window to disambiguate group ids.",
1732
+ matches: matches.map((match) => summarizeGroupMatch(match, windowLabels)),
1733
+ availableGroups: listGroupSummaries(snapshot),
1734
+ },
1735
+ };
1736
+ }
1737
+ return { match: matches[0] };
1738
+ }
1739
+ function resolveMoveTarget(snapshot, params) {
1740
+ const beforeTabId = Number(params.beforeTabId);
1741
+ const afterTabId = Number(params.afterTabId);
1742
+ const beforeGroupTitle = typeof params.beforeGroupTitle === "string" ? params.beforeGroupTitle.trim() : "";
1743
+ const afterGroupTitle = typeof params.afterGroupTitle === "string" ? params.afterGroupTitle.trim() : "";
1744
+ const targets = [
1745
+ Number.isFinite(beforeTabId) ? "before-tab" : null,
1746
+ Number.isFinite(afterTabId) ? "after-tab" : null,
1747
+ beforeGroupTitle ? "before-group" : null,
1748
+ afterGroupTitle ? "after-group" : null,
1749
+ ].filter(Boolean);
1750
+ if (targets.length === 0) {
1751
+ return { error: { message: "Missing target position (--before/--after)" } };
1752
+ }
1753
+ if (targets.length > 1) {
1754
+ return { error: { message: "Only one target position is allowed" } };
1755
+ }
1756
+ const windows = snapshot.windows;
1757
+ const findTab = (tabId) => {
1758
+ for (const win of windows) {
1759
+ const tab = win.tabs.find((entry) => entry.tabId === tabId);
1760
+ if (tab) {
1761
+ return { tab, windowId: win.windowId };
1762
+ }
1763
+ }
1764
+ return null;
1765
+ };
1766
+ if (targets[0] === "before-tab" || targets[0] === "after-tab") {
1767
+ const tabId = targets[0] === "before-tab" ? beforeTabId : afterTabId;
1768
+ if (!Number.isFinite(tabId)) {
1769
+ return { error: { message: "Invalid tab target" } };
1770
+ }
1771
+ const match = findTab(tabId);
1772
+ if (!match) {
1773
+ return { error: { message: "Target tab not found" } };
1774
+ }
1775
+ const index = normalizeTabIndex(match.tab.index);
1776
+ if (!Number.isFinite(index)) {
1777
+ return { error: { message: "Target tab index unavailable" } };
1778
+ }
1779
+ return {
1780
+ windowId: match.windowId,
1781
+ index: targets[0] === "before-tab" ? index : index + 1,
1782
+ anchor: { type: "tab", tabId },
1783
+ };
1784
+ }
1785
+ const groupTitle = targets[0] === "before-group" ? beforeGroupTitle : afterGroupTitle;
1786
+ const windowId = Number.isFinite(params.windowId) ? Number(params.windowId) : undefined;
1787
+ const resolved = resolveGroupByTitle(snapshot, groupTitle, windowId);
1788
+ if (resolved.error) {
1789
+ return resolved;
1790
+ }
1791
+ const match = resolved.match;
1792
+ if (!match.tabs.length) {
1793
+ return { error: { message: "Target group has no tabs" } };
1794
+ }
1795
+ const indices = match.tabs
1796
+ .map((tab) => normalizeTabIndex(tab.index))
1797
+ .filter((value) => value != null);
1798
+ if (!indices.length) {
1799
+ return { error: { message: "Target group indices unavailable" } };
1800
+ }
1801
+ const minIndex = Math.min(...indices);
1802
+ const maxIndex = Math.max(...indices);
1803
+ return {
1804
+ windowId: match.windowId,
1805
+ index: targets[0] === "before-group" ? minIndex : maxIndex + 1,
1806
+ anchor: { type: "group", groupId: match.group.groupId, groupTitle },
1807
+ };
1808
+ }
1809
+ async function moveTab(params) {
1810
+ const tabIds = Array.isArray(params.tabIds) ? params.tabIds.map(Number) : [];
1811
+ const tabId = Number.isFinite(params.tabId)
1812
+ ? Number(params.tabId)
1813
+ : tabIds.length
1814
+ ? Number(tabIds[0])
1815
+ : null;
1816
+ if (!tabId) {
1817
+ throw new Error("Missing tabId");
1818
+ }
1819
+ const snapshot = await getTabSnapshot();
1820
+ const windows = snapshot.windows;
1821
+ const sourceWindow = windows.find((win) => win.tabs.some((tab) => tab.tabId === tabId));
1822
+ if (!sourceWindow) {
1823
+ throw new Error("Source tab not found");
1824
+ }
1825
+ const sourceTab = sourceWindow.tabs.find((tab) => tab.tabId === tabId);
1826
+ if (!sourceTab) {
1827
+ throw new Error("Source tab not found");
1828
+ }
1829
+ const newWindow = params.newWindow === true;
1830
+ const hasTarget = Number.isFinite(params.beforeTabId)
1831
+ || Number.isFinite(params.afterTabId)
1832
+ || (typeof params.beforeGroupTitle === "string" && params.beforeGroupTitle.trim())
1833
+ || (typeof params.afterGroupTitle === "string" && params.afterGroupTitle.trim());
1834
+ if (newWindow) {
1835
+ if (hasTarget) {
1836
+ throw new Error("Cannot combine --new-window with --before/--after");
1837
+ }
1838
+ const createdWindow = await chrome.windows.create({ tabId, focused: false });
1839
+ const targetWindowId = createdWindow.id;
1840
+ let targetIndex = 0;
1841
+ const createdTab = createdWindow.tabs?.find((tab) => tab.id === tabId) || null;
1842
+ if (createdTab && Number.isFinite(createdTab.index)) {
1843
+ targetIndex = createdTab.index;
1844
+ }
1845
+ else {
1846
+ try {
1847
+ const updated = await chrome.tabs.get(tabId);
1848
+ targetIndex = updated.index;
1849
+ }
1850
+ catch {
1851
+ targetIndex = 0;
1852
+ }
1853
+ }
1854
+ return {
1855
+ tabId,
1856
+ from: { windowId: sourceWindow.windowId, index: sourceTab.index },
1857
+ to: { windowId: targetWindowId, index: targetIndex },
1858
+ summary: { movedTabs: 1 },
1859
+ undo: {
1860
+ action: "move-tab",
1861
+ tabId,
1862
+ from: {
1863
+ windowId: sourceWindow.windowId,
1864
+ index: sourceTab.index,
1865
+ groupId: sourceTab.groupId,
1866
+ groupTitle: sourceTab.groupTitle,
1867
+ groupColor: sourceTab.groupColor,
1868
+ groupCollapsed: sourceTab.groupCollapsed ?? null,
1869
+ },
1870
+ to: {
1871
+ windowId: targetWindowId,
1872
+ index: targetIndex,
1873
+ },
1874
+ },
1875
+ txid: params.txid || null,
1876
+ };
1877
+ }
1878
+ let normalizedParams = params;
1879
+ if (params.windowId != null) {
1880
+ const resolvedWindowId = resolveWindowIdFromParams(snapshot, params.windowId);
1881
+ normalizedParams = { ...params, windowId: resolvedWindowId ?? undefined };
1882
+ }
1883
+ const target = resolveMoveTarget(snapshot, normalizedParams);
1884
+ if (target.error) {
1885
+ throw target.error;
1886
+ }
1887
+ const targetWindowId = target.windowId;
1888
+ let targetIndex = target.index;
1889
+ const sourceIndex = normalizeTabIndex(sourceTab.index);
1890
+ if (Number.isFinite(sourceIndex) && sourceWindow.windowId === targetWindowId && sourceIndex < targetIndex) {
1891
+ targetIndex -= 1;
1892
+ }
1893
+ const moved = await chrome.tabs.move(tabId, { windowId: targetWindowId, index: targetIndex });
1894
+ return {
1895
+ tabId,
1896
+ from: { windowId: sourceWindow.windowId, index: sourceTab.index },
1897
+ to: { windowId: targetWindowId, index: moved.index },
1898
+ summary: { movedTabs: 1 },
1899
+ undo: {
1900
+ action: "move-tab",
1901
+ tabId,
1902
+ from: {
1903
+ windowId: sourceWindow.windowId,
1904
+ index: sourceTab.index,
1905
+ groupId: sourceTab.groupId,
1906
+ groupTitle: sourceTab.groupTitle,
1907
+ groupColor: sourceTab.groupColor,
1908
+ groupCollapsed: sourceTab.groupCollapsed ?? null,
1909
+ },
1910
+ to: {
1911
+ windowId: targetWindowId,
1912
+ index: moved.index,
1913
+ },
1914
+ },
1915
+ txid: params.txid || null,
1916
+ };
1917
+ }
1918
+ async function moveGroup(params) {
1919
+ const groupId = Number.isFinite(params.groupId) ? Number(params.groupId) : null;
1920
+ const groupTitle = typeof params.groupTitle === "string" ? params.groupTitle.trim() : "";
1921
+ if (!groupId && !groupTitle) {
1922
+ throw new Error("Missing group identifier");
1923
+ }
1924
+ const snapshot = await getTabSnapshot();
1925
+ const windowIdParam = params.windowId != null ? resolveWindowIdFromParams(snapshot, params.windowId) ?? undefined : undefined;
1926
+ const resolvedGroup = groupId != null
1927
+ ? resolveGroupById(snapshot, groupId)
1928
+ : resolveGroupByTitle(snapshot, groupTitle, windowIdParam);
1929
+ if (resolvedGroup.error) {
1930
+ throw resolvedGroup.error;
1931
+ }
1932
+ const source = resolvedGroup.match;
1933
+ if (!source.tabs.length) {
1934
+ throw new Error("Group has no tabs to move");
1935
+ }
1936
+ const newWindow = params.newWindow === true;
1937
+ const hasTarget = Number.isFinite(params.beforeTabId)
1938
+ || Number.isFinite(params.afterTabId)
1939
+ || (typeof params.beforeGroupTitle === "string" && params.beforeGroupTitle.trim())
1940
+ || (typeof params.afterGroupTitle === "string" && params.afterGroupTitle.trim());
1941
+ if (newWindow) {
1942
+ if (hasTarget) {
1943
+ throw new Error("Cannot combine --new-window with --before/--after");
1944
+ }
1945
+ const tabIds = source.tabs.map((tab) => tab.tabId).filter((id) => typeof id === "number");
1946
+ const [firstTabId, ...restTabIds] = tabIds;
1947
+ if (!firstTabId) {
1948
+ throw new Error("Group has no tabs to move");
1949
+ }
1950
+ const createdWindow = await chrome.windows.create({ tabId: firstTabId, focused: false });
1951
+ const targetWindowId = createdWindow.id;
1952
+ if (restTabIds.length > 0) {
1953
+ await chrome.tabs.move(restTabIds, { windowId: targetWindowId, index: -1 });
1954
+ }
1955
+ let newGroupId = null;
1956
+ try {
1957
+ newGroupId = await chrome.tabs.group({ tabIds, createProperties: { windowId: targetWindowId } });
1958
+ await chrome.tabGroups.update(newGroupId, {
1959
+ title: source.group.title || "",
1960
+ color: source.group.color || "grey",
1961
+ collapsed: source.group.collapsed || false,
1962
+ });
1963
+ }
1964
+ catch (error) {
1965
+ log("Failed to regroup tabs", error);
1966
+ }
1967
+ const undoTabs = source.tabs
1968
+ .map((tab) => ({
1969
+ tabId: tab.tabId,
1970
+ windowId: tab.windowId,
1971
+ index: tab.index,
1972
+ groupId: tab.groupId,
1973
+ groupTitle: tab.groupTitle,
1974
+ groupColor: tab.groupColor,
1975
+ groupCollapsed: source.group.collapsed ?? null,
1976
+ }))
1977
+ .filter((tab) => typeof tab.tabId === "number");
1978
+ return {
1979
+ groupId: source.group.groupId,
1980
+ windowId: source.windowId,
1981
+ movedToWindowId: targetWindowId,
1982
+ newGroupId,
1983
+ summary: { movedTabs: tabIds.length },
1984
+ undo: {
1985
+ action: "move-group",
1986
+ groupId: source.group.groupId,
1987
+ windowId: source.windowId,
1988
+ movedToWindowId: targetWindowId,
1989
+ groupTitle: source.group.title ?? null,
1990
+ groupColor: source.group.color ?? null,
1991
+ groupCollapsed: source.group.collapsed ?? null,
1992
+ tabs: undoTabs,
1993
+ },
1994
+ txid: params.txid || null,
1995
+ };
1996
+ }
1997
+ const target = resolveMoveTarget(snapshot, params);
1998
+ if (target.error) {
1999
+ throw target.error;
2000
+ }
2001
+ if (target.anchor?.type === "tab") {
2002
+ const anchorTabId = target.anchor.tabId;
2003
+ if (source.tabs.some((tab) => tab.tabId === anchorTabId)) {
2004
+ throw new Error("Target tab is within the source group");
2005
+ }
2006
+ }
2007
+ if (target.anchor?.type === "group") {
2008
+ const anchorGroupId = target.anchor.groupId;
2009
+ if (anchorGroupId === source.group.groupId && source.windowId === target.windowId) {
2010
+ throw new Error("Target group matches source group");
2011
+ }
2012
+ }
2013
+ const tabIds = source.tabs.map((tab) => tab.tabId).filter((id) => typeof id === "number");
2014
+ const indices = source.tabs
2015
+ .map((tab) => normalizeTabIndex(tab.index))
2016
+ .filter((value) => value != null);
2017
+ const minIndex = Math.min(...indices);
2018
+ const maxIndex = Math.max(...indices);
2019
+ const targetWindowId = target.windowId;
2020
+ let targetIndex = target.index;
2021
+ if (source.windowId === targetWindowId && targetIndex > maxIndex) {
2022
+ targetIndex -= tabIds.length;
2023
+ }
2024
+ const moved = await chrome.tabs.move(tabIds, { windowId: targetWindowId, index: targetIndex });
2025
+ const movedList = Array.isArray(moved) ? moved : [moved];
2026
+ let newGroupId = null;
2027
+ if (targetWindowId !== source.windowId) {
2028
+ try {
2029
+ const movedIds = movedList.map((tab) => tab.id).filter((id) => typeof id === "number");
2030
+ if (movedIds.length > 0) {
2031
+ newGroupId = await chrome.tabs.group({ tabIds: movedIds, createProperties: { windowId: targetWindowId } });
2032
+ await chrome.tabGroups.update(newGroupId, {
2033
+ title: source.group.title || "",
2034
+ color: source.group.color || "grey",
2035
+ collapsed: source.group.collapsed || false,
2036
+ });
2037
+ }
2038
+ }
2039
+ catch (error) {
2040
+ log("Failed to regroup tabs", error);
2041
+ }
2042
+ }
2043
+ const undoTabs = source.tabs
2044
+ .map((tab) => ({
2045
+ tabId: tab.tabId,
2046
+ windowId: tab.windowId,
2047
+ index: tab.index,
2048
+ groupId: tab.groupId,
2049
+ groupTitle: tab.groupTitle,
2050
+ groupColor: tab.groupColor,
2051
+ groupCollapsed: source.group.collapsed ?? null,
2052
+ }))
2053
+ .filter((tab) => typeof tab.tabId === "number");
2054
+ return {
2055
+ groupId: source.group.groupId,
2056
+ windowId: source.windowId,
2057
+ movedToWindowId: targetWindowId,
2058
+ newGroupId,
2059
+ summary: { movedTabs: tabIds.length },
2060
+ undo: {
2061
+ action: "move-group",
2062
+ groupId: source.group.groupId,
2063
+ windowId: source.windowId,
2064
+ movedToWindowId: targetWindowId,
2065
+ groupTitle: source.group.title ?? null,
2066
+ groupColor: source.group.color ?? null,
2067
+ groupCollapsed: source.group.collapsed ?? null,
2068
+ tabs: undoTabs,
2069
+ },
2070
+ txid: params.txid || null,
2071
+ };
2072
+ }
2073
+ async function mergeWindow(params) {
2074
+ const fromWindowId = Number.isFinite(params.fromWindowId)
2075
+ ? Number(params.fromWindowId)
2076
+ : Number(params.windowId);
2077
+ const toWindowId = Number.isFinite(params.toWindowId) ? Number(params.toWindowId) : null;
2078
+ if (!Number.isFinite(fromWindowId) || !Number.isFinite(toWindowId)) {
2079
+ throw new Error("Missing source or target window id");
2080
+ }
2081
+ if (fromWindowId === toWindowId) {
2082
+ throw new Error("Source and target windows must differ");
2083
+ }
2084
+ const snapshot = await getTabSnapshot();
2085
+ const windows = snapshot.windows;
2086
+ const sourceWindow = windows.find((win) => win.windowId === fromWindowId);
2087
+ if (!sourceWindow) {
2088
+ throw new Error("Source window not found");
2089
+ }
2090
+ const targetWindow = windows.find((win) => win.windowId === toWindowId);
2091
+ if (!targetWindow) {
2092
+ throw new Error("Target window not found");
2093
+ }
2094
+ const rawTabIds = Array.isArray(params.tabIds) ? params.tabIds.map(Number) : [];
2095
+ const tabIdSet = new Set(rawTabIds.filter((id) => Number.isFinite(id)));
2096
+ const skipped = [];
2097
+ let selectedTabs = sourceWindow.tabs;
2098
+ if (tabIdSet.size > 0) {
2099
+ const sourceTabIds = new Set(sourceWindow.tabs.map((tab) => tab.tabId).filter((id) => typeof id === "number"));
2100
+ for (const tabId of tabIdSet) {
2101
+ if (!sourceTabIds.has(tabId)) {
2102
+ skipped.push({ tabId, reason: "not_in_source" });
2103
+ }
2104
+ }
2105
+ selectedTabs = sourceWindow.tabs.filter((tab) => tabIdSet.has(tab.tabId));
2106
+ }
2107
+ if (selectedTabs.length === 0) {
2108
+ return {
2109
+ fromWindowId,
2110
+ toWindowId,
2111
+ summary: { movedTabs: 0, movedGroups: 0, skippedTabs: skipped.length, closedSource: false },
2112
+ skipped,
2113
+ groups: [],
2114
+ undo: {
2115
+ action: "merge-window",
2116
+ fromWindowId,
2117
+ toWindowId,
2118
+ closedSource: false,
2119
+ tabs: [],
2120
+ },
2121
+ };
2122
+ }
2123
+ const orderedTabs = [...selectedTabs].sort((a, b) => {
2124
+ const aIndex = Number(a.index);
2125
+ const bIndex = Number(b.index);
2126
+ if (!Number.isFinite(aIndex) && !Number.isFinite(bIndex)) {
2127
+ return 0;
2128
+ }
2129
+ if (!Number.isFinite(aIndex)) {
2130
+ return 1;
2131
+ }
2132
+ if (!Number.isFinite(bIndex)) {
2133
+ return -1;
2134
+ }
2135
+ return aIndex - bIndex;
2136
+ });
2137
+ const groupById = new Map();
2138
+ for (const group of sourceWindow.groups) {
2139
+ groupById.set(group.groupId, group);
2140
+ }
2141
+ const plans = [];
2142
+ let currentPlan = null;
2143
+ for (const tab of orderedTabs) {
2144
+ const rawGroupId = tab.groupId;
2145
+ const groupId = typeof rawGroupId === "number" && rawGroupId !== -1 ? rawGroupId : null;
2146
+ if (!currentPlan || currentPlan.groupId !== groupId) {
2147
+ currentPlan = { groupId, tabs: [] };
2148
+ plans.push(currentPlan);
2149
+ }
2150
+ currentPlan.tabs.push(tab);
2151
+ }
2152
+ let movedTabs = 0;
2153
+ let movedGroups = 0;
2154
+ const groups = [];
2155
+ const undoTabs = [];
2156
+ for (const plan of plans) {
2157
+ const tabIds = plan.tabs.map((tab) => tab.tabId).filter((id) => typeof id === "number");
2158
+ if (!tabIds.length) {
2159
+ continue;
2160
+ }
2161
+ let moved;
2162
+ try {
2163
+ moved = await chrome.tabs.move(tabIds, { windowId: toWindowId, index: -1 });
2164
+ }
2165
+ catch (error) {
2166
+ for (const tabId of tabIds) {
2167
+ skipped.push({ tabId, reason: "move_failed" });
2168
+ }
2169
+ log("Failed to move tabs", error);
2170
+ continue;
2171
+ }
2172
+ const movedList = Array.isArray(moved) ? moved : [moved];
2173
+ const movedIds = movedList.map((tab) => tab.id).filter((id) => typeof id === "number");
2174
+ movedTabs += movedIds.length;
2175
+ for (const entry of plan.tabs) {
2176
+ if (typeof entry.tabId !== "number") {
2177
+ continue;
2178
+ }
2179
+ const meta = groupById.get(entry.groupId);
2180
+ undoTabs.push({
2181
+ tabId: entry.tabId,
2182
+ windowId: entry.windowId,
2183
+ index: entry.index,
2184
+ groupId: entry.groupId,
2185
+ groupTitle: entry.groupTitle,
2186
+ groupColor: entry.groupColor,
2187
+ groupCollapsed: meta ? meta.collapsed : null,
2188
+ });
2189
+ }
2190
+ if (plan.groupId != null && movedIds.length > 0) {
2191
+ movedGroups += 1;
2192
+ let newGroupId = null;
2193
+ try {
2194
+ newGroupId = await chrome.tabs.group({ tabIds: movedIds, createProperties: { windowId: toWindowId } });
2195
+ const meta = groupById.get(plan.groupId);
2196
+ if (meta) {
2197
+ await chrome.tabGroups.update(newGroupId, {
2198
+ title: meta.title || "",
2199
+ color: meta.color || "grey",
2200
+ collapsed: meta.collapsed || false,
2201
+ });
2202
+ }
2203
+ }
2204
+ catch (error) {
2205
+ log("Failed to regroup tabs", error);
2206
+ }
2207
+ groups.push({ sourceGroupId: plan.groupId, newGroupId });
2208
+ }
2209
+ }
2210
+ let closedSource = false;
2211
+ if (params.closeSource === true) {
2212
+ try {
2213
+ const remainingTabs = await chrome.tabs.query({ windowId: fromWindowId });
2214
+ if (remainingTabs.length === 0) {
2215
+ await chrome.windows.remove(fromWindowId);
2216
+ closedSource = true;
2217
+ }
2218
+ }
2219
+ catch (error) {
2220
+ log("Failed to close source window", error);
2221
+ }
2222
+ }
2223
+ return {
2224
+ fromWindowId,
2225
+ toWindowId,
2226
+ summary: { movedTabs, movedGroups, skippedTabs: skipped.length, closedSource },
2227
+ skipped,
2228
+ groups,
2229
+ undo: {
2230
+ action: "merge-window",
2231
+ fromWindowId,
2232
+ toWindowId,
2233
+ closedSource,
2234
+ tabs: undoTabs,
2235
+ },
2236
+ txid: params.txid || null,
2237
+ };
2238
+ }
2239
+ async function listGroups(params) {
2240
+ const snapshot = await getTabSnapshot();
2241
+ const windows = snapshot.windows;
2242
+ const windowLabels = buildWindowLabels(snapshot);
2243
+ const windowIdParam = params.windowId != null ? resolveWindowIdFromParams(snapshot, params.windowId) : null;
2244
+ if (windowIdParam && !windows.some((win) => win.windowId === windowIdParam)) {
2245
+ throw new Error("Window not found");
2246
+ }
2247
+ const groups = [];
2248
+ for (const win of windows) {
2249
+ if (windowIdParam && win.windowId !== windowIdParam) {
2250
+ continue;
2251
+ }
2252
+ const counts = new Map();
2253
+ for (const tab of win.tabs) {
2254
+ const groupId = tab.groupId;
2255
+ if (typeof groupId === "number" && groupId !== -1) {
2256
+ counts.set(groupId, (counts.get(groupId) || 0) + 1);
2257
+ }
2258
+ }
2259
+ for (const group of win.groups) {
2260
+ const groupId = group.groupId;
2261
+ groups.push({
2262
+ windowId: win.windowId,
2263
+ windowLabel: windowLabels.get(win.windowId) ?? null,
2264
+ groupId,
2265
+ title: group.title ?? null,
2266
+ color: group.color ?? null,
2267
+ collapsed: group.collapsed ?? null,
2268
+ tabCount: counts.get(groupId) || 0,
2269
+ });
2270
+ }
2271
+ }
2272
+ return { groups };
2273
+ }
2274
+ async function groupUpdate(params) {
2275
+ const groupId = Number.isFinite(params.groupId) ? Number(params.groupId) : null;
2276
+ const groupTitle = typeof params.groupTitle === "string" ? params.groupTitle.trim() : "";
2277
+ if (!groupId && !groupTitle) {
2278
+ throw new Error("Missing group identifier");
2279
+ }
2280
+ const snapshot = await getTabSnapshot();
2281
+ const windows = snapshot.windows;
2282
+ const windowIdParam = params.windowId != null ? resolveWindowIdFromParams(snapshot, params.windowId) : null;
2283
+ if (windowIdParam && !windows.some((win) => win.windowId === windowIdParam)) {
2284
+ throw new Error("Window not found");
2285
+ }
2286
+ let match;
2287
+ if (groupId != null) {
2288
+ const resolved = resolveGroupById(snapshot, groupId);
2289
+ if (resolved.error) {
2290
+ throw resolved.error;
2291
+ }
2292
+ match = resolved.match;
2293
+ if (windowIdParam && windowIdParam !== match.windowId) {
2294
+ throw new Error("Group is not in the specified window");
2295
+ }
2296
+ }
2297
+ else {
2298
+ const resolved = resolveGroupByTitle(snapshot, groupTitle, windowIdParam || undefined);
2299
+ if (resolved.error) {
2300
+ throw resolved.error;
2301
+ }
2302
+ match = resolved.match;
2303
+ }
2304
+ const update = {};
2305
+ if (typeof params.title === "string") {
2306
+ update.title = params.title;
2307
+ }
2308
+ if (typeof params.color === "string" && params.color.trim()) {
2309
+ update.color = params.color.trim();
2310
+ }
2311
+ if (typeof params.collapsed === "boolean") {
2312
+ update.collapsed = params.collapsed;
2313
+ }
2314
+ if (!Object.keys(update).length) {
2315
+ throw new Error("Missing group update fields");
2316
+ }
2317
+ const updated = await chrome.tabGroups.update(match.group.groupId, update);
2318
+ return {
2319
+ groupId: updated.id,
2320
+ windowId: updated.windowId,
2321
+ title: updated.title,
2322
+ color: updated.color,
2323
+ collapsed: updated.collapsed,
2324
+ undo: {
2325
+ action: "group-update",
2326
+ groupId: updated.id,
2327
+ windowId: match.windowId,
2328
+ previous: {
2329
+ title: match.group.title ?? null,
2330
+ color: match.group.color ?? null,
2331
+ collapsed: match.group.collapsed ?? null,
2332
+ },
2333
+ },
2334
+ txid: params.txid || null,
2335
+ };
2336
+ }
2337
+ async function groupUngroup(params) {
2338
+ const groupId = Number.isFinite(params.groupId) ? Number(params.groupId) : null;
2339
+ const groupTitle = typeof params.groupTitle === "string" ? params.groupTitle.trim() : "";
2340
+ if (!groupId && !groupTitle) {
2341
+ throw new Error("Missing group identifier");
2342
+ }
2343
+ const snapshot = await getTabSnapshot();
2344
+ const windows = snapshot.windows;
2345
+ const windowIdParam = params.windowId != null ? resolveWindowIdFromParams(snapshot, params.windowId) : null;
2346
+ if (windowIdParam && !windows.some((win) => win.windowId === windowIdParam)) {
2347
+ throw new Error("Window not found");
2348
+ }
2349
+ let match;
2350
+ if (groupId != null) {
2351
+ const resolved = resolveGroupById(snapshot, groupId);
2352
+ if (resolved.error) {
2353
+ throw resolved.error;
2354
+ }
2355
+ match = resolved.match;
2356
+ if (windowIdParam && windowIdParam !== match.windowId) {
2357
+ throw new Error("Group is not in the specified window");
2358
+ }
2359
+ }
2360
+ else {
2361
+ const resolved = resolveGroupByTitle(snapshot, groupTitle, windowIdParam || undefined);
2362
+ if (resolved.error) {
2363
+ throw resolved.error;
2364
+ }
2365
+ match = resolved.match;
2366
+ }
2367
+ const undoTabs = match.tabs
2368
+ .map((tab) => ({
2369
+ tabId: tab.tabId,
2370
+ windowId: tab.windowId,
2371
+ index: tab.index,
2372
+ groupId: tab.groupId,
2373
+ groupTitle: tab.groupTitle,
2374
+ groupColor: tab.groupColor,
2375
+ groupCollapsed: match.group.collapsed ?? null,
2376
+ }))
2377
+ .filter((tab) => typeof tab.tabId === "number");
2378
+ const tabIds = match.tabs
2379
+ .map((tab) => tab.tabId)
2380
+ .filter((tabId) => typeof tabId === "number");
2381
+ if (tabIds.length) {
2382
+ await chrome.tabs.ungroup(tabIds);
2383
+ }
2384
+ return {
2385
+ groupId: match.group.groupId,
2386
+ groupTitle: match.group.title || null,
2387
+ windowId: match.windowId,
2388
+ summary: {
2389
+ ungroupedTabs: tabIds.length,
2390
+ },
2391
+ undo: {
2392
+ action: "group-ungroup",
2393
+ groupId: match.group.groupId,
2394
+ windowId: match.windowId,
2395
+ groupTitle: match.group.title || null,
2396
+ groupColor: match.group.color || null,
2397
+ groupCollapsed: match.group.collapsed ?? null,
2398
+ tabs: undoTabs,
2399
+ },
2400
+ txid: params.txid || null,
2401
+ };
2402
+ }
2403
+ async function groupAssign(params) {
2404
+ const rawTabIds = Array.isArray(params.tabIds) ? params.tabIds.map(Number) : [];
2405
+ const tabIds = rawTabIds.filter((id) => Number.isFinite(id));
2406
+ if (!tabIds.length) {
2407
+ throw new Error("Missing tabIds");
2408
+ }
2409
+ const groupId = Number.isFinite(params.groupId) ? Number(params.groupId) : null;
2410
+ const groupTitle = typeof params.groupTitle === "string" ? params.groupTitle.trim() : "";
2411
+ if (!groupId && !groupTitle) {
2412
+ throw new Error("Missing group identifier");
2413
+ }
2414
+ const snapshot = await getTabSnapshot();
2415
+ const windows = snapshot.windows;
2416
+ const windowIdParam = params.windowId != null ? resolveWindowIdFromParams(snapshot, params.windowId) : null;
2417
+ if (windowIdParam && !windows.some((win) => win.windowId === windowIdParam)) {
2418
+ throw new Error("Window not found");
2419
+ }
2420
+ const tabIndex = new Map();
2421
+ for (const win of windows) {
2422
+ for (const tab of win.tabs) {
2423
+ if (typeof tab.tabId === "number") {
2424
+ tabIndex.set(tab.tabId, { tab, windowId: win.windowId });
2425
+ }
2426
+ }
2427
+ }
2428
+ const skipped = [];
2429
+ const resolvedTabIds = [];
2430
+ const sourceWindows = new Set();
2431
+ const undoTabs = [];
2432
+ for (const tabId of tabIds) {
2433
+ const entry = tabIndex.get(tabId);
2434
+ if (!entry) {
2435
+ skipped.push({ tabId, reason: "not_found" });
2436
+ continue;
2437
+ }
2438
+ resolvedTabIds.push(tabId);
2439
+ sourceWindows.add(entry.windowId);
2440
+ const tab = entry.tab;
2441
+ undoTabs.push({
2442
+ tabId,
2443
+ windowId: entry.windowId,
2444
+ index: tab.index,
2445
+ groupId: tab.groupId,
2446
+ groupTitle: tab.groupTitle,
2447
+ groupColor: tab.groupColor,
2448
+ groupCollapsed: tab.groupCollapsed ?? null,
2449
+ });
2450
+ }
2451
+ if (!resolvedTabIds.length) {
2452
+ throw new Error("No matching tabs found");
2453
+ }
2454
+ let targetGroupId = null;
2455
+ let targetWindowId = null;
2456
+ let targetTitle = null;
2457
+ let created = false;
2458
+ if (groupId != null) {
2459
+ const resolved = resolveGroupById(snapshot, groupId);
2460
+ if (resolved.error) {
2461
+ throw resolved.error;
2462
+ }
2463
+ const match = resolved.match;
2464
+ targetGroupId = match.group.groupId;
2465
+ targetWindowId = match.windowId;
2466
+ targetTitle = typeof match.group.title === "string" ? match.group.title : null;
2467
+ if (windowIdParam && windowIdParam !== targetWindowId) {
2468
+ throw new Error("Group is not in the specified window");
2469
+ }
2470
+ }
2471
+ else {
2472
+ const resolved = resolveGroupByTitle(snapshot, groupTitle, windowIdParam || undefined);
2473
+ if (resolved.error) {
2474
+ const error = resolved.error;
2475
+ if (error.message === "No matching group title found" && params.create === true) {
2476
+ targetWindowId = windowIdParam || (sourceWindows.size === 1 ? Array.from(sourceWindows)[0] : null);
2477
+ if (!targetWindowId) {
2478
+ throw new Error("Multiple source windows. Provide --window to create a new group.");
2479
+ }
2480
+ targetTitle = groupTitle;
2481
+ created = true;
2482
+ }
2483
+ else {
2484
+ throw error;
2485
+ }
2486
+ }
2487
+ else {
2488
+ const match = resolved.match;
2489
+ targetGroupId = match.group.groupId;
2490
+ targetWindowId = match.windowId;
2491
+ targetTitle = typeof match.group.title === "string" && match.group.title ? match.group.title : groupTitle;
2492
+ }
2493
+ }
2494
+ if (!targetWindowId) {
2495
+ throw new Error("Target window not found");
2496
+ }
2497
+ const moveIds = resolvedTabIds.filter((tabId) => {
2498
+ const entry = tabIndex.get(tabId);
2499
+ return entry && entry.windowId !== targetWindowId;
2500
+ });
2501
+ if (moveIds.length > 0) {
2502
+ await chrome.tabs.move(moveIds, { windowId: targetWindowId, index: -1 });
2503
+ }
2504
+ let assignedGroupId = targetGroupId;
2505
+ if (targetGroupId != null) {
2506
+ await chrome.tabs.group({ groupId: targetGroupId, tabIds: resolvedTabIds });
2507
+ }
2508
+ else {
2509
+ assignedGroupId = await chrome.tabs.group({ tabIds: resolvedTabIds, createProperties: { windowId: targetWindowId } });
2510
+ const update = {};
2511
+ if (targetTitle) {
2512
+ update.title = targetTitle;
2513
+ }
2514
+ if (typeof params.color === "string" && params.color.trim()) {
2515
+ update.color = params.color.trim();
2516
+ }
2517
+ if (typeof params.collapsed === "boolean") {
2518
+ update.collapsed = params.collapsed;
2519
+ }
2520
+ if (Object.keys(update).length > 0) {
2521
+ try {
2522
+ await chrome.tabGroups.update(assignedGroupId, update);
2523
+ }
2524
+ catch (error) {
2525
+ log("Failed to update group", error);
2526
+ }
2527
+ }
2528
+ created = true;
2529
+ }
2530
+ return {
2531
+ groupId: assignedGroupId,
2532
+ groupTitle: targetTitle || groupTitle || null,
2533
+ windowId: targetWindowId,
2534
+ created,
2535
+ summary: {
2536
+ movedTabs: moveIds.length,
2537
+ groupedTabs: resolvedTabIds.length,
2538
+ skippedTabs: skipped.length,
2539
+ },
2540
+ skipped,
2541
+ undo: {
2542
+ action: "group-assign",
2543
+ groupId: assignedGroupId,
2544
+ groupTitle: targetTitle || groupTitle || null,
2545
+ groupColor: typeof params.color === "string" && params.color.trim() ? params.color.trim() : null,
2546
+ groupCollapsed: typeof params.collapsed === "boolean" ? params.collapsed : null,
2547
+ created,
2548
+ tabs: undoTabs,
2549
+ },
2550
+ txid: params.txid || null,
2551
+ };
2552
+ }
2553
+ async function ensureArchiveWindow() {
2554
+ await ensureArchiveWindowIdLoaded();
2555
+ if (state.archiveWindowId) {
2556
+ try {
2557
+ await chrome.windows.get(state.archiveWindowId);
2558
+ return state.archiveWindowId;
2559
+ }
2560
+ catch {
2561
+ state.archiveWindowId = null;
2562
+ }
2563
+ }
2564
+ const created = await chrome.windows.create({ focused: false });
2565
+ state.archiveWindowId = created.id;
2566
+ await chrome.storage.local.set({ archiveWindowId: created.id });
2567
+ return created.id;
2568
+ }
2569
+ function buildWindowLabels(snapshot) {
2570
+ const labels = new Map();
2571
+ snapshot.windows.forEach((win, index) => {
2572
+ labels.set(win.windowId, `W${index + 1}`);
2573
+ });
2574
+ return labels;
2575
+ }
2576
+ function normalizeTabIndex(value) {
2577
+ const index = Number(value);
2578
+ return Number.isFinite(index) ? index : null;
2579
+ }
2580
+ function selectTabsByScope(snapshot, params) {
2581
+ const allTabs = flattenTabs(snapshot);
2582
+ if (params.tabIds && params.tabIds.length) {
2583
+ const idSet = new Set(params.tabIds.map(Number));
2584
+ return { tabs: allTabs.filter((tab) => idSet.has(tab.tabId)) };
2585
+ }
2586
+ if (params.groupId) {
2587
+ const groupId = Number(params.groupId);
2588
+ return { tabs: allTabs.filter((tab) => tab.groupId === groupId) };
2589
+ }
2590
+ if (params.groupTitle) {
2591
+ const windowId = params.windowId != null ? resolveWindowIdFromParams(snapshot, params.windowId) ?? undefined : undefined;
2592
+ const resolved = resolveGroupByTitle(snapshot, params.groupTitle, windowId);
2593
+ if (resolved.error) {
2594
+ return { tabs: [], error: resolved.error };
2595
+ }
2596
+ const match = resolved.match;
2597
+ return {
2598
+ tabs: allTabs.filter((tab) => tab.groupId === match.group.groupId && tab.windowId === match.windowId),
2599
+ };
2600
+ }
2601
+ if (params.windowId) {
2602
+ const windowId = resolveWindowIdFromParams(snapshot, params.windowId);
2603
+ if (!Number.isFinite(windowId)) {
2604
+ return { tabs: [] };
2605
+ }
2606
+ return { tabs: allTabs.filter((tab) => tab.windowId === windowId) };
2607
+ }
2608
+ if (params.all) {
2609
+ return { tabs: allTabs };
2610
+ }
2611
+ const focusedWindow = snapshot.windows.find((win) => win.focused);
2612
+ if (!focusedWindow) {
2613
+ return { tabs: [] };
2614
+ }
2615
+ return { tabs: focusedWindow.tabs };
2616
+ }
2617
+ async function archiveTabs(params) {
2618
+ const snapshot = await getTabSnapshot();
2619
+ const windowLabels = buildWindowLabels(snapshot);
2620
+ let windowsToProcess = snapshot.windows;
2621
+ if (params.windowId) {
2622
+ const resolvedWindowId = resolveWindowIdFromParams(snapshot, params.windowId);
2623
+ windowsToProcess = resolvedWindowId != null
2624
+ ? windowsToProcess.filter((win) => win.windowId === resolvedWindowId)
2625
+ : [];
2626
+ }
2627
+ else if (!params.all) {
2628
+ const focused = windowsToProcess.find((win) => win.focused);
2629
+ windowsToProcess = focused ? [focused] : [];
2630
+ }
2631
+ if (params.groupTitle || params.groupId || params.tabIds) {
2632
+ const selected = selectTabsByScope(snapshot, params);
2633
+ if (selected.error) {
2634
+ throw selected.error;
2635
+ }
2636
+ windowsToProcess = snapshot.windows
2637
+ .map((win) => ({
2638
+ windowId: win.windowId,
2639
+ focused: win.focused,
2640
+ state: win.state,
2641
+ tabs: win.tabs.filter((tab) => selected.tabs.some((sel) => sel.tabId === tab.tabId)),
2642
+ groups: win.groups,
2643
+ }))
2644
+ .filter((win) => win.tabs.length > 0);
2645
+ }
2646
+ if (windowsToProcess.length === 0) {
2647
+ return {
2648
+ txid: params.txid || null,
2649
+ summary: { movedTabs: 0, movedGroups: 0, skippedTabs: 0 },
2650
+ archiveWindowId: null,
2651
+ skipped: [],
2652
+ undo: { action: "archive", tabs: [] },
2653
+ };
2654
+ }
2655
+ const archiveWindowId = await ensureArchiveWindow();
2656
+ const undoTabs = [];
2657
+ const skipped = [];
2658
+ let movedGroups = 0;
2659
+ let movedTabs = 0;
2660
+ for (const window of windowsToProcess) {
2661
+ const groupsById = new Map();
2662
+ for (const group of window.groups) {
2663
+ groupsById.set(group.groupId, group);
2664
+ }
2665
+ const groupedTabs = new Map();
2666
+ const ungroupedTabs = [];
2667
+ for (const tab of window.tabs) {
2668
+ if (tab.tabId == null) {
2669
+ continue;
2670
+ }
2671
+ if (tab.groupId === -1) {
2672
+ ungroupedTabs.push(tab);
2673
+ }
2674
+ else {
2675
+ if (!groupedTabs.has(tab.groupId)) {
2676
+ groupedTabs.set(tab.groupId, []);
2677
+ }
2678
+ groupedTabs.get(tab.groupId)?.push(tab);
2679
+ }
2680
+ }
2681
+ const windowLabel = windowLabels.get(window.windowId) || `W${window.windowId}`;
2682
+ const plans = [];
2683
+ for (const [groupId, tabs] of groupedTabs.entries()) {
2684
+ const group = groupsById.get(groupId) || null;
2685
+ plans.push({
2686
+ windowId: window.windowId,
2687
+ windowLabel,
2688
+ group,
2689
+ tabs,
2690
+ isUngrouped: false,
2691
+ });
2692
+ }
2693
+ if (ungroupedTabs.length > 0) {
2694
+ plans.push({
2695
+ windowId: window.windowId,
2696
+ windowLabel,
2697
+ group: null,
2698
+ tabs: ungroupedTabs,
2699
+ isUngrouped: true,
2700
+ });
2701
+ }
2702
+ for (const plan of plans) {
2703
+ const tabIds = plan.tabs.map((tab) => tab.tabId);
2704
+ if (tabIds.length === 0) {
2705
+ continue;
2706
+ }
2707
+ const tabById = new Map();
2708
+ for (const tab of plan.tabs) {
2709
+ tabById.set(tab.tabId, tab);
2710
+ }
2711
+ let moved;
2712
+ try {
2713
+ moved = await chrome.tabs.move(tabIds, { windowId: archiveWindowId, index: -1 });
2714
+ }
2715
+ catch (error) {
2716
+ for (const tabId of tabIds) {
2717
+ skipped.push({ tabId, reason: "move_failed" });
2718
+ }
2719
+ log("Failed to move tabs", error);
2720
+ continue;
2721
+ }
2722
+ const movedList = Array.isArray(moved) ? moved : [moved];
2723
+ const movedIds = movedList.map((tab) => tab.id);
2724
+ movedTabs += movedIds.length;
2725
+ for (const movedId of movedIds) {
2726
+ const tab = tabById.get(movedId);
2727
+ if (!tab) {
2728
+ continue;
2729
+ }
2730
+ undoTabs.push({
2731
+ tabId: tab.tabId,
2732
+ url: tab.url,
2733
+ title: tab.title,
2734
+ pinned: tab.pinned,
2735
+ active: tab.active,
2736
+ from: {
2737
+ windowId: tab.windowId,
2738
+ index: tab.index,
2739
+ groupId: tab.groupId,
2740
+ groupTitle: plan.group ? plan.group.title : null,
2741
+ groupColor: plan.group ? plan.group.color : null,
2742
+ groupCollapsed: plan.group ? plan.group.collapsed : null,
2743
+ },
2744
+ });
2745
+ }
2746
+ const titleBase = plan.group && plan.group.title
2747
+ ? plan.group.title
2748
+ : plan.isUngrouped
2749
+ ? "Ungrouped"
2750
+ : "Group";
2751
+ const archiveTitle = `${plan.windowLabel} - ${titleBase}`;
2752
+ const groupColor = plan.group && plan.group.color
2753
+ ? plan.group.color
2754
+ : "grey";
2755
+ try {
2756
+ const newGroupId = await chrome.tabs.group({ tabIds: movedIds, createProperties: { windowId: archiveWindowId } });
2757
+ await chrome.tabGroups.update(newGroupId, { title: archiveTitle, color: groupColor });
2758
+ movedGroups += 1;
2759
+ }
2760
+ catch (error) {
2761
+ log("Failed to group archived tabs", error);
2762
+ }
2763
+ }
2764
+ }
2765
+ return {
2766
+ txid: params.txid || null,
2767
+ summary: {
2768
+ movedTabs,
2769
+ movedGroups,
2770
+ skippedTabs: skipped.length,
2771
+ },
2772
+ archiveWindowId,
2773
+ skipped,
2774
+ undo: {
2775
+ action: "archive",
2776
+ tabs: undoTabs,
2777
+ },
2778
+ };
2779
+ }
2780
+ async function getTabsByIds(tabIds) {
2781
+ const results = [];
2782
+ for (const tabId of tabIds) {
2783
+ try {
2784
+ const tab = await chrome.tabs.get(tabId);
2785
+ results.push(tab);
2786
+ }
2787
+ catch {
2788
+ results.push(null);
2789
+ }
2790
+ }
2791
+ return results;
2792
+ }
2793
+ async function closeTabs(params) {
2794
+ const mode = params.mode || "direct";
2795
+ if (mode === "direct" && !params.confirmed) {
2796
+ throw new Error("Direct close requires confirmation");
2797
+ }
2798
+ let tabIds = params.tabIds || [];
2799
+ if (!tabIds.length && (params.groupTitle || params.groupId || params.windowId)) {
2800
+ const snapshot = await getTabSnapshot();
2801
+ const selection = selectTabsByScope(snapshot, params);
2802
+ if (selection.error) {
2803
+ throw selection.error;
2804
+ }
2805
+ tabIds = selection.tabs.map((tab) => tab.tabId);
2806
+ }
2807
+ if (!tabIds.length) {
2808
+ return {
2809
+ txid: params.txid || null,
2810
+ summary: { closedTabs: 0, skippedTabs: 0 },
2811
+ skipped: [],
2812
+ undo: { action: "close", tabs: [] },
2813
+ };
2814
+ }
2815
+ const expectedUrls = params.expectedUrls || {};
2816
+ const tabInfos = await getTabsByIds(tabIds);
2817
+ const validTabs = [];
2818
+ const skipped = [];
2819
+ const groups = await chrome.tabGroups.query({});
2820
+ const groupById = new Map(groups.map((group) => [group.id, group]));
2821
+ for (let i = 0; i < tabIds.length; i += 1) {
2822
+ const tabId = tabIds[i];
2823
+ const tab = tabInfos[i];
2824
+ if (!tab) {
2825
+ skipped.push({ tabId, reason: "not_found" });
2826
+ continue;
2827
+ }
2828
+ const expected = expectedUrls[String(tabId)];
2829
+ if (expected && tab.url !== expected) {
2830
+ skipped.push({ tabId, reason: "url_mismatch" });
2831
+ continue;
2832
+ }
2833
+ const group = tab.groupId !== -1 ? groupById.get(tab.groupId) : null;
2834
+ validTabs.push({
2835
+ tabId,
2836
+ url: tab.url,
2837
+ title: tab.title,
2838
+ pinned: tab.pinned,
2839
+ active: tab.active,
2840
+ from: {
2841
+ windowId: tab.windowId,
2842
+ index: tab.index,
2843
+ groupId: tab.groupId,
2844
+ groupTitle: group ? group.title : null,
2845
+ groupColor: group ? group.color : null,
2846
+ groupCollapsed: group ? group.collapsed : null,
2847
+ },
2848
+ });
2849
+ }
2850
+ if (validTabs.length > 0) {
2851
+ await chrome.tabs.remove(validTabs.map((tab) => tab.tabId));
2852
+ }
2853
+ return {
2854
+ txid: params.txid || null,
2855
+ summary: {
2856
+ closedTabs: validTabs.length,
2857
+ skippedTabs: skipped.length,
2858
+ },
2859
+ skipped,
2860
+ undo: {
2861
+ action: "close",
2862
+ tabs: validTabs.map((tab) => ({
2863
+ url: tab.url,
2864
+ title: tab.title,
2865
+ pinned: tab.pinned,
2866
+ active: tab.active,
2867
+ from: tab.from,
2868
+ })),
2869
+ },
2870
+ };
2871
+ }
2872
+ async function extractDescription(tabId) {
2873
+ try {
2874
+ const [{ result }] = await chrome.scripting.executeScript({
2875
+ target: { tabId },
2876
+ func: () => {
2877
+ const pickContent = (selector) => {
2878
+ const el = document.querySelector(selector);
2879
+ if (!el) {
2880
+ return "";
2881
+ }
2882
+ const content = el.getAttribute("content") || el.textContent || "";
2883
+ return content.trim();
2884
+ };
2885
+ let description = pickContent("meta[name='description']") ||
2886
+ pickContent("meta[property='og:description']") ||
2887
+ pickContent("meta[name='twitter:description']");
2888
+ if (!description) {
2889
+ const h1 = document.querySelector("h1");
2890
+ const h1Text = h1 ? h1.textContent?.trim() : "";
2891
+ const paragraphs = Array.from(document.querySelectorAll("p"))
2892
+ .map((p) => p.textContent?.replace(/\s+/g, " ").trim() || "")
2893
+ .filter((text) => text.length > 40);
2894
+ const pText = paragraphs.length ? paragraphs[0] : "";
2895
+ if (h1Text && pText) {
2896
+ description = `${h1Text} - ${pText}`;
2897
+ }
2898
+ else {
2899
+ description = h1Text || pText;
2900
+ }
2901
+ }
2902
+ return description.replace(/\s+/g, " ").trim();
2903
+ },
2904
+ });
2905
+ if (!result) {
2906
+ return "";
2907
+ }
2908
+ return String(result).slice(0, DESCRIPTION_MAX_LENGTH);
2909
+ }
2910
+ catch {
2911
+ return "";
2912
+ }
2913
+ }
2914
+ async function reportTabs(params) {
2915
+ const snapshot = await getTabSnapshot();
2916
+ const selection = selectTabsByScope(snapshot, params);
2917
+ if (selection.error) {
2918
+ throw selection.error;
2919
+ }
2920
+ const windowLabels = buildWindowLabels(snapshot);
2921
+ const tabs = selection.tabs;
2922
+ const entries = [];
2923
+ for (const tab of tabs) {
2924
+ const description = isScriptableUrl(tab.url) ? await extractDescription(tab.tabId) : "";
2925
+ entries.push({
2926
+ tabId: tab.tabId,
2927
+ windowId: tab.windowId,
2928
+ windowLabel: windowLabels.get(tab.windowId) || `W${tab.windowId}`,
2929
+ groupId: tab.groupId,
2930
+ groupTitle: tab.groupTitle,
2931
+ groupColor: tab.groupColor,
2932
+ url: tab.url,
2933
+ title: tab.title,
2934
+ description,
2935
+ lastFocusedAt: tab.lastFocusedAt,
2936
+ });
2937
+ }
2938
+ return {
2939
+ generatedAt: Date.now(),
2940
+ entries,
2941
+ totals: {
2942
+ tabs: entries.length,
2943
+ },
2944
+ };
2945
+ }
2946
+ async function ensureWindow(windowId) {
2947
+ if (windowId) {
2948
+ try {
2949
+ const existing = await chrome.windows.get(windowId);
2950
+ if (existing) {
2951
+ return windowId;
2952
+ }
2953
+ }
2954
+ catch {
2955
+ // fall through to create
2956
+ }
2957
+ }
2958
+ const created = await chrome.windows.create({ focused: false });
2959
+ return created.id;
2960
+ }
2961
+ async function restoreTabsFromUndo(entries) {
2962
+ const skipped = [];
2963
+ const restored = [];
2964
+ const windowMap = new Map();
2965
+ for (const entry of entries) {
2966
+ const tabId = Number(entry.tabId);
2967
+ if (!Number.isFinite(tabId)) {
2968
+ skipped.push({ tabId: entry.tabId, reason: "missing_tab" });
2969
+ continue;
2970
+ }
2971
+ const sourceWindowId = Number(entry.windowId);
2972
+ if (!Number.isFinite(sourceWindowId)) {
2973
+ skipped.push({ tabId, reason: "missing_window" });
2974
+ continue;
2975
+ }
2976
+ let targetWindowId = windowMap.get(sourceWindowId);
2977
+ if (!targetWindowId) {
2978
+ targetWindowId = await ensureWindow(sourceWindowId);
2979
+ windowMap.set(sourceWindowId, targetWindowId);
2980
+ }
2981
+ try {
2982
+ await chrome.tabs.move(tabId, { windowId: targetWindowId, index: -1 });
2983
+ restored.push({ tabId, entry, targetWindowId });
2984
+ }
2985
+ catch {
2986
+ skipped.push({ tabId, reason: "move_failed" });
2987
+ }
2988
+ }
2989
+ const groupsByWindow = new Map();
2990
+ for (const item of restored) {
2991
+ const entry = item.entry;
2992
+ const rawGroupId = typeof entry.groupId === "number" ? entry.groupId : null;
2993
+ const groupId = rawGroupId != null && rawGroupId !== -1 ? rawGroupId : null;
2994
+ const groupTitle = typeof entry.groupTitle === "string" ? entry.groupTitle : null;
2995
+ if (!groupId && !groupTitle) {
2996
+ continue;
2997
+ }
2998
+ const groupKey = groupId != null ? `id:${groupId}` : `title:${groupTitle}`;
2999
+ const key = `${item.targetWindowId}:${groupKey}`;
3000
+ if (!groupsByWindow.has(key)) {
3001
+ groupsByWindow.set(key, {
3002
+ windowId: item.targetWindowId,
3003
+ groupId,
3004
+ groupTitle,
3005
+ groupColor: typeof entry.groupColor === "string" ? entry.groupColor : null,
3006
+ groupCollapsed: typeof entry.groupCollapsed === "boolean" ? entry.groupCollapsed : null,
3007
+ tabIds: [],
3008
+ });
3009
+ }
3010
+ groupsByWindow.get(key)?.tabIds.push(item.tabId);
3011
+ }
3012
+ for (const group of groupsByWindow.values()) {
3013
+ if (group.tabIds.length === 0) {
3014
+ continue;
3015
+ }
3016
+ let targetGroupId = null;
3017
+ if (group.groupId != null) {
3018
+ try {
3019
+ targetGroupId = await chrome.tabs.group({ groupId: group.groupId, tabIds: group.tabIds });
3020
+ }
3021
+ catch {
3022
+ targetGroupId = null;
3023
+ }
3024
+ }
3025
+ if (targetGroupId == null) {
3026
+ try {
3027
+ targetGroupId = await chrome.tabs.group({
3028
+ tabIds: group.tabIds,
3029
+ createProperties: { windowId: group.windowId },
3030
+ });
3031
+ }
3032
+ catch (error) {
3033
+ log("Failed to regroup tabs", error);
3034
+ continue;
3035
+ }
3036
+ }
3037
+ const update = {};
3038
+ if (typeof group.groupTitle === "string") {
3039
+ update.title = group.groupTitle;
3040
+ }
3041
+ if (typeof group.groupColor === "string" && group.groupColor) {
3042
+ update.color = group.groupColor;
3043
+ }
3044
+ if (typeof group.groupCollapsed === "boolean") {
3045
+ update.collapsed = group.groupCollapsed;
3046
+ }
3047
+ if (Object.keys(update).length > 0) {
3048
+ try {
3049
+ await chrome.tabGroups.update(targetGroupId, update);
3050
+ }
3051
+ catch (error) {
3052
+ log("Failed to update restored group", error);
3053
+ }
3054
+ }
3055
+ }
3056
+ const orderByWindow = new Map();
3057
+ for (const item of restored) {
3058
+ const index = Number(item.entry.index);
3059
+ if (!Number.isFinite(index)) {
3060
+ continue;
3061
+ }
3062
+ if (!orderByWindow.has(item.targetWindowId)) {
3063
+ orderByWindow.set(item.targetWindowId, []);
3064
+ }
3065
+ orderByWindow.get(item.targetWindowId)?.push({ tabId: item.tabId, index });
3066
+ }
3067
+ for (const [targetWindowId, items] of orderByWindow.entries()) {
3068
+ const ordered = [...items].sort((a, b) => a.index - b.index);
3069
+ for (const item of ordered) {
3070
+ try {
3071
+ await chrome.tabs.move(item.tabId, { windowId: targetWindowId, index: item.index });
3072
+ }
3073
+ catch {
3074
+ // ignore ordering failures
3075
+ }
3076
+ }
3077
+ }
3078
+ return {
3079
+ summary: {
3080
+ restoredTabs: restored.length,
3081
+ skippedTabs: skipped.length,
3082
+ },
3083
+ skipped,
3084
+ };
3085
+ }
3086
+ async function undoGroupUpdate(undo) {
3087
+ const groupId = Number(undo.groupId);
3088
+ if (!Number.isFinite(groupId)) {
3089
+ return {
3090
+ summary: { restoredGroups: 0, skippedGroups: 1 },
3091
+ skipped: [{ groupId: undo.groupId, reason: "missing_group" }],
3092
+ };
3093
+ }
3094
+ const previous = undo.previous || {};
3095
+ const update = {};
3096
+ if (typeof previous.title === "string") {
3097
+ update.title = previous.title;
3098
+ }
3099
+ if (typeof previous.color === "string" && previous.color) {
3100
+ update.color = previous.color;
3101
+ }
3102
+ if (typeof previous.collapsed === "boolean") {
3103
+ update.collapsed = previous.collapsed;
3104
+ }
3105
+ if (!Object.keys(update).length) {
3106
+ return {
3107
+ summary: { restoredGroups: 0, skippedGroups: 1 },
3108
+ skipped: [{ groupId, reason: "missing_values" }],
3109
+ };
3110
+ }
3111
+ try {
3112
+ await chrome.tabGroups.update(groupId, update);
3113
+ return {
3114
+ summary: { restoredGroups: 1, skippedGroups: 0 },
3115
+ skipped: [],
3116
+ };
3117
+ }
3118
+ catch {
3119
+ return {
3120
+ summary: { restoredGroups: 0, skippedGroups: 1 },
3121
+ skipped: [{ groupId, reason: "update_failed" }],
3122
+ };
3123
+ }
3124
+ }
3125
+ async function undoGroupUngroup(undo) {
3126
+ const tabs = undo.tabs || [];
3127
+ return await restoreTabsFromUndo(tabs);
3128
+ }
3129
+ async function undoGroupAssign(undo) {
3130
+ const tabs = undo.tabs || [];
3131
+ return await restoreTabsFromUndo(tabs);
3132
+ }
3133
+ async function undoMoveTab(undo) {
3134
+ const from = undo.from || {};
3135
+ const entry = {
3136
+ tabId: undo.tabId,
3137
+ windowId: from.windowId,
3138
+ index: from.index,
3139
+ groupId: from.groupId,
3140
+ groupTitle: from.groupTitle,
3141
+ groupColor: from.groupColor,
3142
+ groupCollapsed: from.groupCollapsed,
3143
+ };
3144
+ return await restoreTabsFromUndo([entry]);
3145
+ }
3146
+ async function undoMoveGroup(undo) {
3147
+ const tabs = undo.tabs || [];
3148
+ return await restoreTabsFromUndo(tabs);
3149
+ }
3150
+ async function undoMergeWindow(undo) {
3151
+ const tabs = undo.tabs || [];
3152
+ return await restoreTabsFromUndo(tabs);
3153
+ }
3154
+ async function undoArchive(undo) {
3155
+ const tabs = undo.tabs || [];
3156
+ const restored = [];
3157
+ const skipped = [];
3158
+ const windowMap = new Map();
3159
+ for (const entry of tabs) {
3160
+ if (!entry.tabId) {
3161
+ skipped.push({ tabId: entry.tabId, reason: "missing_tab" });
3162
+ continue;
3163
+ }
3164
+ let targetWindowId = windowMap.get(entry.from.windowId);
3165
+ if (!targetWindowId) {
3166
+ targetWindowId = await ensureWindow(entry.from.windowId);
3167
+ windowMap.set(entry.from.windowId, targetWindowId);
3168
+ }
3169
+ try {
3170
+ await chrome.tabs.move(entry.tabId, { windowId: targetWindowId, index: -1 });
3171
+ restored.push({ tabId: entry.tabId, targetWindowId });
3172
+ }
3173
+ catch {
3174
+ skipped.push({ tabId: entry.tabId, reason: "move_failed" });
3175
+ }
3176
+ }
3177
+ const restoredSet = new Set(restored.map((item) => item.tabId));
3178
+ const groupsByWindow = new Map();
3179
+ for (const entry of tabs) {
3180
+ if (!restoredSet.has(entry.tabId)) {
3181
+ continue;
3182
+ }
3183
+ const targetWindowId = windowMap.get(entry.from.windowId) || entry.from.windowId;
3184
+ const hasGroupId = entry.from.groupId != null && entry.from.groupId !== -1;
3185
+ const hasGroupTitle = entry.from.groupTitle != null;
3186
+ if (!hasGroupId && !hasGroupTitle) {
3187
+ continue;
3188
+ }
3189
+ const groupKey = hasGroupId ? entry.from.groupId : entry.from.groupTitle;
3190
+ const key = `${targetWindowId}:${groupKey}`;
3191
+ if (!groupsByWindow.has(key)) {
3192
+ groupsByWindow.set(key, []);
3193
+ }
3194
+ groupsByWindow.get(key)?.push(entry);
3195
+ }
3196
+ for (const [key, groupTabs] of groupsByWindow.entries()) {
3197
+ const [windowIdPart] = key.split(":");
3198
+ const targetWindowId = Number(windowIdPart);
3199
+ const tabIds = groupTabs.map((entry) => entry.tabId).filter(Boolean);
3200
+ if (!tabIds.length) {
3201
+ continue;
3202
+ }
3203
+ try {
3204
+ const groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId: targetWindowId } });
3205
+ await chrome.tabGroups.update(groupId, {
3206
+ title: groupTabs[0].from.groupTitle || "",
3207
+ color: groupTabs[0].from.groupColor || "grey",
3208
+ collapsed: groupTabs[0].from.groupCollapsed || false,
3209
+ });
3210
+ }
3211
+ catch (error) {
3212
+ log("Failed to recreate group", error);
3213
+ }
3214
+ }
3215
+ for (const [originalWindowId, targetWindowId] of windowMap.entries()) {
3216
+ const windowTabs = tabs
3217
+ .filter((entry) => entry.from.windowId === originalWindowId && restoredSet.has(entry.tabId))
3218
+ .sort((a, b) => a.from.index - b.from.index);
3219
+ for (const entry of windowTabs) {
3220
+ if (!entry.tabId) {
3221
+ continue;
3222
+ }
3223
+ try {
3224
+ await chrome.tabs.move(entry.tabId, { windowId: targetWindowId, index: entry.from.index });
3225
+ }
3226
+ catch {
3227
+ // ignore ordering failures
3228
+ }
3229
+ }
3230
+ const activeTab = windowTabs.find((entry) => entry.active);
3231
+ if (activeTab && activeTab.tabId) {
3232
+ try {
3233
+ await chrome.tabs.update(activeTab.tabId, { active: true });
3234
+ }
3235
+ catch {
3236
+ // ignore
3237
+ }
3238
+ }
3239
+ }
3240
+ return {
3241
+ summary: {
3242
+ restoredTabs: restored.length,
3243
+ skippedTabs: skipped.length,
3244
+ },
3245
+ skipped,
3246
+ };
3247
+ }
3248
+ async function undoClose(undo) {
3249
+ const tabs = undo.tabs || [];
3250
+ const restored = [];
3251
+ const skipped = [];
3252
+ const windowMap = new Map();
3253
+ for (const entry of tabs) {
3254
+ if (!entry.url) {
3255
+ skipped.push({ url: entry.url, reason: "missing_url" });
3256
+ continue;
3257
+ }
3258
+ let targetWindowId = windowMap.get(entry.from.windowId);
3259
+ if (!targetWindowId) {
3260
+ targetWindowId = await ensureWindow(entry.from.windowId);
3261
+ windowMap.set(entry.from.windowId, targetWindowId);
3262
+ }
3263
+ try {
3264
+ const created = await chrome.tabs.create({
3265
+ windowId: targetWindowId,
3266
+ url: entry.url,
3267
+ active: false,
3268
+ pinned: entry.pinned,
3269
+ });
3270
+ restored.push({ tabId: created.id, entry });
3271
+ }
3272
+ catch {
3273
+ skipped.push({ url: entry.url, reason: "create_failed" });
3274
+ }
3275
+ }
3276
+ const groupsByWindow = new Map();
3277
+ for (const item of restored) {
3278
+ const entry = item.entry;
3279
+ const targetWindowId = windowMap.get(entry.from.windowId) || entry.from.windowId;
3280
+ const hasGroupId = entry.from.groupId != null && entry.from.groupId !== -1;
3281
+ const hasGroupTitle = entry.from.groupTitle != null;
3282
+ if (!hasGroupId && !hasGroupTitle) {
3283
+ continue;
3284
+ }
3285
+ const groupKey = hasGroupId ? entry.from.groupId : entry.from.groupTitle;
3286
+ const key = `${targetWindowId}:${groupKey}`;
3287
+ if (!groupsByWindow.has(key)) {
3288
+ groupsByWindow.set(key, []);
3289
+ }
3290
+ groupsByWindow.get(key)?.push({ tabId: item.tabId, entry });
3291
+ }
3292
+ for (const [key, groupTabs] of groupsByWindow.entries()) {
3293
+ const [windowIdPart] = key.split(":");
3294
+ const targetWindowId = Number(windowIdPart);
3295
+ const tabIds = groupTabs.map((item) => item.tabId).filter(Boolean);
3296
+ if (!tabIds.length) {
3297
+ continue;
3298
+ }
3299
+ try {
3300
+ const groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId: targetWindowId } });
3301
+ await chrome.tabGroups.update(groupId, {
3302
+ title: groupTabs[0].entry.from.groupTitle || "",
3303
+ color: groupTabs[0].entry.from.groupColor || "grey",
3304
+ collapsed: groupTabs[0].entry.from.groupCollapsed || false,
3305
+ });
3306
+ }
3307
+ catch (error) {
3308
+ log("Failed to recreate group", error);
3309
+ }
3310
+ }
3311
+ for (const [originalWindowId, targetWindowId] of windowMap.entries()) {
3312
+ const windowTabs = restored
3313
+ .map((item) => ({ tabId: item.tabId, entry: item.entry }))
3314
+ .filter((item) => item.entry.from.windowId === originalWindowId)
3315
+ .sort((a, b) => a.entry.from.index - b.entry.from.index);
3316
+ for (const item of windowTabs) {
3317
+ try {
3318
+ await chrome.tabs.move(item.tabId, { windowId: targetWindowId, index: item.entry.from.index });
3319
+ }
3320
+ catch {
3321
+ // ignore ordering failures
3322
+ }
3323
+ }
3324
+ const activeTab = windowTabs.find((item) => item.entry.active);
3325
+ if (activeTab) {
3326
+ try {
3327
+ await chrome.tabs.update(activeTab.tabId, { active: true });
3328
+ }
3329
+ catch {
3330
+ // ignore
3331
+ }
3332
+ }
3333
+ }
3334
+ return {
3335
+ summary: {
3336
+ restoredTabs: restored.length,
3337
+ skippedTabs: skipped.length,
3338
+ },
3339
+ skipped,
3340
+ };
3341
+ }
3342
+ async function undoTransaction(params) {
3343
+ if (!params.record || !params.record.undo) {
3344
+ throw new Error("Undo record missing");
3345
+ }
3346
+ const undo = params.record.undo;
3347
+ if (undo.action === "archive") {
3348
+ return await undoArchive(undo);
3349
+ }
3350
+ if (undo.action === "close") {
3351
+ return await undoClose(undo);
3352
+ }
3353
+ if (undo.action === "group-update") {
3354
+ return await undoGroupUpdate(undo);
3355
+ }
3356
+ if (undo.action === "group-ungroup") {
3357
+ return await undoGroupUngroup(undo);
3358
+ }
3359
+ if (undo.action === "group-assign") {
3360
+ return await undoGroupAssign(undo);
3361
+ }
3362
+ if (undo.action === "move-tab") {
3363
+ return await undoMoveTab(undo);
3364
+ }
3365
+ if (undo.action === "move-group") {
3366
+ return await undoMoveGroup(undo);
3367
+ }
3368
+ if (undo.action === "merge-window") {
3369
+ return await undoMergeWindow(undo);
3370
+ }
3371
+ throw new Error(`Unknown undo action: ${undo.action}`);
3372
+ }