surf-cli 2.18.0 → 2.20.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.
@@ -0,0 +1,362 @@
1
+ /** Retries use fresh tabs, so caller scripts must be read-only or idempotent. */
2
+
3
+ const { applyOptionsPrelude } = require("./script-options.cjs");
4
+
5
+ const DEFAULT_RETRY_COUNT = 1;
6
+ const DEFAULT_RETRY_DELAY_MS = 500;
7
+ const MAX_RETRY_COUNT = 5;
8
+ const ROW_KEY_CANDIDATES = ["rows", "items", "results", "entries", "records", "data"];
9
+
10
+ const TRANSIENT_TAB_ERROR_MARKERS = [
11
+ "navigated or closed",
12
+ "Detached while handling command",
13
+ "Cannot find default execution context",
14
+ "Execution context was destroyed",
15
+ "Receiving end does not exist",
16
+ "Content script not loaded",
17
+ "no longer exists",
18
+ "Target closed",
19
+ ];
20
+
21
+ const RETRYABLE_ERROR_CODES = new Set(["empty_result", "page_timeout", "tab_gone", "target_gone"]);
22
+
23
+ const FATAL_READINESS_CODES = new Set(["page_login", "page_challenge", "page_not_found", "page_error"]);
24
+
25
+ class ExtractError extends Error {
26
+ constructor(code, message, details = {}) {
27
+ super(message);
28
+ this.name = "ExtractError";
29
+ this.code = code;
30
+ this.details = details;
31
+ }
32
+ }
33
+
34
+ function errorMessageOf(error) {
35
+ if (error instanceof Error) return error.message;
36
+ if (typeof error === "string") return error;
37
+ if (error && typeof error === "object") {
38
+ const text = error.content?.[0]?.text;
39
+ if (typeof text === "string") return text;
40
+ if (typeof error.message === "string") return error.message;
41
+ return JSON.stringify(error);
42
+ }
43
+ return String(error);
44
+ }
45
+
46
+ function errorCodeOf(error) {
47
+ if (error && typeof error === "object" && typeof error.code === "string") return error.code;
48
+ return null;
49
+ }
50
+
51
+ function isTransientTabError(error) {
52
+ const message = errorMessageOf(error);
53
+ return TRANSIENT_TAB_ERROR_MARKERS.some((marker) => message.includes(marker));
54
+ }
55
+
56
+ /**
57
+ * Whether a failed attempt is worth a fresh tab. Login bounces, challenges
58
+ * and not-found pages are not: the next tab lands on the same page.
59
+ */
60
+ function isRetryableExtractionError(error) {
61
+ const code = errorCodeOf(error);
62
+ if (code && FATAL_READINESS_CODES.has(code)) return false;
63
+ if (code && RETRYABLE_ERROR_CODES.has(code)) return true;
64
+ return isTransientTabError(error);
65
+ }
66
+
67
+ function responseText(response) {
68
+ const text = response?.result?.content?.[0]?.text;
69
+ return typeof text === "string" ? text : null;
70
+ }
71
+
72
+ function responseError(response, stage) {
73
+ if (!response || !response.error) return null;
74
+ const err = response.error;
75
+ const code = errorCodeOf(err) || "tool_error";
76
+ return new ExtractError(code, errorMessageOf(err), { stage, ...(err.details || {}) });
77
+ }
78
+
79
+ function parseExtractionOutput(text) {
80
+ if (text === null || text === undefined || text.trim() === "" || text.trim() === "undefined") {
81
+ throw new ExtractError(
82
+ "no_output",
83
+ "The extraction script returned nothing. End it with `return { rows: [...] }` or `return [...]`.",
84
+ );
85
+ }
86
+ try {
87
+ return JSON.parse(text);
88
+ } catch (error) {
89
+ throw new ExtractError("invalid_output", `The extraction script did not return JSON: ${error.message}`, {
90
+ preview: text.slice(0, 200),
91
+ });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Pick the row array out of the script result: the result itself when it
97
+ * is an array, `--rows <key>` when given, else the first conventional key
98
+ * holding an array. Returns null when the result has no row concept.
99
+ */
100
+ function selectRows(data, rowsKey) {
101
+ if (rowsKey) {
102
+ const rows = data && typeof data === "object" && !Array.isArray(data) ? data[rowsKey] : undefined;
103
+ if (!Array.isArray(rows)) {
104
+ throw new ExtractError("rows_key_missing", `The script result has no array at "${rowsKey}"`, {
105
+ keys: data && typeof data === "object" ? Object.keys(data) : [],
106
+ });
107
+ }
108
+ return rows;
109
+ }
110
+ if (Array.isArray(data)) return data;
111
+ if (data && typeof data === "object") {
112
+ for (const key of ROW_KEY_CANDIDATES) {
113
+ if (Array.isArray(data[key])) return data[key];
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Zero rows is a failure unless the caller opts in. A logged-out render,
121
+ * a selector miss or a half-loaded page all look like "no results"; only
122
+ * the caller knows whether an empty result is plausible.
123
+ */
124
+ function enforceRowsInvariant(rows, { allowEmpty = false, readiness } = {}) {
125
+ if (!Array.isArray(rows) || rows.length > 0 || allowEmpty) return;
126
+ if (readiness?.state === "empty") return;
127
+ throw new ExtractError(
128
+ "empty_result",
129
+ "The extraction returned zero rows (the page may be logged out, blocked, or the selectors missed). Pass --allow-empty to accept an empty result, or --empty-text to recognise the page's own no-results message.",
130
+ { rows: 0 },
131
+ );
132
+ }
133
+
134
+ function cellText(value) {
135
+ let text;
136
+ if (value === null || value === undefined) text = "";
137
+ else if (typeof value === "string") text = value;
138
+ else text = JSON.stringify(value);
139
+ return text.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
140
+ }
141
+
142
+ /** Markdown for humans and LLMs: metadata bullets, then a table of rows. */
143
+ function renderExtractionMarkdown(data, rows, { title = "Extraction" } = {}) {
144
+ const lines = [`# ${title}`, ""];
145
+ if (data && typeof data === "object" && !Array.isArray(data)) {
146
+ for (const [key, value] of Object.entries(data)) {
147
+ if (Array.isArray(value) || (value && typeof value === "object")) continue;
148
+ lines.push(`- ${key}: ${cellText(value)}`);
149
+ }
150
+ if (lines.length > 2) lines.push("");
151
+ }
152
+ if (!Array.isArray(rows)) {
153
+ lines.push("```json", JSON.stringify(data, null, 2), "```");
154
+ return lines.join("\n");
155
+ }
156
+ lines.push(`${rows.length} row${rows.length === 1 ? "" : "s"}`, "");
157
+ if (rows.length === 0) return lines.join("\n").trimEnd();
158
+ if (!rows.every((row) => row !== null && typeof row === "object" && !Array.isArray(row))) {
159
+ for (const row of rows) lines.push(`- ${cellText(row)}`);
160
+ return lines.join("\n");
161
+ }
162
+ const columns = [];
163
+ for (const row of rows) {
164
+ for (const key of Object.keys(row)) {
165
+ if (!columns.includes(key)) columns.push(key);
166
+ }
167
+ }
168
+ lines.push(`| ${columns.join(" | ")} |`);
169
+ lines.push(`| ${columns.map(() => "---").join(" | ")} |`);
170
+ for (const row of rows) {
171
+ lines.push(`| ${columns.map((column) => cellText(row[column])).join(" | ")} |`);
172
+ }
173
+ return lines.join("\n");
174
+ }
175
+
176
+ function normalizeRetry(retry) {
177
+ const count = Number.isInteger(retry?.count) ? Math.max(0, Math.min(retry.count, MAX_RETRY_COUNT)) : DEFAULT_RETRY_COUNT;
178
+ const delayMs = Number.isFinite(retry?.delayMs) && retry.delayMs >= 0 ? retry.delayMs : DEFAULT_RETRY_DELAY_MS;
179
+ return { count, delayMs };
180
+ }
181
+
182
+ function readinessArgs(ready = {}) {
183
+ const args = {};
184
+ if (ready.selector) args.selector = ready.selector;
185
+ if (ready.text) args.text = ready.text;
186
+ if (ready.urlPrefix) args.urlPrefix = ready.urlPrefix;
187
+ if (ready.emptyText) args.emptyText = ready.emptyText;
188
+ if (ready.timeout !== undefined) args.timeout = ready.timeout;
189
+ if (ready.interval !== undefined) args.interval = ready.interval;
190
+ return args;
191
+ }
192
+
193
+ /** Tab id from the stable structured field on a tab.new host response. */
194
+ function tabIdFromResponse(response) {
195
+ const failure = responseError(response, "tab.new");
196
+ if (failure) throw failure;
197
+ const tabId = response?.result?.tabId;
198
+ if (Number.isInteger(tabId) && tabId > 0) return tabId;
199
+ throw new ExtractError("no_tab", "tab.new did not return a structured tab id");
200
+ }
201
+
202
+ function parseToolJson(response, stage) {
203
+ const failure = responseError(response, stage);
204
+ if (failure) throw failure;
205
+ const text = responseText(response);
206
+ if (text === null) return null;
207
+ try {
208
+ return JSON.parse(text);
209
+ } catch {
210
+ return text;
211
+ }
212
+ }
213
+
214
+ /** Drop transport-only fields from the readiness metadata returned to callers. */
215
+ function cleanReadiness(readiness) {
216
+ if (!readiness || typeof readiness !== "object") return readiness;
217
+ const { id, _resolvedTabId, _resolvedWindowId, _hint, ...publicReadiness } = readiness;
218
+ return publicReadiness;
219
+ }
220
+
221
+ async function runAttemptOnTab(executeTool, tabId, settings) {
222
+ const readiness = cleanReadiness(
223
+ parseToolJson(await executeTool("wait.ready", readinessArgs(settings.ready), tabId), "wait.ready"),
224
+ );
225
+ const code = applyOptionsPrelude(settings.code, settings.options);
226
+ const jsResponse = await executeTool("js", { code }, tabId);
227
+ const failure = responseError(jsResponse, "js");
228
+ if (failure) throw failure;
229
+ const data = parseExtractionOutput(responseText(jsResponse));
230
+ const rows = selectRows(data, settings.rowsKey);
231
+ enforceRowsInvariant(rows, { allowEmpty: settings.allowEmpty, readiness });
232
+ return { data, rows, readiness };
233
+ }
234
+
235
+ /**
236
+ * @param {object} settings
237
+ * @param {(tool: string, args: object, tabId?: number) => Promise<object>} settings.executeTool
238
+ * Sends one tool request. `tabId` overrides the target for owned tabs; when
239
+ * it is undefined the caller's default target (session/tab/window) applies.
240
+ * @param {string} settings.code Page-side script; must `return` JSON.
241
+ * @param {string} [settings.url] Page to open. Required unless `target` is set.
242
+ * @param {object} [settings.options] Exposed to the script as SURF_OPTIONS.
243
+ * @param {object} [settings.ready] wait.ready expectations (selector, text, urlPrefix, emptyText, timeout, interval).
244
+ * @param {{count?: number, delayMs?: number}} [settings.retry]
245
+ * @param {boolean} [settings.keepTab] Leave the owned tab open on success.
246
+ * @param {boolean} [settings.allowEmpty]
247
+ * @param {string} [settings.rowsKey]
248
+ * @param {boolean} [settings.target] Use the caller's target instead of an owned tab.
249
+ * @param {(error: unknown) => boolean} [settings.isRetryable]
250
+ * @param {(ms: number) => Promise<void>} [settings.sleep]
251
+ * @param {(event: object) => void} [settings.onEvent]
252
+ */
253
+ async function runExtraction(settings) {
254
+ const {
255
+ executeTool,
256
+ code,
257
+ url,
258
+ target = false,
259
+ keepTab = false,
260
+ isRetryable = isRetryableExtractionError,
261
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
262
+ onEvent = () => {},
263
+ } = settings;
264
+ if (typeof executeTool !== "function") throw new Error("runExtraction requires executeTool");
265
+ if (typeof code !== "string" || code.trim() === "") throw new ExtractError("no_script", "An extraction script is required (--file or --code)");
266
+ if (!target && !url) throw new ExtractError("no_url", "A URL is required unless --tab-id or --session names the page to read");
267
+
268
+ if (target) {
269
+ // Caller-supplied target: navigate once if asked, never close, never retry.
270
+ if (url) {
271
+ const navigation = await executeTool("navigate", { url });
272
+ const failure = responseError(navigation, "navigate");
273
+ if (failure) throw failure;
274
+ }
275
+ onEvent({ type: "attempt", attempt: 1, of: 1, mode: "target" });
276
+ const attempt = await runAttemptOnTab(executeTool, undefined, settings);
277
+ return { ...attempt, rowCount: Array.isArray(attempt.rows) ? attempt.rows.length : null, attempts: 1, mode: "target", url: url ?? null };
278
+ }
279
+
280
+ const retry = normalizeRetry(settings.retry);
281
+ const attempts = retry.count + 1;
282
+ let lastError = null;
283
+ let attemptsMade = 0;
284
+
285
+ for (let attempt = 1; attempt <= attempts; attempt++) {
286
+ attemptsMade = attempt;
287
+ if (attempt > 1) await sleep(retry.delayMs);
288
+ onEvent({ type: "attempt", attempt, of: attempts, mode: "owned-tab" });
289
+ let tabId = null;
290
+ let result;
291
+ try {
292
+ tabId = tabIdFromResponse(await executeTool("tab.new", { url }));
293
+ result = await runAttemptOnTab(executeTool, tabId, settings);
294
+ } catch (error) {
295
+ lastError = error;
296
+ let cleanupError = null;
297
+ if (tabId) {
298
+ try {
299
+ const closed = await executeTool("tab.close", { id: tabId }, tabId);
300
+ const closeFailure = responseError(closed, "tab.close");
301
+ if (closeFailure) throw closeFailure;
302
+ } catch (closeError) {
303
+ cleanupError = closeError;
304
+ onEvent({ type: "close-failed", attempt, tabId, error: errorMessageOf(closeError) });
305
+ }
306
+ }
307
+ if (cleanupError) {
308
+ throw new ExtractError("cleanup_failed", `Extraction failed and the owned tab could not be closed: ${errorMessageOf(cleanupError)}`, {
309
+ stage: "tab.close",
310
+ tabId,
311
+ attempts: attempt,
312
+ extractionError: { code: errorCodeOf(error), message: errorMessageOf(error) },
313
+ });
314
+ }
315
+ const retryable = attempt < attempts && isRetryable(error);
316
+ onEvent({ type: "attempt-failed", attempt, of: attempts, error: errorMessageOf(error), code: errorCodeOf(error), retryable });
317
+ if (!retryable) break;
318
+ continue;
319
+ }
320
+
321
+ if (!keepTab) {
322
+ try {
323
+ const closed = await executeTool("tab.close", { id: tabId }, tabId);
324
+ const closeFailure = responseError(closed, "tab.close");
325
+ if (closeFailure) throw closeFailure;
326
+ } catch (error) {
327
+ throw new ExtractError("cleanup_failed", `Extraction succeeded but the owned tab could not be closed: ${errorMessageOf(error)}`, {
328
+ stage: "tab.close",
329
+ tabId,
330
+ attempts: attempt,
331
+ extractionSucceeded: true,
332
+ rowCount: Array.isArray(result.rows) ? result.rows.length : null,
333
+ });
334
+ }
335
+ }
336
+ return {
337
+ ...result,
338
+ rowCount: Array.isArray(result.rows) ? result.rows.length : null,
339
+ attempts: attempt,
340
+ mode: "owned-tab",
341
+ url,
342
+ tabId: keepTab ? tabId : null,
343
+ };
344
+ }
345
+ if (lastError instanceof ExtractError) {
346
+ lastError.details = { ...lastError.details, attempts: attemptsMade };
347
+ throw lastError;
348
+ }
349
+ throw new ExtractError(errorCodeOf(lastError) || "extraction_failed", errorMessageOf(lastError), { attempts: attemptsMade });
350
+ }
351
+
352
+ module.exports = {
353
+ ExtractError,
354
+ enforceRowsInvariant,
355
+ isRetryableExtractionError,
356
+ isTransientTabError,
357
+ parseExtractionOutput,
358
+ renderExtractionMarkdown,
359
+ runExtraction,
360
+ selectRows,
361
+ tabIdFromResponse,
362
+ };
@@ -228,6 +228,21 @@ function formatToolContent(result, log = () => {}, options = {}) {
228
228
  ];
229
229
  }
230
230
 
231
+ if (result.semanticObservation !== undefined) {
232
+ return text(JSON.stringify({
233
+ pageContent: result.pageContent,
234
+ viewport: result.viewport,
235
+ semanticObservation: result.semanticObservation,
236
+ }));
237
+ }
238
+
239
+ if (
240
+ result.identity !== undefined && result.matches !== undefined ||
241
+ result.scopeToken !== undefined && result.geometry !== undefined
242
+ ) {
243
+ return text(JSON.stringify(result));
244
+ }
245
+
231
246
  if (result.pageContent !== undefined) {
232
247
  const content = result.pageContent || "No content";
233
248
  let output = '';
@@ -442,6 +457,11 @@ function formatToolContent(result, log = () => {}, options = {}) {
442
457
 
443
458
  // Bug fix: Handle success with metrics/frames/readyState/hint in one block
444
459
  if (result.success) {
460
+ // Keep the response envelope intact so the CLI can print body bytes verbatim
461
+ // for text output while still producing structured output under --json.
462
+ if (typeof result.body === "string" && Object.hasOwn(result, "base64Encoded")) {
463
+ return text(JSON.stringify(result));
464
+ }
445
465
  if (result.metrics) {
446
466
  return text(JSON.stringify(result.metrics, null, 2));
447
467
  }
@@ -462,13 +482,37 @@ function formatToolContent(result, log = () => {}, options = {}) {
462
482
  }
463
483
 
464
484
  // Strip internal fields before JSON output
465
- const { _resolvedTabId, _hint, ...cleanResult } = result;
485
+ const { _resolvedTabId, _resolvedWindowId, _hint, ...cleanResult } = result;
466
486
  if (_hint) {
467
487
  return text(JSON.stringify(cleanResult) + `\n[hint] ${_hint}`);
468
488
  }
469
489
  return text(JSON.stringify(cleanResult));
470
490
  }
471
491
 
492
+ /**
493
+ * Readiness expectations accept both CLI flag spelling (--url-prefix) and
494
+ * socket API spelling (urlPrefix). Empty values are dropped.
495
+ */
496
+ function readinessExpectations(a) {
497
+ const pick = (...keys) => {
498
+ for (const key of keys) {
499
+ const value = a[key];
500
+ if (typeof value === "string" && value.trim() !== "") return value;
501
+ }
502
+ return undefined;
503
+ };
504
+ const expect = {
505
+ selector: pick("selector"),
506
+ text: pick("text"),
507
+ urlPrefix: pick("urlPrefix", "url-prefix"),
508
+ emptyText: pick("emptyText", "empty-text"),
509
+ };
510
+ for (const key of Object.keys(expect)) {
511
+ if (expect[key] === undefined) delete expect[key];
512
+ }
513
+ return expect;
514
+ }
515
+
472
516
  /**
473
517
  * Map computer action to extension message
474
518
  */
@@ -477,7 +521,7 @@ function mapComputerAction(args, tabId) {
477
521
  const { action, text, scroll_direction, scroll_amount,
478
522
  start_coordinate, ref, duration, modifiers } = a;
479
523
  const coordinate = a.coordinate || (a.x !== undefined && a.y !== undefined ? [a.x, a.y] : undefined);
480
- const baseMsg = { tabId };
524
+ const baseMsg = { tabId, ...(Number.isInteger(a.semanticFrameId) ? { frameId: a.semanticFrameId } : {}) };
481
525
 
482
526
  if (!action) {
483
527
  return { type: "UNSUPPORTED_ACTION", action: null, message: "No action specified for computer tool" };
@@ -488,7 +532,7 @@ function mapComputerAction(args, tabId) {
488
532
  return { type: "EXECUTE_SCREENSHOT", ...baseMsg };
489
533
 
490
534
  case "left_click":
491
- if (ref) return { type: "CLICK_REF", ref, button: "left", ...baseMsg };
535
+ if (ref) return { type: "CLICK_REF", ref, button: "left", expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
492
536
  if (a.selector) return { type: "CLICK_SELECTOR", selector: a.selector, index: a.index || 0, button: "left", ...baseMsg };
493
537
  return { type: "EXECUTE_CLICK", x: coordinate?.[0], y: coordinate?.[1], modifiers, ...baseMsg };
494
538
 
@@ -506,7 +550,7 @@ function mapComputerAction(args, tabId) {
506
550
 
507
551
  case "type": {
508
552
  if (ref) {
509
- return { type: "FORM_FILL", data: [{ ref, value: text }], ...baseMsg };
553
+ return { type: "FORM_FILL", data: [{ ref, value: text }], expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
510
554
  }
511
555
  const typeSelector = a.selector || a.into;
512
556
  if (typeSelector) {
@@ -546,7 +590,7 @@ function mapComputerAction(args, tabId) {
546
590
  right: { deltaX: amount, deltaY: 0 },
547
591
  };
548
592
  const { deltaX, deltaY } = deltas[direction] || { deltaX: 0, deltaY: 0 };
549
- return { type: "EXECUTE_SCROLL", deltaX, deltaY, x: coordinate?.[0], y: coordinate?.[1], ...baseMsg };
593
+ return { type: "EXECUTE_SCROLL", deltaX, deltaY, x: coordinate?.[0], y: coordinate?.[1], expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
550
594
  }
551
595
 
552
596
  case "scroll_to":
@@ -585,14 +629,14 @@ function mapComputerAction(args, tabId) {
585
629
  * Map tool name and args to extension message
586
630
  */
587
631
  function mapToolToMessage(tool, args, tabId) {
588
- const baseMsg = { tabId };
589
632
  const a = args || {};
633
+ const baseMsg = { tabId, ...(Number.isInteger(a.semanticFrameId) ? { frameId: a.semanticFrameId } : {}) };
590
634
 
591
635
  switch (tool) {
592
636
  case "computer":
593
637
  return mapComputerAction(args, tabId);
594
638
  case "navigate":
595
- return { type: "EXECUTE_NAVIGATE", url: a.url, ...baseMsg };
639
+ return { type: "EXECUTE_NAVIGATE", url: a.url, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
596
640
  case "read_page":
597
641
  return {
598
642
  type: "READ_PAGE",
@@ -856,9 +900,9 @@ function mapToolToMessage(tool, args, tabId) {
856
900
  case "js":
857
901
  return { type: "EXECUTE_JAVASCRIPT", code: a.code, ...baseMsg };
858
902
  case "scroll.top":
859
- return { type: "SCROLL_TO_POSITION", position: "top", selector: a.selector, ...baseMsg };
903
+ return { type: "SCROLL_TO_POSITION", position: "top", selector: a.selector, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
860
904
  case "scroll.bottom":
861
- return { type: "SCROLL_TO_POSITION", position: "bottom", selector: a.selector, ...baseMsg };
905
+ return { type: "SCROLL_TO_POSITION", position: "bottom", selector: a.selector, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
862
906
  case "scroll.info":
863
907
  return { type: "GET_SCROLL_INFO", selector: a.selector, ...baseMsg };
864
908
  case "scroll.to":
@@ -871,10 +915,23 @@ function mapToolToMessage(tool, args, tabId) {
871
915
  return { type: "WAIT_FOR_URL", pattern: a.pattern || a.url, timeout: a.timeout, ...baseMsg };
872
916
  case "wait.dom":
873
917
  return { type: "WAIT_FOR_DOM_STABLE", stable: a.stable || 100, timeout: a.timeout || 5000, ...baseMsg };
918
+ case "wait.ready":
919
+ return {
920
+ type: "WAIT_FOR_READY",
921
+ expect: readinessExpectations(a),
922
+ timeout: a.timeout,
923
+ interval: a.interval,
924
+ accept: a.accept,
925
+ ...baseMsg,
926
+ };
927
+ case "page.readiness":
928
+ return { type: "PAGE_READINESS", expect: readinessExpectations(a), ...baseMsg };
874
929
  case "wait.load":
875
930
  return { type: "WAIT_FOR_LOAD", timeout: a.timeout || 30000, ...baseMsg };
876
931
  case "frame.list":
877
932
  return { type: "GET_FRAMES", ...baseMsg };
933
+ case "frame.diagnose":
934
+ return { type: "FRAME_DIAGNOSE", ...baseMsg };
878
935
  case "frame.switch":
879
936
  return {
880
937
  type: "FRAME_SWITCH",
@@ -929,7 +986,7 @@ function mapToolToMessage(tool, args, tabId) {
929
986
  if (typeof fillData === "string") {
930
987
  try { fillData = JSON.parse(fillData); } catch (e) { throw new Error("invalid --data JSON"); }
931
988
  }
932
- return { type: "FORM_FILL", data: fillData, ...baseMsg };
989
+ return { type: "FORM_FILL", data: fillData, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
933
990
  case "perf.start":
934
991
  return { type: "PERF_START", categories: a.categories ? a.categories.split(",") : undefined, ...baseMsg };
935
992
  case "perf.stop":
@@ -961,10 +1018,30 @@ function mapToolToMessage(tool, args, tabId) {
961
1018
  compact: a.compact || false,
962
1019
  maxBytes,
963
1020
  forceFullSnapshot: a.compact === true || maxBytes !== undefined,
1021
+ ...(a.semanticObservation === true ? { semanticObservation: true } : {}),
964
1022
  },
965
1023
  ...baseMsg
966
1024
  };
967
1025
  }
1026
+ case "semantic.localCompare":
1027
+ return {
1028
+ type: "SEMANTIC_LOCAL_COMPARE",
1029
+ ref: a.ref,
1030
+ predicate: a.predicate,
1031
+ expectedIdentity: a.semanticExpectedIdentity,
1032
+ ...baseMsg,
1033
+ };
1034
+ case "semantic.scrollScope":
1035
+ if (!["inspect", "top", "advance"].includes(a.action)) {
1036
+ throw new Error("semantic scroll scope action must be inspect, top, or advance");
1037
+ }
1038
+ return {
1039
+ type: "SEMANTIC_SCROLL_SCOPE",
1040
+ action: a.action,
1041
+ scopeToken: a.scopeToken,
1042
+ expectedIdentity: a.semanticExpectedIdentity,
1043
+ ...baseMsg,
1044
+ };
968
1045
  case "page.text":
969
1046
  return { type: "GET_PAGE_TEXT", ...baseMsg };
970
1047
  case "page.html":
@@ -1280,4 +1357,49 @@ function mapToolToMessage(tool, args, tabId) {
1280
1357
  }
1281
1358
  }
1282
1359
 
1283
- module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage };
1360
+ function applySemanticExpectedIdentity(request, extensionMessage, args) {
1361
+ const fail = (code, message) => {
1362
+ const error = new Error(message);
1363
+ error.code = code;
1364
+ throw error;
1365
+ };
1366
+ const expected = args?.semanticExpectedIdentity;
1367
+ const identityRequired = ["SEMANTIC_LOCAL_COMPARE", "SEMANTIC_SCROLL_SCOPE"].includes(extensionMessage?.type);
1368
+ if (expected === undefined) {
1369
+ if (identityRequired) fail("invalid_expected_identity", "semantic expected identity is required for this action");
1370
+ return;
1371
+ }
1372
+ const guardedTypes = [
1373
+ "CLICK_REF", "FORM_FILL", "EXECUTE_NAVIGATE", "EXECUTE_SCROLL", "SCROLL_TO_POSITION",
1374
+ "SEMANTIC_LOCAL_COMPARE", "SEMANTIC_SCROLL_SCOPE",
1375
+ ];
1376
+ if (!extensionMessage || !guardedTypes.includes(extensionMessage.type)) {
1377
+ fail("invalid_expected_identity", "semantic expected identity is not valid for this action");
1378
+ }
1379
+ const domAction = ["CLICK_REF", "FORM_FILL", "SEMANTIC_LOCAL_COMPARE"].includes(extensionMessage.type);
1380
+ const stringFields = ["browserEpoch", "fullUrl", "documentToken", ...(domAction ? ["ref", "role", "name", "type"] : [])];
1381
+ if (!expected || typeof expected !== "object" || stringFields.some((field) => typeof expected[field] !== "string")) {
1382
+ fail("invalid_expected_identity", "invalid semantic expected identity");
1383
+ }
1384
+ if (!Number.isInteger(expected.tabId) || !Number.isInteger(expected.frameId)) {
1385
+ fail("invalid_expected_identity", "invalid semantic expected identity");
1386
+ }
1387
+ const actualFrameId = Number.isInteger(extensionMessage.frameId) ? extensionMessage.frameId : 0;
1388
+ if (
1389
+ request?.browserIdentity?.browserEpoch !== expected.browserEpoch ||
1390
+ request?.target?.tabId !== expected.tabId ||
1391
+ actualFrameId !== expected.frameId ||
1392
+ domAction && extensionMessage.ref && extensionMessage.ref !== expected.ref ||
1393
+ extensionMessage.type === "FORM_FILL" &&
1394
+ (!Array.isArray(extensionMessage.data) || extensionMessage.data.length !== 1 || extensionMessage.data[0]?.ref !== expected.ref)
1395
+ ) {
1396
+ fail("stale_observation", "stale_observation");
1397
+ }
1398
+ extensionMessage.expectedIdentity = {
1399
+ fullUrl: expected.fullUrl,
1400
+ documentToken: expected.documentToken,
1401
+ ...(domAction ? { ref: expected.ref, role: expected.role, name: expected.name, type: expected.type } : {}),
1402
+ };
1403
+ }
1404
+
1405
+ module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage, readinessExpectations, applySemanticExpectedIdentity };
@@ -15,6 +15,9 @@ const QUEUE_TIMEOUT_MS = 60000;
15
15
  const DEFAULT_DEADLINE_MS = 60000;
16
16
  const MAX_DEADLINE_MS = 50 * 60 * 1000;
17
17
  const CLEANUP_GRACE_MS = 60000;
18
+ const READINESS_DEFAULT_TIMEOUT_MS = 20000;
19
+ const READINESS_MAX_TIMEOUT_MS = 120000;
20
+ const READINESS_DEADLINE_GRACE_MS = 5000;
18
21
  const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
19
22
  ai: 300,
20
23
  aistudio: 300,
@@ -29,6 +32,13 @@ const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
29
32
  };
30
33
 
31
34
  function resolveRequestDeadlineMs(tool, args = {}) {
35
+ if (tool === "wait.ready") {
36
+ const requestedMs = Number(args?.timeout);
37
+ const timeoutMs = Number.isFinite(requestedMs) && requestedMs > 0
38
+ ? Math.min(requestedMs, READINESS_MAX_TIMEOUT_MS)
39
+ : READINESS_DEFAULT_TIMEOUT_MS;
40
+ return timeoutMs + READINESS_DEADLINE_GRACE_MS;
41
+ }
32
42
  const defaultSeconds = PROVIDER_DEFAULT_TIMEOUT_SECONDS[tool];
33
43
  if (defaultSeconds === undefined) return DEFAULT_DEADLINE_MS;
34
44
  const rawTimeout = tool === "playbook.run" && args && typeof args === "object" && !Array.isArray(args)