@anweat/dsh-browser 0.1.17 → 0.2.0-rc.1

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