btcp-browser-agent 0.1.11 → 0.1.14

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/package.json CHANGED
@@ -1,69 +1,69 @@
1
- {
2
- "name": "btcp-browser-agent",
3
- "version": "0.1.11",
4
- "description": "Give AI agents the power to control browsers. A foundation for building agentic systems with smart DOM snapshots and stable element references.",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js",
12
- "default": "./dist/index.js"
13
- },
14
- "./core": {
15
- "types": "./packages/core/dist/index.d.ts",
16
- "import": "./packages/core/dist/index.js",
17
- "default": "./packages/core/dist/index.js"
18
- },
19
- "./extension": {
20
- "types": "./packages/extension/dist/index.d.ts",
21
- "import": "./packages/extension/dist/index.js",
22
- "default": "./packages/extension/dist/index.js"
23
- },
24
- "./extension/content": {
25
- "types": "./packages/extension/dist/content.d.ts",
26
- "import": "./packages/extension/dist/content.js",
27
- "default": "./packages/extension/dist/content.js"
28
- },
29
- "./extension/background": {
30
- "types": "./packages/extension/dist/background.d.ts",
31
- "import": "./packages/extension/dist/background.js",
32
- "default": "./packages/extension/dist/background.js"
33
- }
34
- },
35
- "scripts": {
36
- "build": "npm run build:packages && tsc -p tsconfig.build.json",
37
- "build:packages": "tsc -p packages/core/tsconfig.json && tsc -p packages/extension/tsconfig.json && tsc -p packages/cli/tsconfig.json",
38
- "clean": "rm -rf dist packages/*/dist",
39
- "prepare": "npm run build",
40
- "test": "vitest run",
41
- "test:watch": "vitest",
42
- "typecheck": "tsc --noEmit"
43
- },
44
- "workspaces": [
45
- "packages/core",
46
- "packages/extension",
47
- "packages/cli"
48
- ],
49
- "files": [
50
- "dist",
51
- "packages/core/dist",
52
- "packages/extension/dist",
53
- "!**/__tests__",
54
- "!**/*.map"
55
- ],
56
- "license": "Apache-2.0",
57
- "repository": {
58
- "type": "git",
59
- "url": "git+https://github.com/browser-tool-calling-protocol/btcp-browser-agent.git"
60
- },
61
- "dependencies": {},
62
- "devDependencies": {
63
- "@types/chrome": "^0.0.268",
64
- "@types/node": "^20.10.0",
65
- "jsdom": "^24.0.0",
66
- "typescript": "^5.3.0",
67
- "vitest": "^2.0.0"
68
- }
69
- }
1
+ {
2
+ "name": "btcp-browser-agent",
3
+ "version": "0.1.14",
4
+ "description": "Give AI agents the power to control browsers. A foundation for building agentic systems with smart DOM snapshots and stable element references.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./core": {
15
+ "types": "./packages/core/dist/index.d.ts",
16
+ "import": "./packages/core/dist/index.js",
17
+ "default": "./packages/core/dist/index.js"
18
+ },
19
+ "./extension": {
20
+ "types": "./packages/extension/dist/index.d.ts",
21
+ "import": "./packages/extension/dist/index.js",
22
+ "default": "./packages/extension/dist/index.js"
23
+ },
24
+ "./extension/content": {
25
+ "types": "./packages/extension/dist/content.d.ts",
26
+ "import": "./packages/extension/dist/content.js",
27
+ "default": "./packages/extension/dist/content.js"
28
+ },
29
+ "./extension/background": {
30
+ "types": "./packages/extension/dist/background.d.ts",
31
+ "import": "./packages/extension/dist/background.js",
32
+ "default": "./packages/extension/dist/background.js"
33
+ }
34
+ },
35
+ "scripts": {
36
+ "build": "npm run build:packages && tsc -p tsconfig.build.json",
37
+ "build:packages": "tsc -p packages/core/tsconfig.json && tsc -p packages/extension/tsconfig.json && tsc -p packages/cli/tsconfig.json",
38
+ "clean": "rm -rf dist packages/*/dist",
39
+ "prepare": "npm run build",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "typecheck": "tsc --noEmit"
43
+ },
44
+ "workspaces": [
45
+ "packages/core",
46
+ "packages/extension",
47
+ "packages/cli"
48
+ ],
49
+ "files": [
50
+ "dist",
51
+ "packages/core/dist",
52
+ "packages/extension/dist",
53
+ "!**/__tests__",
54
+ "!**/*.map"
55
+ ],
56
+ "license": "Apache-2.0",
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/browser-tool-calling-protocol/btcp-browser-agent.git"
60
+ },
61
+ "dependencies": {},
62
+ "devDependencies": {
63
+ "@types/chrome": "^0.0.268",
64
+ "@types/node": "^20.10.0",
65
+ "jsdom": "^24.0.0",
66
+ "typescript": "^5.3.0",
67
+ "vitest": "^2.0.0"
68
+ }
69
+ }
@@ -349,31 +349,99 @@ export class DOMActions {
349
349
  }
