btcp-browser-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CLAUDE.md +230 -0
  2. package/LICENSE +21 -0
  3. package/README.md +309 -0
  4. package/SKILL.md +143 -0
  5. package/SNAPSHOT_IMPROVEMENTS.md +302 -0
  6. package/USAGE.md +146 -0
  7. package/dist/index.d.ts +34 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +35 -0
  10. package/dist/index.js.map +1 -0
  11. package/docs/browser-cli-design.md +500 -0
  12. package/examples/chrome-extension/CHANGELOG.md +210 -0
  13. package/examples/chrome-extension/DEBUG.md +231 -0
  14. package/examples/chrome-extension/ERROR_FIXED.md +147 -0
  15. package/examples/chrome-extension/QUICK_TEST.md +189 -0
  16. package/examples/chrome-extension/README.md +149 -0
  17. package/examples/chrome-extension/SESSION_ONLY_MODE.md +305 -0
  18. package/examples/chrome-extension/TEST_WITH_YOUR_TABS.md +97 -0
  19. package/examples/chrome-extension/build.js +43 -0
  20. package/examples/chrome-extension/manifest.json +37 -0
  21. package/examples/chrome-extension/package-lock.json +1063 -0
  22. package/examples/chrome-extension/package.json +21 -0
  23. package/examples/chrome-extension/popup.html +195 -0
  24. package/examples/chrome-extension/src/background.ts +12 -0
  25. package/examples/chrome-extension/src/content.ts +7 -0
  26. package/examples/chrome-extension/src/popup.ts +303 -0
  27. package/examples/chrome-extension/src/scenario-google-github.ts +389 -0
  28. package/examples/chrome-extension/test-page.html +127 -0
  29. package/examples/chrome-extension/tests/README.md +206 -0
  30. package/examples/chrome-extension/tests/scenario-google-to-github-star.ts +380 -0
  31. package/examples/chrome-extension/tsconfig.json +14 -0
  32. package/examples/snapshots/README.md +207 -0
  33. package/examples/snapshots/amazon-com-detail.html +9528 -0
  34. package/examples/snapshots/amazon-com-detail.snapshot.txt +997 -0
  35. package/examples/snapshots/convert-snapshots.ts +97 -0
  36. package/examples/snapshots/edition-cnn-com.html +13292 -0
  37. package/examples/snapshots/edition-cnn-com.snapshot.txt +562 -0
  38. package/examples/snapshots/github-com-microsoft-vscode.html +2916 -0
  39. package/examples/snapshots/github-com-microsoft-vscode.snapshot.txt +455 -0
  40. package/examples/snapshots/google-search.html +20012 -0
  41. package/examples/snapshots/google-search.snapshot.txt +195 -0
  42. package/examples/snapshots/metadata.json +86 -0
  43. package/examples/snapshots/npr-org-templates.html +2031 -0
  44. package/examples/snapshots/npr-org-templates.snapshot.txt +224 -0
  45. package/examples/snapshots/stackoverflow-com.html +5216 -0
  46. package/examples/snapshots/stackoverflow-com.snapshot.txt +2404 -0
  47. package/examples/snapshots/test-all-mode.html +46 -0
  48. package/examples/snapshots/test-all-mode.snapshot.txt +5 -0
  49. package/examples/snapshots/validate.test.ts +296 -0
  50. package/package.json +65 -0
  51. package/packages/cli/package.json +42 -0
  52. package/packages/cli/src/__tests__/cli.test.ts +434 -0
  53. package/packages/cli/src/__tests__/errors.test.ts +226 -0
  54. package/packages/cli/src/__tests__/executor.test.ts +275 -0
  55. package/packages/cli/src/__tests__/formatter.test.ts +260 -0
  56. package/packages/cli/src/__tests__/parser.test.ts +288 -0
  57. package/packages/cli/src/__tests__/suggestions.test.ts +255 -0
  58. package/packages/cli/src/commands/back.ts +22 -0
  59. package/packages/cli/src/commands/check.ts +33 -0
  60. package/packages/cli/src/commands/clear.ts +33 -0
  61. package/packages/cli/src/commands/click.ts +32 -0
  62. package/packages/cli/src/commands/closetab.ts +31 -0
  63. package/packages/cli/src/commands/eval.ts +41 -0
  64. package/packages/cli/src/commands/fill.ts +30 -0
  65. package/packages/cli/src/commands/focus.ts +33 -0
  66. package/packages/cli/src/commands/forward.ts +22 -0
  67. package/packages/cli/src/commands/goto.ts +34 -0
  68. package/packages/cli/src/commands/help.ts +162 -0
  69. package/packages/cli/src/commands/hover.ts +34 -0
  70. package/packages/cli/src/commands/index.ts +129 -0
  71. package/packages/cli/src/commands/newtab.ts +35 -0
  72. package/packages/cli/src/commands/press.ts +40 -0
  73. package/packages/cli/src/commands/reload.ts +25 -0
  74. package/packages/cli/src/commands/screenshot.ts +27 -0
  75. package/packages/cli/src/commands/scroll.ts +64 -0
  76. package/packages/cli/src/commands/select.ts +35 -0
  77. package/packages/cli/src/commands/snapshot.ts +21 -0
  78. package/packages/cli/src/commands/tab.ts +32 -0
  79. package/packages/cli/src/commands/tabs.ts +26 -0
  80. package/packages/cli/src/commands/text.ts +27 -0
  81. package/packages/cli/src/commands/title.ts +17 -0
  82. package/packages/cli/src/commands/type.ts +38 -0
  83. package/packages/cli/src/commands/uncheck.ts +33 -0
  84. package/packages/cli/src/commands/url.ts +17 -0
  85. package/packages/cli/src/commands/wait.ts +54 -0
  86. package/packages/cli/src/errors.ts +164 -0
  87. package/packages/cli/src/executor.ts +68 -0
  88. package/packages/cli/src/formatter.ts +215 -0
  89. package/packages/cli/src/index.ts +257 -0
  90. package/packages/cli/src/parser.ts +195 -0
  91. package/packages/cli/src/suggestions.ts +207 -0
  92. package/packages/cli/src/terminal/Terminal.ts +365 -0
  93. package/packages/cli/src/terminal/index.ts +5 -0
  94. package/packages/cli/src/types.ts +155 -0
  95. package/packages/cli/tsconfig.json +20 -0
  96. package/packages/core/package.json +35 -0
  97. package/packages/core/src/actions.ts +1210 -0
  98. package/packages/core/src/errors.ts +296 -0
  99. package/packages/core/src/index.test.ts +638 -0
  100. package/packages/core/src/index.ts +220 -0
  101. package/packages/core/src/ref-map.ts +107 -0
  102. package/packages/core/src/snapshot.ts +873 -0
  103. package/packages/core/src/types.ts +536 -0
  104. package/packages/core/tsconfig.json +23 -0
  105. package/packages/extension/README.md +129 -0
  106. package/packages/extension/package.json +43 -0
  107. package/packages/extension/src/background.ts +888 -0
  108. package/packages/extension/src/content.ts +172 -0
  109. package/packages/extension/src/index.ts +579 -0
  110. package/packages/extension/src/session-manager.ts +385 -0
  111. package/packages/extension/src/session-types.ts +144 -0
  112. package/packages/extension/src/types.ts +162 -0
  113. package/packages/extension/tsconfig.json +28 -0
  114. package/src/index.ts +64 -0
  115. package/tsconfig.build.json +12 -0
  116. package/tsconfig.json +26 -0
  117. package/vitest.config.ts +13 -0
