@flrande/bak-extension 0.2.4 → 0.3.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.
package/src/background.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import type { ConsoleEntry, Locator } from '@flrande/bak-protocol';
2
2
  import { isSupportedAutomationUrl } from './url-policy.js';
3
3
  import { computeReconnectDelayMs } from './reconnect.js';
4
+ import {
5
+ DEFAULT_WORKSPACE_ID,
6
+ type WorkspaceBrowser,
7
+ type WorkspaceColor,
8
+ type WorkspaceRecord,
9
+ type WorkspaceTab,
10
+ type WorkspaceWindow,
11
+ WorkspaceManager
12
+ } from './workspace.js';
4
13
 
5
14
  interface CliRequest {
6
15
  id: string;
@@ -35,6 +44,7 @@ const DEFAULT_PORT = 17373;
35
44
  const STORAGE_KEY_TOKEN = 'pairToken';
36
45
  const STORAGE_KEY_PORT = 'cliPort';
37
46
  const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
47
+ const STORAGE_KEY_WORKSPACE = 'agentWorkspace';
38
48
  const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
39
49
 
40
50
  let ws: WebSocket | null = null;
@@ -116,6 +126,184 @@ function normalizeUnhandledError(error: unknown): CliResponse['error'] {
116
126
  return toError('E_INTERNAL', message);
117
127
  }
118
128
 
129
+ function toTabInfo(tab: chrome.tabs.Tab): WorkspaceTab {
130
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
131
+ throw new Error('Tab is missing runtime identifiers');
132
+ }
133
+ return {
134
+ id: tab.id,
135
+ title: tab.title ?? '',
136
+ url: tab.url ?? '',
137
+ active: Boolean(tab.active),
138
+ windowId: tab.windowId,
139
+ groupId: typeof tab.groupId === 'number' && tab.groupId >= 0 ? tab.groupId : null
140
+ };
141
+ }
142
+
143
+ async function loadWorkspaceState(): Promise<WorkspaceRecord | null> {
144
+ const stored = await chrome.storage.local.get(STORAGE_KEY_WORKSPACE);
145
+ const state = stored[STORAGE_KEY_WORKSPACE];
146
+ if (!state || typeof state !== 'object') {
147
+ return null;
148
+ }
149
+ return state as WorkspaceRecord;
150
+ }
151
+
152
+ async function saveWorkspaceState(state: WorkspaceRecord | null): Promise<void> {
153
+ if (state === null) {
154
+ await chrome.storage.local.remove(STORAGE_KEY_WORKSPACE);
155
+ return;
156
+ }
157
+ await chrome.storage.local.set({ [STORAGE_KEY_WORKSPACE]: state });
158
+ }
159
+
160
+ const workspaceBrowser: WorkspaceBrowser = {
161
+ async getTab(tabId) {
162
+ try {
163
+ return toTabInfo(await chrome.tabs.get(tabId));
164
+ } catch {
165
+ return null;
166
+ }
167
+ },
168
+ async getActiveTab() {
169
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
170
+ const tab = tabs[0];
171
+ if (!tab) {
172
+ return null;
173
+ }
174
+ return toTabInfo(tab);
175
+ },
176
+ async listTabs(filter) {
177
+ const tabs = await chrome.tabs.query(filter?.windowId ? { windowId: filter.windowId } : {});
178
+ return tabs
179
+ .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
180
+ .map((tab) => toTabInfo(tab));
181
+ },
182
+ async createTab(options) {
183
+ const createdTab = await chrome.tabs.create({
184
+ windowId: options.windowId,
185
+ url: options.url ?? 'about:blank',
186
+ active: options.active
187
+ });
188
+ if (!createdTab) {
189
+ throw new Error('Tab creation returned no tab');
190
+ }
191
+ return toTabInfo(createdTab);
192
+ },
193
+ async updateTab(tabId, options) {
194
+ const updatedTab = await chrome.tabs.update(tabId, {
195
+ active: options.active,
196
+ url: options.url
197
+ });
198
+ if (!updatedTab) {
199
+ throw new Error(`Tab update returned no tab for ${tabId}`);
200
+ }
201
+ return toTabInfo(updatedTab);
202
+ },
203
+ async closeTab(tabId) {
204
+ await chrome.tabs.remove(tabId);
205
+ },
206
+ async getWindow(windowId) {
207
+ try {
208
+ const window = await chrome.windows.get(windowId);
209
+ return {
210
+ id: window.id!,
211
+ focused: Boolean(window.focused)
212
+ } satisfies WorkspaceWindow;
213
+ } catch {
214
+ return null;
215
+ }
216
+ },
217
+ async createWindow(options) {
218
+ const previouslyFocusedWindow =
219
+ options.focused === true
220
+ ? null
221
+ : (await chrome.windows.getAll()).find((window) => window.focused === true && typeof window.id === 'number') ?? null;
222
+ const previouslyFocusedTab =
223
+ previouslyFocusedWindow?.id !== undefined
224
+ ? (await chrome.tabs.query({ windowId: previouslyFocusedWindow.id, active: true })).find((tab) => typeof tab.id === 'number') ?? null
225
+ : null;
226
+ const created = await chrome.windows.create({
227
+ url: options.url ?? 'about:blank',
228
+ focused: true
229
+ });
230
+ if (!created || typeof created.id !== 'number') {
231
+ throw new Error('Window missing id');
232
+ }
233
+ if (options.focused !== true && previouslyFocusedWindow?.id && previouslyFocusedWindow.id !== created.id) {
234
+ await chrome.windows.update(previouslyFocusedWindow.id, { focused: true });
235
+ if (typeof previouslyFocusedTab?.id === 'number') {
236
+ await chrome.tabs.update(previouslyFocusedTab.id, { active: true });
237
+ }
238
+ }
239
+ const finalWindow = await chrome.windows.get(created.id);
240
+ return {
241
+ id: finalWindow.id!,
242
+ focused: Boolean(finalWindow.focused)
243
+ };
244
+ },
245
+ async updateWindow(windowId, options) {
246
+ const updated = await chrome.windows.update(windowId, {
247
+ focused: options.focused
248
+ });
249
+ if (!updated || typeof updated.id !== 'number') {
250
+ throw new Error('Window missing id');
251
+ }
252
+ return {
253
+ id: updated.id,
254
+ focused: Boolean(updated.focused)
255
+ };
256
+ },
257
+ async closeWindow(windowId) {
258
+ await chrome.windows.remove(windowId);
259
+ },
260
+ async getGroup(groupId) {
261
+ try {
262
+ const group = await chrome.tabGroups.get(groupId);
263
+ return {
264
+ id: group.id,
265
+ windowId: group.windowId,
266
+ title: group.title ?? '',
267
+ color: group.color as WorkspaceColor,
268
+ collapsed: Boolean(group.collapsed)
269
+ };
270
+ } catch {
271
+ return null;
272
+ }
273
+ },
274
+ async groupTabs(tabIds, groupId) {
275
+ return await chrome.tabs.group({
276
+ tabIds: tabIds as [number, ...number[]],
277
+ groupId
278
+ });
279
+ },
280
+ async updateGroup(groupId, options) {
281
+ const updated = await chrome.tabGroups.update(groupId, {
282
+ title: options.title,
283
+ color: options.color,
284
+ collapsed: options.collapsed
285
+ });
286
+ if (!updated) {
287
+ throw new Error(`Tab group update returned no group for ${groupId}`);
288
+ }
289
+ return {
290
+ id: updated.id,
291
+ windowId: updated.windowId,
292
+ title: updated.title ?? '',
293
+ color: updated.color as WorkspaceColor,
294
+ collapsed: Boolean(updated.collapsed)
295
+ };
296
+ }
297
+ };
298
+
299
+ const workspaceManager = new WorkspaceManager(
300
+ {
301
+ load: loadWorkspaceState,
302
+ save: saveWorkspaceState
303
+ },
304
+ workspaceBrowser
305
+ );
306
+
119
307
  async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