350
350
  async type(selector, text, options = {}) {
351
351
  const element = this.getElement(selector);
352
- if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
352
+ // Check if element is contenteditable
353
+ const isContentEditable = element.getAttribute('contenteditable') === 'true' ||
354
+ element.getAttribute('contenteditable') === '';
355
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || isContentEditable)) {
353
356
  const actualType = element.tagName.toLowerCase();
354
357
  const availableActions = this.getAvailableActionsForElement(element);
355
- throw createElementNotCompatibleError(selector, 'type', actualType, ['input', 'textarea'], availableActions);
358
+ throw createElementNotCompatibleError(selector, 'type', actualType, ['input', 'textarea', 'contenteditable'], availableActions);
356
359
  }
357
- element.focus();
358
- if (options.clear) {
359
- element.value = '';
360
- element.dispatchEvent(new Event('input', { bubbles: true }));
360
+ // Focus the element (cast to HTMLElement for contenteditable)
361
+ if (element instanceof HTMLElement) {
362
+ element.focus();
361
363
  }
362
- for (const char of text) {
363
- element.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true }));
364
- element.dispatchEvent(new KeyboardEvent('keypress', { key: char, bubbles: true }));
365
- element.value += char;
366
- element.dispatchEvent(new Event('input', { bubbles: true }));
367
- element.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true }));
368
- if (options.delay) {
369
- await this.sleep(options.delay);
364
+ // Handle contenteditable elements differently
365
+ if (isContentEditable) {
366
+ const htmlElement = element;
367
+ if (options.clear) {
368
+ htmlElement.textContent = '';
369
+ htmlElement.dispatchEvent(new Event('input', { bubbles: true }));
370
+ }
371
+ for (const char of text) {
372
+ htmlElement.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true }));
373
+ htmlElement.dispatchEvent(new KeyboardEvent('keypress', { key: char, bubbles: true }));
374
+ // Insert text at cursor position or append
375
+ const selection = this.window.getSelection();
376
+ if (selection && selection.rangeCount > 0) {
377
+ const range = selection.getRangeAt(0);
378
+ range.deleteContents();
379
+ const textNode = this.document.createTextNode(char);
380
+ range.insertNode(textNode);
381
+ range.setStartAfter(textNode);
382
+ range.setEndAfter(textNode);
383
+ selection.removeAllRanges();
384
+ selection.addRange(range);
385
+ }
386
+ else {
387
+ htmlElement.textContent += char;
388
+ }
389
+ htmlElement.dispatchEvent(new Event('input', { bubbles: true }));
390
+ htmlElement.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true }));
391
+ if (options.delay) {
392
+ await this.sleep(options.delay);
393
+ }
394
+ }
395
+ htmlElement.dispatchEvent(new Event('change', { bubbles: true }));
396
+ // Wait for verification that textContent contains typed text
397
+ const result = await waitForAssertion(() => {
398
+ const content = htmlElement.textContent || '';
399
+ const expected = text;
400
+ const actual = content;
401
+ if (!content.includes(text)) {
402
+ return {
403
+ success: false,
404
+ error: `Expected textContent to contain "${text}"`,
405
+ description: 'textContent check',
406
+ expected,
407
+ actual
408
+ };
409
+ }
410
+ return {
411
+ success: true,
412
+ error: null,
413
+ description: 'textContent check',
414
+ expected,
415
+ actual
416
+ };
417
+ }, { timeout: 1000, interval: 50 });
418
+ if (!result.success) {
419
+ throw createVerificationError('type', result, selector);
370
420
  }
371
421
  }
