agentic-browser 2.5.0 → 2.6.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.
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import net from "node:net";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import WebSocket, { WebSocketServer } from "ws";
7
+ import WebSocket from "ws";
8
8
  import crypto from "node:crypto";
9
9
  import { URL as URL$1 } from "node:url";
10
10
  import { z } from "zod";
@@ -24,6 +24,7 @@ function discoverChrome(explicitPath) {
24
24
  //#endregion
25
25
  //#region src/session/browser-controller.ts
26
26
  var CdpConnection = class CdpConnection {
27
+ ws;
27
28
  nextId = 0;
28
29
  pendingRequests = /* @__PURE__ */ new Map();
29
30
  pendingEvents = /* @__PURE__ */ new Map();
@@ -151,6 +152,26 @@ var CdpConnection = class CdpConnection {
151
152
  this.pendingEvents.set(method, waiters);
152
153
  });
153
154
  }
155
+ /**
156
+ * Resolve on the first event of `method` whose params satisfy `predicate`.
157
+ * Uses the persistent onEvent channel so non-matching events (e.g. subframe
158
+ * navigations) don't consume the wait.
159
+ */
160
+ waitForEventMatching(method, predicate, timeoutMs = 5e3) {
161
+ return new Promise((resolve, reject) => {
162
+ let unsubscribe = () => {};
163
+ const timeout = setTimeout(() => {
164
+ unsubscribe();
165
+ reject(/* @__PURE__ */ new Error(`Timed out waiting for ${method}`));
166
+ }, timeoutMs);
167
+ unsubscribe = this.onEvent(method, (params) => {
168
+ if (!predicate(params)) return;
169
+ clearTimeout(timeout);
170
+ unsubscribe();
171
+ resolve(params);
172
+ });
173
+ });
174
+ }
154
175
  onEvent(method, handler) {
155
176
  const handlers = this.eventHandlers.get(method) ?? /* @__PURE__ */ new Set();
156
177
  const wrapped = (params) => {
@@ -175,6 +196,10 @@ async function getJson(url, timeoutMs = 5e3) {
175
196
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`);
176
197
  return await response.json();
177
198
  }
199
+ /** Debugger connection timing */
200
+ const DEBUGGER_READY_TIMEOUT_MS = 15e3;
201
+ const DEBUGGER_INITIAL_RETRY_DELAY_MS = 50;
202
+ const DEBUGGER_MAX_RETRY_DELAY_MS = 250;
178
203
  /** Check if the debug port is accepting TCP connections (faster than an HTTP fetch). */
179
204
  function probePort(port) {
180
205
  return new Promise((resolve) => {
@@ -193,16 +218,15 @@ function probePort(port) {
193
218
  });
194
219
  }
195
220
  async function waitForDebugger(port) {
196
- const maxMs = 15e3;
197
221
  const start = Date.now();
198
- let delay = 50;
199
- while (Date.now() - start < maxMs) {
222
+ let delay = DEBUGGER_INITIAL_RETRY_DELAY_MS;
223
+ while (Date.now() - start < DEBUGGER_READY_TIMEOUT_MS) {
200
224
  if (await probePort(port)) try {
201
225
  await getJson(`http://127.0.0.1:${port}/json/version`);
202
226
  return;
203
227
  } catch {}
204
228
  await new Promise((resolve) => setTimeout(resolve, delay));
205
- delay = Math.min(delay * 2, 250);
229
+ delay = Math.min(delay * 2, DEBUGGER_MAX_RETRY_DELAY_MS);
206
230
  }
207
231
  throw new Error("Chrome debug endpoint did not become ready in time");
208
232
  }
@@ -228,7 +252,12 @@ async function createTarget(cdpUrl, url = "about:blank") {
228
252
  * Apply stealth patches that remove common automation fingerprints.
229
253
  * These run before any page loads so sites cannot detect the bot.
230
254
  */
231
- const STEALTH_SCRIPT = `
255
+ const STEALTH_SCRIPT = `(() => {
256
+ // Guard: registered once per CDP session, so re-connects would re-run the patches
257
+ // and the non-configurable defineProperty calls below would throw.
258
+ if (window.__agenticStealthApplied) return;
259
+ window.__agenticStealthApplied = true;
260
+
232
261
  // Remove navigator.webdriver flag
233
262
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
234
263
 
@@ -270,8 +299,13 @@ const STEALTH_SCRIPT = `
270
299
  return origQuery.call(this, params);
271
300
  };
272
301
  }
273
- `;
274
- /** Verify the page is ready, apply stealth, and optionally set a custom user-agent. */
302
+ })();`;
303
+ /**
304
+ * Verify the page is ready, apply stealth, and optionally set a custom user-agent.
305
+ * Returns the user-agent override that was applied (if any) so callers can re-apply
306
+ * it on later CDP sessions — script/UA overrides are scoped to the session that set
307
+ * them and die when this bootstrap connection closes.
308
+ */
275
309
  async function initTarget(targetWsUrl, userAgent) {
276
310
  const conn = await CdpConnection.connect(targetWsUrl);
277
311
  try {
@@ -288,18 +322,38 @@ async function initTarget(targetWsUrl, userAgent) {
288
322
  returnByValue: true,
289
323
  awaitPromise: true
290
324
  });
291
- if (userAgent) await conn.send("Network.setUserAgentOverride", { userAgent });
292
- else try {
325
+ if (userAgent) {
326
+ await conn.send("Network.setUserAgentOverride", { userAgent });
327
+ return userAgent;
328
+ }
329
+ try {
293
330
  const { userAgent: currentUA } = await conn.send("Browser.getVersion");
294
331
  if (currentUA?.includes("HeadlessChrome")) {
295
332
  const cleanUA = currentUA.replace(/HeadlessChrome/g, "Chrome");
296
333
  await conn.send("Network.setUserAgentOverride", { userAgent: cleanUA });
334
+ return cleanUA;
297
335
  }
298
336
  } catch {}
337
+ return;
299
338
  } finally {
300
339
  conn.close();
301
340
  }
302
341
  }
342
+ /**
343
+ * Run Runtime.evaluate and surface in-page exceptions as errors.
344
+ * CDP reports page-side throws via `exceptionDetails` on an otherwise successful
345
+ * response — callers that read `result.value` directly would treat a throw as an
346
+ * empty (successful) result.
347
+ */
348
+ async function evaluateOnPage(conn, params) {
349
+ const result = await conn.send("Runtime.evaluate", params);
350
+ const details = result.exceptionDetails;
351
+ if (details) {
352
+ const raw = details.exception?.description ?? (typeof details.exception?.value === "string" ? details.exception.value : void 0) ?? details.text ?? "Page evaluation failed";
353
+ throw new Error(raw.split("\n")[0].replace(/^Error:\s*/, ""));
354
+ }
355
+ return result;
356
+ }
303
357
  async function getFreePort() {
304
358
  return await new Promise((resolve, reject) => {
305
359
  const server = net.createServer();
@@ -532,7 +586,7 @@ ${LOCATOR_RUNTIME_HELPERS}
532
586
  ${body}
533
587
  })()`;
534
588
  }
