@anweat/dsh-browser 0.1.17 → 0.2.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 (169) hide show
  1. package/README.md +266 -88
  2. package/assets/skills/dsh-browser/SKILL.md +120 -0
  3. package/assets/skills/dsh-browser/references/develop.md +48 -0
  4. package/assets/skills/dsh-browser/references/observe.md +91 -0
  5. package/assets/skills/dsh-browser/references/opencli.md +25 -0
  6. package/assets/skills/dsh-browser/references/recipes.md +149 -0
  7. package/assets/skills/dsh-browser/references/userscripts.md +44 -0
  8. package/cordis.patch.yml +11 -3
  9. package/lib/actions/act.d.ts +3 -0
  10. package/lib/actions/act.js +147 -0
  11. package/lib/actions/act.js.map +1 -0
  12. package/lib/actions/automation.d.ts +3 -0
  13. package/lib/actions/automation.js +356 -0
  14. package/lib/actions/automation.js.map +1 -0
  15. package/lib/actions/crawl.d.ts +3 -0
  16. package/lib/actions/crawl.js +28 -0
  17. package/lib/actions/crawl.js.map +1 -0
  18. package/lib/actions/errors.d.ts +53 -0
  19. package/lib/actions/errors.js +178 -0
  20. package/lib/actions/errors.js.map +1 -0
  21. package/lib/actions/index-view.d.ts +42 -0
  22. package/lib/actions/index-view.js +268 -0
  23. package/lib/actions/index-view.js.map +1 -0
  24. package/lib/actions/inspect.d.ts +3 -0
  25. package/lib/actions/inspect.js +35 -0
  26. package/lib/actions/inspect.js.map +1 -0
  27. package/lib/actions/observe.d.ts +3 -0
  28. package/lib/actions/observe.js +121 -0
  29. package/lib/actions/observe.js.map +1 -0
  30. package/lib/actions/opencli.d.ts +3 -0
  31. package/lib/actions/opencli.js +51 -0
  32. package/lib/actions/opencli.js.map +1 -0
  33. package/lib/actions/registry.d.ts +35 -0
  34. package/lib/actions/registry.js +89 -0
  35. package/lib/actions/registry.js.map +1 -0
  36. package/lib/actions/run.d.ts +34 -0
  37. package/lib/actions/run.js +226 -0
  38. package/lib/actions/run.js.map +1 -0
  39. package/lib/actions/runtime.d.ts +3 -0
  40. package/lib/actions/runtime.js +33 -0
  41. package/lib/actions/runtime.js.map +1 -0
  42. package/lib/actions/schema.d.ts +31 -0
  43. package/lib/actions/schema.js +309 -0
  44. package/lib/actions/schema.js.map +1 -0
  45. package/lib/actions/script.d.ts +3 -0
  46. package/lib/actions/script.js +97 -0
  47. package/lib/actions/script.js.map +1 -0
  48. package/lib/actions/shared.d.ts +31 -0
  49. package/lib/actions/shared.js +45 -0
  50. package/lib/actions/shared.js.map +1 -0
  51. package/lib/actions/surface.d.ts +22 -0
  52. package/lib/actions/surface.js +18 -0
  53. package/lib/actions/surface.js.map +1 -0
  54. package/lib/actions/target.d.ts +3 -0
  55. package/lib/actions/target.js +66 -0
  56. package/lib/actions/target.js.map +1 -0
  57. package/lib/actions/types.d.ts +181 -0
  58. package/lib/actions/types.js +48 -0
  59. package/lib/actions/types.js.map +1 -0
  60. package/lib/activation-rules.d.ts +29 -0
  61. package/lib/activation-rules.js +33 -0
  62. package/lib/activation-rules.js.map +1 -0
  63. package/lib/approval-policy.d.ts +40 -2
  64. package/lib/approval-policy.js +184 -97
  65. package/lib/approval-policy.js.map +1 -1
  66. package/lib/automation-assets-rpc.d.ts +8 -2
  67. package/lib/automation-assets-rpc.js +68 -10
  68. package/lib/automation-assets-rpc.js.map +1 -1
  69. package/lib/automation-assets.d.ts +152 -4
  70. package/lib/automation-assets.js +255 -15
  71. package/lib/automation-assets.js.map +1 -1
  72. package/lib/automation-convert.d.ts +23 -0
  73. package/lib/automation-convert.js +110 -0
  74. package/lib/automation-convert.js.map +1 -0
  75. package/lib/automation-development.d.ts +26 -0
  76. package/lib/automation-development.js +47 -0
  77. package/lib/automation-development.js.map +1 -1
  78. package/lib/automation-execution.d.ts +57 -1
  79. package/lib/automation-execution.js +167 -11
  80. package/lib/automation-execution.js.map +1 -1
  81. package/lib/automation-journal.d.ts +92 -0
  82. package/lib/automation-journal.js +449 -0
  83. package/lib/automation-journal.js.map +1 -0
  84. package/lib/automation-v2.d.ts +138 -0
  85. package/lib/automation-v2.js +631 -0
  86. package/lib/automation-v2.js.map +1 -0
  87. package/lib/automation.d.ts +82 -5
  88. package/lib/automation.js +239 -82
  89. package/lib/automation.js.map +1 -1
  90. package/lib/browser-service.d.ts +309 -32
  91. package/lib/browser-service.js +636 -182
  92. package/lib/browser-service.js.map +1 -1
  93. package/lib/client/SettingsCard.js +95 -57
  94. package/lib/client/SettingsCard.js.map +1 -1
  95. package/lib/client/asset-editor.d.ts +55 -0
  96. package/lib/client/asset-editor.js +101 -0
  97. package/lib/client/asset-editor.js.map +1 -0
  98. package/lib/client/automation-assets-client.d.ts +99 -2
  99. package/lib/client/automation-assets-client.js +243 -10
  100. package/lib/client/automation-assets-client.js.map +1 -1
  101. package/lib/client/form.d.ts +48 -4
  102. package/lib/client/form.js +142 -29
  103. package/lib/client/form.js.map +1 -1
  104. package/lib/client/index.d.ts +25 -2
  105. package/lib/client/index.js +5 -1
  106. package/lib/client/index.js.map +1 -1
  107. package/lib/client/locales.d.ts +85 -6
  108. package/lib/client/locales.js +64 -12
  109. package/lib/client/locales.js.map +1 -1
  110. package/lib/client/prompts-form.d.ts +67 -0
  111. package/lib/client/prompts-form.js +140 -0
  112. package/lib/client/prompts-form.js.map +1 -0
  113. package/lib/client/prompts-status-client.d.ts +34 -0
  114. package/lib/client/prompts-status-client.js +53 -0
  115. package/lib/client/prompts-status-client.js.map +1 -0
  116. package/lib/client/settings-namespace.d.ts +1 -1
  117. package/lib/client/settings-namespace.js +1 -1
  118. package/lib/client/settings-namespace.js.map +1 -1
  119. package/lib/client.js +1915 -330
  120. package/lib/client.js.map +1 -1
  121. package/lib/config.d.ts +50 -1
  122. package/lib/config.js +31 -1
  123. package/lib/config.js.map +1 -1
  124. package/lib/dialogs.d.ts +37 -0
  125. package/lib/dialogs.js +73 -0
  126. package/lib/dialogs.js.map +1 -0
  127. package/lib/freedom.d.ts +23 -7
  128. package/lib/freedom.js +48 -59
  129. package/lib/freedom.js.map +1 -1
  130. package/lib/index.d.ts +5 -13
  131. package/lib/index.js +51 -30
  132. package/lib/index.js.map +1 -1
  133. package/lib/journal.d.ts +109 -0
  134. package/lib/journal.js +238 -0
  135. package/lib/journal.js.map +1 -0
  136. package/lib/locator.d.ts +82 -0
  137. package/lib/locator.js +190 -0
  138. package/lib/locator.js.map +1 -0
  139. package/lib/observe-script.d.ts +27 -0
  140. package/lib/observe-script.js +565 -0
  141. package/lib/observe-script.js.map +1 -0
  142. package/lib/observe.d.ts +179 -0
  143. package/lib/observe.js +368 -0
  144. package/lib/observe.js.map +1 -0
  145. package/lib/policy-keys.d.ts +30 -0
  146. package/lib/policy-keys.js +34 -0
  147. package/lib/policy-keys.js.map +1 -0
  148. package/lib/prompt-limits.d.ts +30 -0
  149. package/lib/prompt-limits.js +31 -0
  150. package/lib/prompt-limits.js.map +1 -0
  151. package/lib/prompts.d.ts +131 -0
  152. package/lib/prompts.js +295 -0
  153. package/lib/prompts.js.map +1 -0
  154. package/lib/skill.d.ts +100 -0
  155. package/lib/skill.js +181 -0
  156. package/lib/skill.js.map +1 -0
  157. package/lib/tool-defs.d.ts +72 -0
  158. package/lib/tool-defs.js +67 -0
  159. package/lib/tool-defs.js.map +1 -0
  160. package/lib/tools.d.ts +19 -4
  161. package/lib/tools.js +124 -922
  162. package/lib/tools.js.map +1 -1
  163. package/package.json +15 -11
  164. package/scripts/check-host-rpc.mjs +11 -4
  165. package/scripts/dump-prompts.mjs +29 -0
  166. package/scripts/measure-tool-surface.mjs +190 -0
  167. package/lib/settings-scope.d.ts +0 -56
  168. package/lib/settings-scope.js +0 -51
  169. package/lib/settings-scope.js.map +0 -1