372
- element.dispatchEvent(new Event('change', { bubbles: true }));
373
- // Wait for verification that value contains typed text
374
- const result = await waitForAssertion(() => assertValueContains(element, text), { timeout: 1000, interval: 50 });
375
- if (!result.success) {
376
- throw createVerificationError('type', result, selector);
422
+ else {
423
+ // Handle regular input/textarea elements
424
+ const inputElement = element;
425
+ if (options.clear) {
426
+ inputElement.value = '';
427
+ inputElement.dispatchEvent(new Event('input', { bubbles: true }));
428
+ }
429
+ for (const char of text) {
430
+ inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true }));
431
+ inputElement.dispatchEvent(new KeyboardEvent('keypress', { key: char, bubbles: true }));
432
+ inputElement.value += char;
433
+ inputElement.dispatchEvent(new Event('input', { bubbles: true }));
434
+ inputElement.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true }));
435
+ if (options.delay) {
436
+ await this.sleep(options.delay);
437
+ }
438
+ }
439
+ inputElement.dispatchEvent(new Event('change', { bubbles: true }));
440
+ // Wait for verification that value contains typed text
441
+ const result = await waitForAssertion(() => assertValueContains(inputElement, text), { timeout: 1000, interval: 50 });
442
+ if (!result.success) {
443
+ throw createVerificationError('type', result, selector);
444
+ }
377
445
  }
378
446
  return { success: true, error: null };
379
447
  }