120
308
  try {
121
309
  const current = await chrome.tabs.get(tabId);
@@ -183,33 +371,55 @@ async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TI
183
371
  });
184
372
  }
185
373
 
374
+ async function waitForTabUrl(tabId: number, expectedUrl: string, timeoutMs = 10_000): Promise<void> {
375
+ const deadline = Date.now() + timeoutMs;
376
+ while (Date.now() < deadline) {
377
+ try {
378
+ const tab = await chrome.tabs.get(tabId);
379
+ const currentUrl = tab.url ?? '';
380
+ const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
381
+ if (currentUrl === expectedUrl || pendingUrl === expectedUrl) {
382
+ return;
383
+ }
384
+ } catch {
385
+ // Ignore transient lookup failures while the tab is navigating.
386
+ }
387
+ await new Promise((resolve) => setTimeout(resolve, 100));
388
+ }
389
+
390
+ throw new Error(`tab url timeout: ${tabId} -> ${expectedUrl}`);
391
+ }
392
+
186
393
  interface WithTabOptions {
187
394
  requireSupportedAutomationUrl?: boolean;
188
395
  }
189
396
 
190
- async function withTab(tabId?: number, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
397
+ async function withTab(target: { tabId?: number; workspaceId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
191
398
  const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
192
399
  const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
193
400
  if (!tab.id) {
194
401
  throw toError('E_NOT_FOUND', 'Tab missing id');
195
402
  }
196
- if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab.url)) {
403
+ const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
404
+ if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab.url) && !isSupportedAutomationUrl(pendingUrl)) {
197
405
  throw toError('E_PERMISSION', 'Unsupported tab URL: only http/https pages can be automated', {
198
- url: tab.url ?? ''
406
+ url: tab.url ?? pendingUrl ?? ''
199
407
  });
200
408
  }
