btcp-browser-agent 0.1.0 → 0.1.2

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