@flrande/bak-extension 0.3.8 → 0.5.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,1160 +1,1181 @@
1
- import type { ConsoleEntry, Locator } from '@flrande/bak-protocol';
2
- import { isSupportedAutomationUrl } from './url-policy.js';
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';
13
-
14
- interface CliRequest {
15
- id: string;
16
- method: string;
17
- params?: Record<string, unknown>;
18
- }
19
-
20
- interface CliResponse {
21
- id: string;
22
- ok: boolean;
23
- result?: unknown;
24
- error?: {
25
- code: string;
26
- message: string;
27
- data?: Record<string, unknown>;
28
- };
29
- }
30
-
31
- interface ExtensionConfig {
32
- token: string;
33
- port: number;
34
- debugRichText: boolean;
35
- }
36
-
37
- interface RuntimeErrorDetails {
38
- message: string;
39
- context: 'config' | 'socket' | 'request' | 'parse';
40
- at: number;
41
- }
42
-
43
- const DEFAULT_PORT = 17373;
44
- const STORAGE_KEY_TOKEN = 'pairToken';
45
- const STORAGE_KEY_PORT = 'cliPort';
46
- const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
47
- const STORAGE_KEY_WORKSPACE = 'agentWorkspace';
48
- const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
49
-
50
- let ws: WebSocket | null = null;
51
- let reconnectTimer: number | null = null;
52
- let nextReconnectInMs: number | null = null;
53
- let reconnectAttempt = 0;
54
- let lastError: RuntimeErrorDetails | null = null;
55
- let manualDisconnect = false;
56
-
57
- async function getConfig(): Promise<ExtensionConfig> {
58
- const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
59
- return {
60
- token: typeof stored[STORAGE_KEY_TOKEN] === 'string' ? stored[STORAGE_KEY_TOKEN] : '',
61
- port: typeof stored[STORAGE_KEY_PORT] === 'number' ? stored[STORAGE_KEY_PORT] : DEFAULT_PORT,
62
- debugRichText: stored[STORAGE_KEY_DEBUG_RICH_TEXT] === true
63
- };
64
- }
65
-
66
- async function setConfig(config: Partial<ExtensionConfig>): Promise<void> {
67
- const payload: Record<string, unknown> = {};
68
- if (typeof config.token === 'string') {
69
- payload[STORAGE_KEY_TOKEN] = config.token;
70
- }
71
- if (typeof config.port === 'number') {
72
- payload[STORAGE_KEY_PORT] = config.port;
73
- }
74
- if (typeof config.debugRichText === 'boolean') {
75
- payload[STORAGE_KEY_DEBUG_RICH_TEXT] = config.debugRichText;
76
- }
77
- if (Object.keys(payload).length > 0) {
78
- await chrome.storage.local.set(payload);
79
- }
80
- }
81
-
82
- function setRuntimeError(message: string, context: RuntimeErrorDetails['context']): void {
83
- lastError = {
84
- message,
85
- context,
86
- at: Date.now()
87
- };
88
- }
89
-
90
- function clearReconnectTimer(): void {
91
- if (reconnectTimer !== null) {
92
- clearTimeout(reconnectTimer);
93
- reconnectTimer = null;
94
- }
95
- nextReconnectInMs = null;
96
- }
97
-
98
- function sendResponse(payload: CliResponse): void {
99
- if (ws && ws.readyState === WebSocket.OPEN) {
100
- ws.send(JSON.stringify(payload));
101
- }
102
- }
103
-
104
- function toError(code: string, message: string, data?: Record<string, unknown>): CliResponse['error'] {
105
- return { code, message, data };
106
- }
107
-
108
- function normalizeUnhandledError(error: unknown): CliResponse['error'] {
109
- if (typeof error === 'object' && error !== null && 'code' in error) {
110
- return error as CliResponse['error'];
111
- }
112
-
113
- const message = error instanceof Error ? error.message : String(error);
114
- const lower = message.toLowerCase();
115
-
116
- if (lower.includes('no tab with id') || lower.includes('no window with id')) {
117
- return toError('E_NOT_FOUND', message);
118
- }
119
- if (lower.includes('invalid url') || lower.includes('url is invalid')) {
120
- return toError('E_INVALID_PARAMS', message);
121
- }
122
- if (lower.includes('cannot access contents of url') || lower.includes('permission denied')) {
123
- return toError('E_PERMISSION', message);
124
- }
125
-
126
- return toError('E_INTERNAL', message);
127
- }
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
-
307
- async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
308
- try {
309
- const current = await chrome.tabs.get(tabId);
310
- if (current.status === 'complete') {
311
- return;
312
- }
313
- } catch {
314
- return;
315
- }
316
-
317
- await new Promise<void>((resolve, reject) => {
318
- let done = false;
319
- const probeStatus = (): void => {
320
- void chrome.tabs
321
- .get(tabId)
322
- .then((tab) => {
323
- if (tab.status === 'complete') {
324
- finish();
325
- }
326
- })
327
- .catch(() => {
328
- finish(new Error(`tab removed before load complete: ${tabId}`));
329
- });
330
- };
331
-
332
- const finish = (error?: Error): void => {
333
- if (done) {
334
- return;
335
- }
336
- done = true;
337
- clearTimeout(timeoutTimer);
338
- clearInterval(pollTimer);
339
- chrome.tabs.onUpdated.removeListener(onUpdated);
340
- chrome.tabs.onRemoved.removeListener(onRemoved);
341
- if (error) {
342
- reject(error);
343
- return;
344
- }
345
- resolve();
346
- };
347
-
348
- const onUpdated = (updatedTabId: number, changeInfo: { status?: string }): void => {
349
- if (updatedTabId !== tabId) {
350
- return;
351
- }
352
- if (changeInfo.status === 'complete') {
353
- finish();
354
- }
355
- };
356
-
357
- const onRemoved = (removedTabId: number): void => {
358
- if (removedTabId === tabId) {
359
- finish(new Error(`tab removed before load complete: ${tabId}`));
360
- }
361
- };
362
-
363
- const pollTimer = setInterval(probeStatus, 250);
364
- const timeoutTimer = setTimeout(() => {
365
- finish(new Error(`tab load timeout: ${tabId}`));
366
- }, timeoutMs);
367
-
368
- chrome.tabs.onUpdated.addListener(onUpdated);
369
- chrome.tabs.onRemoved.addListener(onRemoved);
370
- probeStatus();
371
- });
372
- }
373
-
374
- async function waitForTabUrl(tabId: number, expectedUrl: string, timeoutMs = 10_000): Promise<void> {
375
- const normalizedExpectedUrl = normalizeComparableTabUrl(expectedUrl);
376
- const deadline = Date.now() + timeoutMs;
377
- while (Date.now() < deadline) {
378
- try {
379
- const tab = await chrome.tabs.get(tabId);
380
- const currentUrl = tab.url ?? '';
381
- const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
382
- if (
383
- normalizeComparableTabUrl(currentUrl) === normalizedExpectedUrl ||
384
- normalizeComparableTabUrl(pendingUrl) === normalizedExpectedUrl
385
- ) {
386
- return;
387
- }
388
- } catch {
389
- // Ignore transient lookup failures while the tab is navigating.
390
- }
391
- await new Promise((resolve) => setTimeout(resolve, 100));
392
- }
393
-
394
- throw new Error(`tab url timeout: ${tabId} -> ${expectedUrl}`);
395
- }
396
-
397
- function normalizeComparableTabUrl(url: string): string {
398
- const raw = url.trim();
399
- if (!raw) {
400
- return raw;
401
- }
402
- try {
403
- const parsed = new URL(raw);
404
- parsed.hash = '';
405
- return parsed.href;
406
- } catch {
407
- return raw;
408
- }
409
- }
410
-
411
- async function finalizeOpenedWorkspaceTab(
412
- opened: Awaited<ReturnType<WorkspaceManager['openTab']>>,
413
- expectedUrl?: string
414
- ): Promise<Awaited<ReturnType<WorkspaceManager['openTab']>>> {
415
- if (expectedUrl && expectedUrl !== 'about:blank') {
416
- await waitForTabUrl(opened.tab.id, expectedUrl).catch(() => undefined);
417
- }
418
- let refreshedTab = opened.tab;
419
- try {
420
- const rawTab = await chrome.tabs.get(opened.tab.id);
421
- const pendingUrl = 'pendingUrl' in rawTab && typeof rawTab.pendingUrl === 'string' ? rawTab.pendingUrl : '';
422
- const currentUrl = rawTab.url ?? '';
423
- const effectiveUrl =
424
- currentUrl && currentUrl !== 'about:blank'
425
- ? currentUrl
426
- : pendingUrl && pendingUrl !== 'about:blank'
427
- ? pendingUrl
428
- : currentUrl || pendingUrl || opened.tab.url;
429
- refreshedTab = {
430
- ...toTabInfo(rawTab),
431
- url: effectiveUrl
432
- };
433
- } catch {
434
- refreshedTab = (await workspaceBrowser.getTab(opened.tab.id)) ?? opened.tab;
435
- }
436
- const refreshedWorkspace = (await workspaceManager.getWorkspaceInfo(opened.workspace.id)) ?? {
437
- ...opened.workspace,
438
- tabs: opened.workspace.tabs.map((tab) => (tab.id === refreshedTab.id ? refreshedTab : tab))
439
- };
440
-
441
- return {
442
- workspace: refreshedWorkspace,
443
- tab: refreshedTab
444
- };
445
- }
446
-
447
- interface WithTabOptions {
448
- requireSupportedAutomationUrl?: boolean;
449
- }
450
-
451
- async function withTab(target: { tabId?: number; workspaceId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
452
- const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
453
- const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
454
- if (!tab.id) {
455
- throw toError('E_NOT_FOUND', 'Tab missing id');
456
- }
457
- const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
458
- if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab.url) && !isSupportedAutomationUrl(pendingUrl)) {
459
- throw toError('E_PERMISSION', 'Unsupported tab URL: only http/https pages can be automated', {
460
- url: tab.url ?? pendingUrl ?? ''
461
- });
462
- }
463
- return tab;
464
- };
465
-
466
- if (typeof target.tabId === 'number') {
467
- const tab = await chrome.tabs.get(target.tabId);
468
- return validate(tab);
469
- }
470
-
471
- const resolved = await workspaceManager.resolveTarget({
472
- tabId: target.tabId,
473
- workspaceId: typeof target.workspaceId === 'string' ? target.workspaceId : undefined,
474
- createIfMissing: false
475
- });
476
- const tab = await chrome.tabs.get(resolved.tab.id);
477
- return validate(tab);
478
- }
479
-
480
- async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string> {
481
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
482
- throw toError('E_NOT_FOUND', 'Tab screenshot requires tab id and window id');
483
- }
484
-
485
- const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true });
486
- const activeTab = activeTabs[0];
487
- const shouldSwitch = activeTab?.id !== tab.id;
488
-
489
- if (shouldSwitch) {
490
- await chrome.tabs.update(tab.id, { active: true });
491
- await new Promise((resolve) => setTimeout(resolve, 80));
492
- }
493
-
494
- try {
495
- return await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
496
- } finally {
497
- if (shouldSwitch && typeof activeTab?.id === 'number') {
498
- try {
499
- await chrome.tabs.update(activeTab.id, { active: true });
500
- } catch {
501
- // Ignore restore errors if the original tab no longer exists.
502
- }
503
- }
504
- }
505
- }
506
-
507
- async function sendToContent<TResponse>(tabId: number, message: Record<string, unknown>): Promise<TResponse> {
508
- const maxAttempts = 10;
509
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
510
- try {
511
- const response = await chrome.tabs.sendMessage(tabId, message);
512
- if (typeof response === 'undefined') {
513
- throw new Error('Content script returned undefined response');
514
- }
515
- return response as TResponse;
516
- } catch (error) {
517
- const detail = error instanceof Error ? error.message : String(error);
518
- const retriable =
519
- detail.includes('Receiving end does not exist') ||
520
- detail.includes('Could not establish connection') ||
521
- detail.includes('No tab with id') ||
522
- detail.includes('message port closed before a response was received') ||
523
- detail.includes('Content script returned undefined response');
524
- if (!retriable || attempt >= maxAttempts) {
525
- throw toError('E_NOT_READY', 'Content script unavailable', { detail });
526
- }
527
- await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
528
- }
529
- }
530
-
531
- throw toError('E_NOT_READY', 'Content script unavailable');
532
- }
533
-
534
- interface FocusContext {
535
- windowId: number | null;
536
- tabId: number | null;
537
- }
538
-
539
- async function captureFocusContext(): Promise<FocusContext> {
540
- const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
541
- const activeTab = activeTabs.find((tab) => typeof tab.id === 'number' && typeof tab.windowId === 'number') ?? null;
542
- return {
543
- windowId: activeTab?.windowId ?? null,
544
- tabId: activeTab?.id ?? null
545
- };
546
- }
547
-
548
- async function restoreFocusContext(context: FocusContext): Promise<void> {
549
- if (context.windowId !== null) {
550
- try {
551
- await chrome.windows.update(context.windowId, { focused: true });
552
- } catch {
553
- // Ignore restore errors if the original window no longer exists.
554
- }
555
- }
556
- if (context.tabId !== null) {
557
- try {
558
- await chrome.tabs.update(context.tabId, { active: true });
559
- } catch {
560
- // Ignore restore errors if the original tab no longer exists.
561
- }
562
- }
563
- }
564
-
565
- async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
566
- if (!enabled) {
567
- return action();
568
- }
569
-
570
- const focusContext = await captureFocusContext();
571
- try {
572
- return await action();
573
- } finally {
574
- await restoreFocusContext(focusContext);
575
- }
576
- }
577
-
578
- function requireRpcEnvelope(
579
- method: string,
580
- value: unknown
581
- ): { ok: boolean; result?: unknown; error?: CliResponse['error'] } {
582
- if (typeof value !== 'object' || value === null || typeof (value as { ok?: unknown }).ok !== 'boolean') {
583
- throw toError('E_NOT_READY', `Content script returned malformed response for ${method}`);
584
- }
585
- return value as { ok: boolean; result?: unknown; error?: CliResponse['error'] };
586
- }
587
-
588
- async function forwardContentRpc(
589
- tabId: number,
590
- method: string,
591
- params: Record<string, unknown>
592
- ): Promise<unknown> {
593
- const raw = await sendToContent<unknown>(tabId, {
594
- type: 'bak.rpc',
595
- method,
596
- params
597
- });
598
- const response = requireRpcEnvelope(method, raw);
599
-
600
- if (!response.ok) {
601
- throw response.error ?? toError('E_INTERNAL', `${method} failed`);
602
- }
603
-
604
- return response.result;
605
- }
606
-
607
- async function handleRequest(request: CliRequest): Promise<unknown> {
608
- const params = request.params ?? {};
609
- const target = {
610
- tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
611
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined
612
- };
613
-
614
- const rpcForwardMethods = new Set([
615
- 'page.title',
616
- 'page.url',
617
- 'page.text',
618
- 'page.dom',
619
- 'page.accessibilityTree',
620
- 'page.scrollTo',
621
- 'page.metrics',
622
- 'element.hover',
623
- 'element.doubleClick',
624
- 'element.rightClick',
625
- 'element.dragDrop',
626
- 'element.select',
627
- 'element.check',
628
- 'element.uncheck',
629
- 'element.scrollIntoView',
630
- 'element.focus',
631
- 'element.blur',
632
- 'element.get',
633
- 'keyboard.press',
634
- 'keyboard.type',
635
- 'keyboard.hotkey',
636
- 'mouse.move',
637
- 'mouse.click',
638
- 'mouse.wheel',
639
- 'file.upload',
640
- 'context.enterFrame',
641
- 'context.exitFrame',
642
- 'context.enterShadow',
643
- 'context.exitShadow',
644
- 'context.reset',
645
- 'network.list',
646
- 'network.get',
647
- 'network.waitFor',
648
- 'network.clear',
649
- 'debug.dumpState'
650
- ]);
651
-
652
- switch (request.method) {
653
- case 'session.ping': {
654
- return { ok: true, ts: Date.now() };
655
- }
656
- case 'tabs.list': {
657
- const tabs = await chrome.tabs.query({});
658
- return {
659
- tabs: tabs
660
- .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
661
- .map((tab) => toTabInfo(tab))
662
- };
663
- }
664
- case 'tabs.getActive': {
665
- const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
666
- const tab = tabs[0];
667
- if (!tab || typeof tab.id !== 'number') {
668
- return { tab: null };
669
- }
670
- return {
671
- tab: toTabInfo(tab)
672
- };
673
- }
674
- case 'tabs.get': {
675
- const tabId = Number(params.tabId);
676
- const tab = await chrome.tabs.get(tabId);
677
- if (typeof tab.id !== 'number') {
678
- throw toError('E_NOT_FOUND', 'Tab missing id');
679
- }
680
- return {
681
- tab: toTabInfo(tab)
682
- };
683
- }
684
- case 'tabs.focus': {
685
- const tabId = Number(params.tabId);
686
- await chrome.tabs.update(tabId, { active: true });
687
- return { ok: true };
688
- }
689
- case 'tabs.new': {
690
- if (typeof params.workspaceId === 'string' || params.windowId === undefined) {
691
- const expectedUrl = (params.url as string | undefined) ?? 'about:blank';
692
- const opened = await preserveHumanFocus(true, async () => {
693
- return await workspaceManager.openTab({
694
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : DEFAULT_WORKSPACE_ID,
695
- url: expectedUrl,
696
- active: params.active === true,
697
- focus: false
698
- });
699
- });
700
- const stabilized = await finalizeOpenedWorkspaceTab(opened, expectedUrl);
701
- return {
702
- tabId: stabilized.tab.id,
703
- windowId: stabilized.tab.windowId,
704
- groupId: stabilized.workspace.groupId,
705
- workspaceId: stabilized.workspace.id
706
- };
707
- }
708
- const tab = await chrome.tabs.create({
709
- url: (params.url as string | undefined) ?? 'about:blank',
710
- windowId: typeof params.windowId === 'number' ? params.windowId : undefined,
711
- active: params.active === true
712
- });
713
- if (params.addToGroup === true && typeof tab.id === 'number') {
714
- const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
715
- return {
716
- tabId: tab.id,
717
- windowId: tab.windowId,
718
- groupId
719
- };
720
- }
721
- return {
722
- tabId: tab.id,
723
- windowId: tab.windowId
724
- };
725
- }
726
- case 'tabs.close': {
727
- const tabId = Number(params.tabId);
728
- await chrome.tabs.remove(tabId);
729
- return { ok: true };
730
- }
731
- case 'workspace.ensure': {
732
- return preserveHumanFocus(params.focus !== true, async () => {
733
- return await workspaceManager.ensureWorkspace({
734
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
735
- focus: params.focus === true,
736
- initialUrl: typeof params.url === 'string' ? params.url : undefined
737
- });
738
- });
739
- }
740
- case 'workspace.info': {
741
- return {
742
- workspace: await workspaceManager.getWorkspaceInfo(typeof params.workspaceId === 'string' ? params.workspaceId : undefined)
743
- };
744
- }
745
- case 'workspace.openTab': {
746
- const expectedUrl = typeof params.url === 'string' ? params.url : undefined;
747
- const opened = await preserveHumanFocus(params.focus !== true, async () => {
748
- return await workspaceManager.openTab({
749
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
750
- url: expectedUrl,
751
- active: params.active === true,
752
- focus: params.focus === true
753
- });
754
- });
755
- return await finalizeOpenedWorkspaceTab(opened, expectedUrl);
756
- }
757
- case 'workspace.listTabs': {
758
- return await workspaceManager.listTabs(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
759
- }
760
- case 'workspace.getActiveTab': {
761
- return await workspaceManager.getActiveTab(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
762
- }
763
- case 'workspace.setActiveTab': {
764
- return await workspaceManager.setActiveTab(Number(params.tabId), typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
765
- }
766
- case 'workspace.focus': {
767
- return await workspaceManager.focus(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
768
- }
769
- case 'workspace.reset': {
770
- return await preserveHumanFocus(params.focus !== true, async () => {
771
- return await workspaceManager.reset({
772
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
773
- focus: params.focus === true,
774
- initialUrl: typeof params.url === 'string' ? params.url : undefined
775
- });
776
- });
777
- }
778
- case 'workspace.close': {
779
- return await workspaceManager.close(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
780
- }
781
- case 'page.goto': {
782
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
783
- const tab = await withTab(target, {
784
- requireSupportedAutomationUrl: false
785
- });
786
- const url = String(params.url ?? 'about:blank');
787
- await chrome.tabs.update(tab.id!, { url });
788
- await waitForTabUrl(tab.id!, url);
789
- await forwardContentRpc(tab.id!, 'page.url', { tabId: tab.id }).catch(() => undefined);
790
- await waitForTabComplete(tab.id!, 5_000).catch(() => undefined);
791
- return { ok: true };
792
- });
793
- }
794
- case 'page.back': {
795
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
796
- const tab = await withTab(target);
797
- await chrome.tabs.goBack(tab.id!);
798
- await waitForTabComplete(tab.id!);
799
- return { ok: true };
800
- });
801
- }
802
- case 'page.forward': {
803
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
804
- const tab = await withTab(target);
805
- await chrome.tabs.goForward(tab.id!);
806
- await waitForTabComplete(tab.id!);
807
- return { ok: true };
808
- });
809
- }
810
- case 'page.reload': {
811
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
812
- const tab = await withTab(target);
813
- await chrome.tabs.reload(tab.id!);
814
- await waitForTabComplete(tab.id!);
815
- return { ok: true };
816
- });
817
- }
818
- case 'page.viewport': {
819
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
820
- const tab = await withTab(target, {
821
- requireSupportedAutomationUrl: false
822
- });
823
- if (typeof tab.windowId !== 'number') {
824
- throw toError('E_NOT_FOUND', 'Tab window unavailable');
825
- }
826
-
827
- const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
828
- const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
829
- if (width || height) {
830
- await chrome.windows.update(tab.windowId, {
831
- width,
832
- height
833
- });
834
- }
835
-
836
- const viewport = (await forwardContentRpc(tab.id!, 'page.viewport', {})) as {
837
- width: number;
838
- height: number;
839
- devicePixelRatio: number;
840
- };
841
- const viewWidth = typeof width === 'number' ? width : viewport.width ?? tab.width ?? 0;
842
- const viewHeight = typeof height === 'number' ? height : viewport.height ?? tab.height ?? 0;
843
- return {
844
- width: viewWidth,
845
- height: viewHeight,
846
- devicePixelRatio: viewport.devicePixelRatio
847
- };
848
- });
849
- }
850
- case 'page.snapshot': {
851
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
852
- const tab = await withTab(target);
853
- if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
854
- throw toError('E_NOT_FOUND', 'Tab missing id');
855
- }
856
- const includeBase64 = params.includeBase64 !== false;
857
- const config = await getConfig();
858
- const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
859
- type: 'bak.collectElements',
860
- debugRichText: config.debugRichText
861
- });
862
- const imageData = await captureAlignedTabScreenshot(tab);
863
- return {
864
- imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
865
- elements: elements.elements,
866
- tabId: tab.id,
867
- url: tab.url ?? ''
868
- };
869
- });
870
- }
871
- case 'element.click': {
872
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
873
- const tab = await withTab(target);
874
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
875
- type: 'bak.performAction',
876
- action: 'click',
877
- locator: params.locator as Locator,
878
- requiresConfirm: params.requiresConfirm === true
879
- });
880
- if (!response.ok) {
881
- throw response.error ?? toError('E_INTERNAL', 'element.click failed');
882
- }
883
- return { ok: true };
884
- });
885
- }
886
- case 'element.type': {
887
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
888
- const tab = await withTab(target);
889
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
890
- type: 'bak.performAction',
891
- action: 'type',
892
- locator: params.locator as Locator,
893
- text: String(params.text ?? ''),
894
- clear: Boolean(params.clear),
895
- requiresConfirm: params.requiresConfirm === true
896
- });
897
- if (!response.ok) {
898
- throw response.error ?? toError('E_INTERNAL', 'element.type failed');
899
- }
900
- return { ok: true };
901
- });
902
- }
903
- case 'element.scroll': {
904
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
905
- const tab = await withTab(target);
906
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
907
- type: 'bak.performAction',
908
- action: 'scroll',
909
- locator: params.locator as Locator,
910
- dx: Number(params.dx ?? 0),
911
- dy: Number(params.dy ?? 320)
912
- });
913
- if (!response.ok) {
914
- throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
915
- }
916
- return { ok: true };
917
- });
918
- }
919
- case 'page.wait': {
920
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
921
- const tab = await withTab(target);
922
- const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
923
- type: 'bak.waitFor',
924
- mode: String(params.mode ?? 'selector'),
925
- value: String(params.value ?? ''),
926
- timeoutMs: Number(params.timeoutMs ?? 5000)
927
- });
928
- if (!response.ok) {
929
- throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
930
- }
931
- return { ok: true };
932
- });
933
- }
934
- case 'debug.getConsole': {
935
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
936
- const tab = await withTab(target);
937
- const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
938
- type: 'bak.getConsole',
939
- limit: Number(params.limit ?? 50)
940
- });
941
- return { entries: response.entries };
942
- });
943
- }
944
- case 'ui.selectCandidate': {
945
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
946
- const tab = await withTab(target);
947
- const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
948
- tab.id!,
949
- {
950
- type: 'bak.selectCandidate',
951
- candidates: params.candidates
952
- }
953
- );
954
- if (!response.ok || !response.selectedEid) {
955
- throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
956
- }
957
- return { selectedEid: response.selectedEid };
958
- });
959
- }
960
- default:
961
- if (rpcForwardMethods.has(request.method)) {
962
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
963
- const tab = await withTab(target);
964
- return await forwardContentRpc(tab.id!, request.method, {
965
- ...params,
966
- tabId: tab.id
967
- });
968
- });
969
- }
970
- throw toError('E_NOT_FOUND', `Unsupported method from CLI bridge: ${request.method}`);
971
- }
972
- }
973
-
974
- function scheduleReconnect(reason: string): void {
975
- if (manualDisconnect) {
976
- return;
977
- }
978
- if (reconnectTimer !== null) {
979
- return;
980
- }
981
-
982
- const delayMs = computeReconnectDelayMs(reconnectAttempt);
983
- reconnectAttempt += 1;
984
- nextReconnectInMs = delayMs;
985
- reconnectTimer = setTimeout(() => {
986
- reconnectTimer = null;
987
- nextReconnectInMs = null;
988
- void connectWebSocket();
989
- }, delayMs) as unknown as number;
990
-
991
- if (!lastError) {
992
- setRuntimeError(`Reconnect scheduled: ${reason}`, 'socket');
993
- }
994
- }
995
-
996
- async function connectWebSocket(): Promise<void> {
997
- clearReconnectTimer();
998
- if (manualDisconnect) {
999
- return;
1000
- }
1001
-
1002
- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
1003
- return;
1004
- }
1005
-
1006
- const config = await getConfig();
1007
- if (!config.token) {
1008
- setRuntimeError('Pair token is empty', 'config');
1009
- return;
1010
- }
1011
-
1012
- const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
1013
- ws = new WebSocket(url);
1014
-
1015
- ws.addEventListener('open', () => {
1016
- manualDisconnect = false;
1017
- reconnectAttempt = 0;
1018
- lastError = null;
1019
- ws?.send(JSON.stringify({
1020
- type: 'hello',
1021
- role: 'extension',
1022
- version: '0.3.8',
1023
- ts: Date.now()
1024
- }));
1025
- });
1026
-
1027
- ws.addEventListener('message', (event) => {
1028
- try {
1029
- const request = JSON.parse(String(event.data)) as CliRequest;
1030
- if (!request.id || !request.method) {
1031
- return;
1032
- }
1033
- void handleRequest(request)
1034
- .then((result) => {
1035
- sendResponse({ id: request.id, ok: true, result });
1036
- })
1037
- .catch((error: unknown) => {
1038
- const normalized = normalizeUnhandledError(error);
1039
- sendResponse({ id: request.id, ok: false, error: normalized });
1040
- });
1041
- } catch (error) {
1042
- setRuntimeError(error instanceof Error ? error.message : String(error), 'parse');
1043
- sendResponse({
1044
- id: 'parse-error',
1045
- ok: false,
1046
- error: toError('E_INTERNAL', error instanceof Error ? error.message : String(error))
1047
- });
1048
- }
1049
- });
1050
-
1051
- ws.addEventListener('close', () => {
1052
- ws = null;
1053
- scheduleReconnect('socket-closed');
1054
- });
1055
-
1056
- ws.addEventListener('error', () => {
1057
- setRuntimeError('Cannot connect to bak cli', 'socket');
1058
- ws?.close();
1059
- });
1060
- }
1061
-
1062
- chrome.tabs.onRemoved.addListener((tabId) => {
1063
- void loadWorkspaceState().then(async (state) => {
1064
- if (!state || !state.tabIds.includes(tabId)) {
1065
- return;
1066
- }
1067
- const nextTabIds = state.tabIds.filter((id) => id !== tabId);
1068
- await saveWorkspaceState({
1069
- ...state,
1070
- tabIds: nextTabIds,
1071
- activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
1072
- primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
1073
- });
1074
- });
1075
- });
1076
-
1077
- chrome.tabs.onActivated.addListener((activeInfo) => {
1078
- void loadWorkspaceState().then(async (state) => {
1079
- if (!state || state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
1080
- return;
1081
- }
1082
- await saveWorkspaceState({
1083
- ...state,
1084
- activeTabId: activeInfo.tabId
1085
- });
1086
- });
1087
- });
1088
-
1089
- chrome.windows.onRemoved.addListener((windowId) => {
1090
- void loadWorkspaceState().then(async (state) => {
1091
- if (!state || state.windowId !== windowId) {
1092
- return;
1093
- }
1094
- await saveWorkspaceState({
1095
- ...state,
1096
- windowId: null,
1097
- groupId: null,
1098
- tabIds: [],
1099
- activeTabId: null,
1100
- primaryTabId: null
1101
- });
1102
- });
1103
- });
1104
-
1105
- chrome.runtime.onInstalled.addListener(() => {
1106
- void setConfig({ port: DEFAULT_PORT, debugRichText: false });
1107
- });
1108
-
1109
- chrome.runtime.onStartup.addListener(() => {
1110
- void connectWebSocket();
1111
- });
1112
-
1113
- void connectWebSocket();
1114
-
1115
- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1116
- if (message?.type === 'bak.updateConfig') {
1117
- manualDisconnect = false;
1118
- void setConfig({
1119
- token: message.token,
1120
- port: Number(message.port ?? DEFAULT_PORT),
1121
- debugRichText: message.debugRichText === true
1122
- }).then(() => {
1123
- ws?.close();
1124
- void connectWebSocket().then(() => sendResponse({ ok: true }));
1125
- });
1126
- return true;
1127
- }
1128
-
1129
- if (message?.type === 'bak.getState') {
1130
- void getConfig().then((config) => {
1131
- sendResponse({
1132
- ok: true,
1133
- connected: ws?.readyState === WebSocket.OPEN,
1134
- hasToken: Boolean(config.token),
1135
- port: config.port,
1136
- debugRichText: config.debugRichText,
1137
- lastError: lastError?.message ?? null,
1138
- lastErrorAt: lastError?.at ?? null,
1139
- lastErrorContext: lastError?.context ?? null,
1140
- reconnectAttempt,
1141
- nextReconnectInMs
1142
- });
1143
- });
1144
- return true;
1145
- }
1146
-
1147
- if (message?.type === 'bak.disconnect') {
1148
- manualDisconnect = true;
1149
- clearReconnectTimer();
1150
- reconnectAttempt = 0;
1151
- ws?.close();
1152
- ws = null;
1153
- sendResponse({ ok: true });
1154
- return false;
1155
- }
1156
-
1157
- return false;
1
+ import type { ConsoleEntry, Locator } from '@flrande/bak-protocol';
2
+ import { isSupportedAutomationUrl } from './url-policy.js';
3
+ import { computeReconnectDelayMs } from './reconnect.js';
4
+ import {
5
+ LEGACY_STORAGE_KEY_WORKSPACE,
6
+ LEGACY_STORAGE_KEY_WORKSPACES,
7
+ resolveSessionBindingStateMap,
8
+ STORAGE_KEY_SESSION_BINDINGS
9
+ } from './session-binding-storage.js';
10
+ import {
11
+ type WorkspaceBrowser as SessionBindingBrowser,
12
+ type WorkspaceColor as SessionBindingColor,
13
+ type WorkspaceRecord as SessionBindingRecord,
14
+ type WorkspaceTab as SessionBindingTab,
15
+ type WorkspaceWindow as SessionBindingWindow,
16
+ WorkspaceManager as SessionBindingManager
17
+ } from './workspace.js';
18
+
19
+ interface CliRequest {
20
+ id: string;
21
+ method: string;
22
+ params?: Record<string, unknown>;
23
+ }
24
+
25
+ interface CliResponse {
26
+ id: string;
27
+ ok: boolean;
28
+ result?: unknown;
29
+ error?: {
30
+ code: string;
31
+ message: string;
32
+ data?: Record<string, unknown>;
33
+ };
34
+ }
35
+
36
+ interface ExtensionConfig {
37
+ token: string;
38
+ port: number;
39
+ debugRichText: boolean;
40
+ }
41
+
42
+ interface RuntimeErrorDetails {
43
+ message: string;
44
+ context: 'config' | 'socket' | 'request' | 'parse';
45
+ at: number;
46
+ }
47
+
48
+ const DEFAULT_PORT = 17373;
49
+ const STORAGE_KEY_TOKEN = 'pairToken';
50
+ const STORAGE_KEY_PORT = 'cliPort';
51
+ const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
52
+ const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
53
+
54
+ let ws: WebSocket | null = null;
55
+ let reconnectTimer: number | null = null;
56
+ let nextReconnectInMs: number | null = null;
57
+ let reconnectAttempt = 0;
58
+ let lastError: RuntimeErrorDetails | null = null;
59
+ let manualDisconnect = false;
60
+
61
+ async function getConfig(): Promise<ExtensionConfig> {
62
+ const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
63
+ return {
64
+ token: typeof stored[STORAGE_KEY_TOKEN] === 'string' ? stored[STORAGE_KEY_TOKEN] : '',
65
+ port: typeof stored[STORAGE_KEY_PORT] === 'number' ? stored[STORAGE_KEY_PORT] : DEFAULT_PORT,
66
+ debugRichText: stored[STORAGE_KEY_DEBUG_RICH_TEXT] === true
67
+ };
68
+ }
69
+
70
+ async function setConfig(config: Partial<ExtensionConfig>): Promise<void> {
71
+ const payload: Record<string, unknown> = {};
72
+ if (typeof config.token === 'string') {
73
+ payload[STORAGE_KEY_TOKEN] = config.token;
74
+ }
75
+ if (typeof config.port === 'number') {
76
+ payload[STORAGE_KEY_PORT] = config.port;
77
+ }
78
+ if (typeof config.debugRichText === 'boolean') {
79
+ payload[STORAGE_KEY_DEBUG_RICH_TEXT] = config.debugRichText;
80
+ }
81
+ if (Object.keys(payload).length > 0) {
82
+ await chrome.storage.local.set(payload);
83
+ }
84
+ }
85
+
86
+ function setRuntimeError(message: string, context: RuntimeErrorDetails['context']): void {
87
+ lastError = {
88
+ message,
89
+ context,
90
+ at: Date.now()
91
+ };
92
+ }
93
+
94
+ function clearReconnectTimer(): void {
95
+ if (reconnectTimer !== null) {
96
+ clearTimeout(reconnectTimer);
97
+ reconnectTimer = null;
98
+ }
99
+ nextReconnectInMs = null;
100
+ }
101
+
102
+ function sendResponse(payload: CliResponse): void {
103
+ if (ws && ws.readyState === WebSocket.OPEN) {
104
+ ws.send(JSON.stringify(payload));
105
+ }
106
+ }
107
+
108
+ function toError(code: string, message: string, data?: Record<string, unknown>): CliResponse['error'] {
109
+ return { code, message, data };
110
+ }
111
+
112
+ function normalizeUnhandledError(error: unknown): CliResponse['error'] {
113
+ if (typeof error === 'object' && error !== null && 'code' in error) {
114
+ return error as CliResponse['error'];
115
+ }
116
+
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ const lower = message.toLowerCase();
119
+
120
+ if (lower.includes('no tab with id') || lower.includes('no window with id')) {
121
+ return toError('E_NOT_FOUND', message);
122
+ }
123
+ if (lower.includes('workspace') && lower.includes('does not exist')) {
124
+ return toError('E_NOT_FOUND', message);
125
+ }
126
+ if (lower.includes('does not belong to workspace') || lower.includes('is missing from workspace')) {
127
+ return toError('E_NOT_FOUND', message);
128
+ }
129
+ if (lower.includes('invalid url') || lower.includes('url is invalid')) {
130
+ return toError('E_INVALID_PARAMS', message);
131
+ }
132
+ if (lower.includes('cannot access contents of url') || lower.includes('permission denied')) {
133
+ return toError('E_PERMISSION', message);
134
+ }
135
+
136
+ return toError('E_INTERNAL', message);
137
+ }
138
+
139
+ function toTabInfo(tab: chrome.tabs.Tab): SessionBindingTab {
140
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
141
+ throw new Error('Tab is missing runtime identifiers');
142
+ }
143
+ return {
144
+ id: tab.id,
145
+ title: tab.title ?? '',
146
+ url: tab.url ?? '',
147
+ active: Boolean(tab.active),
148
+ windowId: tab.windowId,
149
+ groupId: typeof tab.groupId === 'number' && tab.groupId >= 0 ? tab.groupId : null
150
+ };
151
+ }
152
+
153
+ async function loadWorkspaceStateMap(): Promise<Record<string, SessionBindingRecord>> {
154
+ const stored = await chrome.storage.local.get([
155
+ STORAGE_KEY_SESSION_BINDINGS,
156
+ LEGACY_STORAGE_KEY_WORKSPACES,
157
+ LEGACY_STORAGE_KEY_WORKSPACE
158
+ ]);
159
+ return resolveSessionBindingStateMap(stored);
160
+ }
161
+
162
+ async function loadWorkspaceState(workspaceId: string): Promise<SessionBindingRecord | null> {
163
+ const stateMap = await loadWorkspaceStateMap();
164
+ return stateMap[workspaceId] ?? null;
165
+ }
166
+
167
+ async function listWorkspaceStates(): Promise<SessionBindingRecord[]> {
168
+ return Object.values(await loadWorkspaceStateMap());
169
+ }
170
+
171
+ async function saveWorkspaceState(state: SessionBindingRecord): Promise<void> {
172
+ const stateMap = await loadWorkspaceStateMap();
173
+ stateMap[state.id] = state;
174
+ await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
175
+ await chrome.storage.local.remove([LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
176
+ }
177
+
178
+ async function deleteWorkspaceState(workspaceId: string): Promise<void> {
179
+ const stateMap = await loadWorkspaceStateMap();
180
+ delete stateMap[workspaceId];
181
+ if (Object.keys(stateMap).length === 0) {
182
+ await chrome.storage.local.remove([STORAGE_KEY_SESSION_BINDINGS, LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
183
+ return;
184
+ }
185
+ await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
186
+ await chrome.storage.local.remove([LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
187
+ }
188
+
189
+ const workspaceBrowser: SessionBindingBrowser = {
190
+ async getTab(tabId) {
191
+ try {
192
+ return toTabInfo(await chrome.tabs.get(tabId));
193
+ } catch {
194
+ return null;
195
+ }
196
+ },
197
+ async getActiveTab() {
198
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
199
+ const tab = tabs[0];
200
+ if (!tab) {
201
+ return null;
202
+ }
203
+ return toTabInfo(tab);
204
+ },
205
+ async listTabs(filter) {
206
+ const tabs = await chrome.tabs.query(filter?.windowId ? { windowId: filter.windowId } : {});
207
+ return tabs
208
+ .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
209
+ .map((tab) => toTabInfo(tab));
210
+ },
211
+ async createTab(options) {
212
+ const createdTab = await chrome.tabs.create({
213
+ windowId: options.windowId,
214
+ url: options.url ?? 'about:blank',
215
+ active: options.active
216
+ });
217
+ if (!createdTab) {
218
+ throw new Error('Tab creation returned no tab');
219
+ }
220
+ return toTabInfo(createdTab);
221
+ },
222
+ async updateTab(tabId, options) {
223
+ const updatedTab = await chrome.tabs.update(tabId, {
224
+ active: options.active,
225
+ url: options.url
226
+ });
227
+ if (!updatedTab) {
228
+ throw new Error(`Tab update returned no tab for ${tabId}`);
229
+ }
230
+ return toTabInfo(updatedTab);
231
+ },
232
+ async closeTab(tabId) {
233
+ await chrome.tabs.remove(tabId);
234
+ },
235
+ async getWindow(windowId) {
236
+ try {
237
+ const window = await chrome.windows.get(windowId);
238
+ return {
239
+ id: window.id!,
240
+ focused: Boolean(window.focused)
241
+ } satisfies SessionBindingWindow;
242
+ } catch {
243
+ return null;
244
+ }
245
+ },
246
+ async createWindow(options) {
247
+ const previouslyFocusedWindow =
248
+ options.focused === true
249
+ ? null
250
+ : (await chrome.windows.getAll()).find((window) => window.focused === true && typeof window.id === 'number') ?? null;
251
+ const previouslyFocusedTab =
252
+ previouslyFocusedWindow?.id !== undefined
253
+ ? (await chrome.tabs.query({ windowId: previouslyFocusedWindow.id, active: true })).find((tab) => typeof tab.id === 'number') ?? null
254
+ : null;
255
+ const created = await chrome.windows.create({
256
+ url: options.url ?? 'about:blank',
257
+ focused: true
258
+ });
259
+ if (!created || typeof created.id !== 'number') {
260
+ throw new Error('Window missing id');
261
+ }
262
+ if (options.focused !== true && previouslyFocusedWindow?.id && previouslyFocusedWindow.id !== created.id) {
263
+ await chrome.windows.update(previouslyFocusedWindow.id, { focused: true });
264
+ if (typeof previouslyFocusedTab?.id === 'number') {
265
+ await chrome.tabs.update(previouslyFocusedTab.id, { active: true });
266
+ }
267
+ }
268
+ const finalWindow = await chrome.windows.get(created.id);
269
+ return {
270
+ id: finalWindow.id!,
271
+ focused: Boolean(finalWindow.focused)
272
+ };
273
+ },
274
+ async updateWindow(windowId, options) {
275
+ const updated = await chrome.windows.update(windowId, {
276
+ focused: options.focused
277
+ });
278
+ if (!updated || typeof updated.id !== 'number') {
279
+ throw new Error('Window missing id');
280
+ }
281
+ return {
282
+ id: updated.id,
283
+ focused: Boolean(updated.focused)
284
+ };
285
+ },
286
+ async closeWindow(windowId) {
287
+ await chrome.windows.remove(windowId);
288
+ },
289
+ async getGroup(groupId) {
290
+ try {
291
+ const group = await chrome.tabGroups.get(groupId);
292
+ return {
293
+ id: group.id,
294
+ windowId: group.windowId,
295
+ title: group.title ?? '',
296
+ color: group.color as SessionBindingColor,
297
+ collapsed: Boolean(group.collapsed)
298
+ };
299
+ } catch {
300
+ return null;
301
+ }
302
+ },
303
+ async groupTabs(tabIds, groupId) {
304
+ return await chrome.tabs.group({
305
+ tabIds: tabIds as [number, ...number[]],
306
+ groupId
307
+ });
308
+ },
309
+ async updateGroup(groupId, options) {
310
+ const updated = await chrome.tabGroups.update(groupId, {
311
+ title: options.title,
312
+ color: options.color,
313
+ collapsed: options.collapsed
314
+ });
315
+ if (!updated) {
316
+ throw new Error(`Tab group update returned no group for ${groupId}`);
317
+ }
318
+ return {
319
+ id: updated.id,
320
+ windowId: updated.windowId,
321
+ title: updated.title ?? '',
322
+ color: updated.color as SessionBindingColor,
323
+ collapsed: Boolean(updated.collapsed)
324
+ };
325
+ }
326
+ };
327
+
328
+ const bindingManager = new SessionBindingManager(
329
+ {
330
+ load: loadWorkspaceState,
331
+ save: saveWorkspaceState,
332
+ delete: deleteWorkspaceState,
333
+ list: listWorkspaceStates
334
+ },
335
+ workspaceBrowser
336
+ );
337
+
338
+ async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
339
+ try {
340
+ const current = await chrome.tabs.get(tabId);
341
+ if (current.status === 'complete') {
342
+ return;
343
+ }
344
+ } catch {
345
+ return;
346
+ }
347
+
348
+ await new Promise<void>((resolve, reject) => {
349
+ let done = false;
350
+ const probeStatus = (): void => {
351
+ void chrome.tabs
352
+ .get(tabId)
353
+ .then((tab) => {
354
+ if (tab.status === 'complete') {
355
+ finish();
356
+ }
357
+ })
358
+ .catch(() => {
359
+ finish(new Error(`tab removed before load complete: ${tabId}`));
360
+ });
361
+ };
362
+
363
+ const finish = (error?: Error): void => {
364
+ if (done) {
365
+ return;
366
+ }
367
+ done = true;
368
+ clearTimeout(timeoutTimer);
369
+ clearInterval(pollTimer);
370
+ chrome.tabs.onUpdated.removeListener(onUpdated);
371
+ chrome.tabs.onRemoved.removeListener(onRemoved);
372
+ if (error) {
373
+ reject(error);
374
+ return;
375
+ }
376
+ resolve();
377
+ };
378
+
379
+ const onUpdated = (updatedTabId: number, changeInfo: { status?: string }): void => {
380
+ if (updatedTabId !== tabId) {
381
+ return;
382
+ }
383
+ if (changeInfo.status === 'complete') {
384
+ finish();
385
+ }
386
+ };
387
+
388
+ const onRemoved = (removedTabId: number): void => {
389
+ if (removedTabId === tabId) {
390
+ finish(new Error(`tab removed before load complete: ${tabId}`));
391
+ }
392
+ };
393
+
394
+ const pollTimer = setInterval(probeStatus, 250);
395
+ const timeoutTimer = setTimeout(() => {
396
+ finish(new Error(`tab load timeout: ${tabId}`));
397
+ }, timeoutMs);
398
+
399
+ chrome.tabs.onUpdated.addListener(onUpdated);
400
+ chrome.tabs.onRemoved.addListener(onRemoved);
401
+ probeStatus();
402
+ });
403
+ }
404
+
405
+ async function waitForTabUrl(tabId: number, expectedUrl: string, timeoutMs = 10_000): Promise<void> {
406
+ const normalizedExpectedUrl = normalizeComparableTabUrl(expectedUrl);
407
+ const deadline = Date.now() + timeoutMs;
408
+ while (Date.now() < deadline) {
409
+ try {
410
+ const tab = await chrome.tabs.get(tabId);
411
+ const currentUrl = tab.url ?? '';
412
+ const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
413
+ if (
414
+ normalizeComparableTabUrl(currentUrl) === normalizedExpectedUrl ||
415
+ normalizeComparableTabUrl(pendingUrl) === normalizedExpectedUrl
416
+ ) {
417
+ return;
418
+ }
419
+ } catch {
420
+ // Ignore transient lookup failures while the tab is navigating.
421
+ }
422
+ await new Promise((resolve) => setTimeout(resolve, 100));
423
+ }
424
+
425
+ throw new Error(`tab url timeout: ${tabId} -> ${expectedUrl}`);
426
+ }
427
+
428
+ function normalizeComparableTabUrl(url: string): string {
429
+ const raw = url.trim();
430
+ if (!raw) {
431
+ return raw;
432
+ }
433
+ try {
434
+ const parsed = new URL(raw);
435
+ parsed.hash = '';
436
+ return parsed.href;
437
+ } catch {
438
+ return raw;
439
+ }
440
+ }
441
+
442
+ async function finalizeOpenedWorkspaceTab(
443
+ opened: Awaited<ReturnType<SessionBindingManager['openTab']>>,
444
+ expectedUrl?: string
445
+ ): Promise<Awaited<ReturnType<SessionBindingManager['openTab']>>> {
446
+ if (expectedUrl && expectedUrl !== 'about:blank') {
447
+ await waitForTabUrl(opened.tab.id, expectedUrl).catch(() => undefined);
448
+ }
449
+ let refreshedTab = opened.tab;
450
+ try {
451
+ const rawTab = await chrome.tabs.get(opened.tab.id);
452
+ const pendingUrl = 'pendingUrl' in rawTab && typeof rawTab.pendingUrl === 'string' ? rawTab.pendingUrl : '';
453
+ const currentUrl = rawTab.url ?? '';
454
+ const effectiveUrl =
455
+ currentUrl && currentUrl !== 'about:blank'
456
+ ? currentUrl
457
+ : pendingUrl && pendingUrl !== 'about:blank'
458
+ ? pendingUrl
459
+ : currentUrl || pendingUrl || opened.tab.url;
460
+ refreshedTab = {
461
+ ...toTabInfo(rawTab),
462
+ url: effectiveUrl
463
+ };
464
+ } catch {
465
+ refreshedTab = (await workspaceBrowser.getTab(opened.tab.id)) ?? opened.tab;
466
+ }
467
+ const refreshedWorkspace = (await bindingManager.getWorkspaceInfo(opened.workspace.id)) ?? {
468
+ ...opened.workspace,
469
+ tabs: opened.workspace.tabs.map((tab) => (tab.id === refreshedTab.id ? refreshedTab : tab))
470
+ };
471
+
472
+ return {
473
+ workspace: refreshedWorkspace,
474
+ tab: refreshedTab
475
+ };
476
+ }
477
+
478
+ interface WithTabOptions {
479
+ requireSupportedAutomationUrl?: boolean;
480
+ }
481
+
482
+ async function withTab(target: { tabId?: number; workspaceId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
483
+ const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
484
+ const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
485
+ if (!tab.id) {
486
+ throw toError('E_NOT_FOUND', 'Tab missing id');
487
+ }
488
+ const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
489
+ if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab.url) && !isSupportedAutomationUrl(pendingUrl)) {
490
+ throw toError('E_PERMISSION', 'Unsupported tab URL: only http/https pages can be automated', {
491
+ url: tab.url ?? pendingUrl ?? ''
492
+ });
493
+ }
494
+ return tab;
495
+ };
496
+
497
+ if (typeof target.tabId === 'number') {
498
+ const tab = await chrome.tabs.get(target.tabId);
499
+ return validate(tab);
500
+ }
501
+
502
+ const resolved = await bindingManager.resolveTarget({
503
+ tabId: target.tabId,
504
+ workspaceId: typeof target.workspaceId === 'string' ? target.workspaceId : undefined,
505
+ createIfMissing: false
506
+ });
507
+ const tab = await chrome.tabs.get(resolved.tab.id);
508
+ return validate(tab);
509
+ }
510
+
511
+ async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string> {
512
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
513
+ throw toError('E_NOT_FOUND', 'Tab screenshot requires tab id and window id');
514
+ }
515
+
516
+ const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true });
517
+ const activeTab = activeTabs[0];
518
+ const shouldSwitch = activeTab?.id !== tab.id;
519
+
520
+ if (shouldSwitch) {
521
+ await chrome.tabs.update(tab.id, { active: true });
522
+ await new Promise((resolve) => setTimeout(resolve, 80));
523
+ }
524
+
525
+ try {
526
+ return await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
527
+ } finally {
528
+ if (shouldSwitch && typeof activeTab?.id === 'number') {
529
+ try {
530
+ await chrome.tabs.update(activeTab.id, { active: true });
531
+ } catch {
532
+ // Ignore restore errors if the original tab no longer exists.
533
+ }
534
+ }
535
+ }
536
+ }
537
+
538
+ async function sendToContent<TResponse>(tabId: number, message: Record<string, unknown>): Promise<TResponse> {
539
+ const maxAttempts = 10;
540
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
541
+ try {
542
+ const response = await chrome.tabs.sendMessage(tabId, message);
543
+ if (typeof response === 'undefined') {
544
+ throw new Error('Content script returned undefined response');
545
+ }
546
+ return response as TResponse;
547
+ } catch (error) {
548
+ const detail = error instanceof Error ? error.message : String(error);
549
+ const retriable =
550
+ detail.includes('Receiving end does not exist') ||
551
+ detail.includes('Could not establish connection') ||
552
+ detail.includes('No tab with id') ||
553
+ detail.includes('message port closed before a response was received') ||
554
+ detail.includes('Content script returned undefined response');
555
+ if (!retriable || attempt >= maxAttempts) {
556
+ throw toError('E_NOT_READY', 'Content script unavailable', { detail });
557
+ }
558
+ await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
559
+ }
560
+ }
561
+
562
+ throw toError('E_NOT_READY', 'Content script unavailable');
563
+ }
564
+
565
+ interface FocusContext {
566
+ windowId: number | null;
567
+ tabId: number | null;
568
+ }
569
+
570
+ async function captureFocusContext(): Promise<FocusContext> {
571
+ const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
572
+ const activeTab = activeTabs.find((tab) => typeof tab.id === 'number' && typeof tab.windowId === 'number') ?? null;
573
+ return {
574
+ windowId: activeTab?.windowId ?? null,
575
+ tabId: activeTab?.id ?? null
576
+ };
577
+ }
578
+
579
+ async function restoreFocusContext(context: FocusContext): Promise<void> {
580
+ if (context.windowId !== null) {
581
+ try {
582
+ await chrome.windows.update(context.windowId, { focused: true });
583
+ } catch {
584
+ // Ignore restore errors if the original window no longer exists.
585
+ }
586
+ }
587
+ if (context.tabId !== null) {
588
+ try {
589
+ await chrome.tabs.update(context.tabId, { active: true });
590
+ } catch {
591
+ // Ignore restore errors if the original tab no longer exists.
592
+ }
593
+ }
594
+ }
595
+
596
+ async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
597
+ if (!enabled) {
598
+ return action();
599
+ }
600
+
601
+ const focusContext = await captureFocusContext();
602
+ try {
603
+ return await action();
604
+ } finally {
605
+ await restoreFocusContext(focusContext);
606
+ }
607
+ }
608
+
609
+ function requireRpcEnvelope(
610
+ method: string,
611
+ value: unknown
612
+ ): { ok: boolean; result?: unknown; error?: CliResponse['error'] } {
613
+ if (typeof value !== 'object' || value === null || typeof (value as { ok?: unknown }).ok !== 'boolean') {
614
+ throw toError('E_NOT_READY', `Content script returned malformed response for ${method}`);
615
+ }
616
+ return value as { ok: boolean; result?: unknown; error?: CliResponse['error'] };
617
+ }
618
+
619
+ async function forwardContentRpc(
620
+ tabId: number,
621
+ method: string,
622
+ params: Record<string, unknown>
623
+ ): Promise<unknown> {
624
+ const raw = await sendToContent<unknown>(tabId, {
625
+ type: 'bak.rpc',
626
+ method,
627
+ params
628
+ });
629
+ const response = requireRpcEnvelope(method, raw);
630
+
631
+ if (!response.ok) {
632
+ throw response.error ?? toError('E_INTERNAL', `${method} failed`);
633
+ }
634
+
635
+ return response.result;
636
+ }
637
+
638
+ async function handleRequest(request: CliRequest): Promise<unknown> {
639
+ const params = request.params ?? {};
640
+ const target = {
641
+ tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
642
+ workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined
643
+ };
644
+
645
+ const rpcForwardMethods = new Set([
646
+ 'page.title',
647
+ 'page.url',
648
+ 'page.text',
649
+ 'page.dom',
650
+ 'page.accessibilityTree',
651
+ 'page.scrollTo',
652
+ 'page.metrics',
653
+ 'element.hover',
654
+ 'element.doubleClick',
655
+ 'element.rightClick',
656
+ 'element.dragDrop',
657
+ 'element.select',
658
+ 'element.check',
659
+ 'element.uncheck',
660
+ 'element.scrollIntoView',
661
+ 'element.focus',
662
+ 'element.blur',
663
+ 'element.get',
664
+ 'keyboard.press',
665
+ 'keyboard.type',
666
+ 'keyboard.hotkey',
667
+ 'mouse.move',
668
+ 'mouse.click',
669
+ 'mouse.wheel',
670
+ 'file.upload',
671
+ 'context.get',
672
+ 'context.set',
673
+ 'context.enterFrame',
674
+ 'context.exitFrame',
675
+ 'context.enterShadow',
676
+ 'context.exitShadow',
677
+ 'context.reset',
678
+ 'network.list',
679
+ 'network.get',
680
+ 'network.waitFor',
681
+ 'network.clear',
682
+ 'debug.dumpState'
683
+ ]);
684
+
685
+ switch (request.method) {
686
+ case 'session.ping': {
687
+ return { ok: true, ts: Date.now() };
688
+ }
689
+ case 'tabs.list': {
690
+ const tabs = await chrome.tabs.query({});
691
+ return {
692
+ tabs: tabs
693
+ .filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
694
+ .map((tab) => toTabInfo(tab))
695
+ };
696
+ }
697
+ case 'tabs.getActive': {
698
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
699
+ const tab = tabs[0];
700
+ if (!tab || typeof tab.id !== 'number') {
701
+ return { tab: null };
702
+ }
703
+ return {
704
+ tab: toTabInfo(tab)
705
+ };
706
+ }
707
+ case 'tabs.get': {
708
+ const tabId = Number(params.tabId);
709
+ const tab = await chrome.tabs.get(tabId);
710
+ if (typeof tab.id !== 'number') {
711
+ throw toError('E_NOT_FOUND', 'Tab missing id');
712
+ }
713
+ return {
714
+ tab: toTabInfo(tab)
715
+ };
716
+ }
717
+ case 'tabs.focus': {
718
+ const tabId = Number(params.tabId);
719
+ await chrome.tabs.update(tabId, { active: true });
720
+ return { ok: true };
721
+ }
722
+ case 'tabs.new': {
723
+ const tab = await chrome.tabs.create({
724
+ url: (params.url as string | undefined) ?? 'about:blank',
725
+ windowId: typeof params.windowId === 'number' ? params.windowId : undefined,
726
+ active: params.active === true
727
+ });
728
+ if (params.addToGroup === true && typeof tab.id === 'number') {
729
+ const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
730
+ return {
731
+ tabId: tab.id,
732
+ windowId: tab.windowId,
733
+ groupId
734
+ };
735
+ }
736
+ return {
737
+ tabId: tab.id,
738
+ windowId: tab.windowId
739
+ };
740
+ }
741
+ case 'tabs.close': {
742
+ const tabId = Number(params.tabId);
743
+ await chrome.tabs.remove(tabId);
744
+ return { ok: true };
745
+ }
746
+ case 'workspace.ensure': {
747
+ return preserveHumanFocus(params.focus !== true, async () => {
748
+ return await bindingManager.ensureWorkspace({
749
+ workspaceId: String(params.workspaceId ?? ''),
750
+ focus: params.focus === true,
751
+ initialUrl: typeof params.url === 'string' ? params.url : undefined
752
+ });
753
+ });
754
+ }
755
+ case 'workspace.info': {
756
+ return {
757
+ workspace: await bindingManager.getWorkspaceInfo(String(params.workspaceId ?? ''))
758
+ };
759
+ }
760
+ case 'workspace.openTab': {
761
+ const expectedUrl = typeof params.url === 'string' ? params.url : undefined;
762
+ const opened = await preserveHumanFocus(params.focus !== true, async () => {
763
+ return await bindingManager.openTab({
764
+ workspaceId: String(params.workspaceId ?? ''),
765
+ url: expectedUrl,
766
+ active: params.active === true,
767
+ focus: params.focus === true
768
+ });
769
+ });
770
+ return await finalizeOpenedWorkspaceTab(opened, expectedUrl);
771
+ }
772
+ case 'workspace.listTabs': {
773
+ return await bindingManager.listTabs(String(params.workspaceId ?? ''));
774
+ }
775
+ case 'workspace.getActiveTab': {
776
+ return await bindingManager.getActiveTab(String(params.workspaceId ?? ''));
777
+ }
778
+ case 'workspace.setActiveTab': {
779
+ return await bindingManager.setActiveTab(Number(params.tabId), String(params.workspaceId ?? ''));
780
+ }
781
+ case 'workspace.focus': {
782
+ return await bindingManager.focus(String(params.workspaceId ?? ''));
783
+ }
784
+ case 'workspace.reset': {
785
+ return await preserveHumanFocus(params.focus !== true, async () => {
786
+ return await bindingManager.reset({
787
+ workspaceId: String(params.workspaceId ?? ''),
788
+ focus: params.focus === true,
789
+ initialUrl: typeof params.url === 'string' ? params.url : undefined
790
+ });
791
+ });
792
+ }
793
+ case 'workspace.close': {
794
+ return await bindingManager.close(String(params.workspaceId ?? ''));
795
+ }
796
+ case 'page.goto': {
797
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
798
+ const tab = await withTab(target, {
799
+ requireSupportedAutomationUrl: false
800
+ });
801
+ const url = String(params.url ?? 'about:blank');
802
+ await chrome.tabs.update(tab.id!, { url });
803
+ await waitForTabUrl(tab.id!, url);
804
+ await forwardContentRpc(tab.id!, 'page.url', { tabId: tab.id }).catch(() => undefined);
805
+ await waitForTabComplete(tab.id!, 5_000).catch(() => undefined);
806
+ return { ok: true };
807
+ });
808
+ }
809
+ case 'page.back': {
810
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
811
+ const tab = await withTab(target);
812
+ await chrome.tabs.goBack(tab.id!);
813
+ await waitForTabComplete(tab.id!);
814
+ return { ok: true };
815
+ });
816
+ }
817
+ case 'page.forward': {
818
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
819
+ const tab = await withTab(target);
820
+ await chrome.tabs.goForward(tab.id!);
821
+ await waitForTabComplete(tab.id!);
822
+ return { ok: true };
823
+ });
824
+ }
825
+ case 'page.reload': {
826
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
827
+ const tab = await withTab(target);
828
+ await chrome.tabs.reload(tab.id!);
829
+ await waitForTabComplete(tab.id!);
830
+ return { ok: true };
831
+ });
832
+ }
833
+ case 'page.viewport': {
834
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
835
+ const tab = await withTab(target, {
836
+ requireSupportedAutomationUrl: false
837
+ });
838
+ if (typeof tab.windowId !== 'number') {
839
+ throw toError('E_NOT_FOUND', 'Tab window unavailable');
840
+ }
841
+
842
+ const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
843
+ const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
844
+ if (width || height) {
845
+ await chrome.windows.update(tab.windowId, {
846
+ width,
847
+ height
848
+ });
849
+ }
850
+
851
+ const viewport = (await forwardContentRpc(tab.id!, 'page.viewport', {})) as {
852
+ width: number;
853
+ height: number;
854
+ devicePixelRatio: number;
855
+ };
856
+ const viewWidth = typeof width === 'number' ? width : viewport.width ?? tab.width ?? 0;
857
+ const viewHeight = typeof height === 'number' ? height : viewport.height ?? tab.height ?? 0;
858
+ return {
859
+ width: viewWidth,
860
+ height: viewHeight,
861
+ devicePixelRatio: viewport.devicePixelRatio
862
+ };
863
+ });
864
+ }
865
+ case 'page.snapshot': {
866
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
867
+ const tab = await withTab(target);
868
+ if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
869
+ throw toError('E_NOT_FOUND', 'Tab missing id');
870
+ }
871
+ const includeBase64 = params.includeBase64 !== false;
872
+ const config = await getConfig();
873
+ const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
874
+ type: 'bak.collectElements',
875
+ debugRichText: config.debugRichText
876
+ });
877
+ const imageData = await captureAlignedTabScreenshot(tab);
878
+ return {
879
+ imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
880
+ elements: elements.elements,
881
+ tabId: tab.id,
882
+ url: tab.url ?? ''
883
+ };
884
+ });
885
+ }
886
+ case 'element.click': {
887
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
888
+ const tab = await withTab(target);
889
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
890
+ type: 'bak.performAction',
891
+ action: 'click',
892
+ locator: params.locator as Locator,
893
+ requiresConfirm: params.requiresConfirm === true
894
+ });
895
+ if (!response.ok) {
896
+ throw response.error ?? toError('E_INTERNAL', 'element.click failed');
897
+ }
898
+ return { ok: true };
899
+ });
900
+ }
901
+ case 'element.type': {
902
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
903
+ const tab = await withTab(target);
904
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
905
+ type: 'bak.performAction',
906
+ action: 'type',
907
+ locator: params.locator as Locator,
908
+ text: String(params.text ?? ''),
909
+ clear: Boolean(params.clear),
910
+ requiresConfirm: params.requiresConfirm === true
911
+ });
912
+ if (!response.ok) {
913
+ throw response.error ?? toError('E_INTERNAL', 'element.type failed');
914
+ }
915
+ return { ok: true };
916
+ });
917
+ }
918
+ case 'element.scroll': {
919
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
920
+ const tab = await withTab(target);
921
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
922
+ type: 'bak.performAction',
923
+ action: 'scroll',
924
+ locator: params.locator as Locator,
925
+ dx: Number(params.dx ?? 0),
926
+ dy: Number(params.dy ?? 320)
927
+ });
928
+ if (!response.ok) {
929
+ throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
930
+ }
931
+ return { ok: true };
932
+ });
933
+ }
934
+ case 'page.wait': {
935
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
936
+ const tab = await withTab(target);
937
+ const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
938
+ type: 'bak.waitFor',
939
+ mode: String(params.mode ?? 'selector'),
940
+ value: String(params.value ?? ''),
941
+ timeoutMs: Number(params.timeoutMs ?? 5000)
942
+ });
943
+ if (!response.ok) {
944
+ throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
945
+ }
946
+ return { ok: true };
947
+ });
948
+ }
949
+ case 'debug.getConsole': {
950
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
951
+ const tab = await withTab(target);
952
+ const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
953
+ type: 'bak.getConsole',
954
+ limit: Number(params.limit ?? 50)
955
+ });
956
+ return { entries: response.entries };
957
+ });
958
+ }
959
+ case 'ui.selectCandidate': {
960
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
961
+ const tab = await withTab(target);
962
+ const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
963
+ tab.id!,
964
+ {
965
+ type: 'bak.selectCandidate',
966
+ candidates: params.candidates
967
+ }
968
+ );
969
+ if (!response.ok || !response.selectedEid) {
970
+ throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
971
+ }
972
+ return { selectedEid: response.selectedEid };
973
+ });
974
+ }
975
+ default:
976
+ if (rpcForwardMethods.has(request.method)) {
977
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
978
+ const tab = await withTab(target);
979
+ return await forwardContentRpc(tab.id!, request.method, {
980
+ ...params,
981
+ tabId: tab.id
982
+ });
983
+ });
984
+ }
985
+ throw toError('E_NOT_FOUND', `Unsupported method from CLI bridge: ${request.method}`);
986
+ }
987
+ }
988
+
989
+ function scheduleReconnect(reason: string): void {
990
+ if (manualDisconnect) {
991
+ return;
992
+ }
993
+ if (reconnectTimer !== null) {
994
+ return;
995
+ }
996
+
997
+ const delayMs = computeReconnectDelayMs(reconnectAttempt);
998
+ reconnectAttempt += 1;
999
+ nextReconnectInMs = delayMs;
1000
+ reconnectTimer = setTimeout(() => {
1001
+ reconnectTimer = null;
1002
+ nextReconnectInMs = null;
1003
+ void connectWebSocket();
1004
+ }, delayMs) as unknown as number;
1005
+
1006
+ if (!lastError) {
1007
+ setRuntimeError(`Reconnect scheduled: ${reason}`, 'socket');
1008
+ }
1009
+ }
1010
+
1011
+ async function connectWebSocket(): Promise<void> {
1012
+ clearReconnectTimer();
1013
+ if (manualDisconnect) {
1014
+ return;
1015
+ }
1016
+
1017
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
1018
+ return;
1019
+ }
1020
+
1021
+ const config = await getConfig();
1022
+ if (!config.token) {
1023
+ setRuntimeError('Pair token is empty', 'config');
1024
+ return;
1025
+ }
1026
+
1027
+ const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
1028
+ ws = new WebSocket(url);
1029
+
1030
+ ws.addEventListener('open', () => {
1031
+ manualDisconnect = false;
1032
+ reconnectAttempt = 0;
1033
+ lastError = null;
1034
+ ws?.send(JSON.stringify({
1035
+ type: 'hello',
1036
+ role: 'extension',
1037
+ version: '0.5.0',
1038
+ ts: Date.now()
1039
+ }));
1040
+ });
1041
+
1042
+ ws.addEventListener('message', (event) => {
1043
+ try {
1044
+ const request = JSON.parse(String(event.data)) as CliRequest;
1045
+ if (!request.id || !request.method) {
1046
+ return;
1047
+ }
1048
+ void handleRequest(request)
1049
+ .then((result) => {
1050
+ sendResponse({ id: request.id, ok: true, result });
1051
+ })
1052
+ .catch((error: unknown) => {
1053
+ const normalized = normalizeUnhandledError(error);
1054
+ sendResponse({ id: request.id, ok: false, error: normalized });
1055
+ });
1056
+ } catch (error) {
1057
+ setRuntimeError(error instanceof Error ? error.message : String(error), 'parse');
1058
+ sendResponse({
1059
+ id: 'parse-error',
1060
+ ok: false,
1061
+ error: toError('E_INTERNAL', error instanceof Error ? error.message : String(error))
1062
+ });
1063
+ }
1064
+ });
1065
+
1066
+ ws.addEventListener('close', () => {
1067
+ ws = null;
1068
+ scheduleReconnect('socket-closed');
1069
+ });
1070
+
1071
+ ws.addEventListener('error', () => {
1072
+ setRuntimeError('Cannot connect to bak cli', 'socket');
1073
+ ws?.close();
1074
+ });
1075
+ }
1076
+
1077
+ chrome.tabs.onRemoved.addListener((tabId) => {
1078
+ void listWorkspaceStates().then(async (states) => {
1079
+ for (const state of states) {
1080
+ if (!state.tabIds.includes(tabId)) {
1081
+ continue;
1082
+ }
1083
+ const nextTabIds = state.tabIds.filter((id) => id !== tabId);
1084
+ await saveWorkspaceState({
1085
+ ...state,
1086
+ tabIds: nextTabIds,
1087
+ activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
1088
+ primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
1089
+ });
1090
+ }
1091
+ });
1092
+ });
1093
+
1094
+ chrome.tabs.onActivated.addListener((activeInfo) => {
1095
+ void listWorkspaceStates().then(async (states) => {
1096
+ for (const state of states) {
1097
+ if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
1098
+ continue;
1099
+ }
1100
+ await saveWorkspaceState({
1101
+ ...state,
1102
+ activeTabId: activeInfo.tabId
1103
+ });
1104
+ }
1105
+ });
1106
+ });
1107
+
1108
+ chrome.windows.onRemoved.addListener((windowId) => {
1109
+ void listWorkspaceStates().then(async (states) => {
1110
+ for (const state of states) {
1111
+ if (state.windowId !== windowId) {
1112
+ continue;
1113
+ }
1114
+ await saveWorkspaceState({
1115
+ ...state,
1116
+ windowId: null,
1117
+ groupId: null,
1118
+ tabIds: [],
1119
+ activeTabId: null,
1120
+ primaryTabId: null
1121
+ });
1122
+ }
1123
+ });
1124
+ });
1125
+
1126
+ chrome.runtime.onInstalled.addListener(() => {
1127
+ void setConfig({ port: DEFAULT_PORT, debugRichText: false });
1128
+ });
1129
+
1130
+ chrome.runtime.onStartup.addListener(() => {
1131
+ void connectWebSocket();
1132
+ });
1133
+
1134
+ void connectWebSocket();
1135
+
1136
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1137
+ if (message?.type === 'bak.updateConfig') {
1138
+ manualDisconnect = false;
1139
+ void setConfig({
1140
+ token: message.token,
1141
+ port: Number(message.port ?? DEFAULT_PORT),
1142
+ debugRichText: message.debugRichText === true
1143
+ }).then(() => {
1144
+ ws?.close();
1145
+ void connectWebSocket().then(() => sendResponse({ ok: true }));
1146
+ });
1147
+ return true;
1148
+ }
1149
+
1150
+ if (message?.type === 'bak.getState') {
1151
+ void getConfig().then((config) => {
1152
+ sendResponse({
1153
+ ok: true,
1154
+ connected: ws?.readyState === WebSocket.OPEN,
1155
+ hasToken: Boolean(config.token),
1156
+ port: config.port,
1157
+ debugRichText: config.debugRichText,
1158
+ lastError: lastError?.message ?? null,
1159
+ lastErrorAt: lastError?.at ?? null,
1160
+ lastErrorContext: lastError?.context ?? null,
1161
+ reconnectAttempt,
1162
+ nextReconnectInMs
1163
+ });
1164
+ });
1165
+ return true;
1166
+ }
1167
+
1168
+ if (message?.type === 'bak.disconnect') {
1169
+ manualDisconnect = true;
1170
+ clearReconnectTimer();
1171
+ reconnectAttempt = 0;
1172
+ ws?.close();
1173
+ ws = null;
1174
+ sendResponse({ ok: true });
1175
+ return false;
1176
+ }
1177
+
1178
+ return false;
1158
1179
  });
1159
1180
 
1160
1181