201
409
  return tab;
202
410
  };
203
411
 
204
- if (typeof tabId === 'number') {
205
- const tab = await chrome.tabs.get(tabId);
412
+ if (typeof target.tabId === 'number') {
413
+ const tab = await chrome.tabs.get(target.tabId);
206
414
  return validate(tab);
207
415
  }
208
- const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
209
- const tab = tabs[0];
210
- if (!tab) {
211
- throw toError('E_NOT_FOUND', 'No active tab');
212
- }
416
+
417
+ const resolved = await workspaceManager.resolveTarget({
418
+ tabId: target.tabId,
419
+ workspaceId: typeof target.workspaceId === 'string' ? target.workspaceId : undefined,
420
+ createIfMissing: false
421
+ });
422
+ const tab = await chrome.tabs.get(resolved.tab.id);
213
423
  return validate(tab);
214
424
  }
215
425
 
@@ -241,7 +451,7 @@ async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string
241
451
  }
242
452
 
243
453
  async function sendToContent<TResponse>(tabId: number, message: Record<string, unknown>): Promise<TResponse> {
244
- const maxAttempts = 6;
454
+ const maxAttempts = 10;
245
455
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
246
456
  try {
247
457
  const response = await chrome.tabs.sendMessage(tabId, message);
@@ -260,13 +470,57 @@ async function sendToContent<TResponse>(tabId: number, message: Record<string, u
260
470
  if (!retriable || attempt >= maxAttempts) {
261
471
  throw toError('E_NOT_READY', 'Content script unavailable', { detail });
262
472
  }
263
- await new Promise((resolve) => setTimeout(resolve, 150 * attempt));
473
+ await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
264
474
  }
265
475
  }
266
476
 
267
477
  throw toError('E_NOT_READY', 'Content script unavailable');
268
478
  }
269
479
 