@@ -26,9 +26,19 @@ import { AuthProfileStore, hostAllowed } from "./auth-profiles.js";
26
26
  import { applyRuleSteps, resolveRulePack } from "./rule-packs.js";
27
27
  import { runRecipe } from "./automation.js";
28
28
  import { BUILTIN_SCRIPTS, builtinScript, executeUserscript, validateUserscript } from "./scripts.js";
29
- import { configuredBrowserTools } from "./freedom.js";
29
+ import { configuredBrowserActions, configuredBrowserTools } from "./freedom.js";
30
30
  import { filterOpencliCatalog, parseOpencliCatalog } from "./opencli-catalog.js";
31
31
  import { UsageGovernor } from "./usage-policy.js";
32
+ import { installDialogGuard } from "./dialogs.js";
33
+ import { TargetStaleError, resolveLocator, withStrictLocator } from "./locator.js";
34
+ import { SENSITIVE_NAME, SessionJournal } from "./journal.js";
35
+ import { describeScan, fitObservation, normalizeObserve, scanPage, scopeBase } from "./observe.js";
36
+ /**
37
+ * How many sessions may hold a browser context+page at once. Past this, the
38
+ * least-recently-used session is closed, so a long-lived profile cannot
39
+ * accumulate contexts until the browser dies.
40
+ */
41
+ const DEFAULT_MAX_SESSIONS = 8;
32
42
  /** Rules-aware content extractor (runs in the page). */