535
- const SPECIAL_KEY_PAYLOADS = new Map([
589
+ const SPECIAL_KEY_PAYLOADS = /* @__PURE__ */ new Map([
536
590
  ["Enter", {
537
591
  key: "Enter",
538
592
  code: "Enter",
@@ -694,18 +748,45 @@ function buildKeyDispatchPayload(inputKey) {
694
748
  nativeVirtualKeyCode: 0
695
749
  };
696
750
  }
751
+ /** Connection pool configuration */
752
+ const CONNECTION_IDLE_TIMEOUT_MS = 300 * 1e3;
753
+ const CONNECTION_CLEANUP_INTERVAL_MS = 6e4;
754
+ /**
755
+ * True when the error means the socket was closed/not-open — i.e. the request
756
+ * was never delivered, so re-running it cannot double-apply a side effect.
757
+ * Deliberately excludes CDP timeouts: a timed-out request may have reached
758
+ * Chrome, so replaying it for a non-idempotent op could fire the action twice.
759
+ */
760
+ function isConnectionError(error) {
761
+ const message = error instanceof Error ? error.message : String(error);
762
+ return message.includes("CDP connection closed") || message.includes("WebSocket not open");
763
+ }
697
764
  var ChromeCdpBrowserController = class {
765
+ baseDir;
766
+ connectionFactory;
698
767
  connections = /* @__PURE__ */ new Map();
699
768
  cleanupInterval;
769
+ /**
770
+ * User-agent override resolved at target init, keyed by target ws URL.
771
+ * Stealth script + UA override are scoped to the CDP session that set them,
772
+ * so every pooled connection must re-apply them (initTarget's bootstrap
773
+ * connection closes right after launch).
774
+ */
775
+ userAgentOverrides = /* @__PURE__ */ new Map();
700
776
  constructor(baseDir, connectionFactory = CdpConnection.connect) {
701
777
  this.baseDir = baseDir;
702
778
  this.connectionFactory = connectionFactory;
703
779
  this.cleanupInterval = setInterval(() => {
704
- const cutoff = Date.now() - 300 * 1e3;
780
+ const cutoff = Date.now() - CONNECTION_IDLE_TIMEOUT_MS;
705
781
  for (const [url, entry] of this.connections) if (entry.lastUsedAt < cutoff) this.dropConnection(url);
706
- }, 6e4);
782
+ }, CONNECTION_CLEANUP_INTERVAL_MS);
707
783
  this.cleanupInterval.unref();
708
784
  }
785
+ /** Stop the cleanup interval and close all connections. Call when disposing the controller. */
786
+ dispose() {
787
+ clearInterval(this.cleanupInterval);
788
+ for (const url of this.connections.keys()) this.dropConnection(url);
789
+ }
709
790
  async getConnection(targetWsUrl) {
710
791
  const cached = this.connections.get(targetWsUrl);
711
792
  if (cached) {
@@ -713,6 +794,7 @@ var ChromeCdpBrowserController = class {
713
794
  return cached.conn;
714
795
  }
715
796
  const conn = await this.connectionFactory(targetWsUrl);
797
+ await this.applySessionOverrides(conn, targetWsUrl);
716
798
  this.connections.set(targetWsUrl, {
717
799
  conn,
718
800
  enabled: {
@@ -723,6 +805,26 @@ var ChromeCdpBrowserController = class {
723
805
  });
724
806
  return conn;
725
807
  }
808
+ /**
809
+ * Re-apply session-scoped stealth patches + UA override on a fresh connection.
810
+ * Overrides are per-CDP-session, so every pooled connection must re-apply them
811
+ * — including in a fresh CLI process that reconnects to an existing target
812
+ * (the in-memory override map is empty there, so fall back to deriving one).
813
+ */
814
+ async applySessionOverrides(conn, targetWsUrl) {
815
+ try {
816
+ await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: STEALTH_SCRIPT });
817
+ let userAgent = this.userAgentOverrides.get(targetWsUrl);
818
+ if (!userAgent) try {
819
+ const { userAgent: currentUA } = await conn.send("Browser.getVersion");
820
+ if (currentUA?.includes("HeadlessChrome")) {
821
+ userAgent = currentUA.replace(/HeadlessChrome/g, "Chrome");
822
+ this.userAgentOverrides.set(targetWsUrl, userAgent);
823
+ }
824
+ } catch {}
825
+ if (userAgent) await conn.send("Emulation.setUserAgentOverride", { userAgent });
826
+ } catch {}
827
+ }
726
828
  dropConnection(targetWsUrl) {
727
829
  const cached = this.connections.get(targetWsUrl);
728
830
  if (!cached) return;
@@ -740,7 +842,8 @@ var ChromeCdpBrowserController = class {
740
842
  if (!port) throw new Error(`Invalid CDP URL: could not extract port from ${cdpUrl}`);
741
843
  await waitForDebugger(port);
742
844
  const targetWsUrl = await createTarget(cdpUrl);
743
- await initTarget(targetWsUrl, options?.userAgent);
845
+ const appliedUa = await initTarget(targetWsUrl, options?.userAgent);
846
+ if (appliedUa) this.userAgentOverrides.set(targetWsUrl, appliedUa);
744
847
  return {
745
848
  pid: 0,
746
849
  cdpUrl,
@@ -808,7 +911,8 @@ var ChromeCdpBrowserController = class {
808
911
  const cdpUrl = `http://127.0.0.1:${port}`;
809
912
  const targetWsUrl = await createTarget(cdpUrl, "about:blank");
810
913
  if (!child.pid) throw new Error("Failed to launch Chrome process");
811
- await initTarget(targetWsUrl, userAgent);
914
+ const appliedUa = await initTarget(targetWsUrl, userAgent);
915
+ if (appliedUa) this.userAgentOverrides.set(targetWsUrl, appliedUa);
812
916
  return {
813
917
  pid: child.pid,
814
918
  cdpUrl,
@@ -823,12 +927,22 @@ var ChromeCdpBrowserController = class {
823
927
  }
824
928
  throw new Error(lastError?.message ?? "Unable to launch Chrome");
825
929
  }
826
- /** Execute fn with a pooled connection; on failure drop connection and retry once. */
827
- async withRetry(targetWsUrl, fn) {
930
+ /**
931
+ * Execute fn with a pooled connection; on failure drop the connection and retry once.
932
+ *
933
+ * `retryOn`:
934
+ * - "any" (default): retry any error — safe for reads/idempotent work.
935
+ * - "connection": retry only when the connection itself died. Use for
936
+ * non-idempotent input (click/type/press/toggle/select): a mid-operation
937
+ * connection drop after an event was already dispatched must not replay it,
938
+ * and in-page errors (selector missing, covered element) are not transient.
939
+ */
940
+ async withRetry(targetWsUrl, fn, retryOn = "any") {
828
941
  let conn = await this.getConnection(targetWsUrl);
829
942
  try {
830
943
  return await fn(conn);
831
- } catch {
944
+ } catch (error) {
945
+ if (retryOn === "connection" && !isConnectionError(error)) throw error;
832
946
  this.dropConnection(targetWsUrl);
833
947
  conn = await this.getConnection(targetWsUrl);
834
948
  return await fn(conn);
@@ -837,11 +951,12 @@ var ChromeCdpBrowserController = class {
837
951
  async navigate(targetWsUrl, url) {
838
952
  return await this.withRetry(targetWsUrl, async (conn) => {
839
953
  await this.ensureEnabled(targetWsUrl);
840
- const navigatedPromise = conn.waitForEvent("Page.frameNavigated", 6e3).catch(() => void 0);
841
- const loadPromise = Promise.race([conn.waitForEvent("Page.loadEventFired", 6e3), conn.waitForEvent("Page.frameStoppedLoading", 6e3)]);
954
+ const mainFrameNavigated = conn.waitForEventMatching ? conn.waitForEventMatching("Page.frameNavigated", (params) => !params.frame?.parentId, 6e3).catch(() => void 0) : conn.waitForEvent("Page.frameNavigated", 6e3).catch(() => void 0);
842
955
  const navResult = await conn.send("Page.navigate", { url });
843
956
  if (navResult.errorText) throw new Error(`Navigation failed: ${navResult.errorText}`);
844
- const navigatedEvent = await navigatedPromise;
957
+ const mainFrameId = navResult.frameId;
958
+ const loadPromise = Promise.race([conn.waitForEvent("Page.loadEventFired", 6e3), conn.waitForEventMatching ? conn.waitForEventMatching("Page.frameStoppedLoading", (params) => !mainFrameId || params.frameId === mainFrameId, 6e3) : conn.waitForEvent("Page.frameStoppedLoading", 6e3)]);
959
+ const navigatedEvent = await mainFrameNavigated;
845
960
  try {
846
961
  await loadPromise;
847
962
  } catch {}
@@ -849,12 +964,18 @@ var ChromeCdpBrowserController = class {
849
964
  });
850
965
  }
851
966
  async handleDialogAction(targetWsUrl, payload) {
967
+ const DIALOG_WAIT_TIMEOUT_MS = 500;
968
+ const DIALOG_POLL_INTERVAL_MS = 10;
969
+ const MAX_POLL_ATTEMPTS = 10;
852
970
  return await this.withRetry(targetWsUrl, async (conn) => {
853
971
  await this.ensureEnabled(targetWsUrl);
854
972
  const cached = this.connections.get(targetWsUrl);
855
973
  if (!cached?.pendingDialog) try {
856
- await conn.waitForEvent("Page.javascriptDialogOpening", 500);
857
- await new Promise((r) => setTimeout(r, 50));
974
+ await conn.waitForEvent("Page.javascriptDialogOpening", DIALOG_WAIT_TIMEOUT_MS);
975
+ for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
976
+ if (cached?.pendingDialog) break;
977
+ await new Promise((r) => setTimeout(r, DIALOG_POLL_INTERVAL_MS));
978
+ }
858
979
  } catch {
859
980
  return "no dialog present";
860
981
  }
@@ -871,7 +992,8 @@ var ChromeCdpBrowserController = class {
871
992
  });
872
993
  }
873
994
  async preparePointerTarget(conn, payload) {
874
- const expression = buildLocatorRuntimeExpression("payload", payload, `
995
+ const point = (await evaluateOnPage(conn, {
996
+ expression: buildLocatorRuntimeExpression("payload", payload, `
875
997
  const resolved = resolveWithFallbacks(payload.selector, payload.fallbackSelectors);
876
998
  const el = resolved.element;
877
999
  ensureInView(el);
@@ -888,9 +1010,7 @@ var ChromeCdpBrowserController = class {
888
1010
  }
889
1011
 
890
1012
  return { x: point.x, y: point.y };
891
- `);
892
- const point = (await conn.send("Runtime.evaluate", {
893
- expression,
1013
+ `),
894
1014
  returnByValue: true,
895
1015
  awaitPromise: true
896
1016
  })).result.value;
@@ -901,7 +1021,8 @@ var ChromeCdpBrowserController = class {
901
1021
  };
902
1022
  }
903
1023
  async prepareTypeTarget(conn, payload) {
904
- const expression = buildLocatorRuntimeExpression("payload", payload, `
1024
+ const value = (await evaluateOnPage(conn, {
1025
+ expression: buildLocatorRuntimeExpression("payload", payload, `
905
1026
  const resolved = resolveWithFallbacks(payload.selector, payload.fallbackSelectors);
906
1027
  const el = resolved.element;
907
1028
  ensureInView(el);
@@ -950,12 +1071,12 @@ var ChromeCdpBrowserController = class {
950
1071
  }
951
1072
 
952
1073
  return 'ready';
953
- `);
954
- return (await conn.send("Runtime.evaluate", {
955
- expression,
1074
+ `),
956
1075
  returnByValue: true,
957
1076
  awaitPromise: true
958
- })).result.value ?? "ready";
1077
+ })).result.value;
1078
+ if (value !== "ready" && value !== "cleared") throw new Error("Unable to resolve type target");
1079
+ return value;
959
1080
  }
960
1081
  async dispatchMouseClick(conn, x, y) {
961
1082
  await conn.send("Input.dispatchMouseEvent", {
@@ -981,7 +1102,8 @@ var ChromeCdpBrowserController = class {
981
1102
  }
982
1103
  async focusTarget(conn, payload) {
983
1104
  if (!payload.selector) return;
984
- const expression = buildLocatorRuntimeExpression("payload", payload, `
1105
+ await evaluateOnPage(conn, {
1106
+ expression: buildLocatorRuntimeExpression("payload", payload, `
985
1107
  const resolved = resolveWithFallbacks(payload.selector, payload.fallbackSelectors);
986
1108
  const el = resolved.element;
987
1109
  ensureInView(el);
@@ -989,9 +1111,7 @@ var ChromeCdpBrowserController = class {
989
1111
  el.focus();
990
1112
  }
991
1113
  return true;
992
- `);
993
- await conn.send("Runtime.evaluate", {
994
- expression,
1114
+ `),
995
1115
  returnByValue: true,
996
1116
  awaitPromise: true
997
1117
  });
@@ -1018,8 +1138,8 @@ var ChromeCdpBrowserController = class {
1018
1138
  async interact(targetWsUrl, payload) {
1019
1139
  if (payload.action === "goBack") return await this.withRetry(targetWsUrl, async (conn) => {
1020
1140
  await this.ensureEnabled(targetWsUrl);
1021
- const navigatedPromise = conn.waitForEvent("Page.frameNavigated", 3e3).catch(() => void 0);
1022
- await conn.send("Runtime.evaluate", {
1141
+ const navigatedPromise = (conn.waitForEventMatching ? conn.waitForEventMatching("Page.frameNavigated", (params) => !params.frame?.parentId, 3e3) : conn.waitForEvent("Page.frameNavigated", 3e3)).catch(() => void 0);
1142
+ await evaluateOnPage(conn, {
1023
1143
  expression: "history.back()",
1024
1144
  returnByValue: true
1025
1145
  });
@@ -1032,8 +1152,8 @@ var ChromeCdpBrowserController = class {
1032
1152
  });
1033
1153
  if (payload.action === "goForward") return await this.withRetry(targetWsUrl, async (conn) => {
1034
1154
  await this.ensureEnabled(targetWsUrl);
1035
- const navigatedPromise = conn.waitForEvent("Page.frameNavigated", 3e3).catch(() => void 0);
1036
- await conn.send("Runtime.evaluate", {
1155
+ const navigatedPromise = (conn.waitForEventMatching ? conn.waitForEventMatching("Page.frameNavigated", (params) => !params.frame?.parentId, 3e3) : conn.waitForEvent("Page.frameNavigated", 3e3)).catch(() => void 0);
1156
+ await evaluateOnPage(conn, {
1037
1157
  expression: "history.forward()",
1038
1158
  returnByValue: true
1039
1159
  });
@@ -1083,7 +1203,7 @@ var ChromeCdpBrowserController = class {
1083
1203
  }
1084
1204
  if (await this.prepareTypeTarget(conn, payload) === "ready") await conn.send("Input.insertText", { text: payload.text ?? "" });
1085
1205
  return "typed";
1086
- });
1206
+ }, "connection");
1087
1207
  const expression = buildLocatorRuntimeExpression("payload", payload, `
1088
1208
  if (payload.action === 'waitFor') {
1089
1209
  const timeout = payload.timeoutMs ?? 2000;
@@ -1131,12 +1251,12 @@ var ChromeCdpBrowserController = class {
1131
1251
  `);
1132
1252
  return await this.withRetry(targetWsUrl, async (conn) => {
1133
1253
  await this.ensureEnabled(targetWsUrl);
1134
- return (await conn.send("Runtime.evaluate", {
1254
+ return (await evaluateOnPage(conn, {
1135
1255
  expression,
1136
1256
  returnByValue: true,
1137
1257
  awaitPromise: true
1138
1258
  })).result.value ?? "";
1139
- });
1259
+ }, "connection");
1140
1260
  }
1141
1261
  async screenshot(targetWsUrl, options) {
1142
1262
  return await this.withRetry(targetWsUrl, async (conn) => {
@@ -1155,6 +1275,7 @@ var ChromeCdpBrowserController = class {
1155
1275
  height: metrics.contentSize.height,
1156
1276
  scale: 1
1157
1277
  };
1278
+ params.captureBeyondViewport = true;
1158
1279
  }
1159
1280
  return {
1160
1281
  data: (await conn.send("Page.captureScreenshot", params)).data,
@@ -1164,7 +1285,7 @@ var ChromeCdpBrowserController = class {
1164
1285
  }
1165
1286
  async getCurrentUrl(targetWsUrl) {
1166
1287
  return await this.withRetry(targetWsUrl, async (conn) => {
1167
- return (await conn.send("Runtime.evaluate", {
1288
+ return (await evaluateOnPage(conn, {
1168
1289
  expression: "window.location.href",
1169
1290
  returnByValue: true
1170
1291
  })).result.value ?? "";
@@ -1212,7 +1333,7 @@ var ChromeCdpBrowserController = class {
1212
1333
  `);
1213
1334
  return await this.withRetry(targetWsUrl, async (conn) => {
1214
1335
  await this.ensureEnabled(targetWsUrl);
1215
- const limited = normalizeRuntimeContentPayload((await conn.send("Runtime.evaluate", {
1336
+ const limited = normalizeRuntimeContentPayload((await evaluateOnPage(conn, {
1216
1337
  expression,
1217
1338
  returnByValue: true,
1218
1339
  awaitPromise: true
@@ -1449,7 +1570,7 @@ var ChromeCdpBrowserController = class {
1449
1570
  `);
1450
1571
  const pageSummary = await this.withRetry(targetWsUrl, async (conn) => {
1451
1572
  await this.ensureEnabled(targetWsUrl);
1452
- return (await conn.send("Runtime.evaluate", {
1573
+ return (await evaluateOnPage(conn, {
1453
1574
  expression,
1454
1575
  returnByValue: true,
1455
1576
  awaitPromise: true
@@ -1521,10 +1642,11 @@ var ChromeCdpBrowserController = class {
1521
1642
  let nodes;
1522
1643
  if (selector) {
1523
1644
  const objectGroup = "agentic-browser-a11y";
1524
- const selected = await conn.send("Runtime.evaluate", {
1645
+ const selected = await evaluateOnPage(conn, {
1525
1646
  expression: buildLocatorRuntimeExpression("selector", selector, `
1526
1647
  return resolveLocator(selector);
1527
1648
  `),
1649
+ awaitPromise: true,
1528
1650
  objectGroup
1529
1651
  });
1530
1652
  const objectId = selected.result.objectId;
@@ -1915,10 +2037,12 @@ var ChromeCdpBrowserController = class {
1915
2037
  };
1916
2038
  return await this.withRetry(targetWsUrl, async (conn) => {
1917
2039
  await this.ensureEnabled(targetWsUrl);
1918
- return extract(await conn.send("Runtime.evaluate", {
2040
+ const result = await evaluateOnPage(conn, {
1919
2041
  expression,
1920
- returnByValue: true
1921
- }));
2042
+ returnByValue: true,
2043
+ awaitPromise: true
2044
+ });
2045
+ return extract(result);
1922
2046
  });
1923
2047
  }
1924
2048
  /**
@@ -2143,6 +2267,28 @@ var ChromeCdpBrowserController = class {
2143
2267
  }
2144
2268
  return results;
2145
2269
  }
2270
+ // Only click buttons that live inside something that looks like a consent
2271
+ // banner: a dialog/modal, an element with consent-ish id/class, or a
2272
+ // fixed/sticky overlay — AND whose container text actually talks about
2273
+ // cookies/consent. Without this the loose text patterns above would click
2274
+ // arbitrary page buttons ("Accept invitation", "I agree" form submits).
2275
+ const containerTextPattern = /cookie|consent|privacy|gdpr|datenschutz|einwillig|zustimm|tracking|personal data|persönliche daten|données personnelles|datos personales/i;
2276
+ function findBannerContainer(el) {
2277
+ let node = el;
2278
+ let depth = 0;
2279
+ while (node && node.nodeType === 1 && depth < 20) {
2280
+ const role = (node.getAttribute('role') || '').toLowerCase();
2281
+ if (role === 'dialog' || role === 'alertdialog') return node;
2282
+ if (node.getAttribute('aria-modal') === 'true') return node;
2283
+ const idClass = (node.id + ' ' + (typeof node.className === 'string' ? node.className : '')).toLowerCase();
2284
+ if (/cookie|consent|gdpr|cmp|privacy|datenschutz/.test(idClass)) return node;
2285
+ const style = getComputedStyle(node);
2286
+ if (style.position === 'fixed' || style.position === 'sticky') return node;
2287
+ node = node.parentElement || (node.getRootNode && node.getRootNode().host) || null;
2288
+ depth++;
2289
+ }
2290
+ return null;
2291
+ }
2146
2292
  const candidates = collectButtons(document);
2147
2293
  for (const el of candidates) {
2148
2294
  if (!isVisible(el)) continue;
@@ -2150,6 +2296,10 @@ var ChromeCdpBrowserController = class {
2150
2296
  if (text.length > 60) continue;
2151
2297
  for (const pat of patterns) {
2152
2298
  if (pat.test(text)) {
2299
+ const container = findBannerContainer(el);
2300
+ if (!container) break;
2301
+ const containerText = (container.textContent || '').slice(0, 4000);
2302
+ if (!containerTextPattern.test(containerText)) break;
2153
2303
  el.click();
2154
2304
  return JSON.stringify({ dismissed: true, method: 'text', detail: text });
2155
2305
  }
@@ -2160,7 +2310,7 @@ var ChromeCdpBrowserController = class {
2160
2310
  })()`;
2161
2311
  return await this.withRetry(targetWsUrl, async (conn) => {
2162
2312
  await this.ensureEnabled(targetWsUrl);
2163
- const raw = (await conn.send("Runtime.evaluate", {
2313
+ const raw = (await evaluateOnPage(conn, {
2164
2314
  expression,
2165
2315
  returnByValue: true
2166
2316
  })).result.value ?? "{\"dismissed\":false}";
@@ -2244,7 +2394,7 @@ var ChromeCdpBrowserController = class {
2244
2394
  })()`;
2245
2395
  return await this.withRetry(targetWsUrl, async (conn) => {
2246
2396
  await this.ensureEnabled(targetWsUrl);
2247
- const raw = (await conn.send("Runtime.evaluate", {
2397
+ const raw = (await evaluateOnPage(conn, {
2248
2398
  expression,
2249
2399
  returnByValue: true
2250
2400
  })).result.value ?? "{\"dismissed\":false}";
@@ -2265,14 +2415,21 @@ var ChromeCdpBrowserController = class {
2265
2415
  await conn.send("Accessibility.enable");
2266
2416
  await conn.send("Page.enable");
2267
2417
  const { nodes } = await conn.send("Accessibility.getFullAXTree");
2268
- const allNodes = [...nodes];
2418
+ const nodeSets = [{
2419
+ nodes,
2420
+ trusted: false
2421
+ }];
2269
2422
  try {
2270
2423
  const { frameTree } = await conn.send("Page.getFrameTree");
2271
2424
  for (const child of frameTree.childFrames ?? []) {
2272
2425
  const url = child.frame.url ?? "";
2273
- if (/consent|cookie|privacy|sourcepoint|didomi|cmp|gdpr|trustarc|consentmanager|sp_message|onetrust|usercentrics|consent-cdn/i.test(url) || url === "about:blank") try {
2426
+ const consentFrame = /consent|cookie|privacy|sourcepoint|didomi|cmp|gdpr|trustarc|consentmanager|sp_message|onetrust|usercentrics|consent-cdn/i.test(url);
2427
+ if (consentFrame || url === "about:blank") try {
2274
2428
  const frameResult = await conn.send("Accessibility.getFullAXTree", { frameId: child.frame.id });
2275
- allNodes.push(...frameResult.nodes);
2429
+ nodeSets.push({
2430
+ nodes: frameResult.nodes,
2431
+ trusted: consentFrame
2432
+ });
2276
2433
  } catch {}
2277
2434
  }
2278
2435
  } catch {}
@@ -2309,21 +2466,38 @@ var ChromeCdpBrowserController = class {
2309
2466
  /accepter(\s+alle)?/i,
2310
2467
  /aksepter(\s+alle)?/i
2311
2468
  ];
2469
+ const consentAncestorName = /cookie|consent|privacy|gdpr|datenschutz|einwillig|zustimm/i;
2470
+ const hasConsentAncestor = (byId, node) => {
2471
+ let current = node.parentId ? byId.get(node.parentId) : void 0;
2472
+ let depth = 0;
2473
+ while (current && depth < 50) {
2474
+ const role = current.role?.value;
2475
+ if (role === "dialog" || role === "alertdialog") return true;
2476
+ const name = current.name?.value;
2477
+ if (name && consentAncestorName.test(name)) return true;
2478
+ current = current.parentId ? byId.get(current.parentId) : void 0;
2479
+ depth++;
2480
+ }
2481
+ return false;
2482
+ };
2312
2483
  const candidates = [];
2313
- for (const node of allNodes) {
2314
- if (node.ignored) continue;
2315
- const role = node.role?.value;
2316
- if (role !== "button" && role !== "link") continue;
2317
- const name = node.name?.value?.trim();
2318
- if (!name || name.length > 50) continue;
2319
- if (node.properties?.some((p) => p.name === "disabled" && p.value.value === true)) continue;
2320
- for (const pat of consentPatterns) if (pat.test(name) && node.backendDOMNodeId) {
2321
- candidates.push({
2322
- nodeId: node.nodeId,
2323
- backendDOMNodeId: node.backendDOMNodeId,
2324
- name
2325
- });
2326
- break;
2484
+ for (const { nodes: setNodes, trusted } of nodeSets) {
2485
+ const byId = new Map(setNodes.map((n) => [n.nodeId, n]));
2486
+ for (const node of setNodes) {
2487
+ if (node.ignored) continue;
2488
+ const role = node.role?.value;
2489
+ if (role !== "button" && role !== "link") continue;
2490
+ const name = node.name?.value?.trim();
2491
+ if (!name || name.length > 50) continue;
2492
+ if (node.properties?.some((p) => p.name === "disabled" && p.value.value === true)) continue;
2493
+ for (const pat of consentPatterns) if (pat.test(name) && node.backendDOMNodeId) {
2494
+ if (trusted || hasConsentAncestor(byId, node)) candidates.push({
2495
+ nodeId: node.nodeId,
2496
+ backendDOMNodeId: node.backendDOMNodeId,
2497
+ name
2498
+ });
2499
+ break;
2500
+ }
2327
2501
  }
2328
2502
  }
2329
2503
  if (candidates.length === 0) return { dismissed: false };
@@ -2467,10 +2641,116 @@ var MockBrowserController = class {
2467
2641
  closeConnection(_targetWsUrl) {}
2468
2642
  };
2469
2643
  //#endregion
2644
+ //#region src/lib/domain-schemas.ts
2645
+ const InteractActionSchema = z.enum([
2646
+ "click",
2647
+ "type",
2648
+ "press",
2649
+ "waitFor",
2650
+ "evaluate",
2651
+ "scroll",
2652
+ "hover",
2653
+ "select",
2654
+ "toggle",
2655
+ "goBack",
2656
+ "goForward",
2657
+ "refresh",
2658
+ "dialog"
2659
+ ]);
2660
+ const InteractPayloadSchema = z.object({
2661
+ action: InteractActionSchema,
2662
+ selector: z.string().optional(),
2663
+ fallbackSelectors: z.array(z.string()).optional(),
2664
+ text: z.string().optional(),
2665
+ key: z.string().optional(),
2666
+ value: z.string().optional(),
2667
+ scrollX: z.number().optional(),
2668
+ scrollY: z.number().optional(),
2669
+ timeoutMs: z.number().optional()
2670
+ });
2671
+ const SessionStatusSchema = z.enum([
2672
+ "starting",
2673
+ "ready",
2674
+ "disconnected",
2675
+ "failed",
2676
+ "terminated"
2677
+ ]);
2678
+ const CommandTypeSchema = z.enum([
2679
+ "navigate",
2680
+ "interact",
2681
+ "restart",
2682
+ "terminate"
2683
+ ]);
2684
+ const ResultStatusSchema = z.enum([
2685
+ "success",
2686
+ "failed",
2687
+ "timed_out"
2688
+ ]);
2689
+ const ConnectionStateSchema = z.enum([
2690
+ "connecting",
2691
+ "ready",
2692
+ "disconnected",
2693
+ "failed"
2694
+ ]);
2695
+ const EventCategorySchema = z.enum([
2696
+ "lifecycle",
2697
+ "security",
2698
+ "command"
2699
+ ]);
2700
+ const EventSeveritySchema = z.enum([
2701
+ "info",
2702
+ "warning",
2703
+ "error"
2704
+ ]);
2705
+ z.object({
2706
+ sessionId: z.string().min(1),
2707
+ status: SessionStatusSchema,
2708
+ browserType: z.literal("chrome"),
2709
+ startedAt: z.string().datetime(),
2710
+ endedAt: z.string().datetime().optional(),
2711
+ authTokenRef: z.string().min(1)
2712
+ }).refine((session) => session.status === "terminated" ? Boolean(session.endedAt) : true, {
2713
+ message: "endedAt is required for terminated sessions",
2714
+ path: ["endedAt"]
2715
+ });
2716
+ z.object({
2717
+ commandId: z.string().min(1),
2718
+ sessionId: z.string().min(1),
2719
+ type: CommandTypeSchema,
2720
+ payload: z.record(z.string(), z.unknown()),
2721
+ submittedAt: z.string().datetime(),
2722
+ completedAt: z.string().datetime().optional(),
2723
+ resultStatus: ResultStatusSchema.optional(),
2724
+ resultMessage: z.string().optional()
2725
+ }).refine((command) => command.completedAt ? Boolean(command.resultStatus) : true, {
2726
+ message: "resultStatus is required when completedAt is set",
2727
+ path: ["resultStatus"]
2728
+ });
2729
+ z.object({
2730
+ sessionId: z.string().min(1),
2731
+ state: ConnectionStateSchema,
2732
+ lastHeartbeatAt: z.string().datetime().optional(),
2733
+ disconnectReason: z.string().optional()
2734
+ });
2735
+ z.object({
2736
+ sessionId: z.string().min(1),
2737
+ tokenId: z.string().min(1),
2738
+ issuedAt: z.string().datetime(),
2739
+ expiresAt: z.string().datetime().optional(),
2740
+ revokedAt: z.string().datetime().optional()
2741
+ });
2742
+ z.object({
2743
+ eventId: z.string().min(1),
2744
+ sessionId: z.string().min(1),
2745
+ category: EventCategorySchema,
2746
+ message: z.string().min(1),
2747
+ createdAt: z.string().datetime(),
2748
+ severity: EventSeveritySchema
2749
+ });
2750
+ //#endregion
2470
2751
  //#region src/session/session-store.ts
2471
2752
  var SessionStore = class {
2472
2753
  filePath;
2473
- cache = null;
2474
2754
  constructor(baseDir) {
2475
2755
  fs.mkdirSync(baseDir, { recursive: true });
2476
2756
  this.filePath = path.join(baseDir, "sessions.json");
@@ -2538,25 +2818,35 @@ var SessionStore = class {
2538
2818
  this.write(state);
2539
2819
  }
2540
2820
  read() {
2541
- if (this.cache) return this.cache;
2821
+ let raw;
2822
+ try {
2823
+ raw = fs.readFileSync(this.filePath, "utf8");
2824
+ } catch (error) {
2825
+ if (error.code === "ENOENT") return { sessions: {} };
2826
+ throw error;
2827
+ }
2542
2828
  try {
2543
- const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
2544
- this.cache = parsed;
2545
- return parsed;
2829
+ return JSON.parse(raw);
2546
2830
  } catch {
2831
+ try {
2832
+ fs.copyFileSync(this.filePath, `${this.filePath}.corrupt`);
2833
+ } catch {}
2547
2834
  const empty = { sessions: {} };
2548
2835
  this.write(empty);
2549
2836
  return empty;
2550
2837
  }
2551
2838
  }
2552
2839
  write(state) {
2553
- this.cache = state;
2554
- fs.writeFileSync(this.filePath, JSON.stringify(state, null, 2), "utf8");
2840
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
2841
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
2842
+ fs.renameSync(tmp, this.filePath);
2555
2843
  }
2556
2844
  };
2557
2845
  //#endregion
2558
2846
  //#region src/session/session-manager.ts
2559
2847
  var SessionManager = class {
2848
+ ctx;
2849
+ browser;
2560
2850
  store;
2561
2851
  constructor(ctx, browser) {
2562
2852
  this.ctx = ctx;
@@ -2610,7 +2900,7 @@ var SessionManager = class {
2610
2900
  return this.mustGetRecord(recovered.sessionId);
2611
2901
  } catch (restartError) {
2612
2902
  this.recordEvent(sessionId, "lifecycle", "error", `Recovery failed: ${restartError.message}`);
2613
- throw new Error(`Session is not ready and recovery failed: ${restartError.message}`);
2903
+ throw new Error(`Session is not ready and recovery failed: ${restartError.message}`, { cause: restartError });
2614
2904
  }
2615
2905
  throw new Error("Session is terminated. Start a new session.");
2616
2906
  }
@@ -2639,14 +2929,17 @@ var SessionManager = class {
2639
2929
  const finalUrl = await this.browser.navigate(record.targetWsUrl, url);
2640
2930
  this.store.setLastUrl(sessionId, finalUrl);
2641
2931
  resultMessage = `Navigated to ${finalUrl}`;
2642
- } else if (input.type === "interact") resultMessage = `Interaction result: ${await this.browser.interact(record.targetWsUrl, input.payload)}`;
2643
- else if (input.type === "restart") {
2932
+ } else if (input.type === "interact") {
2933
+ const parsedPayload = InteractPayloadSchema.safeParse(input.payload);
2934
+ if (!parsedPayload.success) throw new Error(`Invalid interact payload: ${parsedPayload.error.message}`);
2935
+ resultMessage = `Interaction result: ${await this.browser.interact(record.targetWsUrl, parsedPayload.data)}`;
2936
+ } else if (input.type === "restart") {
2644
2937
  await this.restartSession(sessionId);
2645
2938
  resultMessage = "Session restarted";
2646
2939
  } else if (input.type === "terminate") {
2647
2940
  await this.terminateSession(sessionId);
2648
2941
  resultMessage = "Session terminated";
2649
- }
2942
+ } else throw new Error(`Unsupported command type: ${String(input.type)}`);
2650
2943
  } catch (error) {
2651
2944
  resultStatus = "failed";
2652
2945
  resultMessage = error.message;
@@ -2749,6 +3042,7 @@ var SessionManager = class {
2749
3042
  }
2750
3043
  async restartSession(sessionId) {
2751
3044
  const record = this.mustGetRecord(sessionId);
3045
+ if (record.session.status === "terminated") throw new Error("Session is terminated. Start a new session.");
2752
3046
  if (this.browser.closeConnection) try {
2753
3047
  this.browser.closeConnection(record.targetWsUrl);
2754
3048
  } catch {}
@@ -2780,6 +3074,7 @@ var SessionManager = class {
2780
3074
  }
2781
3075
  rotateAuthToken(sessionId) {
2782
3076
  const record = this.mustGetRecord(sessionId);
3077
+ if (record.session.status === "terminated") throw new Error("Session is terminated. Start a new session.");
2783
3078
  this.ctx.tokenService.revoke(sessionId);
2784
3079
  const nextToken = this.ctx.tokenService.issue(sessionId);
2785
3080
  const updated = {
@@ -2891,8 +3186,10 @@ var SessionManager = class {
2891
3186
  const record = this.store.get(sessionId);
2892
3187
  if (!record) throw new Error("Session not found");
2893
3188
  if (!record.targetWsUrl) throw new Error("Session target is missing. Restart the session.");
2894
- const seeded = this.ctx.tokenService.get(sessionId);
2895
- if (!seeded || seeded !== record.session.authTokenRef) this.ctx.tokenService.seed(sessionId, record.session.authTokenRef);
3189
+ if (record.session.status !== "terminated") {
3190
+ const seeded = this.ctx.tokenService.get(sessionId);
3191
+ if (!seeded || seeded !== record.session.authTokenRef) this.ctx.tokenService.seed(sessionId, record.session.authTokenRef);
3192
+ }
2896
3193
  return record;
2897
3194
  }
2898
3195
  recordEvent(sessionId, category, severity, message) {
@@ -2952,9 +3249,9 @@ var SessionManager = class {
2952
3249
  //#endregion
2953
3250
  //#region src/transport/control-api.ts
2954
3251
  var ControlApi = class {
2955
- constructor(sessions, eventStore) {
3252
+ sessions;
3253
+ constructor(sessions) {
2956
3254
  this.sessions = sessions;
2957
- this.eventStore = eventStore;
2958
3255
  }
2959
3256
  async createSession(input) {
2960
3257
  return await this.sessions.createSession(input);
@@ -2990,7 +3287,7 @@ var ControlApi = class {
2990
3287
  return await this.sessions.getCurrentUrl(sessionId);
2991
3288
  }
2992
3289
  listEvents(sessionId, limit = 100) {
2993
- return { events: this.eventStore.list(sessionId, limit) };
3290
+ return { events: this.sessions.listEvents(sessionId, limit) };
2994
3291
  }
2995
3292
  searchMemory(input) {
2996
3293
  return { results: this.sessions.searchMemory(input) };
@@ -3010,21 +3307,12 @@ var ControlApi = class {
3010
3307
  };
3011
3308
  //#endregion
3012
3309
  //#region src/lib/config.ts
3013
- const DEFAULT_PORT = 43111;
3014
- const DEFAULT_TIMEOUT_MS = 2e3;
3015
3310
  function loadConfig(env = process.env) {
3016
- const wsPort = Number.parseInt(env.AGENTIC_BROWSER_WS_PORT ?? `${DEFAULT_PORT}`, 10);
3017
- const commandTimeoutMs = Number.parseInt(env.AGENTIC_BROWSER_COMMAND_TIMEOUT_MS ?? `${DEFAULT_TIMEOUT_MS}`, 10);
3018
- if (Number.isNaN(wsPort) || wsPort <= 0) throw new Error("AGENTIC_BROWSER_WS_PORT must be a positive integer");
3019
- if (Number.isNaN(commandTimeoutMs) || commandTimeoutMs <= 0) throw new Error("AGENTIC_BROWSER_COMMAND_TIMEOUT_MS must be a positive integer");
3020
3311
  const userProfile = env.AGENTIC_BROWSER_USER_PROFILE;
3021
3312
  let userProfileDir;
3022
3313
  if (userProfile === "true" || userProfile === "default") userProfileDir = "default";
3023
3314
  else if (userProfile && path.isAbsolute(userProfile)) userProfileDir = userProfile;
3024
3315
  return {
3025
- host: env.AGENTIC_BROWSER_HOST ?? "127.0.0.1",
3026
- wsPort,
3027
- commandTimeoutMs,
3028
3316
  dataDir: env.AGENTIC_BROWSER_DIR ?? path.join(os.homedir(), ".agentic-browser"),
3029
3317
  browserExecutablePath: env.AGENTIC_BROWSER_CHROME_EXECUTABLE_PATH ?? env.AGENTIC_BROWSER_CHROME_PATH,
3030
3318
  cdpUrl: env.AGENTIC_BROWSER_CDP_URL,
@@ -3036,6 +3324,7 @@ function loadConfig(env = process.env) {
3036
3324
  //#endregion
3037
3325
  //#region src/observability/logger.ts
3038
3326
  var Logger = class {
3327
+ namespace;
3039
3328
  constructor(namespace) {
3040
3329
  this.namespace = namespace;
3041
3330
  }
@@ -3072,17 +3361,28 @@ var EventStore = class {
3072
3361
  filePath;
3073
3362
  bufferedLines = [];
3074
3363
  flushTimer;
3364
+ pendingFlushDelayMs = Number.POSITIVE_INFINITY;
3075
3365
  flushInFlight;
3076
3366
  constructor(baseDir) {
3077
3367
  fs.mkdirSync(baseDir, { recursive: true });
3078
3368
  this.filePath = path.join(baseDir, "session-events.log");
3079
- process.once("beforeExit", this.flushSync);
3080
- process.once("exit", this.flushSync);
3369
+ process.on("beforeExit", this.flushSync);
3370
+ process.on("exit", this.flushSync);
3371
+ }
3372
+ /**
3373
+ * Remove the process exit handlers and flush any buffered lines.
3374
+ * Call when discarding a store (repeated context creation in tests/embedders
3375
+ * would otherwise leak a listener pair per instance).
3376
+ */
3377
+ dispose() {
3378
+ process.off("beforeExit", this.flushSync);
3379
+ process.off("exit", this.flushSync);
3380
+ this.flushSync();
3081
3381
  }
3082
3382
  append(event) {
3083
3383
  let existing = this.events.get(event.sessionId) ?? [];
3084
3384
  existing.push(event);
3085
- if (existing.length > MAX_EVENTS_PER_SESSION) existing = existing.slice(-MAX_EVENTS_PER_SESSION);
3385
+ if (existing.length > MAX_EVENTS_PER_SESSION) existing = existing.slice(-500);
3086
3386
  this.events.set(event.sessionId, existing);
3087
3387
  this.bufferedLines.push(`${JSON.stringify(event)}\n`);
3088
3388
  if (this.bufferedLines.length >= 20) {
@@ -3100,7 +3400,12 @@ var EventStore = class {
3100
3400
  }
3101
3401
  flushSoon(delayMs) {
3102
3402
  if (this.flushInFlight) return this.flushInFlight;
3103
- if (this.flushTimer) return Promise.resolve();
3403
+ if (this.flushTimer) {
3404
+ if (delayMs >= this.pendingFlushDelayMs) return Promise.resolve();
3405
+ clearTimeout(this.flushTimer);
3406
+ this.flushTimer = void 0;
3407
+ }
3408
+ this.pendingFlushDelayMs = delayMs;
3104
3409
  this.flushTimer = setTimeout(() => {
3105
3410
  this.flushTimer = void 0;
3106
3411
  this.flushAsync();
@@ -3157,40 +3462,6 @@ var SessionTokenService = class {
3157
3462
  }
3158
3463
  };
3159
3464
  //#endregion
3160
- //#region src/transport/ws-server.ts
3161
- var AuthenticatedWsServer = class {
3162
- logger = new Logger("ws-server");
3163
- server;
3164
- constructor(options) {
3165
- this.options = options;
3166
- }
3167
- start(onMessage) {
3168
- this.server = new WebSocketServer({
3169
- host: this.options.host,
3170
- port: this.options.port
3171
- });
3172
- this.server.on("connection", (socket, request) => {
3173
- const url = new URL(request.url ?? "/", `http://${this.options.host}:${this.options.port}`);
3174
- const sessionId = url.searchParams.get("sessionId");
3175
- const token = url.searchParams.get("token");
3176
- if (!sessionId || !token || !this.options.tokenService.validate(sessionId, token)) {
3177
- this.logger.warning("Rejected websocket client", { sessionId });
3178
- socket.close(4401, "Unauthorized");
3179
- return;
3180
- }
3181
- socket.on("message", (payload) => onMessage(socket, payload.toString("utf8")));
3182
- });
3183
- this.logger.info("WebSocket server started", {
3184
- host: this.options.host,
3185
- port: this.options.port
3186
- });
3187
- }
3188
- stop() {
3189
- this.server?.close();
3190
- this.server = void 0;
3191
- }
3192
- };
3193
- //#endregion
3194
3465
  //#region src/memory/memory-index.ts
3195
3466
  function normalize(value) {
3196
3467
  return value.trim().toLowerCase();
@@ -3215,7 +3486,7 @@ function buildSelectorHints(insight) {
3215
3486
  if (!evidence.selector || evidence.result !== "success") continue;
3216
3487
  weightedSelectors.set(evidence.selector, (weightedSelectors.get(evidence.selector) ?? 0) + 3);
3217
3488
  }
3218
- return [...weightedSelectors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([selector]) => selector);
3489
+ return [...weightedSelectors.entries()].toSorted((a, b) => b[1] - a[1]).slice(0, 5).map(([selector]) => selector);
3219
3490
  }
3220
3491
  function buildSelectorAliases(insight) {
3221
3492
  return insight.selectorAliases ?? [];
@@ -3231,10 +3502,11 @@ function scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup
3231
3502
  const insightIntent = normalize(insight.taskIntent);
3232
3503
  const intentMatch = insightIntent === normalizedIntent ? 1 : 0;
3233
3504
  const intentPartial = intentMatch === 1 || insightIntent.includes(normalizedIntent) || normalizedIntent.includes(insightIntent) ? .65 : 0;
3505
+ const intentSignal = Math.max(intentMatch, intentPartial);
3234
3506
  const domainMatch = normalizedDomain && normalize(insight.siteDomain) === normalizedDomain ? 1 : normalizedDomain ? 0 : .6;
3235
3507
  const reliability = .6 * confidenceFromCounts(insight.successCount, insight.failureCount) + .4 * freshnessWeight(insight.freshness);
3236
3508
  const selectorQuality = selectorSignal(insight);
3237
- const score = .5 * Math.max(intentMatch, intentPartial) + .2 * domainMatch + .15 * reliability + .15 * selectorQuality;
3509
+ const score = .5 * intentSignal + .2 * domainMatch + .15 * reliability + .15 * selectorQuality;
3238
3510
  const result = {
3239
3511
  insightId: insight.insightId,
3240
3512
  taskIntent: insight.taskIntent,
@@ -3253,7 +3525,10 @@ function scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup
3253
3525
  cookieBanner: profile.cookieBanner,
3254
3526
  visitCount: profile.visitCount
3255
3527
  };
3256
- return result;
3528
+ return {
3529
+ result,
3530
+ intentSignal
3531
+ };
3257
3532
  }
3258
3533
  var MemoryIndex = class {
3259
3534
  /** Domain → insights index, rebuilt lazily when insight list changes. */
@@ -3280,18 +3555,20 @@ var MemoryIndex = class {
3280
3555
  const limit = input.limit ?? 10;
3281
3556
  this.ensureIndex(insights);
3282
3557
  const candidates = normalizedDomain ? this.domainIndex.get(normalizedDomain) ?? [] : insights;
3558
+ const relevant = (scored) => scored.intentSignal > 0;
3283
3559
  if (limit === 1 && normalizedDomain) {
3284
3560
  let best;
3285
3561
  for (const insight of candidates) {
3286
- const result = scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup);
3287
- if (result.score > 0 && (!best || result.score > best.score)) {
3288
- best = result;
3289
- if (best.score >= .95) break;
3562
+ const scored = scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup);
3563
+ if (!relevant(scored)) continue;
3564
+ if (!best || scored.result.score > best.result.score) {
3565
+ best = scored;
3566
+ if (best.result.score >= 1) break;
3290
3567
  }
3291
3568
  }
3292
- return best ? [best] : [];
3569
+ return best ? [best.result] : [];
3293
3570
  }
3294
- return candidates.map((insight) => scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup)).filter((result) => result.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
3571
+ return candidates.map((insight) => scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup)).filter(relevant).toSorted((a, b) => b.result.score - a.result.score).slice(0, limit).map((scored) => scored.result);
3295
3572
  }
3296
3573
  };
3297
3574
  //#endregion
@@ -3458,11 +3735,15 @@ var SiteProfileStore = class {
3458
3735
  return this.getCache().find((p) => p.domain === normalized);
3459
3736
  }
3460
3737
  upsert(profile) {
3461
- SiteProfileSchema.parse(profile);
3738
+ const normalized = {
3739
+ ...profile,
3740
+ domain: profile.domain.toLowerCase()
3741
+ };
3742
+ SiteProfileSchema.parse(normalized);
3462
3743
  const profiles = this.getCache();
3463
- const index = profiles.findIndex((p) => p.domain === profile.domain);
3464
- if (index >= 0) profiles[index] = profile;
3465
- else profiles.push(profile);
3744
+ const index = profiles.findIndex((p) => p.domain === normalized.domain);
3745
+ if (index >= 0) profiles[index] = normalized;
3746
+ else profiles.push(normalized);
3466
3747
  this.markDirty();
3467
3748
  }
3468
3749
  flushSync() {
@@ -3477,7 +3758,8 @@ var SiteProfileStore = class {
3477
3758
  }
3478
3759
  getCache() {
3479
3760
  if (this.cached) return this.cached;
3480
- this.cached = this.readDisk().profiles;
3761
+ const state = this.readDisk();
3762
+ this.cached = state.profiles;
3481
3763
  return this.cached;
3482
3764
  }
3483
3765
  markDirty() {
@@ -3500,7 +3782,7 @@ var SiteProfileStore = class {
3500
3782
  } catch {
3501
3783
  const obj = raw;
3502
3784
  if (Array.isArray(obj?.profiles)) {
3503
- const salvaged = obj.profiles.filter((item) => SiteProfileSchema.safeParse(item).success);
3785
+ const salvaged = obj.profiles.map((item) => SiteProfileSchema.safeParse(item)).filter((result) => result.success).map((result) => result.data);
3504
3786
  if (salvaged.length > 0) {
3505
3787
  const state = { profiles: salvaged };
3506
3788
  this.backupAndReset();
@@ -3580,6 +3862,15 @@ var TaskInsightStore = class {
3580
3862
  else insights.push(insight);
3581
3863
  this.markDirty();
3582
3864
  }
3865
+ /** Remove an insight by id. Returns true if a record was removed. */
3866
+ remove(insightId) {
3867
+ const insights = this.getCache();
3868
+ const index = insights.findIndex((entry) => entry.insightId === insightId);
3869
+ if (index < 0) return false;
3870
+ insights.splice(index, 1);
3871
+ this.markDirty();
3872
+ return true;
3873
+ }
3583
3874
  replaceMany(insights) {
3584
3875
  for (const insight of insights) TaskInsightSchema.parse(insight);
3585
3876
  this.cached = insights;
@@ -3599,7 +3890,8 @@ var TaskInsightStore = class {
3599
3890
  /** Return the in-memory cache, loading from disk on first access. */
3600
3891
  getCache() {
3601
3892
  if (this.cached) return this.cached;
3602
- this.cached = this.readDisk().insights;
3893
+ const state = this.readDisk();
3894
+ this.cached = state.insights;
3603
3895
  return this.cached;
3604
3896
  }
3605
3897
  markDirty() {
@@ -3623,7 +3915,7 @@ var TaskInsightStore = class {
3623
3915
  } catch {
3624
3916
  const obj = raw;
3625
3917
  if (Array.isArray(obj?.insights)) {
3626
- const salvaged = obj.insights.filter((item) => TaskInsightSchema.safeParse(item).success);
3918
+ const salvaged = obj.insights.map((item) => TaskInsightSchema.safeParse(item)).filter((result) => result.success).map((result) => result.data);
3627
3919
  if (salvaged.length > 0) {
3628
3920
  const state = { insights: salvaged };
3629
3921
  this.backupAndReset();
@@ -3668,10 +3960,17 @@ var TaskInsightStore = class {
3668
3960
  };
3669
3961
  //#endregion
3670
3962
  //#region src/memory/memory-service.ts
3963
+ /** Memory service configuration */
3671
3964
  const SEARCH_CACHE_TTL_MS = 2e3;
3965
+ const MAX_SELECTOR_ALIASES = 10;
3966
+ /** Site profile limits */
3672
3967
  const MAX_NAVIGATION_PATHS = 20;
3673
3968
  const MAX_LAYOUT_FINGERPRINTS = 10;
3674
3969
  const MAX_SELECTOR_PATTERNS = 15;
3970
+ function normalizePathSegment(value) {
3971
+ const [firstSegment = ""] = value.replace(/\/\*$/, "").split("/").filter(Boolean);
3972
+ return firstSegment.toLowerCase();
3973
+ }
3675
3974
  var MemoryService = class {
3676
3975
  store;
3677
3976
  profileStore;
@@ -3729,7 +4028,7 @@ var MemoryService = class {
3729
4028
  fresh: insights.filter((x) => x.freshness === "fresh").length,
3730
4029
  suspect: insights.filter((x) => x.freshness === "suspect").length,
3731
4030
  stale: insights.filter((x) => x.freshness === "stale").length,
3732
- topDomains: [...byDomain.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({
4031
+ topDomains: [...byDomain.entries()].toSorted((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({
3733
4032
  domain,
3734
4033
  count
3735
4034
  }))
@@ -3740,6 +4039,8 @@ var MemoryService = class {
3740
4039
  const now = (/* @__PURE__ */ new Date()).toISOString();
3741
4040
  const verified = {
3742
4041
  ...insight,
4042
+ freshness: "fresh",
4043
+ staleStrikeCount: 0,
3743
4044
  lastVerifiedAt: now,
3744
4045
  updatedAt: now
3745
4046
  };
@@ -3797,6 +4098,7 @@ var MemoryService = class {
3797
4098
  };
3798
4099
  versioned = this.maybeGenerateAliases(versioned);
3799
4100
  this.store.upsert(versioned);
4101
+ this.store.remove(matched.insightId);
3800
4102
  this.invalidateSearchCache();
3801
4103
  return versioned;
3802
4104
  }
@@ -3837,7 +4139,7 @@ var MemoryService = class {
3837
4139
  if (fpIndex >= 0) profile.layoutFingerprints[fpIndex] = fingerprint;
3838
4140
  else {
3839
4141
  profile.layoutFingerprints.push(fingerprint);
3840
- if (profile.layoutFingerprints.length > MAX_LAYOUT_FINGERPRINTS) profile.layoutFingerprints = profile.layoutFingerprints.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)).slice(0, MAX_LAYOUT_FINGERPRINTS);
4142
+ if (profile.layoutFingerprints.length > MAX_LAYOUT_FINGERPRINTS) profile.layoutFingerprints = profile.layoutFingerprints.toSorted((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)).slice(0, MAX_LAYOUT_FINGERPRINTS);
3841
4143
  }
3842
4144
  profile.lastVisitAt = now;
3843
4145
  this.profileStore.upsert(profile);
@@ -3929,12 +4231,8 @@ var MemoryService = class {
3929
4231
  return score;
3930
4232
  }
3931
4233
  sameFirstPathSegment(left, right) {
3932
- const normalize = (value) => {
3933
- const [firstSegment = ""] = value.replace(/\/\*$/, "").split("/").filter(Boolean);
3934
- return firstSegment.toLowerCase();
3935
- };
3936
- const leftSegment = normalize(left);
3937
- const rightSegment = normalize(right);
4234
+ const leftSegment = normalizePathSegment(left);
4235
+ const rightSegment = normalizePathSegment(right);
3938
4236
  return Boolean(leftSegment) && leftSegment === rightSegment;
3939
4237
  }
3940
4238
  createEvidence(input, result, reason) {
@@ -3963,11 +4261,11 @@ var MemoryService = class {
3963
4261
  selector,
3964
4262
  fallbackSelectors: []
3965
4263
  });
3966
- if (aliasMap.size >= 10) break;
4264
+ if (aliasMap.size >= MAX_SELECTOR_ALIASES) break;
3967
4265
  }
3968
4266
  return {
3969
4267
  ...insight,
3970
- selectorAliases: [...aliasMap.values()].slice(0, 10)
4268
+ selectorAliases: [...aliasMap.values()].slice(0, MAX_SELECTOR_ALIASES)
3971
4269
  };
3972
4270
  }
3973
4271
  deriveAliasName(selector) {
@@ -3993,19 +4291,11 @@ var MemoryService = class {
3993
4291
  //#region src/cli/app.ts
3994
4292
  function createAppContext(env = process.env) {
3995
4293
  const config = loadConfig(env);
3996
- const logger = new Logger("app");
3997
- const eventStore = new EventStore(config.dataDir);
3998
- const tokenService = new SessionTokenService();
3999
4294
  return {
4000
4295
  config,
4001
- logger,
4002
- eventStore,
4003
- tokenService,
4004
- wsServer: new AuthenticatedWsServer({
4005
- host: config.host,
4006
- port: config.wsPort,
4007
- tokenService
4008
- }),
4296
+ logger: new Logger("app"),
4297
+ eventStore: new EventStore(config.dataDir),
4298
+ tokenService: new SessionTokenService(),
4009
4299
  memoryService: new MemoryService(config.dataDir)
4010
4300
  };
4011
4301
  }
@@ -4018,7 +4308,7 @@ var AgenticBrowserCore = class {
4018
4308
  constructor(context, browserController) {
4019
4309
  this.context = context;
4020
4310
  this.sessions = new SessionManager(context, browserController);
4021
- this.api = new ControlApi(this.sessions, context.eventStore);
4311
+ this.api = new ControlApi(this.sessions);
4022
4312
  }
4023
4313
  async startSession(input = { browser: "chrome" }) {
4024
4314
  return await this.api.createSession(input);