480
+ interface FocusContext {
481
+ windowId: number | null;
482
+ tabId: number | null;
483
+ }
484
+
485
+ async function captureFocusContext(): Promise<FocusContext> {
486
+ const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
487
+ const activeTab = activeTabs.find((tab) => typeof tab.id === 'number' && typeof tab.windowId === 'number') ?? null;
488
+ return {
489
+ windowId: activeTab?.windowId ?? null,
490
+ tabId: activeTab?.id ?? null
491
+ };
492
+ }
493
+
494
+ async function restoreFocusContext(context: FocusContext): Promise<void> {
495
+ if (context.windowId !== null) {
496
+ try {
497
+ await chrome.windows.update(context.windowId, { focused: true });
498
+ } catch {
499
+ // Ignore restore errors if the original window no longer exists.
500
+ }
501
+ }
502
+ if (context.tabId !== null) {
503
+ try {
504
+ await chrome.tabs.update(context.tabId, { active: true });
505
+ } catch {
506
+ // Ignore restore errors if the original tab no longer exists.
507
+ }
508
+ }
509
+ }
510
+
511
+ async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
512
+ if (!enabled) {
513
+ return action();
514
+ }
515
+
516
+ const focusContext = await captureFocusContext();
517
+ try {
518
+ return await action();
519
+ } finally {
520
+ await restoreFocusContext(focusContext);
521
+ }
522
+ }
523
+
270
524
  function requireRpcEnvelope(
271
525
  method: string,
272
526
  value: unknown
@@ -298,6 +552,10 @@ async function forwardContentRpc(
298
552
 
299
553
  async function handleRequest(request: CliRequest): Promise<unknown> {
300
554
  const params = request.params ?? {};
555
+ const target = {
556
+ tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
557
+ workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined
558
+ };
301
559
 
302
560
  const rpcForwardMethods = new Set([
303
561
  'page.title',
@@ -345,13 +603,8 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
345
603
  const tabs = await chrome.tabs.query({});
346
604
  return {
347
605
  tabs: tabs
348
- .filter((tab) => typeof tab.id === 'number')
349
- .map((tab) => ({
350
- id: tab.id as number,
351
- title: tab.title ?? '',
352
- url: tab.url ?? '',
353
- active: tab.active
354
- }))
606
+ .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
607
+ .map((tab) => toTabInfo(tab))
355
608
  };
356
609
  }
357
610
  case 'tabs.getActive': {
@@ -361,12 +614,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
361
614
  return { tab: null };
362
615
  }
363
616
  return {
364
- tab: {
365
- id: tab.id,
366
- title: tab.title ?? '',
367
- url: tab.url ?? '',
368
- active: Boolean(tab.active)
369
- }
617
+ tab: toTabInfo(tab)
370
618
  };
371
619
  }
372
620
  case 'tabs.get': {
@@ -376,12 +624,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
376
624
  throw toError('E_NOT_FOUND', 'Tab missing id');
377
625
  }
378
626
  return {
379
- tab: {
380
- id: tab.id,
381
- title: tab.title ?? '',
382
- url: tab.url ?? '',
383
- active: Boolean(tab.active)
384
- }
627
+ tab: toTabInfo(tab)
385
628
  };
386
629
  }
387
630
  case 'tabs.focus': {
@@ -390,170 +633,281 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
390
633
  return { ok: true };
391
634
  }
392
635
  case 'tabs.new': {
393
- const tab = await chrome.tabs.create({ url: (params.url as string | undefined) ?? 'about:blank' });
394
- return { tabId: tab.id };
636
+ if (typeof params.workspaceId === 'string' || params.windowId === undefined) {
637
+ const opened = await preserveHumanFocus(true, async () => {
638
+ return await workspaceManager.openTab({
639
+ workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : DEFAULT_WORKSPACE_ID,
640
+ url: (params.url as string | undefined) ?? 'about:blank',
641
+ active: params.active === true,
642
+ focus: false
643
+ });
644
+ });
645
+ return {
646
+ tabId: opened.tab.id,
647
+ windowId: opened.tab.windowId,
648
+ groupId: opened.workspace.groupId,
649
+ workspaceId: opened.workspace.id
650
+ };
651
+ }
652
+ const tab = await chrome.tabs.create({
653
+ url: (params.url as string | undefined) ?? 'about:blank',
654
+ windowId: typeof params.windowId === 'number' ? params.windowId : undefined,
655
+ active: params.active === true
656
+ });
657
+ if (params.addToGroup === true && typeof tab.id === 'number') {
658
+ const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
659
+ return {
660
+ tabId: tab.id,
661
+ windowId: tab.windowId,
662
+ groupId
663
+ };
664
+ }
665
+ return {
666
+ tabId: tab.id,
667
+ windowId: tab.windowId
668
+ };
395
669
  }
396
670
  case 'tabs.close': {
397
671
  const tabId = Number(params.tabId);
398
672
  await chrome.tabs.remove(tabId);
399
673
  return { ok: true };
400
674
  }
675
+ case 'workspace.ensure': {
676
+ return preserveHumanFocus(params.focus !== true, async () => {
677
+ return await workspaceManager.ensureWorkspace({
678
+ workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
679
+ focus: params.focus === true,
680
+ initialUrl: typeof params.url === 'string' ? params.url : undefined
681
+ });
682
+ });
683
+ }
684
+ case 'workspace.info': {
685
+ return {
686
+ workspace: await workspaceManager.getWorkspaceInfo(typeof params.workspaceId === 'string' ? params.workspaceId : undefined)
687
+ };
688
+ }
689
+ case 'workspace.openTab': {
690
+ return await preserveHumanFocus(params.focus !== true, async () => {
691
+ return await workspaceManager.openTab({
692
+ workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
693
+ url: typeof params.url === 'string' ? params.url : undefined,
694
+ active: params.active === true,
695
+ focus: params.focus === true
696
+ });
697
+ });
698
+ }
699
+ case 'workspace.listTabs': {
700
+ return await workspaceManager.listTabs(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
701
+ }
702
+ case 'workspace.getActiveTab': {
703
+ return await workspaceManager.getActiveTab(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
704
+ }
705
+ case 'workspace.setActiveTab': {
706
+ return await workspaceManager.setActiveTab(Number(params.tabId), typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
707
+ }
708
+ case 'workspace.focus': {
709
+ return await workspaceManager.focus(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
710
+ }
711
+ case 'workspace.reset': {
712
+ return await preserveHumanFocus(params.focus !== true, async () => {
713
+ return await workspaceManager.reset({
714
+ workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
715
+ focus: params.focus === true,
716
+ initialUrl: typeof params.url === 'string' ? params.url : undefined
717
+ });
718
+ });
719
+ }
720
+ case 'workspace.close': {
721
+ return await workspaceManager.close(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
722
+ }
401
723
  case 'page.goto': {
402
- const tab = await withTab(params.tabId as number | undefined, {
403
- requireSupportedAutomationUrl: false
724
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
725
+ const tab = await withTab(target, {
726
+ requireSupportedAutomationUrl: false
727
+ });
728
+ const url = String(params.url ?? 'about:blank');
729
+ await chrome.tabs.update(tab.id!, { url });
730
+ await waitForTabUrl(tab.id!, url);
731
+ await forwardContentRpc(tab.id!, 'page.url', { tabId: tab.id }).catch(() => undefined);
732
+ await waitForTabComplete(tab.id!, 5_000).catch(() => undefined);
733
+ return { ok: true };
404
734
  });
405
- await chrome.tabs.update(tab.id!, { url: String(params.url ?? 'about:blank') });
406
- await waitForTabComplete(tab.id!);
407
- return { ok: true };
408
735
  }
409
736
  case 'page.back': {
410
- const tab = await withTab(params.tabId as number | undefined);
411
- await chrome.tabs.goBack(tab.id!);
412
- await waitForTabComplete(tab.id!);
413
- return { ok: true };
737
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
738
+ const tab = await withTab(target);
739
+ await chrome.tabs.goBack(tab.id!);
740
+ await waitForTabComplete(tab.id!);
741
+ return { ok: true };
742
+ });
414
743
  }
415
744
  case 'page.forward': {
416
- const tab = await withTab(params.tabId as number | undefined);
417
- await chrome.tabs.goForward(tab.id!);
418
- await waitForTabComplete(tab.id!);
419
- return { ok: true };
745
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
746
+ const tab = await withTab(target);
747
+ await chrome.tabs.goForward(tab.id!);
748
+ await waitForTabComplete(tab.id!);
749
+ return { ok: true };
750
+ });
420
751
  }
421
752
  case 'page.reload': {
422
- const tab = await withTab(params.tabId as number | undefined);
423
- await chrome.tabs.reload(tab.id!);
424
- await waitForTabComplete(tab.id!);
425
- return { ok: true };
753
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
754
+ const tab = await withTab(target);
755
+ await chrome.tabs.reload(tab.id!);
756
+ await waitForTabComplete(tab.id!);
757
+ return { ok: true };
758
+ });
426
759
  }
427
760
  case 'page.viewport': {
428
- const tab = await withTab(params.tabId as number | undefined, {
429
- requireSupportedAutomationUrl: false
430
- });
431
- if (typeof tab.windowId !== 'number') {
432
- throw toError('E_NOT_FOUND', 'Tab window unavailable');
433
- }
434
-
435
- const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
436
- const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
437
- if (width || height) {
438
- await chrome.windows.update(tab.windowId, {
439
- width,
440
- height
761
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
762
+ const tab = await withTab(target, {
763
+ requireSupportedAutomationUrl: false
441
764
  });
442
- }
765
+ if (typeof tab.windowId !== 'number') {
766
+ throw toError('E_NOT_FOUND', 'Tab window unavailable');
767
+ }
443
768
 
444
- const viewport = (await forwardContentRpc(tab.id!, 'page.viewport', {})) as {
445
- width: number;
446
- height: number;
447
- devicePixelRatio: number;
448
- };
449
- const viewWidth = typeof width === 'number' ? width : viewport.width ?? tab.width ?? 0;
450
- const viewHeight = typeof height === 'number' ? height : viewport.height ?? tab.height ?? 0;
451
- return {
452
- width: viewWidth,
453
- height: viewHeight,
454
- devicePixelRatio: viewport.devicePixelRatio
455
- };
769
+ const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
770
+ const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
771
+ if (width || height) {
772
+ await chrome.windows.update(tab.windowId, {
773
+ width,
774
+ height
775
+ });
776
+ }
777
+
778
+ const viewport = (await forwardContentRpc(tab.id!, 'page.viewport', {})) as {
779
+ width: number;
780
+ height: number;
781
+ devicePixelRatio: number;
782
+ };
783
+ const viewWidth = typeof width === 'number' ? width : viewport.width ?? tab.width ?? 0;
784
+ const viewHeight = typeof height === 'number' ? height : viewport.height ?? tab.height ?? 0;
785
+ return {
786
+ width: viewWidth,
787
+ height: viewHeight,
788
+ devicePixelRatio: viewport.devicePixelRatio
789
+ };
790
+ });
456
791
  }
457
792
  case 'page.snapshot': {
458
- const tab = await withTab(params.tabId as number | undefined);
459
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
460
- throw toError('E_NOT_FOUND', 'Tab missing id');
461
- }
462
- const includeBase64 = params.includeBase64 !== false;
463
- const config = await getConfig();
464
- const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
465
- type: 'bak.collectElements',
466
- debugRichText: config.debugRichText
793
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
794
+ const tab = await withTab(target);
795
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
796
+ throw toError('E_NOT_FOUND', 'Tab missing id');
797
+ }
798
+ const includeBase64 = params.includeBase64 !== false;
799
+ const config = await getConfig();
800
+ const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
801
+ type: 'bak.collectElements',
802
+ debugRichText: config.debugRichText
803
+ });
804
+ const imageData = await captureAlignedTabScreenshot(tab);
805
+ return {
806
+ imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
807
+ elements: elements.elements,
808
+ tabId: tab.id,
809
+ url: tab.url ?? ''
810
+ };
467
811
  });