33
43
  const EXTRACTOR_FN = `(ruleList) => {
34
44
  const doc = document
@@ -146,29 +156,137 @@ function redactCaptureUrl(value) {
146
156
  return redactCaptureText(value);
147
157
  }
148
158
  }
159
+ function newSessionState() {
160
+ return {
161
+ pages: [],
162
+ nextTarget: 1,
163
+ generationClock: 0,
164
+ captureConsole: false,
165
+ captureNetwork: false,
166
+ capturedConsole: [],
167
+ capturedRequests: [],
168
+ lastUsed: 0,
169
+ };
170
+ }
171
+ /** Bucket for callers that carry no agent identity. */
172
+ const SHARED_SESSION = 'shared';
173
+ /**
174
+ * Session key for a tool execution.
175
+ *
176
+ * `ToolExecution.agent` is documented as "the agent on whose behalf the call
177
+ * runs (set by the agent loop)". Two session identities hang off it and BOTH are
178
+ * the session, not the turn:
179
+ *
180
+ * - `agent.session.id` — the live `Agent` face exposes `readonly session:
181
+ * Session`, and `Session.id` is a `SessionId`. This is the path the
182
+ * automation-asset recorder already uses in production, so it is tried first.
183
+ * - `agent.id` — the base `Agent` contract documents `readonly id: SessionId`
184
+ * as "Session-backed Agent identity". Used as a fallback.
185
+ *
186
+ * On DSH 0.2.0-rc.2 both are present and equal (the agent loop documents `id` as the
187
+ * "shared agent/session identity"). `ToolExecution.agent` is typed as the base `Agent`,
188
+ * whose only guaranteed member is `id`, so reading the live face first and the base
189
+ * contract second is a type-level hedge rather than an old-host adaptation.
190
+ *
191
+ * A session therefore keeps ONE page across all of its turns, and two
192
+ * concurrent sessions never share one. Calls carrying no agent at all
193
+ * (automation-asset replays, non-agent dispatchers) land in one shared bucket,
194
+ * which is the pre-existing single-session behaviour rather than a new hazard.
195
+ */
196
+ export function sessionKeyFor(agent) {
197
+ const live = agent?.session?.id;
198
+ if (typeof live === 'string' && live)
199
+ return `session:${live}`;
200
+ const own = agent?.id;
201
+ if (typeof own === 'string' && own)
202
+ return `session:${own}`;
203
+ return SHARED_SESSION;
204
+ }
149
205
  export class BrowserService {
150
206
  config;
151
207
  browser;
152
208
  launching;
153
- activeContext;
154
- activePage;
155
- activeProfile;
156
- activeRulePack;
209
+ /** Per-session interactive state. Replaces the former global activePage. */
210
+ sessions = new Map();
211
+ /** The tracked entry (target id, generation) of every page the service created. */
212
+ pageEntries = new WeakMap();
213
+ clock = 0;
157
214
  authProfiles;
158
215
  usageGovernor;
159
216
  opencliCatalogCache;
160
- captureConsoleEnabled = false;
161
- captureNetworkEnabled = false;
162
- capturedConsole = [];
163
- capturedRequests = [];
217
+ /** Per-session exploration journals (see src/journal.ts). Released with the session, never written to disk. */
218
+ journals = new Map();
219
+ /** How many sessions may hold a context+page at once before LRU eviction. */
220
+ sessionLimit;
164
221
  constructor(config) {
165
222
  this.config = config;
166
223
  this.authProfiles = new AuthProfileStore(config.authProfiles);
167
224
  this.usageGovernor = new UsageGovernor(config.usagePolicy);
225
+ this.sessionLimit = Math.max(1, Number.isInteger(config.maxSessions) ? config.maxSessions : DEFAULT_MAX_SESSIONS);
168
226
  }
169
227
  available() {
170
228
  return this.config.enabled;
171
229
  }
230
+ /**
231
+ * This session's exploration journal, created on first use. It outlives `target.close` (the session is
232
+ * still the same) and is released when the session is evicted or the service closes. A session that never
233
+ * records anything costs nothing: journals are only created by the action dispatcher.
234
+ */
235
+ journalFor(session) {
236
+ let journal = this.journals.get(session);
237
+ if (!journal) {
238
+ journal = new SessionJournal();
239
+ this.journals.set(session, journal);
240
+ // Bounded like the sessions themselves: a journal whose session is long gone cannot pile up.
241
+ while (this.journals.size > this.sessionLimit * 4)
242
+ this.journals.delete(this.journals.keys().next().value);
243
+ }
244
+ return journal;
245
+ }
246
+ /** The active page of a session as `{targetId, generation, url}`, without opening or creating anything. */
247
+ pageStamp(session) {
248
+ const page = this.sessions.get(session)?.page;
249
+ if (!page || page.isClosed?.())
250
+ return undefined;
251
+ let url;
252
+ try {
253
+ url = page.url();
254
+ }
255
+ catch { /* mid-navigation */ }
256
+ return { ...this.stamp(page), ...url ? { url } : {} };
257
+ }
258
+ /**
259
+ * Whether the control a fill/type would hit is a secret field (password, one-time code, name that says so).
260
+ * Best effort and quick: any trouble (no page, several matches, a slow page) answers false, and the journal
261
+ * still treats secret-looking values and secret-looking locators as sensitive.
262
+ */
263
+ async inputSensitivity(target, opts = {}) {
264
+ const page = this.sessions.get(opts.session ?? SHARED_SESSION)?.page;
265
+ if (!page || page.isClosed?.())
266
+ return false;
267
+ try {
268
+ return await resolveLocator(page, target).evaluate((element, source) => {
269
+ const hints = [element.getAttribute('name'), element.id, element.getAttribute('autocomplete'), element.getAttribute('aria-label'), element.getAttribute('placeholder'), element.getAttribute('data-testid')];
270
+ const type = String(element.type ?? '').toLowerCase();
271
+ const auto = String(element.getAttribute('autocomplete') ?? '').toLowerCase();
272
+ const named = new RegExp(source, 'i');
273
+ return type === 'password' || type === 'hidden' || /password|one-time-code|cc-/.test(auto)
274
+ || hints.some((hint) => hint && named.test(hint.replace(/([a-z])([A-Z])/g, '$1 $2')));
275
+ }, SENSITIVE_NAME.source, { timeout: 1_500 }) === true;
276
+ }
277
+ catch {
278
+ return false;
279
+ }
280
+ }
281
+ /**
282
+ * The state bucket belonging to a tool execution's session.
283
+ *
284
+ * Tools use this for the few operations that act on the bucket itself
285
+ * (`target.close`) rather than passing a key into a method.
286
+ */
287
+ sessionState(agent) {
288
+ return this.peek(sessionKeyFor(agent));
289
+ }
172
290
  assertEnabled() {
173
291
  if (!this.config.enabled)
174
292
  throw new Error('dsh-browser: browser service is disabled');
@@ -176,12 +294,83 @@ export class BrowserService {
176
294
  browserConnected(browser = this.browser) {
177
295
  return !!browser && (typeof browser.isConnected !== 'function' || browser.isConnected());
178
296
  }
297
+ /**
298
+ * The state bucket for one session, created on demand.
299
+ *
300
+ * Eviction is least-recently-used and never touches the caller's own bucket,
301
+ * so a session cannot have its page closed out from under it by a burst of
302
+ * other sessions.
303
+ */
304
+ state(session = SHARED_SESSION) {
305
+ const existing = this.sessions.get(session);
306
+ if (existing) {
307
+ existing.lastUsed = ++this.clock;
308
+ return existing;
309
+ }
310
+ const state = newSessionState();
311
+ state.lastUsed = ++this.clock;
312
+ this.sessions.set(session, state);
313
+ void this.evictSessions(session);
314
+ return state;
315
+ }
316
+ /**
317
+ * Look up an existing bucket WITHOUT creating one and WITHOUT evicting.
318
+ *
319
+ * Queries (`runtime.status`, `inspect.console`, `inspect.requests`) must go
320
+ * through this, not {@link state}. Creating a bucket for a session that only
321
+ * *asks* a question would both leak a slot and push a live session past the
322
+ * limit, so a read could evict the very page it was trying to describe.
323
+ */
324
+ peek(session = SHARED_SESSION) {
325
+ const existing = this.sessions.get(session);
326
+ if (existing)
327
+ existing.lastUsed = ++this.clock;
328
+ return existing;
329
+ }
330
+ /** Close the least-recently-used sessions that exceed the limit. */
331
+ async evictSessions(keep) {
332
+ while (this.sessions.size > this.sessionLimit) {
333
+ let victim;
334
+ let oldest = Number.POSITIVE_INFINITY;
335
+ for (const [key, value] of this.sessions) {
336
+ if (key === keep)
337
+ continue;
338
+ if (value.lastUsed < oldest) {
339
+ oldest = value.lastUsed;
340
+ victim = key;
341
+ }
342
+ }
343
+ if (victim === undefined)
344
+ return;
345
+ const state = this.sessions.get(victim);
346
+ this.sessions.delete(victim);
347
+ this.journals.delete(victim);
348
+ if (state)
349
+ await this.disposeState(state);
350
+ }
351
+ }
352
+ /** Close one session's page+context without persisting its auth state. */
353
+ async disposeState(state) {
354
+ const page = state.page;
355
+ const context = state.context;
356
+ state.page = undefined;
357
+ state.pages = [];
358
+ state.context = undefined;
359
+ state.profile = undefined;
360
+ state.rulePack = undefined;
361
+ state.capturedConsole = [];
362
+ state.capturedRequests = [];
363
+ state.captureConsole = false;
364
+ state.captureNetwork = false;
365
+ if (page)
366
+ await page.close().catch(() => { });
367
+ if (context)
368
+ await context.close().catch(() => { });
369
+ }
370
+ /** Drop all per-session state after a browser-level failure. */
179
371
  clearActiveState() {
180
- this.activeContext = undefined;
181
- this.activePage = undefined;
182
- this.activeProfile = undefined;
183
- this.activeRulePack = undefined;
184
- this.resetCapture();
372
+ this.sessions.clear();
373
+ this.journals.clear();
185
374
  }
186
375
  handleBrowserDisconnected(browser) {
187
376
  // A late event from an older process must not invalidate its replacement.
@@ -194,6 +383,9 @@ export class BrowserService {
194
383
  trackBrowser(browser) {
195
384
  this.browser = browser;
196
385
  browser.on?.('disconnected', () => this.handleBrowserDisconnected(browser));
386
+ // Any dialog opened while this browser is live is auto-closed by the guard instead of
387
+ // by playwright-core's unhandled auto-dismiss promise (see dialogs.ts).
388
+ installDialogGuard(browser);
197
389
  return browser;
198
390
  }
199
391
  async ensure() {
@@ -228,7 +420,7 @@ export class BrowserService {
228
420
  return this.trackBrowser(await pw.chromium.launch(launchOptions));
229
421
  }
230
422
  else {
231
- throw new Error('dsh-browser: chromium is not installed for ' + this.config.browserRuntime + '. Run the browser_install tool, or: node "' + browserRuntimeCliPath(this.config.browserRuntime) + '" install chromium');
423
+ throw new Error('dsh-browser: chromium is not installed for ' + this.config.browserRuntime + '. Run the runtime.install action, or: node "' + browserRuntimeCliPath(this.config.browserRuntime) + '" install chromium');
232
424
  }
233
425
  }
234
426
  else {
@@ -646,160 +838,171 @@ export class BrowserService {
646
838
  runUserscript(url, source, opts = {}) {
647
839
  return this.runScript(url, source, opts);
648
840
  }
649
- // ── interactive surface (one persistent context + page) ──────────────────
841
+ // ── interactive surface (one context + page PER SESSION) ──────────────────
842
+ /**
843
+ * Return the calling session's live page, creating one when needed.
844
+ *
845
+ * `session` is the key from {@link sessionKeyFor}. Every read and write below
846
+ * is confined to that bucket, which is what stops one session from observing
847
+ * or mutating another's page, cookies, console or network log.
848
+ */
650
849
  async ensureActivePage(targetUrl, opts = {}) {
651
850
  if (targetUrl && !['http:', 'https:'].includes(new URL(targetUrl).protocol))
652
851
  throw new Error('browser navigation requires an HTTP(S) URL');
653
- if (this.activePage && (this.activePage.isClosed() || !this.browserConnected()))
654
- await this.closePage();
655
- if (this.activePage && !this.activePage.isClosed()) {
852
+ const state = this.state(opts.session);
853
+ if (state.page && (state.page.isClosed() || !this.browserConnected()))
854
+ await this.closePage(state);
855
+ if (state.page && !state.page.isClosed()) {
656
856
  if (!targetUrl)
657
- return this.activePage;
658
- if ((opts.authProfile ?? this.config.defaultAuthProfile) === this.activeProfile?.id && opts.rulePack === this.activeRulePack?.id) {
659
- if (this.activeProfile)
660
- this.authProfiles.resolve(this.activeProfile.id, targetUrl);
661
- if (this.activeRulePack)
662
- resolveRulePack(this.config.rulePacks, this.activeRulePack.id, targetUrl);
663
- return this.activePage;
857
+ return state.page;
858
+ if ((opts.authProfile ?? this.config.defaultAuthProfile) === state.profile?.id && opts.rulePack === state.rulePack?.id) {
859
+ if (state.profile)
860
+ this.authProfiles.resolve(state.profile.id, targetUrl);
861
+ if (state.rulePack)
862
+ resolveRulePack(this.config.rulePacks, state.rulePack.id, targetUrl);
863
+ return state.page;
664
864
  }
665
- await this.closePage();
865
+ await this.closePage(state);
666
866
  }
667
867
  if (targetUrl) {
668
868
  const session = await this.transientContext(targetUrl, opts);
669
- this.activeContext = session.context;
670
- this.activeProfile = session.profile;
671
- this.activeRulePack = session.rulePack;
869
+ state.context = session.context;
870
+ state.profile = session.profile;
871
+ state.rulePack = session.rulePack;
672
872
  }
673
873
  else {
674
874
  const browser = await this.ensure();
675
- this.activeContext = await browser.newContext(storageStateOptions(this.config.storageStatePath, 'global'));
875
+ state.context = await browser.newContext(storageStateOptions(this.config.storageStatePath, 'global'));
676
876
  }
677
- this.activePage = await this.activeContext.newPage();
678
- this.attachCapture(this.activePage);
679
- return this.activePage;
877
+ // A popup (window.open, target=_blank) joins this session's page list; it never takes over the active page by itself.
878
+ state.context.on('page', (opened) => { if (state.context)
879
+ this.trackPage(state, opened); });
880
+ state.page = await state.context.newPage();
881
+ this.trackPage(state, state.page);
882
+ return state.page;
680
883
  }
681
- attachCapture(page) {
682
- const release = () => {
683
- if (this.activePage !== page)
884
+ /**
885
+ * Add a page to the session's list and wire its capture and close handling.
886
+ * Idempotent: the context 'page' event and the explicit call both land here.
887
+ */
888
+ trackPage(state, page) {
889
+ if (state.pages.some(entry => entry.page === page))
890
+ return;
891
+ const entry = { id: 't' + state.nextTarget++, page, generation: ++state.generationClock };
892
+ state.pages.push(entry);
893
+ this.pageEntries.set(page, entry);
894
+ // Any committed navigation of the MAIN frame outdates what was observed: Playwright emits framenavigated for
895
+ // cross-document loads, reloads and history moves and for same-document ones (pushState, hash). Subframes do not count.
896
+ page.on('framenavigated', (frame) => {
897
+ if (frame === page.mainFrame?.())
898
+ entry.generation = ++state.generationClock;
899
+ });
900
+ const gone = () => {
901
+ state.pages = state.pages.filter(entry => entry.page !== page);
902
+ // A page that was not the active one just leaves the list.
903
+ if (state.page !== page)
904
+ return;
905
+ // The active page closed: fall back to another open page of the same context before giving up the session.
906
+ const fallback = state.pages[0]?.page;
907
+ if (fallback && !fallback.isClosed?.()) {
908
+ state.page = fallback;
684
909
  return;
685
- const context = this.activeContext;
686
- const profile = this.activeProfile;
687
- this.clearActiveState();
910
+ }
911
+ const context = state.context;
912
+ const profile = state.profile;
913
+ state.page = undefined;
914
+ state.context = undefined;
915
+ state.profile = undefined;
916
+ state.rulePack = undefined;
688
917
  if (context)
689
918
  void this.persistAndClose({ context, ...profile ? { profile } : {} }).catch(() => { });
690
919
  };
691
- page.on('close', release);
692
- page.on('crash', release);
920
+ page.on('close', gone);
921
+ page.on('crash', gone);
693
922
  page.on('console', (message) => {
694
- if (!this.captureConsoleEnabled)
923
+ if (!state.captureConsole)
695
924
  return;
696
925
  const location = message.location?.();
697
- this.capturedConsole.push({
926
+ state.capturedConsole.push({
698
927
  type: String(message.type?.() ?? 'log').slice(0, 40),
699
928
  text: redactCaptureText(String(message.text?.() ?? '')),
700
929
  ...location?.url ? { url: redactCaptureUrl(location.url) } : {},
701
930
  timestamp: new Date().toISOString(),
702
931
  });
703
- if (this.capturedConsole.length > 200)
704
- this.capturedConsole.splice(0, this.capturedConsole.length - 200);
932
+ if (state.capturedConsole.length > 200)
933
+ state.capturedConsole.splice(0, state.capturedConsole.length - 200);
705
934
  });
706
935
  page.on('response', (response) => {
707
- if (!this.captureNetworkEnabled)
936
+ if (!state.captureNetwork)
708
937
  return;
709
938
  const status = Number(response.status?.() ?? 0);
710
939
  if (status < 400)
711
940
  return;
712
941
  const request = response.request?.();
713
- this.capturedRequests.push({
942
+ state.capturedRequests.push({
714
943
  method: String(request?.method?.() ?? 'GET').slice(0, 20),
715
944
  url: redactCaptureUrl(String(response.url?.() ?? '')),
716
945
  status,
717
946
  timestamp: new Date().toISOString(),
718
947
  });
719
- if (this.capturedRequests.length > 200)
720
- this.capturedRequests.splice(0, this.capturedRequests.length - 200);
948
+ if (state.capturedRequests.length > 200)
949
+ state.capturedRequests.splice(0, state.capturedRequests.length - 200);
721
950
  });
722
951
  page.on('requestfailed', (request) => {
723
- if (!this.captureNetworkEnabled)
952
+ if (!state.captureNetwork)
724
953
  return;
725
- this.capturedRequests.push({
954
+ state.capturedRequests.push({
726
955
  method: String(request.method?.() ?? 'GET').slice(0, 20),
727
956
  url: redactCaptureUrl(String(request.url?.() ?? '')),
728
957
  failure: redactCaptureText(String(request.failure?.()?.errorText ?? 'request failed'), 500),
729
958
  timestamp: new Date().toISOString(),
730
959
  });
731
- if (this.capturedRequests.length > 200)
732
- this.capturedRequests.splice(0, this.capturedRequests.length - 200);
960
+ if (state.capturedRequests.length > 200)
961
+ state.capturedRequests.splice(0, state.capturedRequests.length - 200);
733
962
  });
734
963
  }
735
- resetCapture(capture = []) {
736
- this.captureConsoleEnabled = capture.includes('console');
737
- this.captureNetworkEnabled = capture.includes('network');
738
- this.capturedConsole = [];
739
- this.capturedRequests = [];
964
+ resetCapture(state, capture = []) {
965
+ state.captureConsole = capture.includes('console');
966
+ state.captureNetwork = capture.includes('network');
967
+ state.capturedConsole = [];
968
+ state.capturedRequests = [];
740
969
  }
741
970
  resolveTarget(page, target) {
742
- if (!target || (typeof target !== 'string' && typeof target !== 'object'))
743
- throw new Error('browser target must be a selector string or locator object');
744
- const spec = typeof target === 'string' ? { selector: target } : target;
745
- const modes = [spec.selector, spec.role, spec.text, spec.label].filter(value => value !== undefined);
746
- if (modes.length !== 1)
747
- throw new Error('browser target requires exactly one of selector, role, text, or label');
748
- if (spec.name !== undefined && spec.role === undefined)
749
- throw new Error('browser target name is only valid with role');
750
- let root = page;
751
- if (spec.frame) {
752
- const frameModes = [spec.frame.selector, spec.frame.name, spec.frame.url].filter(value => value !== undefined);
753
- if (frameModes.length !== 1)
754
- throw new Error('browser frame requires exactly one of selector, name, or url');
755
- if (spec.frame.selector)
756
- root = page.frameLocator(boundedString(spec.frame.selector, 'frame selector', 500));
757
- else {
758
- const frame = page.frame(spec.frame.name
759
- ? { name: boundedString(spec.frame.name, 'frame name', 500) }
760
- : { url: boundedString(spec.frame.url, 'frame url', 2_000) });
761
- if (!frame)
762
- throw new Error('browser target frame was not found');
763
- root = frame;
764
- }
765
- }
766
- if (spec.selector)
767
- return root.locator(boundedString(spec.selector, 'selector', 500));
768
- if (spec.role)
769
- return root.getByRole(boundedString(spec.role, 'role', 100), {
770
- ...spec.name !== undefined ? { name: boundedString(spec.name, 'role name', 2_000) } : {},
771
- exact: spec.exact ?? false,
772
- });
773
- if (spec.text)
774
- return root.getByText(boundedString(spec.text, 'text locator', 2_000), { exact: spec.exact ?? false });
775
- return root.getByLabel(boundedString(spec.label, 'label locator', 2_000), { exact: spec.exact ?? false });
971
+ return resolveLocator(page, target);
972
+ }
973
+ /**
974
+ * Run an action on a strict locator. More than one match performs nothing and
975
+ * fails with LOCATOR_AMBIGUOUS plus a summary of the first candidates.
976
+ */
977
+ onTarget(page, target, run) {
978
+ return withStrictLocator(this.resolveTarget(page, target), run);
776
979
  }
777
980
  screenshotFile(options) {
778
981
  const filename = options.filename ?? `shot-${Date.now()}-${uid().slice(0, 8)}.${options.format === 'jpeg' ? 'jpg' : 'png'}`;
779
982
  if (filename !== path.basename(filename) || !/^[\w.() -]{1,160}$/.test(filename)) {
780
- throw new Error('browser_screenshot filename must be a plain file name inside snapshotDir');
983
+ throw new Error('observe.screenshot filename must be a plain file name inside snapshotDir');
781
984
  }
782
985
  const extension = path.extname(filename).toLowerCase();
783
986
  const inferred = extension === '.jpg' || extension === '.jpeg' ? 'jpeg' : extension === '.png' ? 'png' : undefined;
784
987
  const format = options.format ?? inferred ?? 'png';
785
988
  if (inferred && inferred !== format)
786
- throw new Error('browser_screenshot filename extension does not match format');
989
+ throw new Error('observe.screenshot filename extension does not match format');
787
990
  if (!inferred)
788
- throw new Error('browser_screenshot filename must end in .png, .jpg, or .jpeg');
991
+ throw new Error('observe.screenshot filename must end in .png, .jpg, or .jpeg');
789
992
  return { file: path.join(this.config.snapshotDir, filename), format };
790
993
  }
791
994
  async captureScreenshot(page, options = {}) {
792
995
  fs.mkdirSync(this.config.snapshotDir, { recursive: true });
793
996
  if (options.target && options.clip)
794
- throw new Error('browser_screenshot cannot combine target and clip');
997
+ throw new Error('observe.screenshot cannot combine target and clip');
795
998
  if (options.target && options.fullPage)
796
- throw new Error('browser_screenshot cannot combine target and fullPage');
999
+ throw new Error('observe.screenshot cannot combine target and fullPage');
797
1000
  if (options.quality !== undefined && (!Number.isInteger(options.quality) || options.quality < 0 || options.quality > 100)) {
798
- throw new Error('browser_screenshot quality must be an integer from 0 to 100');
1001
+ throw new Error('observe.screenshot quality must be an integer from 0 to 100');
799
1002
  }
800
1003
  const { file, format } = this.screenshotFile(options);
801
1004
  if (format === 'png' && options.quality !== undefined)
802
- throw new Error('browser_screenshot quality is only supported for jpeg');
1005
+ throw new Error('observe.screenshot quality is only supported for jpeg');
803
1006
  const screenshotOptions = {
804
1007
  path: file,
805
1008
  type: format,
@@ -808,7 +1011,7 @@ export class BrowserService {
808
1011
  if (options.clip) {
809
1012
  const { x, y, width, height } = options.clip;
810
1013
  if (![x, y, width, height].every(Number.isFinite) || x < 0 || y < 0 || width <= 0 || height <= 0 || width > 20_000 || height > 20_000) {
811
- throw new Error('browser_screenshot clip must use finite non-negative coordinates and dimensions from 1 to 20,000');
1014
+ throw new Error('observe.screenshot clip must use finite non-negative coordinates and dimensions from 1 to 20,000');
812
1015
  }
813
1016
  screenshotOptions.clip = options.clip;
814
1017
  }
@@ -816,17 +1019,35 @@ export class BrowserService {
816
1019
  screenshotOptions.fullPage = options.fullPage ?? true;
817
1020
  }
818
1021
  if (options.target)
819
- await this.resolveTarget(page, options.target).screenshot(screenshotOptions);
1022
+ await this.onTarget(page, options.target, locator => locator.screenshot(screenshotOptions));
820
1023
  else
821
1024
  await page.screenshot(screenshotOptions);
822
1025
  return file;
823
1026
  }
1027
+ /** `{targetId, generation}` of a tracked page (empty for a page the service does not track). */
1028
+ stamp(page) {
1029
+ const entry = this.pageEntries.get(page);
1030
+ return entry ? { targetId: entry.id, generation: entry.generation } : {};
1031
+ }
1032
+ /**
1033
+ * Refuse to act when the page's generation is not the one the caller observed. Nothing has been done yet
1034
+ * at this point, so the caller only needs to observe again.
1035
+ */
1036
+ assertGeneration(page, expected) {
1037
+ if (expected === undefined)
1038
+ return;
1039
+ const entry = this.pageEntries.get(page);
1040
+ if (!entry || entry.generation === expected)
1041
+ return;
1042
+ throw new TargetStaleError(`The page ${entry.id} is at generation ${entry.generation}, not ${expected}: it navigated since it was observed, so nothing was done.`, { targetId: entry.id, generation: entry.generation });
1043
+ }
824
1044
  async readState(page, includeScreenshot) {
825
1045
  const data = await evaluateExtractor(page, []);
826
1046
  const state = {
827
1047
  url: page.url(),
828
1048
  title: String(data.title ?? ''),
829
1049
  text: capText(String(data.text ?? '').replace(/\n{3,}/g, '\n\n').trim(), 100_000),
1050
+ ...this.stamp(page),
830
1051
  };
831
1052
  if (includeScreenshot)
832
1053
  state.screenshotPath = await this.captureScreenshot(page);
@@ -834,37 +1055,64 @@ export class BrowserService {
834
1055
  }
835
1056
  async open(url, opts = {}) {
836
1057
  const page = await this.ensureActivePage(url, opts);
837
- this.resetCapture(opts.capture);
1058
+ const state = this.state(opts.session);
1059
+ this.resetCapture(state, opts.capture);
838
1060
  page.setDefaultTimeout(30_000);
839
1061
  await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
840
1062
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
841
- await applyRuleSteps(page, this.activeRulePack);
1063
+ await applyRuleSteps(page, state.rulePack);
842
1064
  if (opts.waitMs)
843
1065
  await page.waitForTimeout(opts.waitMs);
844
1066
  return this.readState(page, true);
845
1067
  }
846
1068
  async click(target, opts = {}) {
847
- const page = await this.ensureActivePage();
848
- await this.resolveTarget(page, target).click({ timeout: boundedTimeout(opts.timeoutMs, 'browser_click timeoutMs') });
849
- if (opts.waitMs !== undefined)
850
- await page.waitForTimeout(opts.waitMs);
851
- else
852
- await page.waitForTimeout(500);
1069
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1070
+ this.assertGeneration(page, opts.expectGeneration);
1071
+ await this.onTarget(page, target, locator => locator.click({ timeout: boundedTimeout(opts.timeoutMs, 'act.click timeoutMs') }));
1072
+ // The click may have closed the page it hit (a popup's own close button). That is a success, not TARGET_CLOSED.
1073
+ await page.waitForTimeout(opts.waitMs ?? 500).catch((error) => { if (!page.isClosed())
1074
+ throw error; });
1075
+ if (page.isClosed()) {
1076
+ const fallback = this.peek(opts.session)?.page;
1077
+ if (!fallback || fallback.isClosed())
1078
+ return { url: '', title: '', text: '', closedPage: true };
1079
+ return { ...await this.readState(fallback, true), closedPage: true };
1080
+ }
853
1081
  return this.readState(page, true);
854
1082
  }
855
1083
  async type(target, text, opts = {}) {
856
- const page = await this.ensureActivePage();
857
- await this.resolveTarget(page, target).fill(text, { timeout: boundedTimeout(opts.timeoutMs, 'browser_type timeoutMs') });
1084
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1085
+ this.assertGeneration(page, opts.expectGeneration);
1086
+ await this.onTarget(page, target, locator => locator.fill(text, { timeout: boundedTimeout(opts.timeoutMs, 'act.fill timeoutMs') }));
1087
+ return this.readState(page, false);
1088
+ }
1089
+ /** Type key by key (pressSequentially), so per-key handlers fire; unlike `type`, which replaces the whole value. */
1090
+ async typeKeys(target, text, opts = {}) {
1091
+ const value = boundedString(text, 'act.type text', 10_000);
1092
+ const delay = opts.delayMs ?? 0;
1093
+ if (!Number.isFinite(delay) || delay < 0 || delay > 1_000)
1094
+ throw new Error('act.type delayMs must be between 0 and 1,000');
1095
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1096
+ this.assertGeneration(page, opts.expectGeneration);
1097
+ const timeout = boundedTimeout(opts.timeoutMs, 'act.type timeoutMs');
1098
+ await this.onTarget(page, target, locator => locator.pressSequentially(value, { delay, timeout }));
1099
+ return this.readState(page, false);
1100
+ }
1101
+ async clear(target, opts = {}) {
1102
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1103
+ this.assertGeneration(page, opts.expectGeneration);
1104
+ const timeout = boundedTimeout(opts.timeoutMs, 'act.clear timeoutMs');
1105
+ await this.onTarget(page, target, locator => locator.clear({ timeout }));
858
1106
  return this.readState(page, false);
859
1107
  }
860
1108
  async wait(target, opts = {}) {
861
- const page = await this.ensureActivePage();
1109
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
862
1110
  const modes = [target !== undefined, opts.urlPattern !== undefined, opts.networkIdle === true, opts.timeMs !== undefined].filter(Boolean);
863
1111
  if (modes.length !== 1)
864
- throw new Error('browser_wait requires exactly one target, urlPattern, networkIdle=true, or timeMs');
865
- const timeout = boundedTimeout(opts.timeoutMs, 'browser_wait timeoutMs');
1112
+ throw new Error('act.wait requires exactly one target, urlPattern, networkIdle=true, or timeMs');
1113
+ const timeout = boundedTimeout(opts.timeoutMs, 'act.wait timeoutMs');
866
1114
  if (target !== undefined)
867
- await this.resolveTarget(page, target).waitFor({ state: opts.state ?? 'visible', timeout });
1115
+ await this.onTarget(page, target, locator => locator.waitFor({ state: opts.state ?? 'visible', timeout }));
868
1116
  else if (opts.urlPattern !== undefined)
869
1117
  await page.waitForURL(boundedString(opts.urlPattern, 'urlPattern', 2_000), { timeout });
870
1118
  else if (opts.networkIdle)
@@ -872,70 +1120,72 @@ export class BrowserService {
872
1120
  else {
873
1121
  const timeMs = opts.timeMs;
874
1122
  if (!Number.isFinite(timeMs) || timeMs < 0 || timeMs > 10_000)
875
- throw new Error('browser_wait timeMs must be between 0 and 10,000');
1123
+ throw new Error('act.wait timeMs must be between 0 and 10,000');
876
1124
  await page.waitForTimeout(timeMs);
877
1125
  }
878
1126
  return this.readState(page, false);
879
1127
  }
880
1128
  async press(target, key, opts = {}) {
881
- const page = await this.ensureActivePage();
882
- const value = boundedString(key, 'browser_press key', 100);
1129
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1130
+ this.assertGeneration(page, opts.expectGeneration);
1131
+ const value = boundedString(key, 'act.press key', 100);
883
1132
  if (target !== undefined)
884
- await this.resolveTarget(page, target).press(value, { timeout: boundedTimeout(opts.timeoutMs, 'browser_press timeoutMs') });
1133
+ await this.onTarget(page, target, locator => locator.press(value, { timeout: boundedTimeout(opts.timeoutMs, 'act.press timeoutMs') }));
885
1134
  else
886
1135
  await page.keyboard.press(value);
887
1136
  return this.readState(page, false);
888
1137
  }
889
1138
  async select(target, values, opts = {}) {
890
1139
  if (values.length < 1 || values.length > 20)
891
- throw new Error('browser_select requires 1 to 20 values');
892
- values.forEach(value => boundedString(value, 'browser_select value', 2_000));
893
- const page = await this.ensureActivePage();
894
- await this.resolveTarget(page, target).selectOption([...values], { timeout: boundedTimeout(opts.timeoutMs, 'browser_select timeoutMs') });
1140
+ throw new Error('act.select requires 1 to 20 values');
1141
+ values.forEach(value => boundedString(value, 'act.select value', 2_000));
1142
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1143
+ this.assertGeneration(page, opts.expectGeneration);
1144
+ await this.onTarget(page, target, locator => locator.selectOption([...values], { timeout: boundedTimeout(opts.timeoutMs, 'act.select timeoutMs') }));
895
1145
  return this.readState(page, false);
896
1146
  }
897
1147
  async check(target, checked = true, opts = {}) {
898
- const page = await this.ensureActivePage();
899
- const locator = this.resolveTarget(page, target);
900
- if (checked)
901
- await locator.check({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
902
- else
903
- await locator.uncheck({ timeout: boundedTimeout(opts.timeoutMs, 'browser_check timeoutMs') });
1148
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1149
+ this.assertGeneration(page, opts.expectGeneration);
1150
+ const timeout = boundedTimeout(opts.timeoutMs, 'act.check timeoutMs');
1151
+ await this.onTarget(page, target, locator => checked ? locator.check({ timeout }) : locator.uncheck({ timeout }));
904
1152
  return this.readState(page, false);
905
1153
  }
906
1154
  async hover(target, opts = {}) {
907
- const page = await this.ensureActivePage();
908
- const timeoutMs = boundedTimeout(opts.timeoutMs, 'browser_hover timeoutMs');
1155
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1156
+ this.assertGeneration(page, opts.expectGeneration);
1157
+ const timeoutMs = boundedTimeout(opts.timeoutMs, 'act.hover timeoutMs');
909
1158
  const waitMs = Math.min(Math.max(opts.waitMs ?? 300, 0), timeoutMs);
910
- await this.resolveTarget(page, target).hover({ timeout: timeoutMs });
1159
+ await this.onTarget(page, target, locator => locator.hover({ timeout: timeoutMs }));
911
1160
  await page.waitForTimeout(waitMs);
912
1161
  return this.readState(page, true);
913
1162
  }
914
1163
  async setFiles(target, files, opts = {}) {
915
1164
  if (files.length === 0 || files.length > 20)
916
- throw new Error('browser_set_files requires 1 to 20 files');
1165
+ throw new Error('act.upload requires 1 to 20 files');
917
1166
  const resolved = files.map(file => {
918
1167
  if (!path.isAbsolute(file))
919
- throw new Error('browser_set_files requires absolute file paths: ' + file);
1168
+ throw new Error('act.upload requires absolute file paths: ' + file);
920
1169
  const real = fs.realpathSync(file);
921
1170
  if (!fs.statSync(real).isFile())
922
- throw new Error('browser_set_files path is not a file: ' + file);
1171
+ throw new Error('act.upload path is not a file: ' + file);
923
1172
  return real;
924
1173
  });
925
1174
  const totalBytes = resolved.reduce((total, file) => total + fs.statSync(file).size, 0);
926
1175
  if (totalBytes > 512 * 1024 * 1024)
927
- throw new Error('browser_set_files total upload size exceeds 512 MiB');
928
- const page = await this.ensureActivePage();
929
- await this.resolveTarget(page, target).setInputFiles(resolved, { timeout: boundedTimeout(opts.timeoutMs, 'browser_set_files timeoutMs') });
1176
+ throw new Error('act.upload total upload size exceeds 512 MiB');
1177
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1178
+ this.assertGeneration(page, opts.expectGeneration);
1179
+ await this.onTarget(page, target, locator => locator.setInputFiles(resolved, { timeout: boundedTimeout(opts.timeoutMs, 'act.upload timeoutMs') }));
930
1180
  return { ...await this.readState(page, true), files: resolved.map(file => path.basename(file)) };
931
1181
  }
932
1182
  async evaluate(expression, opts = {}) {
933
1183
  const source = expression.trim();
934
1184
  if (!source)
935
- throw new Error('browser_evaluate requires a JavaScript expression');
1185
+ throw new Error('script.evaluate requires a JavaScript expression');
936
1186
  if (source.length > 20_000)
937
- throw new Error('browser_evaluate expression exceeds 20,000 characters');
938
- const page = await this.ensureActivePage();
1187
+ throw new Error('script.evaluate expression exceeds 20,000 characters');
1188
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
939
1189
  const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? 15_000, 1_000), 30_000);
940
1190
  let timer;
941
1191
  let timedOut = false;
@@ -951,10 +1201,10 @@ export class BrowserService {
951
1201
  finally {
952
1202
  await session.detach().catch(() => { });
953
1203
  }
954
- reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open'));
1204
+ reject(new Error('script.evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open'));
955
1205
  }
956
1206
  catch (error) {
957
- reject(new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; unable to terminate page JavaScript without closing the active page: ' + String(error).slice(0, 200)));
1207
+ reject(new Error('script.evaluate timed out after ' + timeoutMs + 'ms; unable to terminate page JavaScript without closing the active page: ' + String(error).slice(0, 200)));
958
1208
  }
959
1209
  })();
960
1210
  }, timeoutMs);
@@ -967,7 +1217,7 @@ export class BrowserService {
967
1217
  }
968
1218
  catch (error) {
969
1219
  if (timedOut)
970
- throw new Error('browser_evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open');
1220
+ throw new Error('script.evaluate timed out after ' + timeoutMs + 'ms; page JavaScript was terminated and the active page remains open');
971
1221
  throw error;
972
1222
  }
973
1223
  return {
@@ -987,77 +1237,265 @@ export class BrowserService {
987
1237
  clearTimeout(timer);
988
1238
  }
989
1239
  }
1240
+ /**
1241
+ * The pages this session holds: the one it opened plus any popup that page
1242
+ * (or a later one) opened in the same context. Pure query: it never creates
1243
+ * a bucket and never changes the active page.
1244
+ */
1245
+ async listTargets(opts = {}) {
1246
+ const state = this.peek(opts.session);
1247
+ if (!state || !this.browserConnected())
1248
+ return { targets: [] };
1249
+ const targets = [];
1250
+ for (const entry of [...state.pages]) {
1251
+ if (entry.page.isClosed())
1252
+ continue;
1253
+ let title = '';
1254
+ try {
1255
+ title = String(await entry.page.title()).slice(0, 200);
1256
+ }
1257
+ catch { /* a page that is mid-navigation has no title yet */ }
1258
+ targets.push({ id: entry.id, url: entry.page.url(), title, active: entry.page === state.page, generation: entry.generation });
1259
+ }
1260
+ return { targets };
1261
+ }
1262
+ /** Make one of this session's pages the active page: every later action, read, and screenshot targets it. */
1263
+ async selectTarget(id, opts = {}) {
1264
+ const state = this.peek(opts.session);
1265
+ const entry = state?.pages.find(candidate => candidate.id === id && !candidate.page.isClosed());
1266
+ if (!state || !entry)
1267
+ throw new Error(`no active page with target id ${JSON.stringify(id)} in this session; target.list shows the open pages`);
1268
+ state.page = entry.page;
1269
+ await entry.page.bringToFront().catch(() => { });
1270
+ await entry.page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => { });
1271
+ return { id, ...await this.readState(entry.page, false) };
1272
+ }
990
1273
  async scroll(deltaY, opts = {}) {
991
- const page = await this.ensureActivePage();
1274
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1275
+ this.assertGeneration(page, opts.expectGeneration);
992
1276
  await page.mouse?.wheel(0, deltaY || 2000).catch(() => { });
993
1277
  await page.waitForTimeout(opts.waitMs ?? 500);
994
1278
  return this.readState(page, false);
995
1279
  }
996
- async read() {
997
- const page = await this.ensureActivePage();
998
- return this.readState(page, false);
1280
+ async read(opts = {}) {
1281
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1282
+ return this.readingNavigation(page, () => this.readState(page, false));
1283
+ }
1284
+ /**
1285
+ * Run a read of the page. If the page navigates under it ("Execution context was destroyed"), the read is
1286
+ * tried once more on the new document; if that fails the same way the page is still moving, which is a
1287
+ * stale observation (TARGET_STALE), not a failed action. Nothing a read does changes the page.
1288
+ */
1289
+ async readingNavigation(page, run) {
1290
+ const destroyed = (error) => /Execution context was destroyed|Cannot find context with specified id|Frame was detached|frame got detached/i.test(error instanceof Error ? error.message : String(error));
1291
+ try {
1292
+ return await run();
1293
+ }
1294
+ catch (error) {
1295
+ if (!destroyed(error))
1296
+ throw error;
1297
+ }
1298
+ await page.waitForLoadState?.('domcontentloaded', { timeout: 5_000 }).catch(() => { });
1299
+ try {
1300
+ return await run();
1301
+ }
1302
+ catch (error) {
1303
+ if (!destroyed(error))
1304
+ throw error;
1305
+ const stamp = this.stamp(page);
1306
+ throw new TargetStaleError(`The page ${stamp.targetId ?? ''} navigated while it was being read (Execution context was destroyed, twice): the observation is stale and nothing was changed.`, { targetId: stamp.targetId ?? '', generation: stamp.generation ?? 0 });
1307
+ }
1308
+ }
1309
+ /**
1310
+ * Structured observation of the active page (or of one element's subtree): readable text, controls, links,
1311
+ * and tables, each section bounded by `maxItems` and the whole record by `maxBytes` (see src/observe.ts).
1312
+ * With only the default `content` section, no scope and no limits it is exactly {@link read}.
1313
+ */
1314
+ async observe(opts = {}) {
1315
+ const request = normalizeObserve(opts);
1316
+ const page = await this.ensureActivePage(undefined, { session: opts.session });
1317
+ return this.readingNavigation(page, () => this.observeOnce(page, request, opts));
1318
+ }
1319
+ async observeOnce(page, request, opts) {
1320
+ const base = scopeBase(request.target);
1321
+ const structured = request.sections.some(section => section !== 'content');
1322
+ const wantsText = request.sections.includes('content');
1323
+ if (!structured && request.target === undefined && opts.maxBytes === undefined && opts.maxItems === undefined)
1324
+ return { ...await this.readState(page, false) };
1325
+ const generationBefore = this.stamp(page).generation;
1326
+ let title = '';
1327
+ let url = page.url();
1328
+ let text;
1329
+ if (wantsText) {
1330
+ if (request.target === undefined) {
1331
+ const state = await this.readState(page, false);
1332
+ title = state.title;
1333
+ url = state.url;
1334
+ text = state.text;
1335
+ }
1336
+ else {
1337
+ const locator = resolveLocator(page, request.target);
1338
+ text = capText(String(await withStrictLocator(locator, l => l.innerText({ timeout: request.timeoutMs }))).replace(/\n{3,}/g, '\n\n').trim(), 100_000);
1339
+ }
1340
+ }
1341
+ if (!title)
1342
+ title = String(await page.title().catch(() => '')).slice(0, 500);
1343
+ const raw = structured ? await scanPage(page, request, base) : undefined;
1344
+ const described = raw ? await describeScan(page, raw, request, base) : undefined;
1345
+ const stamp = this.stamp(page);
1346
+ const limits = [...described?.limits ?? []];
1347
+ if (generationBefore !== stamp.generation)
1348
+ limits.push('the page navigated while it was being observed; observe again');
1349
+ const head = {
1350
+ url, title,
1351
+ ...stamp.targetId !== undefined ? { targetId: stamp.targetId } : {},
1352
+ // The generation from before the scan: if the page moved meanwhile, a guarded action based on this fails stale.
1353
+ ...generationBefore !== undefined ? { generation: generationBefore } : {},
1354
+ ...described && Object.keys(described.counts).length ? { counts: described.counts } : {},
1355
+ ...raw && raw.shadowRoots > 0 ? { scanned: { shadowRoots: raw.shadowRoots } } : {},
1356
+ ...described?.frames ? { frames: described.frames } : {},
1357
+ ...limits.length ? { limits } : {},
1358
+ };
1359
+ const fitted = fitObservation({
1360
+ head,
1361
+ ...text !== undefined ? { text } : {},
1362
+ ...described?.controls ? { controls: described.controls } : {},
1363
+ ...described?.links ? { links: described.links } : {},
1364
+ ...described?.tables ? { tables: described.tables } : {},
1365
+ }, request.maxBytes, raw?.counts ?? {}, request.maxItems);
1366
+ return { ...fitted.record, ...fitted.truncation ? { truncation: fitted.truncation } : {} };
999
1367
  }
1000
1368
  consoleMessages(opts = {}) {
1001
1369
  const severities = ['debug', 'log', 'info', 'warning', 'error'];
1002
1370
  const threshold = opts.level ? severities.indexOf(opts.level) : 0;
1003
1371
  if (opts.level && threshold < 0)
1004
- throw new Error('browser_console level must be debug, log, info, warning, or error');
1372
+ throw new Error('inspect.console level must be debug, log, info, warning, or error');
1005
1373
  const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
1006
- const records = this.capturedConsole.filter(record => {
1374
+ // A session that never captured has nothing to report; reading must not
1375
+ // mint it a bucket, or `inspect.console` would evict someone's page.
1376
+ const state = this.peek(opts.session) ?? newSessionState();
1377
+ const records = state.capturedConsole.filter(record => {
1007
1378
  const index = severities.indexOf(record.type === 'warn' ? 'warning' : record.type);
1008
1379
  return index < 0 || index >= threshold;
1009
1380
  }).slice(-limit);
1010
1381
  if (opts.clear)
1011
- this.capturedConsole = [];
1012
- return { enabled: this.captureConsoleEnabled, records };
1382
+ state.capturedConsole = [];
1383
+ return { enabled: state.captureConsole, records };
1013
1384
  }
1014
1385
  networkRequests(opts = {}) {
1015
1386
  const limit = Math.min(Math.max(Math.trunc(opts.limit ?? 100), 1), 200);
1016
- const records = this.capturedRequests.slice(-limit);
1387
+ const state = this.peek(opts.session) ?? newSessionState();
1388
+ const records = state.capturedRequests.slice(-limit);
1017
1389
  if (opts.clear)
1018
- this.capturedRequests = [];
1019
- return { enabled: this.captureNetworkEnabled, records };
1390
+ state.capturedRequests = [];
1391
+ return { enabled: state.captureNetwork, records };
1020
1392
  }
1021
1393
  async screenshot(options = {}) {
1022
- const page = await this.ensureActivePage();
1394
+ const page = await this.ensureActivePage(undefined, { session: options.session });
1023
1395
  return { path: await this.captureScreenshot(page, options) };
1024
1396
  }
1397
+ /**
1398
+ * Run a recipe on the session's page.
1399
+ *
1400
+ * A business failure comes back as a value (`executionStatus`, `failedStep`,
1401
+ * `completedSteps`, `effects`), never as an exception; only problems before
1402
+ * any step ran (no page, navigation failure, a malformed recipe) throw.
1403
+ *
1404
+ * Cancelling stops this recipe only: the session page stays open and usable.
1405
+ * A step that is already running cannot be interrupted, so the call returns
1406
+ * once that step has returned. The caller therefore still holds the session
1407
+ * (browser_call is serialized per agent) until the page is quiet again.
1408
+ */
1025
1409
  async recipe(steps, opts = {}) {
1026
- if (!opts.url && (!this.activePage || this.activePage.isClosed()))
1027
- throw new Error('browser recipe requires url or an active browser_open page');
1410
+ if (opts.isolated)
1411
+ return this.isolatedRecipe(steps, opts);
1412
+ const existing = this.peek(opts.session)?.page;
1413
+ if (!opts.url && (!existing || existing.isClosed()))
1414
+ throw new Error('browser recipe requires url or an active target.open page');
1028
1415
  const page = await this.ensureActivePage(opts.url, opts);
1416
+ return this.runRecipeOn(page, this.state(opts.session), steps, opts);
1417
+ }
1418
+ /** A recipe in its own context: nothing of the caller's session (page, cookies, storage, journal) is shared or changed. */
1419
+ async isolatedRecipe(steps, opts) {
1420
+ if (!opts.url)
1421
+ throw new Error('an isolated recipe run requires url');
1422
+ const state = newSessionState();
1423
+ const created = await this.transientContext(opts.url, opts);
1424
+ state.context = created.context;
1425
+ state.profile = created.profile;
1426
+ state.rulePack = created.rulePack;
1427
+ try {
1428
+ state.context.on('page', (opened) => { if (state.context)
1429
+ this.trackPage(state, opened); });
1430
+ state.page = await state.context.newPage();
1431
+ this.trackPage(state, state.page);
1432
+ return await this.runRecipeOn(state.page, state, steps, opts);
1433
+ }
1434
+ finally {
1435
+ await this.closePage(state).catch(() => { });
1436
+ }
1437
+ }
1438
+ async runRecipeOn(page, state, steps, opts) {
1029
1439
  if (opts.url) {
1030
1440
  page.setDefaultTimeout(30_000);
1031
1441
  await this.navigate(page, opts.url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, opts.signal);
1032
1442
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
1033
- await applyRuleSteps(page, this.activeRulePack);
1443
+ await applyRuleSteps(page, state.rulePack);
1034
1444
  if (opts.waitMs)
1035
1445
  await page.waitForTimeout(opts.waitMs);
1036
1446
  }
1037
- const onAbort = () => void this.closePage();
1038
- if (opts.signal?.aborted)
1039
- onAbort();
1040
- else
1041
- opts.signal?.addEventListener('abort', onAbort);
1042
- try {
1043
- const results = await runRecipe(page, steps, () => this.captureScreenshot(page), opts.signal);
1044
- return { ...await this.readState(page, false), steps: results };
1045
- }
1046
- finally {
1047
- opts.signal?.removeEventListener('abort', onAbort);
1447
+ const options = { legacy: opts.legacyRecipe };
1448
+ if (opts.schemaVersion === 2) {
1449
+ let startOrigin;
1450
+ try {
1451
+ startOrigin = new URL(page.url()).origin;
1452
+ }
1453
+ catch { /* about:blank has no origin */ }
1454
+ Object.assign(options, {
1455
+ schemaVersion: 2,
1456
+ ...opts.postconditions ? { postconditions: opts.postconditions } : {},
1457
+ ...opts.outputSchema ? { outputSchema: opts.outputSchema } : {},
1458
+ ...opts.allowedDomains ? { allowedDomains: opts.allowedDomains } : {},
1459
+ ...opts.gotoSameOrigin && startOrigin && startOrigin !== 'null' ? { sameOrigin: startOrigin } : {},
1460
+ // Same navigation path as target.open: usage governor, settle, rule steps.
1461
+ goto: async (target) => {
1462
+ await this.navigate(page, target, { waitUntil: 'domcontentloaded', timeout: 30_000 }, opts.signal);
1463
+ await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
1464
+ await applyRuleSteps(page, state.rulePack);
1465
+ },
1466
+ });
1048
1467
  }
1468
+ const run = await runRecipe(page, steps, () => this.captureScreenshot(page), opts.signal, options);
1469
+ const result = await this.readState(page, false).catch(() => ({ url: page.isClosed() ? '' : page.url(), title: '', text: '' }));
1470
+ return { ...result, ...run, steps: run.completedSteps };
1049
1471
  }
1050
- async closePage() {
1051
- const page = this.activePage;
1052
- const context = this.activeContext;
1053
- const profile = this.activeProfile;
1054
- this.clearActiveState();
1472
+ /**
1473
+ * Close one session's page.
1474
+ *
1475
+ * `session` is the caller's bucket: a tool call closes only the page that
1476
+ * session opened. Only a caller with no session (a service consumer outside
1477
+ * the agent loop) reaches the shared bucket, which preserves the historical
1478
+ * behaviour for those consumers.
1479
+ */
1480
+ async closePage(session) {
1481
+ if (!session)
1482
+ return;
1483
+ const page = session.page;
1484
+ const context = session.context;
1485
+ const profile = session.profile;
1486
+ session.page = undefined;
1487
+ session.pages = [];
1488
+ session.context = undefined;
1489
+ session.profile = undefined;
1490
+ session.rulePack = undefined;
1491
+ session.capturedConsole = [];
1492
+ session.capturedRequests = [];
1055
1493
  if (page)
1056
1494
  await page.close().catch(() => { });
1057
1495
  if (context)
1058
1496
  await this.persistAndClose({ context, ...profile ? { profile } : {} });
1059
1497
  }
1060
- async status() {
1498
+ async status(opts = {}) {
1061
1499
  let chromiumInstalled = false;
1062
1500
  let chromiumExecutablePath;
1063
1501
  try {
@@ -1089,7 +1527,7 @@ export class BrowserService {
1089
1527
  ]
1090
1528
  : [];
1091
1529
  if (!chromiumInstalled && chromiumExecutablePath && (this.config.channel === 'chromium' || !!this.config.executablePath)) {
1092
- runtimeWarnings.push(`Expected Chromium executable is missing: ${chromiumExecutablePath}. Run browser_install for ${this.config.browserRuntime}.`);
1530
+ runtimeWarnings.push(`Expected Chromium executable is missing: ${chromiumExecutablePath}. Run runtime.install for ${this.config.browserRuntime}.`);
1093
1531
  }
1094
1532
  if (this.config.opencliEnabled && !opencliInstalled)
1095
1533
  runtimeWarnings.push('OpenCLI is enabled but its package entry is not installed.');
@@ -1103,7 +1541,8 @@ export class BrowserService {
1103
1541
  opencliInstalled,
1104
1542
  ...resolvedOpencliEntryPath ? { opencliEntryPath: resolvedOpencliEntryPath } : {},
1105
1543
  automationMode: this.config.automationMode,
1106
- exposedTools: configuredBrowserTools(this.config.automationMode, this.config.automationAssets, this.config.enabled),
1544
+ exposedTools: configuredBrowserTools(this.config.automationMode, this.config.automationAssets, this.config.enabled, this.config.toolSurface),
1545
+ exposedActions: configuredBrowserActions(this.config.automationMode, this.config.automationAssets, this.config.enabled),
1107
1546
  directInteractionPolicy: !this.config.enabled || this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'standard' ? 'ask' : 'allow',
1108
1547
  mutatingRecipePolicy: !this.config.enabled || this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'standard' ? 'ask' : 'allow',
1109
1548
  externalUserscriptPolicy: !this.config.enabled || this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'unrestricted' ? 'allow' : 'ask',
@@ -1119,12 +1558,27 @@ export class BrowserService {
1119
1558
  builtinScripts: BUILTIN_SCRIPTS.map(script => script.id),
1120
1559
  externalUserscriptsRequireApproval: ['standard', 'autonomous'].includes(this.config.automationMode),
1121
1560
  mutatingRecipesRequireApproval: this.config.automationMode === 'standard',
1122
- ...(this.browserConnected() && this.activePage && !this.activePage.isClosed() ? { activeUrl: this.activePage.url() } : {}),
1123
- ...(this.browserConnected() && this.activePage && !this.activePage.isClosed() && this.activeProfile ? { activeAuthProfile: this.activeProfile.id } : {}),
1561
+ ...(() => {
1562
+ // Report only the caller's own page, so one session cannot observe that
1563
+ // another session currently has a page open. `peek` keeps this a pure
1564
+ // query: asking about a session never creates or evicts a bucket.
1565
+ const own = this.peek(opts.session);
1566
+ if (!this.browserConnected() || !own?.page || own.page.isClosed())
1567
+ return {};
1568
+ return {
1569
+ activeUrl: own.page.url(),
1570
+ ...own.profile ? { activeAuthProfile: own.profile.id } : {},
1571
+ };
1572
+ })(),
1124
1573
  };
1125
1574
  }
1126
1575
  async close() {
1127
- await this.closePage();
1576
+ // Process teardown (fiber dispose): every session's page and context goes,
1577
+ // because the browser process itself is going away.
1578
+ for (const state of [...this.sessions.values()])
1579
+ await this.closePage(state);
1580
+ this.sessions.clear();
1581
+ this.journals.clear();
1128
1582
  const b = this.browser;
1129
1583
  this.browser = undefined;
1130
1584
  this.launching = undefined;