@@ -796,15 +864,15 @@ export class DOMActions {
796
864
  // Create overlay container with absolute positioning covering entire document
797
865
  this.overlayContainer = this.document.createElement('div');
798
866
  this.overlayContainer.id = 'btcp-highlight-overlay';
799
- this.overlayContainer.style.cssText = `
800
- position: absolute;
801
- top: 0;
802
- left: 0;
803
- width: ${this.document.documentElement.scrollWidth}px;
804
- height: ${this.document.documentElement.scrollHeight}px;
805
- pointer-events: none;
806
- z-index: 999999;
807
- contain: layout style paint;
867
+ this.overlayContainer.style.cssText = `
868
+ position: absolute;
869
+ top: 0;
870
+ left: 0;
871
+ width: ${this.document.documentElement.scrollWidth}px;
872
+ height: ${this.document.documentElement.scrollHeight}px;
873
+ pointer-events: none;
874
+ z-index: 999999;
875
+ contain: layout style paint;
808
876
  `;
809
877
  let highlightedCount = 0;
810
878
  // Create border overlays and labels for each ref
@@ -825,17 +893,17 @@ export class DOMActions {
825
893
  const border = this.document.createElement('div');
826
894
  border.className = 'btcp-ref-border';
827
895
  border.dataset.ref = ref;
828
- border.style.cssText = `
829
- position: absolute;
830
- width: ${bbox.width}px;
831
- height: ${bbox.height}px;
832
- transform: translate3d(${bbox.left + this.window.scrollX}px, ${bbox.top + this.window.scrollY}px, 0);
833
- border: 2px solid rgba(59, 130, 246, 0.8);
834
- border-radius: 2px;
835
- box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
836
- pointer-events: none;
837
- will-change: transform;
838
- contain: layout style paint;
896
+ border.style.cssText = `
897
+ position: absolute;
898
+ width: ${bbox.width}px;
899
+ height: ${bbox.height}px;
900
+ transform: translate3d(${bbox.left + this.window.scrollX}px, ${bbox.top + this.window.scrollY}px, 0);
901
+ border: 2px solid rgba(59, 130, 246, 0.8);
902
+ border-radius: 2px;
903
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
904
+ pointer-events: none;
905
+ will-change: transform;
906
+ contain: layout style paint;
839
907
  `;
840
908
  // Create label
841
909
  const label = this.document.createElement('div');
@@ -843,21 +911,21 @@ export class DOMActions {
843
911
  label.dataset.ref = ref;
844
912
  // Extract number from ref (e.g., "@ref:5" -> "5")
845
913
  label.textContent = ref.replace('@ref:', '');
846
- label.style.cssText = `
847
- position: absolute;
848
- transform: translate3d(${bbox.left + this.window.scrollX}px, ${bbox.top + this.window.scrollY}px, 0);
849
- background: rgba(59, 130, 246, 0.9);
850
- color: white;
851
- padding: 2px 6px;
852
- border-radius: 3px;
853
- font-family: monospace;
854
- font-size: 11px;
855
- font-weight: bold;
856
- box-shadow: 0 2px 4px rgba(0,0,0,0.3);
857
- pointer-events: none;
858
- white-space: nowrap;
859
- will-change: transform;
860
- contain: layout style paint;
914
+ label.style.cssText = `
915
+ position: absolute;
916
+ transform: translate3d(${bbox.left + this.window.scrollX}px, ${bbox.top + this.window.scrollY}px, 0);
917
+ background: rgba(59, 130, 246, 0.9);
918
+ color: white;
919
+ padding: 2px 6px;
920
+ border-radius: 3px;
921
+ font-family: monospace;
922
+ font-size: 11px;
923
+ font-weight: bold;
924
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
925
+ pointer-events: none;
926
+ white-space: nowrap;
927
+ will-change: transform;
928
+ contain: layout style paint;
861
929
  `;
862
930
  this.overlayContainer.appendChild(border);
863
931
  this.overlayContainer.appendChild(label);
@@ -110,6 +110,11 @@ function getRole(element) {
110
110
  const type = element.type || 'text';
111
111
  return INPUT_ROLES[type] || 'textbox';
112
112
  }
113
+ // Detect contenteditable elements (ProseMirror, Quill, TinyMCE, etc.)
114
+ const contentEditable = element.getAttribute('contenteditable');
115
+ if (contentEditable === 'true' || contentEditable === '') {
116
+ return 'textbox';
117
+ }
113
118
  return IMPLICIT_ROLES[tagName] || null;
114
119
  }
115
120
  /**
@@ -320,6 +325,21 @@ function getAccessibleName(element) {
320
325
  if (isImage) {
321
326
  return getImageLabel(element);
322
327
  }
328
+ // Handle contenteditable elements (ProseMirror, Quill, TinyMCE, etc.)
329
+ const contentEditable = element.getAttribute('contenteditable');
330
+ if (contentEditable === 'true' || contentEditable === '') {
331
+ // Try data-placeholder attribute (common in rich text editors)
332
+ const placeholder = element.getAttribute('data-placeholder');
333
+ if (placeholder)
334
+ return placeholder.trim();
335
+ // Try finding placeholder in child paragraph element
336
+ const placeholderEl = element.querySelector('[data-placeholder]');
337
+ if (placeholderEl) {
338
+ const placeholderText = placeholderEl.getAttribute('data-placeholder');
339
+ if (placeholderText)
340
+ return placeholderText.trim();
341
+ }
342
+ }
323
343
  const ariaLabel = element.getAttribute('aria-label');
324
344
  if (ariaLabel)
325
345
  return ariaLabel.trim();
@@ -91,22 +91,23 @@ export declare class BackgroundAgent {
91
91
  */
92
92
  tab(tabId: number): TabHandle;
93
93
  /**
94
- * Get the currently active tab (only if in session)
94
+ * Get the currently active tab (ensures session exists, creates if needed)
95
+ * This is the core "get or create" method that enables automatic session management.
95
96
  */
96
- getActiveTab(): Promise<ChromeTab | null>;
97
+ getActiveTab(): Promise<ChromeTab>;
97
98
  /**
98
- * List all tabs (only in session group)
99
+ * List all tabs in session (ensures session exists, creates if needed)
99
100
  */
100
101
  listTabs(): Promise<TabInfo[]>;
101
102
  /**
102
- * Create a new tab (only in session group)
103
+ * Create a new tab in session (ensures session exists, creates if needed)
103
104
  */
104
105
  newTab(options?: {
105
106
  url?: string;
106
107
  active?: boolean;
107
108
  }): Promise<TabInfo>;
108
109
  /**
109
- * Check if a tab is in the active session
110
+ * Check if a tab is in the active session (ensures session exists, creates if needed)
110
111
  */
111
112
  private isTabInSession;
112
113
  /**
@@ -118,7 +119,7 @@ export declare class BackgroundAgent {
118
119
  */
119
120
  switchTab(tabId: number): Promise<void>;
120
121
  /**
121
- * Navigate to a URL (only in session tabs)
122
+ * Navigate to a URL (session auto-created if needed)
122
123
  * Always waits for page to be idle before returning.
123
124
  * Verifies navigation completed to the expected origin.
124
125
  */
@@ -126,25 +127,25 @@ export declare class BackgroundAgent {
126
127
  waitUntil?: 'load' | 'domcontentloaded';
127
128
  }): Promise<void>;
128
129
  /**
129
- * Go back in history
130
+ * Go back in history (session auto-created if needed)
130
131
  */
131
132
  back(): Promise<void>;
132
133
  /**
133
- * Go forward in history
134
+ * Go forward in history (session auto-created if needed)
134
135
  */
135
136
  forward(): Promise<void>;
136
137
  /**
137
- * Reload the current page
138
+ * Reload the current page (session auto-created if needed)
138
139
  */
139
140
  reload(options?: {
140
141
  bypassCache?: boolean;
141
142
  }): Promise<void>;
142
143
  /**
143
- * Get the current URL
144
+ * Get the current URL (session auto-created if needed)
144
145
  */
145
146
  getUrl(): Promise<string>;
146
147
  /**
147
- * Get the page title
148
+ * Get the page title (session auto-created if needed)
148
149
  */
149
150
  getTitle(): Promise<string>;
150
151
  /**
@@ -180,6 +181,7 @@ export declare class BackgroundAgent {
180
181
  }): Promise<Response>;
181
182
  /**
182
183
  * Send a command to the ContentAgent in a specific tab
184
+ * Session is automatically created if it doesn't exist.
183
185
  */
184
186
  sendToContentAgent(command: Command, tabId?: number): Promise<Response>;
185
187
  /**
@@ -151,38 +151,19 @@ export class BackgroundAgent {
151
151
  // TAB MANAGEMENT
152
152
  // ============================================================================
153
153
  /**
154
- * Get the currently active tab (only if in session)
154
+ * Get the currently active tab (ensures session exists, creates if needed)
155
+ * This is the core "get or create" method that enables automatic session management.
155
156
  */
156
157
  async getActiveTab() {
157
- const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
158
- // If no session, return null
159
- if (sessionGroupId === null) {
160
- return null;
161
- }
162
- // Get active tab and verify it's in the session
163
- return new Promise((resolve) => {
164
- chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
165
- const activeTab = tabs[0];
166
- // Only return if it's in the session group
167
- if (activeTab && activeTab.groupId === sessionGroupId) {
168
- resolve(activeTab);
169
- }
170
- else {
171
- resolve(null);
172
- }
173
- });
174
- });
158
+ const tabId = await this.sessionManager.getSessionTab();
159
+ return chrome.tabs.get(tabId);
175
160
  }
176
161
  /**
177
- * List all tabs (only in session group)
162
+ * List all tabs in session (ensures session exists, creates if needed)
178
163
  */
179
164
  async listTabs() {
180
- // Get active session group ID
181
- const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
182
- // Session is required
183
- if (sessionGroupId === null) {
184
- throw new Error('No active session. Create a session first to manage tabs.');
185
- }
165
+ // Ensure session exists (creates if needed)
166
+ const sessionGroupId = await this.sessionManager.ensureSession();
186
167
  // Only return tabs in the session group
187
168
  const tabs = await new Promise((resolve) => {
188
169
  chrome.tabs.query({ groupId: sessionGroupId }, (t) => resolve(t));
@@ -196,14 +177,11 @@ export class BackgroundAgent {
196
177
  }));
197
178
  }
198
179
  /**
199
- * Create a new tab (only in session group)
180
+ * Create a new tab in session (ensures session exists, creates if needed)
200
181
  */
201
182
  async newTab(options) {
202
- // Require active session
203
- const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
204
- if (sessionGroupId === null) {
205
- throw new Error('No active session. Create a session first to manage tabs.');
206
- }
183
+ // Ensure session exists (creates if needed)
184
+ await this.sessionManager.ensureSession();
207
185
  const tab = await new Promise((resolve) => {
208
186
  chrome.tabs.create({ url: options?.url, active: options?.active ?? true }, (t) => resolve(t));
209
187
  });
@@ -231,14 +209,11 @@ export class BackgroundAgent {
231
209
  };
232
210
  }
233
211
  /**
234
- * Check if a tab is in the active session
212
+ * Check if a tab is in the active session (ensures session exists, creates if needed)
235
213
  */
236
214
  async isTabInSession(tabId) {
237
- const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
238
- // Session is required - no operations allowed without it
239
- if (sessionGroupId === null) {
240
- throw new Error('No active session. Create a session first to manage tabs.');
241
- }
215
+ // Ensure session exists (creates if needed)
216
+ const sessionGroupId = await this.sessionManager.ensureSession();
242
217
  // Check if tab is in the session group
243
218
  const tab = await chrome.tabs.get(tabId);
244
219
  return tab.groupId === sessionGroupId;
@@ -282,19 +257,13 @@ export class BackgroundAgent {
282
257
  // NAVIGATION
283
258
  // ============================================================================
284
259
  /**
285
- * Navigate to a URL (only in session tabs)
260
+ * Navigate to a URL (session auto-created if needed)
286
261
  * Always waits for page to be idle before returning.
287
262
  * Verifies navigation completed to the expected origin.
288
263
  */
289
264
  async navigate(url, _options) {
290
- const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
291
- if (!tabId)
292
- throw new Error('No active tab');
293
- // Validate tab is in session
294
- const inSession = await this.isTabInSession(tabId);
295
- if (!inSession) {
296
- throw new Error('Cannot navigate: tab is not in the active session');
297
- }
265
+ // getActiveTab() ensures session exists and always returns a valid tab
266
+ const tabId = this.activeTabId ?? (await this.getActiveTab()).id;
298
267
  await new Promise((resolve) => {
299
268
  chrome.tabs.update(tabId, { url }, () => resolve());
300
269
  });
@@ -334,52 +303,46 @@ export class BackgroundAgent {
334
303
  }
335
304
  }
336
305
  /**
337
- * Go back in history
306
+ * Go back in history (session auto-created if needed)
338
307
  */
339
308
  async back() {
340
- const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
341
- if (!tabId)
342
- throw new Error('No active tab');
309
+ const tabId = this.activeTabId ?? (await this.getActiveTab()).id;
343
310
  await new Promise((resolve) => {
344
311
  chrome.tabs.goBack(tabId, () => resolve());
345
312
  });
346
313
  }
347
314
  /**
348
- * Go forward in history
315
+ * Go forward in history (session auto-created if needed)
349
316
  */
350
317
  async forward() {
351
- const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
352
- if (!tabId)
353
- throw new Error('No active tab');
318
+ const tabId = this.activeTabId ?? (await this.getActiveTab()).id;
354
319
  await new Promise((resolve) => {
355
320
  chrome.tabs.goForward(tabId, () => resolve());
356
321
  });
357
322
  }
358
323
  /**
359
- * Reload the current page
324
+ * Reload the current page (session auto-created if needed)
360
325
  */
361
326
  async reload(options) {
362
- const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
363
- if (!tabId)
364
- throw new Error('No active tab');
327
+ const tabId = this.activeTabId ?? (await this.getActiveTab()).id;
365
328
  await new Promise((resolve) => {
366
329
  chrome.tabs.reload(tabId, { bypassCache: options?.bypassCache }, () => resolve());
367
330
  });
368
331
  await this.waitForTabLoad(tabId);
369
332
  }
370
333
  /**
371
- * Get the current URL
334
+ * Get the current URL (session auto-created if needed)
372
335
  */
373
336
  async getUrl() {
374
337
  const tab = await this.getActiveTab();
375
- return tab?.url || '';
338
+ return tab.url || '';
376
339
  }
377
340
  /**
378
- * Get the page title
341
+ * Get the page title (session auto-created if needed)
379
342
  */
380
343
  async getTitle() {
381
344
  const tab = await this.getActiveTab();
382
- return tab?.title || '';
345
+ return tab.title || '';
383
346
  }
384
347
  // ============================================================================
385
348
  // SCREENSHOTS
@@ -449,19 +412,14 @@ export class BackgroundAgent {
449
412
  }
450
413
  /**
451
414
  * Send a command to the ContentAgent in a specific tab
415
+ * Session is automatically created if it doesn't exist.
452
416
  */
453
417
  async sendToContentAgent(command, tabId) {
454
418
  // Ensure command has an ID for internal use
455
419
  const id = command.id || generateBgCommandId();
456
420
  const internalCmd = { ...command, id };
457
- const targetTabId = tabId ?? this.activeTabId ?? (await this.getActiveTab())?.id;
458
- if (!targetTabId) {
459
- return {
460
- id,
461
- success: false,
462
- error: 'No active tab for DOM command',
463
- };
464
- }
421
+ // getActiveTab() ensures session exists and always returns a valid tab
422
+ const targetTabId = tabId ?? this.activeTabId ?? (await this.getActiveTab()).id;
465
423
  // Try sending with automatic retry and recovery
466
424
  return this.sendMessageWithRetry(targetTabId, internalCmd);
467
425
  }
@@ -693,7 +651,7 @@ export class BackgroundAgent {
693
651
  case 'popupInitialize': {
694
652
  console.log('[BackgroundAgent] Popup initializing, checking for session reconnection...');
695
653
  // Check if we have a stored session but no active connection
696
- const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
654
+ const sessionGroupId = await this.sessionManager.getActiveSessionGroupIdAsync();
697
655
  if (sessionGroupId === null) {
698
656
  // Try to reconnect from storage
699
657
  const result = await chrome.storage.session.get('btcp_active_session');