468
- const imageData = await captureAlignedTabScreenshot(tab);
469
- return {
470
- imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
471
- elements: elements.elements,
472
- tabId: tab.id,
473
- url: tab.url ?? ''
474
- };
475
812
  }
476
813
  case 'element.click': {
477
- const tab = await withTab(params.tabId as number | undefined);
478
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
479
- type: 'bak.performAction',
480
- action: 'click',
481
- locator: params.locator as Locator,
482
- requiresConfirm: params.requiresConfirm === true
814
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
815
+ const tab = await withTab(target);
816
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
817
+ type: 'bak.performAction',
818
+ action: 'click',
819
+ locator: params.locator as Locator,
820
+ requiresConfirm: params.requiresConfirm === true
821
+ });
822
+ if (!response.ok) {
823
+ throw response.error ?? toError('E_INTERNAL', 'element.click failed');
824
+ }
825
+ return { ok: true };
483
826
  });
484
- if (!response.ok) {
485
- throw response.error ?? toError('E_INTERNAL', 'element.click failed');
486
- }
487
- return { ok: true };
488
827
  }
489
828
  case 'element.type': {
490
- const tab = await withTab(params.tabId as number | undefined);
491
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
492
- type: 'bak.performAction',
493
- action: 'type',
494
- locator: params.locator as Locator,
495
- text: String(params.text ?? ''),
496
- clear: Boolean(params.clear),
497
- requiresConfirm: params.requiresConfirm === true
829
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
830
+ const tab = await withTab(target);
831
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
832
+ type: 'bak.performAction',
833
+ action: 'type',
834
+ locator: params.locator as Locator,
835
+ text: String(params.text ?? ''),
836
+ clear: Boolean(params.clear),
837
+ requiresConfirm: params.requiresConfirm === true
838
+ });
839
+ if (!response.ok) {
840
+ throw response.error ?? toError('E_INTERNAL', 'element.type failed');
841
+ }
842
+ return { ok: true };
498
843
  });
