@poncho-ai/browser 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.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@poncho-ai/browser",
3
+ "version": "0.1.0",
4
+ "description": "Browser automation for Poncho agents, powered by agent-browser",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/cesr/poncho-ai.git",
8
+ "directory": "packages/browser"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "agent-browser": "^0.15.1",
24
+ "@poncho-ai/sdk": "1.0.3"
25
+ },
26
+ "devDependencies": {
27
+ "tsup": "^8.0.0",
28
+ "vitest": "^1.4.0"
29
+ },
30
+ "keywords": [
31
+ "ai",
32
+ "agent",
33
+ "browser",
34
+ "automation"
35
+ ],
36
+ "license": "MIT",
37
+ "scripts": {
38
+ "build": "tsup src/index.ts --format esm --dts",
39
+ "dev": "tsup src/index.ts --format esm --dts --watch",
40
+ "postinstall": "node -e \"if(!process.env.CI&&!process.env.SERVERLESS)require('child_process').execSync('npx playwright install chromium',{stdio:'inherit'})\"",
41
+ "test": "vitest --passWithNoTests",
42
+ "lint": "eslint src/"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { BrowserSession } from "./session.js";
2
+ export { createBrowserTools } from "./tools.js";
3
+ export type {
4
+ BrowserConfig,
5
+ BrowserFrame,
6
+ BrowserStatus,
7
+ ViewportOptions,
8
+ ScreencastOptions,
9
+ MouseInputEvent,
10
+ KeyboardInputEvent,
11
+ ScrollInputEvent,
12
+ } from "./types.js";
package/src/session.ts ADDED
@@ -0,0 +1,595 @@
1
+ import { resolve } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { mkdir } from "node:fs/promises";
4
+ import type {
5
+ BrowserConfig,
6
+ BrowserFrame,
7
+ BrowserStatus,
8
+ ScreencastOptions,
9
+ MouseInputEvent,
10
+ KeyboardInputEvent,
11
+ ScrollInputEvent,
12
+ } from "./types.js";
13
+
14
+ type FrameListener = (frame: BrowserFrame) => void;
15
+ type StatusListener = (status: BrowserStatus) => void;
16
+
17
+ let BrowserManagerCtor: (new () => BrowserManagerInstance) | undefined;
18
+
19
+ interface BrowserManagerInstance {
20
+ isLaunched(): boolean;
21
+ launch(options: Record<string, unknown>): Promise<void>;
22
+ getPage(): { url(): string; title(): Promise<string>; screenshot(opts?: Record<string, unknown>): Promise<Buffer>; goBack(): Promise<unknown>; goForward(): Promise<unknown> };
23
+ getSnapshot(options?: { interactive?: boolean; compact?: boolean }): Promise<{ tree: string; refs: Record<string, unknown> }>;
24
+ getLocatorFromRef(ref: string): { click(): Promise<void> } | null;
25
+ getLocator(selector: string): { fill(text: string): Promise<void>; click(): Promise<void> };
26
+ newTab(): Promise<void>;
27
+ switchTo(index: number): Promise<{ index: number; url: string }>;
28
+ closeTab(index?: number): Promise<{ closed: number; remaining: number }>;
29
+ listTabs(): Promise<Array<{ index: number; url: string; title: string; active: boolean }>>;
30
+ getActiveIndex(): number;
31
+ startScreencast(
32
+ callback: (frame: { data: string; metadata: Record<string, number>; sessionId: number }) => void,
33
+ options?: Record<string, unknown>,
34
+ ): Promise<void>;
35
+ stopScreencast(): Promise<void>;
36
+ isScreencasting(): boolean;
37
+ injectMouseEvent(params: Record<string, unknown>): Promise<void>;
38
+ injectKeyboardEvent(params: Record<string, unknown>): Promise<void>;
39
+ getCDPSession(): Promise<{ send(method: string, params?: Record<string, unknown>): Promise<unknown> }>;
40
+ saveStorageState(path: string): Promise<void>;
41
+ close(): Promise<void>;
42
+ setViewport(width: number, height: number): Promise<void>;
43
+ }
44
+
45
+ async function getBrowserManagerCtor(): Promise<new () => BrowserManagerInstance> {
46
+ if (!BrowserManagerCtor) {
47
+ const mod = await import("agent-browser/dist/browser.js");
48
+ BrowserManagerCtor = mod.BrowserManager as unknown as new () => BrowserManagerInstance;
49
+ }
50
+ return BrowserManagerCtor;
51
+ }
52
+
53
+ const MAX_TABS = 8;
54
+
55
+ // Per-conversation tab state
56
+ interface ConversationTab {
57
+ tabIndex: number;
58
+ url?: string;
59
+ active: boolean;
60
+ lastUsed: number;
61
+ frameListeners: Set<FrameListener>;
62
+ statusListeners: Set<StatusListener>;
63
+ }
64
+
65
+ export class BrowserSession {
66
+ private readonly config: BrowserConfig;
67
+ private readonly sessionId: string;
68
+ private manager: BrowserManagerInstance | undefined;
69
+
70
+ // Tab management: conversationId → tab state
71
+ private readonly tabs = new Map<string, ConversationTab>();
72
+
73
+ // Serialization lock for tab-switching operations
74
+ private _lockQueue: Array<() => void> = [];
75
+ private _locked = false;
76
+
77
+ // Currently screencast conversation (only one at a time due to CDP)
78
+ private _screencastConversation: string | undefined;
79
+
80
+ constructor(sessionId: string, config: BrowserConfig = {}) {
81
+ this.sessionId = sessionId;
82
+ this.config = config;
83
+ }
84
+
85
+ get profileDir(): string {
86
+ return this.config.profileDir
87
+ ?? resolve(homedir(), ".poncho", "browser-profiles", this.sessionId);
88
+ }
89
+
90
+ // -----------------------------------------------------------------------
91
+ // Lock for serializing tab-switching operations
92
+ // -----------------------------------------------------------------------
93
+
94
+ private async lock(): Promise<void> {
95
+ if (!this._locked) {
96
+ this._locked = true;
97
+ return;
98
+ }
99
+ return new Promise<void>((resolve, reject) => {
100
+ const timer = setTimeout(() => {
101
+ const idx = this._lockQueue.indexOf(resolve);
102
+ if (idx !== -1) this._lockQueue.splice(idx, 1);
103
+ reject(new Error("Browser operation timed out waiting for lock (30s)"));
104
+ }, 30_000);
105
+ this._lockQueue.push(() => { clearTimeout(timer); resolve(); });
106
+ });
107
+ }
108
+
109
+ private unlock(): void {
110
+ const next = this._lockQueue.shift();
111
+ if (next) next();
112
+ else this._locked = false;
113
+ }
114
+
115
+ // -----------------------------------------------------------------------
116
+ // Core browser + tab management
117
+ // -----------------------------------------------------------------------
118
+
119
+ private async launchFreshManager(): Promise<BrowserManagerInstance> {
120
+ const Ctor = await getBrowserManagerCtor();
121
+ const mgr = new Ctor();
122
+
123
+ const viewport = this.config.viewport ?? { width: 1280, height: 720 };
124
+ await mkdir(this.profileDir, { recursive: true });
125
+
126
+ await mgr.launch({
127
+ action: "launch",
128
+ headless: this.config.headless ?? true,
129
+ viewport: { width: viewport.width ?? 1280, height: viewport.height ?? 720 },
130
+ executablePath: this.config.executablePath,
131
+ profile: this.profileDir,
132
+ });
133
+
134
+ try {
135
+ const cdp = await mgr.getCDPSession();
136
+ await cdp.send("Debugger.disable");
137
+ } catch { /* best-effort */ }
138
+
139
+ this.manager = mgr;
140
+ return mgr;
141
+ }
142
+
143
+ private async ensureManager(): Promise<BrowserManagerInstance> {
144
+ if (this.manager) {
145
+ try {
146
+ if (this.manager.isLaunched()) return this.manager;
147
+ } catch { /* stale manager */ }
148
+ // Manager exists but is dead/stale -- discard it
149
+ try { await this.manager.close(); } catch { /* */ }
150
+ this.manager = undefined;
151
+ // Clear tab state since they belonged to the dead browser
152
+ for (const [cid, tab] of this.tabs) {
153
+ if (tab.tabIndex >= 0) {
154
+ tab.tabIndex = -1;
155
+ tab.active = false;
156
+ tab.url = undefined;
157
+ }
158
+ }
159
+ }
160
+
161
+ return this.launchFreshManager();
162
+ }
163
+
164
+ private async evictOldestTab(mgr: BrowserManagerInstance): Promise<void> {
165
+ let oldest: { cid: string; tab: ConversationTab } | undefined;
166
+ for (const [cid, tab] of this.tabs) {
167
+ if (tab.tabIndex < 0) continue;
168
+ if (!oldest || tab.lastUsed < oldest.tab.lastUsed) {
169
+ oldest = { cid, tab };
170
+ }
171
+ }
172
+ if (!oldest) return;
173
+ console.log(`[poncho][browser] Evicting idle tab for conversation ${oldest.cid.slice(0, 8)}...`);
174
+ if (this._screencastConversation === oldest.cid) {
175
+ try { await mgr.stopScreencast(); } catch { /* */ }
176
+ this._screencastConversation = undefined;
177
+ }
178
+ if (this.tabs.size > 1) {
179
+ try { await mgr.closeTab(oldest.tab.tabIndex); } catch { /* */ }
180
+ for (const [, t] of this.tabs) {
181
+ if (t.tabIndex > oldest.tab.tabIndex) t.tabIndex--;
182
+ }
183
+ }
184
+ oldest.tab.active = false;
185
+ oldest.tab.url = undefined;
186
+ this.emitStatus(oldest.cid);
187
+ this.tabs.delete(oldest.cid);
188
+ }
189
+
190
+ /** Reconcile tab indices with the manager's actual page list. */
191
+ private async reconcileTabs(mgr: BrowserManagerInstance): Promise<void> {
192
+ try {
193
+ const managerTabs = await mgr.listTabs();
194
+ const managerUrls = managerTabs.map((t) => t.url);
195
+ for (const [cid, tab] of this.tabs) {
196
+ if (tab.tabIndex >= managerUrls.length) {
197
+ tab.active = false;
198
+ tab.url = undefined;
199
+ this.emitStatus(cid);
200
+ this.tabs.delete(cid);
201
+ }
202
+ }
203
+ } catch { /* best-effort */ }
204
+ }
205
+
206
+ private realTabCount(): number {
207
+ let n = 0;
208
+ for (const t of this.tabs.values()) { if (t.tabIndex >= 0) n++; }
209
+ return n;
210
+ }
211
+
212
+ private async switchToConversation(mgr: BrowserManagerInstance, conversationId: string): Promise<ConversationTab> {
213
+ let tab = this.tabs.get(conversationId);
214
+ if (!tab || tab.tabIndex < 0) {
215
+ const realTabs = this.realTabCount();
216
+ if (realTabs >= MAX_TABS) {
217
+ await this.evictOldestTab(mgr);
218
+ }
219
+ if (realTabs > 0) {
220
+ await mgr.newTab();
221
+ }
222
+ const existing = tab;
223
+ tab = {
224
+ tabIndex: mgr.getActiveIndex(),
225
+ active: true,
226
+ lastUsed: Date.now(),
227
+ frameListeners: existing?.frameListeners ?? new Set(),
228
+ statusListeners: existing?.statusListeners ?? new Set(),
229
+ };
230
+ this.tabs.set(conversationId, tab);
231
+ } else {
232
+ if (mgr.getActiveIndex() !== tab.tabIndex) {
233
+ await mgr.switchTo(tab.tabIndex);
234
+ }
235
+ tab.lastUsed = Date.now();
236
+ }
237
+ return tab;
238
+ }
239
+
240
+ /** Check if a conversation has an active browser tab. */
241
+ isActiveFor(conversationId: string): boolean {
242
+ return this.tabs.has(conversationId) && (this.tabs.get(conversationId)!.active);
243
+ }
244
+
245
+ /** Get the current URL for a conversation's tab. */
246
+ getUrl(conversationId: string): string | undefined {
247
+ return this.tabs.get(conversationId)?.url;
248
+ }
249
+
250
+ /** Whether the browser has been launched. */
251
+ get isLaunched(): boolean {
252
+ return !!this.manager?.isLaunched();
253
+ }
254
+
255
+ // -----------------------------------------------------------------------
256
+ // Browser operations (all scoped by conversationId)
257
+ // -----------------------------------------------------------------------
258
+
259
+ async open(conversationId: string, url: string): Promise<{ title?: string }> {
260
+ await this.lock();
261
+ try {
262
+ return await this._doOpen(conversationId, url);
263
+ } catch (err: unknown) {
264
+ const msg = (err as Error)?.message ?? "";
265
+ if (msg.includes("not launched") || msg.includes("closed") || msg.includes("Target closed")) {
266
+ console.log("[poncho][browser] Browser died mid-open, relaunching...");
267
+ try { await this.manager?.close(); } catch { /* */ }
268
+ this.manager = undefined;
269
+ for (const [, t] of this.tabs) {
270
+ if (t.tabIndex >= 0) { t.tabIndex = -1; t.active = false; t.url = undefined; }
271
+ }
272
+ return await this._doOpen(conversationId, url);
273
+ }
274
+ throw err;
275
+ } finally {
276
+ this.unlock();
277
+ }
278
+ }
279
+
280
+ private async _doOpen(conversationId: string, url: string): Promise<{ title?: string }> {
281
+ const mgr = await this.ensureManager();
282
+ const tab = await this.switchToConversation(mgr, conversationId);
283
+ const page = mgr.getPage();
284
+
285
+ await (page as unknown as { goto(url: string, opts?: Record<string, unknown>): Promise<unknown> })
286
+ .goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
287
+
288
+ tab.url = page.url();
289
+ tab.active = true;
290
+ this.emitStatus(conversationId);
291
+
292
+ const title = await page.title();
293
+ return { title: title || undefined };
294
+ }
295
+
296
+ async snapshot(conversationId: string): Promise<string> {
297
+ await this.lock();
298
+ try {
299
+ const mgr = await this.ensureManager();
300
+ await this.switchToConversation(mgr, conversationId);
301
+ const snap = await mgr.getSnapshot({ interactive: true, compact: true });
302
+ return snap.tree;
303
+ } finally {
304
+ this.unlock();
305
+ }
306
+ }
307
+
308
+ async click(conversationId: string, ref: string): Promise<void> {
309
+ await this.lock();
310
+ try {
311
+ const mgr = await this.ensureManager();
312
+ const tab = await this.switchToConversation(mgr, conversationId);
313
+ const locator = mgr.getLocatorFromRef(ref);
314
+ if (!locator) throw new Error(`No element found for ref ${ref}`);
315
+ await locator.click();
316
+ tab.url = mgr.getPage().url();
317
+ } finally {
318
+ this.unlock();
319
+ }
320
+ }
321
+
322
+ async type(conversationId: string, ref: string, text: string): Promise<void> {
323
+ await this.lock();
324
+ try {
325
+ const mgr = await this.ensureManager();
326
+ const tab = await this.switchToConversation(mgr, conversationId);
327
+ const locator = mgr.getLocatorFromRef(ref);
328
+ if (!locator) throw new Error(`No element found for ref ${ref}`);
329
+ await (locator as unknown as { fill(text: string): Promise<void> }).fill(text);
330
+ tab.url = mgr.getPage().url();
331
+ } finally {
332
+ this.unlock();
333
+ }
334
+ }
335
+
336
+ async screenshot(conversationId: string): Promise<string> {
337
+ await this.lock();
338
+ try {
339
+ const mgr = await this.ensureManager();
340
+ await this.switchToConversation(mgr, conversationId);
341
+ const page = mgr.getPage();
342
+ const buf = await page.screenshot({ type: "jpeg", quality: 75 });
343
+ return buf.toString("base64");
344
+ } finally {
345
+ this.unlock();
346
+ }
347
+ }
348
+
349
+ async scroll(conversationId: string, direction: "up" | "down", amount?: number): Promise<void> {
350
+ await this.lock();
351
+ try {
352
+ const mgr = await this.ensureManager();
353
+ await this.switchToConversation(mgr, conversationId);
354
+ const page = mgr.getPage();
355
+ const pixels = amount ?? 600;
356
+ const delta = direction === "down" ? pixels : -pixels;
357
+ await (page as unknown as { evaluate(fn: string): Promise<void> })
358
+ .evaluate(`window.scrollBy(0, ${delta})`);
359
+ } finally {
360
+ this.unlock();
361
+ }
362
+ }
363
+
364
+ async closeTab(conversationId: string): Promise<void> {
365
+ await this.lock();
366
+ try {
367
+ const tab = this.tabs.get(conversationId);
368
+ if (!tab) return;
369
+
370
+ if (this._screencastConversation === conversationId) {
371
+ try { await this.manager?.stopScreencast(); } catch { /* */ }
372
+ this._screencastConversation = undefined;
373
+ }
374
+
375
+ const otherRealTabs = this.realTabCount() - (tab.tabIndex >= 0 ? 1 : 0);
376
+ if (otherRealTabs > 0 && this.manager?.isLaunched() && tab.tabIndex >= 0) {
377
+ try { await this.manager.closeTab(tab.tabIndex); } catch { /* */ }
378
+ for (const [, t] of this.tabs) {
379
+ if (t.tabIndex > tab.tabIndex) t.tabIndex--;
380
+ }
381
+ } else if (this.manager?.isLaunched()) {
382
+ try { await this.manager.close(); } catch { /* */ }
383
+ this.manager = undefined;
384
+ }
385
+
386
+ tab.active = false;
387
+ tab.url = undefined;
388
+ this.emitStatus(conversationId);
389
+ this.tabs.delete(conversationId);
390
+ } finally {
391
+ this.unlock();
392
+ }
393
+ }
394
+
395
+ async navigate(conversationId: string, action: string): Promise<void> {
396
+ await this.lock();
397
+ try {
398
+ const mgr = await this.ensureManager();
399
+ const tab = await this.switchToConversation(mgr, conversationId);
400
+ const page = mgr.getPage();
401
+ if (action === "back") await page.goBack();
402
+ else if (action === "forward") await page.goForward();
403
+ else throw new Error(`Unknown navigation action: ${action}`);
404
+ tab.url = page.url();
405
+ } finally {
406
+ this.unlock();
407
+ }
408
+ }
409
+
410
+ // -----------------------------------------------------------------------
411
+ // Screencast (one active at a time, tied to the viewed conversation)
412
+ // -----------------------------------------------------------------------
413
+
414
+ async startScreencast(conversationId: string, options?: ScreencastOptions): Promise<void> {
415
+ await this.lock();
416
+ try {
417
+ const mgr = await this.ensureManager();
418
+ const tab = this.tabs.get(conversationId);
419
+ if (!tab) { return; }
420
+
421
+ // Always stop any existing screencast so we get a fresh CDP stream
422
+ if (mgr.isScreencasting()) {
423
+ try { await mgr.stopScreencast(); } catch { /* */ }
424
+ }
425
+
426
+ if (mgr.getActiveIndex() !== tab.tabIndex) {
427
+ await mgr.switchTo(tab.tabIndex);
428
+ }
429
+
430
+ this._screencastConversation = conversationId;
431
+ await mgr.startScreencast(
432
+ (frame) => {
433
+ const cid = this._screencastConversation;
434
+ if (!cid) return;
435
+ const t = this.tabs.get(cid);
436
+ if (!t) return;
437
+ const browserFrame: BrowserFrame = {
438
+ data: frame.data,
439
+ width: frame.metadata.deviceWidth,
440
+ height: frame.metadata.deviceHeight,
441
+ timestamp: Date.now(),
442
+ };
443
+ for (const listener of t.frameListeners) {
444
+ try { listener(browserFrame); } catch { /* */ }
445
+ }
446
+ },
447
+ {
448
+ format: options?.format ?? "jpeg",
449
+ quality: options?.quality ?? this.config.quality ?? 60,
450
+ maxWidth: options?.maxWidth ?? this.config.viewport?.width ?? 1280,
451
+ maxHeight: options?.maxHeight ?? this.config.viewport?.height ?? 720,
452
+ everyNthFrame: options?.everyNthFrame ?? this.config.everyNthFrame ?? 2,
453
+ },
454
+ );
455
+ } finally {
456
+ this.unlock();
457
+ }
458
+ }
459
+
460
+ async stopScreencast(): Promise<void> {
461
+ if (!this.manager?.isScreencasting()) return;
462
+ await this.manager.stopScreencast();
463
+ this._screencastConversation = undefined;
464
+ }
465
+
466
+ // -----------------------------------------------------------------------
467
+ // Per-conversation event listeners
468
+ // -----------------------------------------------------------------------
469
+
470
+ onFrame(conversationId: string, listener: FrameListener): () => void {
471
+ let tab = this.tabs.get(conversationId);
472
+ if (!tab) {
473
+ tab = { tabIndex: -1, active: false, lastUsed: Date.now(), frameListeners: new Set(), statusListeners: new Set() };
474
+ this.tabs.set(conversationId, tab);
475
+ }
476
+ tab.frameListeners.add(listener);
477
+ return () => { tab!.frameListeners.delete(listener); };
478
+ }
479
+
480
+ onStatus(conversationId: string, listener: StatusListener): () => void {
481
+ let tab = this.tabs.get(conversationId);
482
+ if (!tab) {
483
+ tab = { tabIndex: -1, active: false, lastUsed: Date.now(), frameListeners: new Set(), statusListeners: new Set() };
484
+ this.tabs.set(conversationId, tab);
485
+ }
486
+ tab.statusListeners.add(listener);
487
+ return () => { tab!.statusListeners.delete(listener); };
488
+ }
489
+
490
+ // -----------------------------------------------------------------------
491
+ // User input injection (all scoped by conversationId)
492
+ // -----------------------------------------------------------------------
493
+
494
+ async injectMouse(conversationId: string, event: MouseInputEvent): Promise<void> {
495
+ await this.lock();
496
+ try {
497
+ const mgr = await this.ensureManager();
498
+ await this.switchToConversation(mgr, conversationId);
499
+ await mgr.injectMouseEvent({
500
+ type: event.type,
501
+ x: event.x,
502
+ y: event.y,
503
+ button: event.button ?? "left",
504
+ clickCount: event.clickCount ?? 1,
505
+ deltaX: event.deltaX ?? 0,
506
+ deltaY: event.deltaY ?? 0,
507
+ });
508
+ } finally {
509
+ this.unlock();
510
+ }
511
+ }
512
+
513
+ async injectKeyboard(conversationId: string, event: KeyboardInputEvent): Promise<void> {
514
+ await this.lock();
515
+ try {
516
+ const mgr = await this.ensureManager();
517
+ await this.switchToConversation(mgr, conversationId);
518
+ const cdp = await mgr.getCDPSession();
519
+ let cdpType: string = event.type;
520
+ if (event.type === "keyDown" && !event.text) cdpType = "rawKeyDown";
521
+ await cdp.send("Input.dispatchKeyEvent", {
522
+ type: cdpType,
523
+ key: event.key,
524
+ code: event.code,
525
+ text: event.text,
526
+ windowsVirtualKeyCode: event.keyCode ?? 0,
527
+ nativeVirtualKeyCode: event.keyCode ?? 0,
528
+ });
529
+ } finally {
530
+ this.unlock();
531
+ }
532
+ }
533
+
534
+ async injectPaste(conversationId: string, text: string): Promise<void> {
535
+ await this.lock();
536
+ try {
537
+ const mgr = await this.ensureManager();
538
+ await this.switchToConversation(mgr, conversationId);
539
+ const cdp = await mgr.getCDPSession();
540
+ await cdp.send("Input.insertText", { text });
541
+ } finally {
542
+ this.unlock();
543
+ }
544
+ }
545
+
546
+ async injectScroll(conversationId: string, event: ScrollInputEvent): Promise<void> {
547
+ await this.injectMouse(conversationId, {
548
+ type: "mouseWheel",
549
+ x: event.x ?? 0,
550
+ y: event.y ?? 0,
551
+ deltaX: event.deltaX,
552
+ deltaY: event.deltaY,
553
+ });
554
+ }
555
+
556
+ // -----------------------------------------------------------------------
557
+ // Session persistence & shutdown
558
+ // -----------------------------------------------------------------------
559
+
560
+ async saveState(storagePath: string): Promise<void> {
561
+ if (!this.manager?.isLaunched()) return;
562
+ await mkdir(resolve(storagePath, ".."), { recursive: true });
563
+ await this.manager.saveStorageState(storagePath);
564
+ }
565
+
566
+ async close(): Promise<void> {
567
+ try { await this.stopScreencast(); } catch { /* */ }
568
+ try { await this.manager?.close(); } catch { /* */ }
569
+ this.manager = undefined;
570
+ for (const [cid, tab] of this.tabs) {
571
+ tab.active = false;
572
+ tab.url = undefined;
573
+ this.emitStatus(cid);
574
+ }
575
+ this.tabs.clear();
576
+ }
577
+
578
+ // -----------------------------------------------------------------------
579
+ // Internals
580
+ // -----------------------------------------------------------------------
581
+
582
+ private emitStatus(conversationId: string): void {
583
+ const tab = this.tabs.get(conversationId);
584
+ const status: BrowserStatus = {
585
+ active: tab?.active ?? false,
586
+ url: tab?.url,
587
+ interactionAllowed: tab?.active ?? false,
588
+ };
589
+ if (tab) {
590
+ for (const listener of tab.statusListeners) {
591
+ try { listener(status); } catch { /* */ }
592
+ }
593
+ }
594
+ }
595
+ }