@openacme/browser 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/binaries.d.ts +63 -0
  2. package/dist/binaries.d.ts.map +1 -0
  3. package/dist/binaries.js +159 -0
  4. package/dist/binaries.js.map +1 -0
  5. package/dist/chrome.d.ts +22 -3
  6. package/dist/chrome.d.ts.map +1 -1
  7. package/dist/chrome.js +117 -16
  8. package/dist/chrome.js.map +1 -1
  9. package/dist/index.d.ts +5 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/manager.d.ts +40 -40
  14. package/dist/manager.d.ts.map +1 -1
  15. package/dist/manager.js +238 -253
  16. package/dist/manager.js.map +1 -1
  17. package/dist/providers/base.d.ts +44 -0
  18. package/dist/providers/base.d.ts.map +1 -0
  19. package/dist/providers/base.js +2 -0
  20. package/dist/providers/base.js.map +1 -0
  21. package/dist/providers/browser-use.d.ts +24 -0
  22. package/dist/providers/browser-use.d.ts.map +1 -0
  23. package/dist/providers/browser-use.js +110 -0
  24. package/dist/providers/browser-use.js.map +1 -0
  25. package/dist/providers/browserbase.d.ts +29 -0
  26. package/dist/providers/browserbase.d.ts.map +1 -0
  27. package/dist/providers/browserbase.js +126 -0
  28. package/dist/providers/browserbase.js.map +1 -0
  29. package/dist/providers/firecrawl.d.ts +22 -0
  30. package/dist/providers/firecrawl.d.ts.map +1 -0
  31. package/dist/providers/firecrawl.js +101 -0
  32. package/dist/providers/firecrawl.js.map +1 -0
  33. package/dist/providers/index.d.ts +20 -0
  34. package/dist/providers/index.d.ts.map +1 -0
  35. package/dist/providers/index.js +27 -0
  36. package/dist/providers/index.js.map +1 -0
  37. package/dist/providers/local.d.ts +35 -0
  38. package/dist/providers/local.d.ts.map +1 -0
  39. package/dist/providers/local.js +132 -0
  40. package/dist/providers/local.js.map +1 -0
  41. package/dist/types.d.ts +27 -1
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +3 -2
package/dist/manager.js CHANGED
@@ -1,129 +1,173 @@
1
- import { killChrome, launchChrome, resolveExecutableOrThrow, resolveUserDataDir, } from "./chrome.js";
2
1
  import { connectOverCdp, isRecoverableDisconnect } from "./cdp.js";
3
2
  import { refLocator } from "./refs.js";
4
3
  import { ariaSnapshot } from "./snapshot.js";
5
4
  const MAX_CONSOLE_PER_PAGE = 200;
6
5
  /**
7
- * Owns the single managed Chrome process for the OpenAcme workforce.
6
+ * Per-agent browser orchestrator. Each agent that calls a browser tool
7
+ * gets its own session — a separate Chrome process for the local provider,
8
+ * a separate cloud session for Browserbase / Browser-Use / Firecrawl.
9
+ * Sessions are lazy and acquired on first tool call.
8
10
  *
9
- * One Chrome under `<dataDir>/browser-profile/`; one shared default
10
- * `BrowserContext` so all agents share cookies / login state. Tabs are
11
- * partitioned per-agent: each agent gets its own `t1`, `t2`, ... alias
12
- * space. Cross-agent tab access is refused.
13
- *
14
- * Lazy: nothing happens until the first tool call. CDP disconnect on
15
- * sleep/wake is recovered transparently — `cachedBrowser` is invalidated
16
- * by the `disconnected` listener and re-established on next call.
11
+ * Why per-agent: shared cookies cascade bans across the workforce, and
12
+ * shared fingerprints get the whole org flagged on social-media-style
13
+ * sites. Each agent is a distinct persona; the runtime should reflect that.
17
14
  */