499
- if (!response.ok) {
500
- throw response.error ?? toError('E_INTERNAL', 'element.type failed');
501
- }
502
- return { ok: true };
503
844
  }
504
845
  case 'element.scroll': {
505
- const tab = await withTab(params.tabId as number | undefined);
506
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
507
- type: 'bak.performAction',
508
- action: 'scroll',
509
- locator: params.locator as Locator,
510
- dx: Number(params.dx ?? 0),
511
- dy: Number(params.dy ?? 320)
846
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
847
+ const tab = await withTab(target);
848
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
849
+ type: 'bak.performAction',
850
+ action: 'scroll',
851
+ locator: params.locator as Locator,
852
+ dx: Number(params.dx ?? 0),
853
+ dy: Number(params.dy ?? 320)
854
+ });
855
+ if (!response.ok) {
856
+ throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
857
+ }
858
+ return { ok: true };
512
859
  });
513
- if (!response.ok) {
514
- throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
515
- }
516
- return { ok: true };
517
860
  }
518
861
  case 'page.wait': {
519
- const tab = await withTab(params.tabId as number | undefined);
520
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
521
- type: 'bak.waitFor',
522
- mode: String(params.mode ?? 'selector'),
523
- value: String(params.value ?? ''),
524
- timeoutMs: Number(params.timeoutMs ?? 5000)
862
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
863
+ const tab = await withTab(target);
864
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
865
+ type: 'bak.waitFor',
866
+ mode: String(params.mode ?? 'selector'),
867
+ value: String(params.value ?? ''),
868
+ timeoutMs: Number(params.timeoutMs ?? 5000)
869
+ });
870
+ if (!response.ok) {
871
+ throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
872
+ }
873
+ return { ok: true };
525
874
  });