@@ -0,0 +1,888 @@
1
+ /**
2
+ * @btcp/extension - Background Script
3
+ *
4
+ * Contains BrowserAgent - the high-level orchestrator that runs in the
5
+ * extension's background/service worker context.
6
+ *
7
+ * BrowserAgent manages:
8
+ * - Tab lifecycle (create, close, switch, list)
9
+ * - Navigation (goto, back, forward, reload)
10
+ * - Screenshots (chrome.tabs.captureVisibleTab)
11
+ * - Session state
12
+ * - Routing DOM commands to ContentAgents in target tabs
13
+ */
14
+
15
+ import type {
16
+ Command,
17
+ ExtensionCommand,
18
+ ExtensionMessage,
19
+ ExtensionResponse,
20
+ Response,
21
+ TabInfo,
22
+ ChromeTab,
23
+ } from './types.js';
24
+ import { SessionManager } from './session-manager.js';
25
+
26
+ /**
27
+ * TabHandle - Interface for interacting with a specific tab
28
+ *
29
+ * Returned by BackgroundAgent.tab(tabId) for tab-specific operations.
30
+ */
31
+ export interface TabHandle {
32
+ readonly tabId: number;
33
+ execute(command: Command): Promise<Response>;
34
+ snapshot(options?: { selector?: string; maxDepth?: number }): Promise<Response>;
35
+ click(selector: string): Promise<Response>;
36
+ fill(selector: string, value: string): Promise<Response>;
37
+ type(selector: string, text: string, options?: { clear?: boolean }): Promise<Response>;
38
+ getText(selector: string): Promise<Response>;
39
+ isVisible(selector: string): Promise<Response>;
40
+ }
41
+
42
+ /**
43
+ * BackgroundAgent - High-level browser automation orchestrator
44
+ *
45
+ * Runs in the extension's background script/service worker.
46
+ * Manages browser-level operations and routes DOM commands to
47
+ * ContentAgent instances running in content scripts.
48
+ *
49
+ * @example Single tab (default - uses activeTabId)
50
+ * ```typescript
51
+ * const agent = new BackgroundAgent();
52
+ * await agent.navigate('https://example.com');
53
+ * await agent.execute({ id: '1', action: 'click', selector: '#submit' });
54
+ * ```
55
+ *
56
+ * @example Multi-tab with explicit tabId
57
+ * ```typescript
58
+ * const agent = new BackgroundAgent();
59
+ *
60
+ * // Open two tabs
61
+ * const tab1 = await agent.newTab({ url: 'https://google.com' });
62
+ * const tab2 = await agent.newTab({ url: 'https://github.com', active: false });
63
+ *
64
+ * // Interact with specific tabs without switching
65
+ * await agent.tab(tab1.id).click('#search');
66
+ * await agent.tab(tab2.id).snapshot();
67
+ *
68
+ * // Or specify tabId in command
69
+ * await agent.execute({ id: '1', action: 'snapshot' }, { tabId: tab2.id });
70
+ * ```
71
+ */
72
+ export class BackgroundAgent {
73
+ private activeTabId: number | null = null;
74
+ private sessionManager: SessionManager;
75
+ private heartbeatInterval: number | null = null;
76
+ private readonly HEARTBEAT_INTERVAL = 30000; // 30 seconds
77
+
78
+ constructor() {
79
+ this.sessionManager = new SessionManager();
80
+ // Initialize active tab on creation
81
+ this.initActiveTab();
82
+ // Start heartbeat to keep session tabs alive
83
+ this.startHeartbeat();
84
+ }
85
+
86
+ private async initActiveTab(): Promise<void> {
87
+ const tab = await this.getActiveTab();
88
+ this.activeTabId = tab?.id ?? null;
89
+ }
90
+
91
+ /**
92
+ * Get the current active tab ID
93
+ */
94
+ getActiveTabId(): number | null {
95
+ return this.activeTabId;
96
+ }
97
+
98
+ /**
99
+ * Set the active tab ID (for manual control)
100
+ */
101
+ setActiveTabId(tabId: number): void {
102
+ this.activeTabId = tabId;
103
+ }
104
+
105
+ /**
106
+ * Get a handle for interacting with a specific tab
107
+ *
108
+ * This allows you to send commands to any tab without switching the active tab.
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * const tab2Handle = browser.tab(tab2.id);
113
+ * await tab2Handle.snapshot();
114
+ * await tab2Handle.click('@ref:5');
115
+ * ```
116
+ */
117
+ tab(tabId: number): TabHandle {
118
+ const agent = this;
119
+ let cmdCounter = 0;
120
+ const genId = () => `tab_${tabId}_${Date.now()}_${cmdCounter++}`;
121
+
122
+ return {
123
+ tabId,
124
+
125
+ execute(command: Command): Promise<Response> {
126
+ return agent.sendToContentAgent(command, tabId);
127
+ },
128
+
129
+ snapshot(options?: { selector?: string; maxDepth?: number }): Promise<Response> {
130
+ return agent.sendToContentAgent({
131
+ id: genId(),
132
+ action: 'snapshot',
133
+ ...options,
134
+ } as Command, tabId);
135
+ },
136
+
137
+ click(selector: string): Promise<Response> {
138
+ return agent.sendToContentAgent({
139
+ id: genId(),
140
+ action: 'click',
141
+ selector,
142
+ } as Command, tabId);
143
+ },
144
+
145
+ fill(selector: string, value: string): Promise<Response> {
146
+ return agent.sendToContentAgent({
147
+ id: genId(),
148
+ action: 'fill',
149
+ selector,
150
+ value,
151
+ } as Command, tabId);
152
+ },
153
+
154
+ type(selector: string, text: string, options?: { clear?: boolean }): Promise<Response> {
155
+ return agent.sendToContentAgent({
156
+ id: genId(),
157
+ action: 'type',
158
+ selector,
159
+ text,
160
+ ...options,
161
+ } as Command, tabId);
162
+ },
163
+
164
+ getText(selector: string): Promise<Response> {
165
+ return agent.sendToContentAgent({
166
+ id: genId(),
167
+ action: 'getText',
168
+ selector,
169
+ } as Command, tabId);
170
+ },
171
+
172
+ isVisible(selector: string): Promise<Response> {
173
+ return agent.sendToContentAgent({
174
+ id: genId(),
175
+ action: 'isVisible',
176
+ selector,
177
+ } as Command, tabId);
178
+ },
179
+ };
180
+ }
181
+
182
+ // ============================================================================
183
+ // TAB MANAGEMENT
184
+ // ============================================================================
185
+
186
+ /**
187
+ * Get the currently active tab (only if in session)
188
+ */
189
+ async getActiveTab(): Promise<ChromeTab | null> {
190
+ const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
191
+
192
+ // If no session, return null
193
+ if (sessionGroupId === null) {
194
+ return null;
195
+ }
196
+
197
+ // Get active tab and verify it's in the session
198
+ return new Promise((resolve) => {
199
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
200
+ const activeTab = tabs[0];
201
+ // Only return if it's in the session group
202
+ if (activeTab && activeTab.groupId === sessionGroupId) {
203
+ resolve(activeTab);
204
+ } else {
205
+ resolve(null);
206
+ }
207
+ });
208
+ });
209
+ }
210
+
211
+ /**
212
+ * List all tabs (only in session group)
213
+ */
214
+ async listTabs(): Promise<TabInfo[]> {
215
+ // Get active session group ID
216
+ const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
217
+
218
+ // Session is required
219
+ if (sessionGroupId === null) {
220
+ throw new Error('No active session. Create a session first to manage tabs.');
221
+ }
222
+
223
+ // Only return tabs in the session group
224
+ const tabs = await new Promise<ChromeTab[]>((resolve) => {
225
+ chrome.tabs.query({ groupId: sessionGroupId }, (t) => resolve(t));
226
+ });
227
+
228
+ return tabs.map((t) => ({
229
+ id: t.id!,
230
+ url: t.url,
231
+ title: t.title,
232
+ active: t.active,
233
+ index: t.index,
234
+ }));
235
+ }
236
+
237
+ /**
238
+ * Create a new tab (only in session group)
239
+ */
240
+ async newTab(options?: { url?: string; active?: boolean }): Promise<TabInfo> {
241
+ // Require active session
242
+ const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
243
+ if (sessionGroupId === null) {
244
+ throw new Error('No active session. Create a session first to manage tabs.');
245
+ }
246
+
247
+ const tab = await new Promise<ChromeTab>((resolve) => {
248
+ chrome.tabs.create(
249
+ { url: options?.url, active: options?.active ?? true },
250
+ (t) => resolve(t)
251
+ );
252
+ });
253
+
254
+ if (options?.url) {
255
+ await this.waitForTabLoad(tab.id!);
256
+ }
257
+
258
+ if (options?.active !== false) {
259
+ this.activeTabId = tab.id!;
260
+ }
261
+
262
+ // Add to active session
263
+ if (tab.id) {
264
+ const added = await this.sessionManager.addTabToActiveSession(tab.id);
265
+ if (!added) {
266
+ // If we couldn't add to session, close the tab
267
+ await chrome.tabs.remove(tab.id);
268
+ throw new Error('Failed to add new tab to session');
269
+ }
270
+ }
271
+
272
+ return {
273
+ id: tab.id!,
274
+ url: tab.url,
275
+ title: tab.title,
276
+ active: tab.active,
277
+ index: tab.index,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Check if a tab is in the active session
283
+ */
284
+ private async isTabInSession(tabId: number): Promise<boolean> {
285
+ const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
286
+
287
+ // Session is required - no operations allowed without it
288
+ if (sessionGroupId === null) {
289
+ throw new Error('No active session. Create a session first to manage tabs.');
290
+ }
291
+
292
+ // Check if tab is in the session group
293
+ const tab = await chrome.tabs.get(tabId);
294
+ return tab.groupId === sessionGroupId;
295
+ }
296
+
297
+ /**
298
+ * Close a tab (only if in session)
299
+ */
300
+ async closeTab(tabId?: number): Promise<void> {
301
+ const targetId = tabId ?? this.activeTabId;
302
+ if (!targetId) throw new Error('No tab to close');
303
+
304
+ // Validate tab is in session
305
+ const inSession = await this.isTabInSession(targetId);
306
+ if (!inSession) {
307
+ throw new Error('Cannot close tab: tab is not in the active session');
308
+ }
309
+
310
+ await new Promise<void>((resolve) => {
311
+ chrome.tabs.remove(targetId, () => resolve());
312
+ });
313
+
314
+ // Update active tab if we closed the current one
315
+ if (targetId === this.activeTabId) {
316
+ const tab = await this.getActiveTab();
317
+ this.activeTabId = tab?.id ?? null;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Switch to a tab (only if in session)
323
+ */
324
+ async switchTab(tabId: number): Promise<void> {
325
+ // Validate tab is in session
326
+ const inSession = await this.isTabInSession(tabId);
327
+ if (!inSession) {
328
+ throw new Error('Cannot switch to tab: tab is not in the active session');
329
+ }
330
+
331
+ await new Promise<void>((resolve) => {
332
+ chrome.tabs.update(tabId, { active: true }, () => resolve());
333
+ });
334
+ this.activeTabId = tabId;
335
+ }
336
+
337
+ // ============================================================================
338
+ // NAVIGATION
339
+ // ============================================================================
340
+
341
+ /**
342
+ * Navigate to a URL (only in session tabs)
343
+ */
344
+ async navigate(url: string, options?: { waitUntil?: 'load' | 'domcontentloaded' }): Promise<void> {
345
+ const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
346
+ if (!tabId) throw new Error('No active tab');
347
+
348
+ // Validate tab is in session
349
+ const inSession = await this.isTabInSession(tabId);
350
+ if (!inSession) {
351
+ throw new Error('Cannot navigate: tab is not in the active session');
352
+ }
353
+
354
+ await new Promise<void>((resolve) => {
355
+ chrome.tabs.update(tabId, { url }, () => resolve());
356
+ });
357
+
358
+ if (options?.waitUntil) {
359
+ await this.waitForTabLoad(tabId);
360
+
361
+ // Clear refs and highlights after navigation completes
362
+ try {
363
+ await this.sendToContentAgent({
364
+ id: `nav_clear_${Date.now()}`,
365
+ action: 'clearHighlight'
366
+ }, tabId);
367
+ } catch (error) {
368
+ // Ignore errors - content script might not be ready yet
369
+ console.log('[BackgroundAgent] Failed to clear highlights after navigation:', error);
370
+ }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Go back in history
376
+ */
377
+ async back(): Promise<void> {
378
+ const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
379
+ if (!tabId) throw new Error('No active tab');
380
+
381
+ await new Promise<void>((resolve) => {
382
+ chrome.tabs.goBack(tabId, () => resolve());
383
+ });
384
+ }
385
+
386
+ /**
387
+ * Go forward in history
388
+ */
389
+ async forward(): Promise<void> {
390
+ const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
391
+ if (!tabId) throw new Error('No active tab');
392
+
393
+ await new Promise<void>((resolve) => {
394
+ chrome.tabs.goForward(tabId, () => resolve());
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Reload the current page
400
+ */
401
+ async reload(options?: { bypassCache?: boolean }): Promise<void> {
402
+ const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
403
+ if (!tabId) throw new Error('No active tab');
404
+
405
+ await new Promise<void>((resolve) => {
406
+ chrome.tabs.reload(tabId, { bypassCache: options?.bypassCache }, () => resolve());
407
+ });
408
+
409
+ await this.waitForTabLoad(tabId);
410
+ }
411
+
412
+ /**
413
+ * Get the current URL
414
+ */
415
+ async getUrl(): Promise<string> {
416
+ const tab = await this.getActiveTab();
417
+ return tab?.url || '';
418
+ }
419
+
420
+ /**
421
+ * Get the page title
422
+ */
423
+ async getTitle(): Promise<string> {
424
+ const tab = await this.getActiveTab();
425
+ return tab?.title || '';
426
+ }
427
+
428
+ // ============================================================================
429
+ // SCREENSHOTS
430
+ // ============================================================================
431
+
432
+ /**
433
+ * Capture a screenshot of the visible tab
434
+ */
435
+ async screenshot(options?: { format?: 'png' | 'jpeg'; quality?: number }): Promise<string> {
436
+ const format = options?.format || 'png';
437
+ const quality = options?.quality;
438
+
439
+ const dataUrl = await new Promise<string>((resolve, reject) => {
440
+ chrome.tabs.captureVisibleTab(
441
+ { format, quality },
442
+ (url) => {
443
+ if (chrome.runtime.lastError) {
444
+ reject(new Error(chrome.runtime.lastError.message));
445
+ } else {
446
+ resolve(url);
447
+ }
448
+ }
449
+ );
450
+ });
451
+
452
+ // Extract base64 data from data URL
453
+ return dataUrl.split(',')[1];
454
+ }
455
+
456
+ // ============================================================================
457
+ // COMMAND EXECUTION
458
+ // ============================================================================
459
+
460
+ /**
461
+ * Execute a command - routes to appropriate handler
462
+ *
463
+ * Browser-level commands (navigate, screenshot, tabs) are handled here.
464
+ * DOM-level commands are forwarded to the ContentAgent in the target tab.
465
+ *
466
+ * @param command - The command to execute
467
+ * @param options - Optional settings including target tabId
468
+ *
469
+ * @example Default (active tab)
470
+ * ```typescript
471
+ * await browser.execute({ id: '1', action: 'snapshot' });
472
+ * ```
473
+ *
474
+ * @example Specific tab
475
+ * ```typescript
476
+ * await browser.execute({ id: '1', action: 'snapshot' }, { tabId: 123 });
477
+ * ```
478
+ */
479
+ async execute(command: Command, options?: { tabId?: number }): Promise<Response> {
480
+ try {
481
+ // Extension commands are handled directly by BrowserAgent
482
+ if (this.isExtensionCommand(command)) {
483
+ return this.executeExtensionCommand(command);
484
+ }
485
+
486
+ // DOM commands are forwarded to ContentAgent in the target tab
487
+ return this.sendToContentAgent(command, options?.tabId);
488
+ } catch (error) {
489
+ return {
490
+ id: command.id,
491
+ success: false,
492
+ error: error instanceof Error ? error.message : String(error),
493
+ };
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Send a command to the ContentAgent in a specific tab
499
+ */
500
+ async sendToContentAgent(command: Command, tabId?: number): Promise<Response> {
501
+ const targetTabId = tabId ?? this.activeTabId ?? (await this.getActiveTab())?.id;
502
+
503
+ if (!targetTabId) {
504
+ return {
505
+ id: command.id,
506
+ success: false,
507
+ error: 'No active tab for DOM command',
508
+ };
509
+ }
510
+
511
+ // Try sending with automatic retry and recovery
512
+ return this.sendMessageWithRetry(targetTabId, command);
513
+ }
514
+
515
+ /**
516
+ * Send message with automatic content script re-injection on failure
517
+ */
518
+ private async sendMessageWithRetry(
519
+ tabId: number,
520
+ command: Command,
521
+ retries = 1
522
+ ): Promise<Response> {
523
+ return new Promise((resolve) => {
524
+ chrome.tabs.sendMessage(
525
+ tabId,
526
+ { type: 'btcp:command', command } satisfies ExtensionMessage,
527
+ { frameId: 0 }, // Target only the main frame, not iframes
528
+ async (response) => {
529
+ if (chrome.runtime.lastError) {
530
+ // Content script not responding - try re-injection
531
+ if (retries > 0) {
532
+ console.log(`[Recovery] Re-injecting content script into tab ${tabId}`);
533
+ const success = await this.reinjectContentScript(tabId);
534
+
535
+ if (success) {
536
+ // Wait briefly for content script to initialize
537
+ await new Promise(r => setTimeout(r, 500));
538
+ // Retry the command
539
+ resolve(this.sendMessageWithRetry(tabId, command, retries - 1));
540
+ return;
541
+ }
542
+ }
543
+
544
+ resolve({
545
+ id: command.id,
546
+ success: false,
547
+ error: chrome.runtime.lastError.message || 'Failed to send message to tab',
548
+ });
549
+ } else {
550
+ const resp = response as ExtensionResponse;
551
+ if (resp.type === 'btcp:response') {
552
+ resolve(resp.response);
553
+ } else {
554
+ resolve({
555
+ id: command.id,
556
+ success: false,
557
+ error: 'Invalid response type',
558
+ });
559
+ }
560
+ }
561
+ }
562
+ );
563
+ });
564
+ }
565
+
566
+ /**
567
+ * Re-inject content script into a tab (for recovery from frozen state)
568
+ */
569
+ private async reinjectContentScript(tabId: number): Promise<boolean> {
570
+ try {
571
+ // Check if tab is ready for injection
572
+ const tab = await chrome.tabs.get(tabId);
573
+ if (!tab.url || tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) {
574
+ return false; // Can't inject into chrome:// or extension pages
575
+ }
576
+
577
+ // Execute content script
578
+ await chrome.scripting.executeScript({
579
+ target: { tabId },
580
+ files: ['content.js'],
581
+ });
582
+
583
+ console.log(`[Recovery] Successfully re-injected content script into tab ${tabId}`);
584
+ return true;
585
+ } catch (error) {
586
+ console.error(`[Recovery] Failed to re-inject content script:`, error);
587
+ return false;
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Start heartbeat to monitor session tabs
593
+ */
594
+ private startHeartbeat(): void {
595
+ this.heartbeatInterval = setInterval(() => {
596
+ this.pingSessionTabs();
597
+ }, this.HEARTBEAT_INTERVAL) as unknown as number;
598
+ }
599
+
600
+ /**
601
+ * Stop heartbeat (for cleanup)
602
+ * Note: Currently not called as service workers are terminated by Chrome
603
+ * Could be used if explicit cleanup is needed in the future
604
+ */
605
+ // @ts-expect-error - Unused but kept for potential future use
606
+ private _stopHeartbeat(): void {
607
+ if (this.heartbeatInterval) {
608
+ clearInterval(this.heartbeatInterval);
609
+ this.heartbeatInterval = null;
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Ping all session tabs to check health
615
+ */
616
+ private async pingSessionTabs(): Promise<void> {
617
+ try {
618
+ const tabs = await this.listTabs().catch(() => []);
619
+
620
+ for (const tab of tabs) {
621
+ chrome.tabs.sendMessage(
622
+ tab.id,
623
+ { type: 'btcp:ping' } satisfies ExtensionMessage,
624
+ { frameId: 0 }, // Target only the main frame, not iframes
625
+ (response) => {
626
+ if (chrome.runtime.lastError) {
627
+ console.log(`[Heartbeat] Tab ${tab.id} unresponsive, will re-inject on next command`);
628
+ } else {
629
+ const resp = response as ExtensionResponse;
630
+ if (resp.type === 'btcp:pong' && !resp.ready) {
631
+ console.log(`[Heartbeat] Tab ${tab.id} content script not ready`);
632
+ }
633
+ }
634
+ }
635
+ );
636
+ }
637
+ } catch (error) {
638
+ // Silently ignore errors during heartbeat (e.g., no active session)
639
+ }
640
+ }
641
+
642
+ // ============================================================================
643
+ // PRIVATE HELPERS
644
+ // ============================================================================
645
+
646
+ private isExtensionCommand(command: Command): command is ExtensionCommand {
647
+ const extensionActions = [
648
+ 'navigate', 'back', 'forward', 'reload',
649
+ 'getUrl', 'getTitle', 'screenshot',
650
+ 'tabNew', 'tabClose', 'tabSwitch', 'tabList',
651
+ 'groupCreate', 'groupUpdate', 'groupDelete', 'groupList',
652
+ 'groupAddTabs', 'groupRemoveTabs', 'groupGet',
653
+ 'sessionGetCurrent', 'popupInitialize',
654
+ ];
655
+ return extensionActions.includes(command.action);
656
+ }
657
+
658
+ private async executeExtensionCommand(command: ExtensionCommand): Promise<Response> {
659
+ switch (command.action) {
660
+ case 'navigate':
661
+ await this.navigate(command.url, { waitUntil: command.waitUntil });
662
+ return { id: command.id, success: true, data: { url: command.url } };
663
+
664
+ case 'back':
665
+ await this.back();
666
+ return { id: command.id, success: true, data: { navigated: 'back' } };
667
+
668
+ case 'forward':
669
+ await this.forward();
670
+ return { id: command.id, success: true, data: { navigated: 'forward' } };
671
+
672
+ case 'reload':
673
+ await this.reload({ bypassCache: command.bypassCache });
674
+ return { id: command.id, success: true, data: { reloaded: true } };
675
+
676
+ case 'getUrl': {
677
+ const url = await this.getUrl();
678
+ return { id: command.id, success: true, data: { url } };
679
+ }
680
+
681
+ case 'getTitle': {
682
+ const title = await this.getTitle();
683
+ return { id: command.id, success: true, data: { title } };
684
+ }
685
+
686
+ case 'screenshot': {
687
+ const screenshot = await this.screenshot({
688
+ format: command.format,
689
+ quality: command.quality,
690
+ });
691
+ return { id: command.id, success: true, data: { screenshot, format: command.format || 'png' } };
692
+ }
693
+
694
+ case 'tabNew': {
695
+ const tab = await this.newTab({ url: command.url, active: command.active });
696
+ return { id: command.id, success: true, data: { tabId: tab.id, url: tab.url } };
697
+ }
698
+
699
+ case 'tabClose':
700
+ await this.closeTab(command.tabId);
701
+ return { id: command.id, success: true, data: { closed: command.tabId ?? this.activeTabId } };
702
+
703
+ case 'tabSwitch':
704
+ await this.switchTab(command.tabId);
705
+ return { id: command.id, success: true, data: { switched: command.tabId } };
706
+
707
+ case 'tabList': {
708
+ const tabs = await this.listTabs();
709
+ return { id: command.id, success: true, data: { tabs } };
710
+ }
711
+
712
+ case 'groupCreate': {
713
+ const group = await this.sessionManager.createGroup({
714
+ tabIds: command.tabIds,
715
+ title: command.title,
716
+ color: command.color,
717
+ collapsed: command.collapsed,
718
+ });
719
+ return { id: command.id, success: true, data: { group } };
720
+ }
721
+
722
+ case 'groupUpdate': {
723
+ const group = await this.sessionManager.updateGroup(command.groupId, {
724
+ title: command.title,
725
+ color: command.color,
726
+ collapsed: command.collapsed,
727
+ });
728
+ return { id: command.id, success: true, data: { group } };
729
+ }
730
+
731
+ case 'groupDelete':
732
+ await this.sessionManager.deleteGroup(command.groupId);
733
+ return { id: command.id, success: true, data: { deleted: command.groupId } };
734
+
735
+ case 'groupList': {
736
+ const groups = await this.sessionManager.listGroups();
737
+ return { id: command.id, success: true, data: { groups } };
738
+ }
739
+
740
+ case 'groupAddTabs':
741
+ await this.sessionManager.addTabsToGroup(command.groupId, command.tabIds);
742
+ return { id: command.id, success: true, data: { addedTabs: command.tabIds } };
743
+
744
+ case 'groupRemoveTabs':
745
+ await this.sessionManager.removeTabsFromGroup(command.tabIds);
746
+ return { id: command.id, success: true, data: { removedTabs: command.tabIds } };
747
+
748
+ case 'groupGet': {
749
+ const group = await this.sessionManager.getGroup(command.groupId);
750
+ return { id: command.id, success: true, data: { group } };
751
+ }
752
+
753
+ case 'sessionGetCurrent': {
754
+ const session = await this.sessionManager.getCurrentSession();
755
+ return { id: command.id, success: true, data: { session } };
756
+ }
757
+
758
+ case 'popupInitialize': {
759
+ console.log('[BackgroundAgent] Popup initializing, checking for session reconnection...');
760
+
761
+ // Check if we have a stored session but no active connection
762
+ const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
763
+
764
+ if (sessionGroupId === null) {
765
+ // Try to reconnect from storage
766
+ const result = await chrome.storage.session.get('btcp_active_session');
767
+ const stored = result['btcp_active_session'] as { groupId?: number } | undefined;
768
+
769
+ if (stored?.groupId) {
770
+ console.log('[BackgroundAgent] Found stored session, attempting reconnection...');
771
+ const reconnected = await this.sessionManager.reconnectSession(stored.groupId);
772
+
773
+ return {
774
+ id: command.id,
775
+ success: true,
776
+ data: { initialized: true, reconnected },
777
+ };
778
+ }
779
+ }
780
+
781
+ return { id: command.id, success: true, data: { initialized: true, reconnected: false } };
782
+ }
783
+
784
+ default:
785
+ throw new Error(`Unknown extension action: ${(command as ExtensionCommand).action}`);
786
+ }
787
+ }
788
+
789
+ private waitForTabLoad(tabId: number, timeout = 30000): Promise<void> {
790
+ return new Promise((resolve, reject) => {
791
+ const startTime = Date.now();
792
+
793
+ const checkTab = () => {
794
+ chrome.tabs.get(tabId, (tab) => {
795
+ if (chrome.runtime.lastError) {
796
+ reject(new Error(chrome.runtime.lastError.message));
797
+ return;
798
+ }
799
+
800
+ if (tab.status === 'complete') {
801
+ resolve();
802
+ return;
803
+ }
804
+
805
+ if (Date.now() - startTime > timeout) {
806
+ reject(new Error('Tab load timeout'));
807
+ return;
808
+ }
809
+
810
+ setTimeout(checkTab, 100);
811
+ });
812
+ };
813
+
814
+ checkTab();
815
+ });
816
+ }
817
+ }
818
+
819
+ // ============================================================================
820
+ // MESSAGE LISTENER SETUP
821
+ // ============================================================================
822
+
823
+ // Singleton instance for message handling
824
+ let backgroundAgent: BackgroundAgent | null = null;
825
+
826
+ /**
827
+ * Get or create the BackgroundAgent singleton
828
+ */
829
+ export function getBackgroundAgent(): BackgroundAgent {
830
+ if (!backgroundAgent) {
831
+ backgroundAgent = new BackgroundAgent();
832
+ }
833
+ return backgroundAgent;
834
+ }
835
+
836
+ /**
837
+ * @deprecated Use getBackgroundAgent instead
838
+ */
839
+ export const getBrowserAgent = getBackgroundAgent;
840
+
841
+ /**
842
+ * Set up the message listener for the background script
843
+ * Call this once in your background.ts to enable command routing
844
+ */
845
+ export function setupMessageListener(): void {
846
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
847
+ const msg = message as ExtensionMessage;
848
+
849
+ if (msg.type !== 'btcp:command') {
850
+ return false;
851
+ }
852
+
853
+ const agent = getBackgroundAgent();
854
+
855
+ // Execute the command (BackgroundAgent handles routing to correct tab)
856
+ agent.execute(msg.command)
857
+ .then((response) => {
858
+ sendResponse({ type: 'btcp:response', response } satisfies ExtensionResponse);
859
+ })
860
+ .catch((error) => {
861
+ sendResponse({
862
+ type: 'btcp:response',
863
+ response: {
864
+ id: msg.command.id,
865
+ success: false,
866
+ error: error instanceof Error ? error.message : String(error),
867
+ },
868
+ } satisfies ExtensionResponse);
869
+ });
870
+
871
+ return true; // Keep channel open for async response
872
+ });
873
+ }
874
+
875
+ // Legacy exports for backwards compatibility
876
+ export const handleCommand = (command: Command, _tabId?: number) =>
877
+ getBackgroundAgent().execute(command);
878
+
879
+ export const executeExtensionCommand = (command: ExtensionCommand) =>
880
+ getBackgroundAgent().execute(command);
881
+
882
+ export const sendToContentScript = (_tabId: number, command: Command) =>
883
+ getBackgroundAgent().sendToContentAgent(command, _tabId);
884
+
885
+ /**
886
+ * @deprecated Use BackgroundAgent instead
887
+ */
888
+ export const BrowserAgent = BackgroundAgent;