18
15
  export class BrowserManager {
19
- userDataDir;
20
- cfg;
21
- running = null;
22
- cachedBrowser = null;
23
- connectingPromise = null;
24
- launchingPromise = null;
25
- // Tab ownership: targetId -> owning agentId
26
- tabOwnership = new Map();
27
- // Per-agent alias spaces (tN ids stable within an agent)
28
- agentTabs = new Map();
29
- // The agent's currently-active tab
30
- activeTabByAgent = new Map(); // agentId -> targetId
31
- // Per-page transient state (console buffer, pending dialog policy)
32
- pageState = new WeakMap();
16
+ provider;
17
+ resolveOverrides;
18
+ ensureOverrides;
19
+ instances = new Map();
20
+ connecting = new Map();
33
21
  constructor(opts) {
34
- this.userDataDir = resolveUserDataDir(opts.dataDir);
35
- this.cfg = opts.config;
22
+ this.provider = opts.provider;
23
+ this.resolveOverrides = opts.resolveOverrides ?? null;
24
+ this.ensureOverrides = opts.ensureOverrides ?? null;
25
+ }
26
+ get providerName() {
27
+ return this.provider.name;
36
28
  }
37
29
  // ───────────────────────── lifecycle ─────────────────────────
38
- async ensureChrome() {
39
- if (this.running && this.running.proc.exitCode === null)
40
- return this.running;
41
- if (this.launchingPromise)
42
- return this.launchingPromise;
43
- const exe = resolveExecutableOrThrow(this.cfg);
44
- this.launchingPromise = launchChrome({
45
- exe,
46
- cdpPort: this.cfg.port,
47
- userDataDir: this.userDataDir,
48
- headless: this.cfg.headless,
49
- noSandbox: this.cfg.noSandbox,
50
- })
51
- .then((r) => {
52
- r.proc.once("exit", () => {
53
- if (this.running === r) {
54
- this.running = null;
55
- this.cachedBrowser = null;
56
- this.resetAllTabState();
57
- }
30
+ async getInstance(agentId) {
31
+ if (!agentId)
32
+ throw new Error("BrowserManager calls require an agentId");
33
+ const existing = this.instances.get(agentId);
34
+ if (existing && this.isInstanceAlive(existing))
35
+ return existing;
36
+ if (existing) {
37
+ this.instances.delete(agentId);
38
+ try {
39
+ await existing.acquired.release();
40
+ }
41
+ catch {
42
+ // best-effort
43
+ }
44
+ }
45
+ const inflight = this.connecting.get(agentId);
46
+ if (inflight)
47
+ return inflight;
48
+ const p = this.connectFor(agentId).finally(() => this.connecting.delete(agentId));
49
+ this.connecting.set(agentId, p);
50
+ return p;
51
+ }
52
+ isInstanceAlive(inst) {
53
+ // CDP path: rely on the underlying Browser's connection state.
54
+ // Pre-built context path: by construction, the provider/manager's
55
+ // close listener already drops the entry.
56
+ if (inst.browser)
57
+ return inst.browser.isConnected();
58
+ return true;
59
+ }
60
+ async connectFor(agentId) {
61
+ let overrides = this.resolveOverrides?.(agentId);
62
+ // Lazy provisioning: if the agent doesn't have a profile yet for the
63
+ // active provider, give AgentManager a chance to create one now and
64
+ // persist it back. First-acquire pays the provision cost; subsequent
65
+ // acquires skip this because the agent def now has the field.
66
+ if (this.ensureOverrides) {
67
+ try {
68
+ overrides = await this.ensureOverrides(agentId, overrides);
69
+ }
70
+ catch {
71
+ // best-effort — fall through with whatever we had
72
+ }
73
+ }
74
+ const acquired = await this.provider.acquire(agentId, { overrides });
75
+ let browser = null;
76
+ let context;
77
+ try {
78
+ if (acquired.preBuiltContext) {
79
+ context = acquired.preBuiltContext;
80
+ }
81
+ else if (acquired.cdpUrl) {
82
+ browser = await connectOverCdp({
83
+ wsUrl: acquired.cdpUrl,
84
+ onDisconnected: (b) => {
85
+ const inst = this.instances.get(agentId);
86
+ if (inst && inst.browser === b)
87
+ this.instances.delete(agentId);
88
+ },
89
+ });
90
+ const ctxs = browser.contexts();
91
+ if (ctxs.length === 0)
92
+ throw new Error("CDP browser has no contexts");
93
+ context = ctxs[0];
94
+ }
95
+ else {
96
+ throw new Error("Provider returned neither cdpUrl nor preBuiltContext");
97
+ }
98
+ }
99
+ catch (e) {
100
+ // Avoid leaking the upstream session when the initial attach fails.
101
+ try {
102
+ await acquired.release();
103
+ }
104
+ catch {
105
+ // best-effort
106
+ }
107
+ throw e;
108
+ }
109
+ const instance = {
110
+ acquired,
111
+ browser,
112
+ context,
113
+ agentTabs: { next: 1, byTabId: new Map(), byPage: new Map() },
114
+ activeTabId: null,
115
+ pageState: new WeakMap(),
116
+ };
117
+ // For pre-built contexts, mirror what the CDP `disconnected` listener
118
+ // does — drop the cache when the context closes.
119
+ if (!browser) {
120
+ context.once("close", () => {
121
+ if (this.instances.get(agentId) === instance)
122
+ this.instances.delete(agentId);
58
123
  });
59
- this.running = r;
60
- return r;
61
- })
62
- .finally(() => {
63
- this.launchingPromise = null;
64
- });
65
- return this.launchingPromise;
66
- }
67
- async getBrowser() {
68
- if (this.cachedBrowser)
69
- return this.cachedBrowser;
70
- if (this.connectingPromise)
71
- return this.connectingPromise;
72
- this.connectingPromise = this.connectFresh().finally(() => {
73
- this.connectingPromise = null;
74
- });
75
- return this.connectingPromise;
76
- }
77
- async connectFresh() {
78
- const running = await this.ensureChrome();
79
- const browser = await connectOverCdp({
80
- wsUrl: running.cdpUrl,
81
- onDisconnected: (b) => {
82
- if (this.cachedBrowser === b)
83
- this.cachedBrowser = null;
84
- },
85
- });
86
- this.cachedBrowser = browser;
87
- return browser;
88
- }
89
- async sharedContext() {
90
- const browser = await this.getBrowser();
91
- const contexts = browser.contexts();
92
- if (contexts.length === 0) {
93
- // Should never happen with connectOverCDP — Chrome always exposes
94
- // the default context — but defensive.
95
- throw new Error("No BrowserContext available on the connected Chrome.");
96
124
  }
97
- const ctx = contexts[0];
98
- return ctx;
99
- }
100
- resetAllTabState() {
101
- this.tabOwnership.clear();
102
- this.agentTabs.clear();
103
- this.activeTabByAgent.clear();
104
- }
105
- async close() {
106
- if (this.cachedBrowser) {
125
+ this.instances.set(agentId, instance);
126
+ return instance;
127
+ }
128
+ async contextFor(agentId) {
129
+ const inst = await this.getInstance(agentId);
130
+ return { inst, ctx: inst.context };
131
+ }
132
+ /** Release one agent's browser session. Idempotent. */
133
+ async closeAgent(agentId) {
134
+ const inst = this.instances.get(agentId);
135
+ if (inst) {
136
+ this.instances.delete(agentId);
107
137
  try {
108
- await this.cachedBrowser.close();
138
+ if (inst.browser)
139
+ await inst.browser.close();
140
+ // Pre-built context lifetime is owned by the provider; releaseAgent
141
+ // below closes it.
109
142
  }
110
143
  catch {
111
- // ignore — we're shutting down
144
+ // best-effort
112
145
  }
113
- this.cachedBrowser = null;
114
146
  }
115
- if (this.running) {
116
- await killChrome(this.running);
117
- this.running = null;
147
+ try {
148
+ await this.provider.releaseAgent(agentId);
149
+ }
150
+ catch {
151
+ // best-effort
152
+ }
153
+ }
154
+ /** Release every agent and shut down the provider. */
155
+ async close() {
156
+ const ids = Array.from(this.instances.keys());
157
+ await Promise.all(ids.map((id) => this.closeAgent(id)));
158
+ try {
159
+ await this.provider.releaseAll();
160
+ }
161
+ catch {
162
+ // best-effort
118
163
  }
119
- this.resetAllTabState();
120
164
  }
121
165
  // ───────────────────────── per-page observation ─────────────────────────
122
- observePage(page) {
123
- if (this.pageState.has(page))
166
+ observePage(inst, page) {
167
+ if (inst.pageState.has(page))
124
168
  return;
125
169
  const state = { console: [], dialogHandler: null };
126
- this.pageState.set(page, state);
170
+ inst.pageState.set(page, state);
127
171
  page.on("console", (msg) => {
128
172
  if (state.console.length >= MAX_CONSOLE_PER_PAGE)
129
173
  state.console.shift();
@@ -144,132 +188,91 @@ export class BrowserManager {
144
188
  });
145
189
  });
146
190
  }
147
- // ───────────────────────── tab ownership ─────────────────────────
148
- agentTabsFor(agentId) {
149
- let s = this.agentTabs.get(agentId);
150
- if (!s) {
151
- s = { next: 1, byTargetId: new Map(), byTabId: new Map() };
152
- this.agentTabs.set(agentId, s);
153
- }
154
- return s;
155
- }
156
- assignTabAlias(agentId, targetId) {
157
- const s = this.agentTabsFor(agentId);
158
- const existing = s.byTargetId.get(targetId);
159
- if (existing)
191
+ // ───────────────────────── tab tracking ─────────────────────────
192
+ /**
193
+ * Tab identity is the Page object itself — stable for the page's
194
+ * lifetime in Playwright. Avoids CDP-specific calls (`newCDPSession`)
195
+ * so Firefox-based backends (Camoufox) work the same as Chromium.
196
+ */
197
+ trackPage(inst, page) {
198
+ const existing = inst.agentTabs.byPage.get(page);
199
+ if (existing) {
200
+ this.observePage(inst, page);
201
+ inst.activeTabId = existing;
160
202
  return existing;
161
- const id = `t${s.next}`;
162
- s.next += 1;
163
- s.byTargetId.set(targetId, id);
164
- s.byTabId.set(id, targetId);
165
- this.tabOwnership.set(targetId, agentId);
166
- return id;
167
- }
168
- trackPageForAgent(agentId, page, targetId) {
169
- this.observePage(page);
170
- const tabId = this.assignTabAlias(agentId, targetId);
171
- this.activeTabByAgent.set(agentId, targetId);
203
+ }
204
+ this.observePage(inst, page);
205
+ const tabId = `t${inst.agentTabs.next}`;
206
+ inst.agentTabs.next += 1;
207
+ inst.agentTabs.byTabId.set(tabId, page);
208
+ inst.agentTabs.byPage.set(page, tabId);
209
+ inst.activeTabId = tabId;
172
210
  page.once("close", () => {
173
- this.releaseTab(targetId);
211
+ inst.agentTabs.byTabId.delete(tabId);
212
+ inst.agentTabs.byPage.delete(page);
213
+ if (inst.activeTabId === tabId)
214
+ inst.activeTabId = null;
174
215
  });
175
216
  return tabId;
176
217
  }
177
- releaseTab(targetId) {
178
- const owner = this.tabOwnership.get(targetId);
179
- if (!owner)
180
- return;
181
- this.tabOwnership.delete(targetId);
182
- const s = this.agentTabs.get(owner);
183
- if (s) {
184
- const alias = s.byTargetId.get(targetId);
185
- if (alias) {
186
- s.byTabId.delete(alias);
187
- s.byTargetId.delete(targetId);
188
- }
189
- }
190
- if (this.activeTabByAgent.get(owner) === targetId) {
191
- this.activeTabByAgent.delete(owner);
192
- }
193
- }
194
- async targetIdOf(page) {
195
- const session = await page.context().newCDPSession(page);
196
- try {
197
- const info = (await session.send("Target.getTargetInfo"));
198
- const id = info.targetInfo?.targetId;
199
- if (!id)
200
- throw new Error("Target.getTargetInfo returned no targetId");
201
- return id;
202
- }
203
- finally {
204
- await session.detach().catch(() => { });
205
- }
206
- }
207
- /**
208
- * Resolve the Page for `tabId` (or the active tab if omitted) and verify
209
- * the agent owns it. Throws on missing tab / cross-agent access.
210
- */
211
218
  async resolvePage(agentId, tabId, opts) {
212
- const s = this.agentTabsFor(agentId);
213
- let targetId;
219
+ const { inst, ctx } = await this.contextFor(agentId);
220
+ let page = null;
221
+ let resolvedTabId = null;
214
222
  if (tabId) {
215
- targetId = s.byTabId.get(tabId);
216
- if (!targetId) {
223
+ const candidate = inst.agentTabs.byTabId.get(tabId);
224
+ if (!candidate)
217
225
  throw new Error(`Tab ${tabId} not found for this agent.`);
226
+ if (candidate.isClosed()) {
227
+ inst.agentTabs.byTabId.delete(tabId);
228
+ inst.agentTabs.byPage.delete(candidate);
229
+ if (inst.activeTabId === tabId)
230
+ inst.activeTabId = null;
231
+ throw new Error(`Tab ${tabId} is no longer open.`);
218
232
  }
233
+ page = candidate;
234
+ resolvedTabId = tabId;
219
235
  }
220
- else {
221
- targetId = this.activeTabByAgent.get(agentId);
222
- }
223
- const ctx = await this.sharedContext();
224
- if (targetId) {
225
- const page = await this.findPageByTargetId(ctx, targetId);
226
- if (page) {
227
- this.observePage(page);
228
- const resolvedTabId = s.byTargetId.get(targetId);
229
- return { page, tabId: resolvedTabId, targetId };
236
+ else if (inst.activeTabId) {
237
+ const candidate = inst.agentTabs.byTabId.get(inst.activeTabId);
238
+ if (candidate && !candidate.isClosed()) {
239
+ page = candidate;
240
+ resolvedTabId = inst.activeTabId;
230
241
  }
231
- // The target id we remember is gone (page closed externally,
232
- // Chrome respawned, etc.). Clean up and fall through.
233
- this.releaseTab(targetId);
234
- targetId = undefined;
235
- }
236
- if (!opts.createIfNone) {
237
- throw new Error("No tabs owned by this agent. Call browser_navigate first to open one.");
238
- }
239
- const page = await ctx.newPage();
240
- const newTargetId = await this.targetIdOf(page);
241
- const newTabId = this.trackPageForAgent(agentId, page, newTargetId);
242
- return { page, tabId: newTabId, targetId: newTargetId };
243
- }
244
- async findPageByTargetId(ctx, targetId) {
245
- for (const p of ctx.pages()) {
246
- try {
247
- const tid = await this.targetIdOf(p);
248
- if (tid === targetId)
249
- return p;
242
+ else {
243
+ if (candidate) {
244
+ inst.agentTabs.byTabId.delete(inst.activeTabId);
245
+ inst.agentTabs.byPage.delete(candidate);
246
+ }
247
+ inst.activeTabId = null;
250
248
  }
251
- catch {
252
- // ignore — the page may have closed mid-iteration
249
+ }
250
+ if (!page) {
251
+ if (!opts.createIfNone) {
252
+ throw new Error("No tabs open for this agent. Call browser_navigate first to open one.");
253
253
  }
254
+ page = await ctx.newPage();
255
+ resolvedTabId = this.trackPage(inst, page);
254
256
  }
255
- return null;
257
+ this.observePage(inst, page);
258
+ return { inst, page, tabId: resolvedTabId };
256
259
  }
257
260
  /**
258
261
  * Run an action with one-shot reconnect on transient CDP disconnects.
259
- * The action gets a fresh `page` resolution on retry.
262
+ * Drops the agent's instance + cloud session before retrying so we
263
+ * don't reuse a server-side session that the provider already killed.
260
264
  */
261
265
  async withReconnect(agentId, tabId, opts, fn) {
262
266
  try {
263
267
  const r = await this.resolvePage(agentId, tabId, opts);
264
- return await fn(r.page, { tabId: r.tabId, targetId: r.targetId });
268
+ return await fn(r.page, { tabId: r.tabId });
265
269
  }
266
270
  catch (e) {
267
271
  if (!isRecoverableDisconnect(e))
268
272
  throw e;
269
- // Invalidate connection + tab state and retry once.
270
- this.cachedBrowser = null;
273
+ await this.closeAgent(agentId);
271
274
  const r = await this.resolvePage(agentId, tabId, opts);
272
- return await fn(r.page, { tabId: r.tabId, targetId: r.targetId });
275
+ return await fn(r.page, { tabId: r.tabId });
273
276
  }
274
277
  }
275
278
  // ───────────────────────── public API ─────────────────────────
@@ -324,10 +327,7 @@ export class BrowserManager {
324
327
  async waitFor(agentId, p) {
325
328
  return this.withReconnect(agentId, p.tabId, { createIfNone: false }, async (page, ids) => {
326
329
  if (p.text) {
327
- await page
328
- .getByText(p.text, { exact: false })
329
- .first()
330
- .waitFor({ timeout: 30_000 });
330
+ await page.getByText(p.text, { exact: false }).first().waitFor({ timeout: 30_000 });
331
331
  }
332
332
  else if (p.textGone) {
333
333
  await page
@@ -343,15 +343,14 @@ export class BrowserManager {
343
343
  }
344
344
  async evaluate(agentId, p) {
345
345
  return this.withReconnect(agentId, p.tabId, { createIfNone: false }, async (page, ids) => {
346
- // `function` is treated as an expression body — the model writes
347
- // something like `document.title` or `(() => Array.from(document.images).length)()`.
348
346
  const result = await page.evaluate(p.function);
349
347
  return { tabId: ids.tabId, result };
350
348
  });
351
349
  }
352
350
  async consoleMessages(agentId, p) {
353
351
  return this.withReconnect(agentId, p.tabId, { createIfNone: false }, async (page, ids) => {
354
- const state = this.pageState.get(page);
352
+ const { inst } = await this.contextFor(agentId);
353
+ const state = inst.pageState.get(page);
355
354
  const messages = state ? [...state.console] : [];
356
355
  if (p.clear && state)
357
356
  state.console = [];
@@ -360,26 +359,16 @@ export class BrowserManager {
360
359
  }
361
360
  // ── tabs ──
362
361
  async tabsList(agentId) {
363
- const ctx = await this.sharedContext();
364
- const s = this.agentTabsFor(agentId);
365
- const active = this.activeTabByAgent.get(agentId);
362
+ const { inst } = await this.contextFor(agentId);
366
363
  const out = [];
367
- for (const page of ctx.pages()) {
368
- let targetId;
369
- try {
370
- targetId = await this.targetIdOf(page);
371
- }
372
- catch {
373
- continue;
374
- }
375
- const tabId = s.byTargetId.get(targetId);
376
- if (!tabId)
364
+ for (const [tabId, page] of inst.agentTabs.byTabId) {
365
+ if (page.isClosed())
377
366
  continue;
378
367
  out.push({
379
368
  tabId,
380
369
  url: page.url(),
381
370
  title: await page.title().catch(() => ""),
382
- active: targetId === active,
371
+ active: tabId === inst.activeTabId,
383
372
  });
384
373
  }
385
374
  out.sort((a, b) => {
@@ -390,12 +379,11 @@ export class BrowserManager {
390
379
  return out;
391
380
  }
392
381
  async tabsNew(agentId, p) {
393
- const ctx = await this.sharedContext();
382
+ const { inst, ctx } = await this.contextFor(agentId);
394
383
  const page = await ctx.newPage();
395
384
  if (p.url)
396
385
  await page.goto(p.url, { waitUntil: "domcontentloaded" });
397
- const targetId = await this.targetIdOf(page);
398
- const tabId = this.trackPageForAgent(agentId, page, targetId);
386
+ const tabId = this.trackPage(inst, page);
399
387
  return {
400
388
  tabId,
401
389
  url: page.url(),
@@ -404,28 +392,34 @@ export class BrowserManager {
404
392
  };
405
393
  }
406
394
  async tabsClose(agentId, p) {
407
- const s = this.agentTabsFor(agentId);
408
- const targetId = s.byTabId.get(p.tabId);
409
- if (!targetId)
395
+ const { inst } = await this.contextFor(agentId);
396
+ const page = inst.agentTabs.byTabId.get(p.tabId);
397
+ if (!page)
410
398
  throw new Error(`Tab ${p.tabId} not found for this agent.`);
411
- const ctx = await this.sharedContext();
412
- const page = await this.findPageByTargetId(ctx, targetId);
413
- if (page)
399
+ if (!page.isClosed()) {
400
+ // page.close() fires the trackPage close handler which drops the maps.
414
401
  await page.close();
415
- this.releaseTab(targetId);
402
+ }
403
+ else {
404
+ inst.agentTabs.byTabId.delete(p.tabId);
405
+ inst.agentTabs.byPage.delete(page);
406
+ if (inst.activeTabId === p.tabId)
407
+ inst.activeTabId = null;
408
+ }
416
409
  }
417
410
  async tabsSelect(agentId, p) {
418
- const s = this.agentTabsFor(agentId);
419
- const targetId = s.byTabId.get(p.tabId);
420
- if (!targetId)
411
+ const { inst } = await this.contextFor(agentId);
412
+ const page = inst.agentTabs.byTabId.get(p.tabId);
413
+ if (!page)
421
414
  throw new Error(`Tab ${p.tabId} not found for this agent.`);
422
- const ctx = await this.sharedContext();
423
- const page = await this.findPageByTargetId(ctx, targetId);
424
- if (!page) {
425
- this.releaseTab(targetId);
415
+ if (page.isClosed()) {
416
+ inst.agentTabs.byTabId.delete(p.tabId);
417
+ inst.agentTabs.byPage.delete(page);
418
+ if (inst.activeTabId === p.tabId)
419
+ inst.activeTabId = null;
426
420
  throw new Error(`Tab ${p.tabId} is no longer open.`);
427
421
  }
428
- this.activeTabByAgent.set(agentId, targetId);
422
+ inst.activeTabId = p.tabId;
429
423
  await page.bringToFront().catch(() => { });
430
424
  return {
431
425
  tabId: p.tabId,
@@ -467,10 +461,6 @@ export class BrowserManager {
467
461
  return { tabId: ids.tabId };
468
462
  });
469
463
  }
470
- /**
471
- * Arm a one-shot dialog handler on the page. The handler resolves the
472
- * NEXT dialog event with accept/dismiss + optional prompt text.
473
- */
474
464
  async handleDialog(agentId, p) {
475
465
  return this.withReconnect(agentId, p.tabId, { createIfNone: false }, async (page, ids) => {
476
466
  await new Promise((resolve, reject) => {
@@ -495,10 +485,7 @@ export class BrowserManager {
495
485
  };
496
486
  page.once("dialog", onDialog);
497
487
  });
498
- return {
499
- tabId: ids.tabId,
500
- result: p.accept ? "accepted" : "dismissed",
501
- };
488
+ return { tabId: ids.tabId, result: p.accept ? "accepted" : "dismissed" };
502
489
  });
503
490
  }
504
491
  async resize(agentId, p) {
@@ -522,9 +509,7 @@ export class BrowserManager {
522
509
  async saveAsPdf(agentId, p) {
523
510
  return this.withReconnect(agentId, p.tabId, { createIfNone: false }, async (page, ids) => {
524
511
  const filename = p.filename ?? `browser-${ids.tabId}-${Date.now()}.pdf`;
525
- const outPath = filename.includes("/")
526
- ? filename
527
- : `/tmp/${filename}`;
512
+ const outPath = filename.includes("/") ? filename : `/tmp/${filename}`;
528
513
  await page.pdf({ path: outPath, format: "Letter" });
529
514
  return { tabId: ids.tabId, path: outPath };
530
515
  });