526
- if (!response.ok) {
527
- throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
528
- }
529
- return { ok: true };
530
875
  }
531
876
  case 'debug.getConsole': {
532
- const tab = await withTab(params.tabId as number | undefined);
533
- const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
534
- type: 'bak.getConsole',
535
- limit: Number(params.limit ?? 50)
877
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
878
+ const tab = await withTab(target);
879
+ const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
880
+ type: 'bak.getConsole',
881
+ limit: Number(params.limit ?? 50)
882
+ });
883
+ return { entries: response.entries };
536
884
  });
537
- return { entries: response.entries };
538
885
  }
539
886
  case 'ui.selectCandidate': {
540
- const tab = await withTab(params.tabId as number | undefined);
541
- const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
542
- tab.id!,
543
- {
544
- type: 'bak.selectCandidate',
545
- candidates: params.candidates
887
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
888
+ const tab = await withTab(target);
889
+ const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
890
+ tab.id!,
891
+ {
892
+ type: 'bak.selectCandidate',
893
+ candidates: params.candidates
894
+ }
895
+ );
896
+ if (!response.ok || !response.selectedEid) {
897
+ throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
546
898
  }
547
- );
548
- if (!response.ok || !response.selectedEid) {
549
- throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
550
- }
551
- return { selectedEid: response.selectedEid };
899
+ return { selectedEid: response.selectedEid };
900
+ });
552
901
  }
553
902
  default:
554
903
  if (rpcForwardMethods.has(request.method)) {
555
- const tab = await withTab(params.tabId as number | undefined);
556
- return forwardContentRpc(tab.id!, request.method, params);
904
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
905
+ const tab = await withTab(target);
906
+ return await forwardContentRpc(tab.id!, request.method, {
907
+ ...params,
908
+ tabId: tab.id
909
+ });
910
+ });
557
911
  }
558
912
  throw toError('E_NOT_FOUND', `Unsupported method from CLI bridge: ${request.method}`);
559
913
  }
@@ -647,6 +1001,49 @@ async function connectWebSocket(): Promise<void> {
647
1001
  });
648
1002
  }
649
1003
 
1004
+ chrome.tabs.onRemoved.addListener((tabId) => {
1005
+ void loadWorkspaceState().then(async (state) => {
1006
+ if (!state || !state.tabIds.includes(tabId)) {
1007
+ return;
1008
+ }
1009
+ const nextTabIds = state.tabIds.filter((id) => id !== tabId);
1010
+ await saveWorkspaceState({
1011
+ ...state,
1012
+ tabIds: nextTabIds,
1013
+ activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
1014
+ primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
1015
+ });
1016
+ });
1017
+ });
1018
+
1019
+ chrome.tabs.onActivated.addListener((activeInfo) => {
1020
+ void loadWorkspaceState().then(async (state) => {
1021
+ if (!state || state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
1022
+ return;
1023
+ }
1024
+ await saveWorkspaceState({
1025
+ ...state,
1026
+ activeTabId: activeInfo.tabId
1027
+ });
1028
+ });
1029
+ });
1030
+
1031
+ chrome.windows.onRemoved.addListener((windowId) => {
1032
+ void loadWorkspaceState().then(async (state) => {
1033
+ if (!state || state.windowId !== windowId) {
1034
+ return;
1035
+ }
1036
+ await saveWorkspaceState({
1037
+ ...state,
1038
+ windowId: null,
1039
+ groupId: null,
1040
+ tabIds: [],
1041
+ activeTabId: null,
1042
+ primaryTabId: null
1043
+ });
1044
+ });
1045
+ });
1046
+
650
1047
  chrome.runtime.onInstalled.addListener(() => {
651
1048
  void setConfig({ port: DEFAULT_PORT, debugRichText: false });
652
1049
  });