pi-agent-browser-native 0.2.44 → 0.2.46

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 (66) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +20 -15
  3. package/docs/ARCHITECTURE.md +12 -10
  4. package/docs/COMMAND_REFERENCE.md +49 -27
  5. package/docs/ELECTRON.md +1 -1
  6. package/docs/RELEASE.md +6 -5
  7. package/docs/REQUIREMENTS.md +6 -3
  8. package/docs/SUPPORT_MATRIX.md +17 -13
  9. package/docs/TOOL_CONTRACT.md +87 -46
  10. package/docs/platform-smoke.md +4 -3
  11. package/extensions/agent-browser/index.ts +43 -450
  12. package/extensions/agent-browser/lib/bash-guard.ts +205 -0
  13. package/extensions/agent-browser/lib/electron/cdp.ts +69 -0
  14. package/extensions/agent-browser/lib/electron/cleanup.ts +5 -58
  15. package/extensions/agent-browser/lib/electron/discovery.ts +2 -9
  16. package/extensions/agent-browser/lib/electron/launch.ts +11 -65
  17. package/extensions/agent-browser/lib/electron/text.ts +13 -0
  18. package/extensions/agent-browser/lib/fs-utils.ts +18 -0
  19. package/extensions/agent-browser/lib/input-modes/job.ts +207 -21
  20. package/extensions/agent-browser/lib/input-modes/params.ts +28 -11
  21. package/extensions/agent-browser/lib/input-modes/semantic-action.ts +22 -2
  22. package/extensions/agent-browser/lib/input-modes/types.ts +5 -1
  23. package/extensions/agent-browser/lib/input-modes.ts +1 -0
  24. package/extensions/agent-browser/lib/json-schema.ts +73 -0
  25. package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +82 -11
  26. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +159 -30
  27. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +53 -2
  28. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +1 -0
  29. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +751 -32
  30. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +38 -7
  31. package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +0 -46
  32. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +10 -1
  33. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +28 -1
  34. package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +1 -6
  35. package/extensions/agent-browser/lib/orchestration/input-plan.ts +15 -3
  36. package/extensions/agent-browser/lib/orchestration/output-file.ts +86 -0
  37. package/extensions/agent-browser/lib/pi-tool-rendering.ts +252 -0
  38. package/extensions/agent-browser/lib/playbook.ts +26 -26
  39. package/extensions/agent-browser/lib/process.ts +1 -1
  40. package/extensions/agent-browser/lib/prompt-policy.ts +1 -18
  41. package/extensions/agent-browser/lib/results/artifact-manifest.ts +1 -4
  42. package/extensions/agent-browser/lib/results/artifact-state.ts +7 -3
  43. package/extensions/agent-browser/lib/results/contracts.ts +6 -2
  44. package/extensions/agent-browser/lib/results/envelope.ts +11 -2
  45. package/extensions/agent-browser/lib/results/network-routes.ts +7 -4
  46. package/extensions/agent-browser/lib/results/network.ts +7 -1
  47. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +88 -20
  48. package/extensions/agent-browser/lib/results/presentation/batch.ts +84 -12
  49. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +81 -26
  50. package/extensions/agent-browser/lib/results/presentation/errors.ts +13 -0
  51. package/extensions/agent-browser/lib/results/presentation/registry.ts +60 -0
  52. package/extensions/agent-browser/lib/results/presentation.ts +10 -1
  53. package/extensions/agent-browser/lib/results/snapshot-high-value-controls.ts +16 -5
  54. package/extensions/agent-browser/lib/results/snapshot.ts +2 -0
  55. package/extensions/agent-browser/lib/runtime.ts +10 -1
  56. package/extensions/agent-browser/lib/session-page-state.ts +15 -6
  57. package/extensions/agent-browser/lib/string-enum-schema.ts +20 -0
  58. package/extensions/agent-browser/lib/web-search.ts +31 -13
  59. package/package.json +2 -2
  60. package/platform-smoke.config.mjs +5 -2
  61. package/scripts/platform-smoke/build-ubuntu-image.mjs +25 -0
  62. package/scripts/platform-smoke/crabbox-runner.mjs +5 -1
  63. package/scripts/platform-smoke/doctor.mjs +6 -2
  64. package/scripts/platform-smoke/linux-image/Dockerfile +3 -5
  65. package/scripts/platform-smoke/targets.mjs +2 -1
  66. package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +0 -154
@@ -1,18 +1,21 @@
1
- import { copyFile, mkdir, stat } from "node:fs/promises";
1
+ import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, extname, isAbsolute, resolve } from "node:path";
3
3
 
4
4
  import { launchElectronApp, type ElectronLaunchSuccess } from "../../electron/launch.js";
5
+ import { pathExists } from "../../fs-utils.js";
5
6
  import { getCompiledSemanticActionSessionPrefix, type CompiledAgentBrowserSemanticAction } from "../../input-modes.js";
6
7
  import { SAFE_AGENT_BROWSER_OPERATION_TIMEOUT_MS } from "../../process.js";
7
8
  import { buildAgentBrowserResultCategoryDetails } from "../../results.js";
9
+ import { buildSnapshotPresentation } from "../../results/snapshot.js";
8
10
  import { buildSessionAwareStaleRefNextActions, buildSessionTabRecoveryNextActions } from "../../results/recovery-next-actions.js";
9
11
  import { resolveVisibleRefActionFromSnapshot } from "../../results/selector-recovery.js";
10
- import { type SessionRefSnapshot } from "../../session-page-state.js";
12
+ import { extractRefSnapshotFromData, type SessionRefSnapshot, type SessionTabTarget } from "../../session-page-state.js";
11
13
  import {
12
14
  buildExecutionPlan,
13
15
  createFreshSessionName,
14
16
  extractCommandTokens,
15
17
  redactInvocationArgs,
18
+ redactSensitiveText,
16
19
  type CompatibilityWorkaround,
17
20
  } from "../../runtime.js";
18
21
  import {
@@ -22,16 +25,20 @@ import {
22
25
  buildSessionDetailFields,
23
26
  buildStaleRefPreflight,
24
27
  collectSessionTabSelection,
28
+ getGuardedRefUsage,
25
29
  getTraceOwnerGuardMessage,
26
30
  runSessionCommandData,
27
31
  shouldPinSessionTabForCommand,
28
32
  } from "./session-state.js";
33
+ import { isRecord } from "../../parsing.js";
29
34
  import { parseBatchStdinJsonArray, parseValidBatchStepEntries } from "../batch-stdin.js";
30
35
  import { buildElectronHostFailureResult, getElectronLaunchFailureCategory, redactRecoveryHint } from "./final-result.js";
31
36
  import { prepareClickDispatchProbe } from "./click-dispatch.js";
32
- import { collectScrollPositionSnapshot, validateQaAttachedPrecondition } from "./diagnostics.js";
33
- import { findRequestedArtifactCloseViolation, findStopBoundaryViolation } from "./prompt-guards.js";
37
+ import { buildScrollNoopNextActions, collectScrollPositionSnapshot, validateQaAttachedPrecondition } from "./diagnostics.js";
38
+ import { findRequestedArtifactCloseViolation } from "./prompt-guards.js";
39
+
34
40
  import type {
41
+ AgentBrowserToolResult,
35
42
  BrowserRunInputFields,
36
43
  BrowserRunOptions,
37
44
  PreparedAgentBrowserArgs,
@@ -40,8 +47,10 @@ import type {
40
47
  ScreenshotArtifactRequest,
41
48
  ScreenshotPathRequest,
42
49
  SemanticActionVisibleRefResolution,
50
+ StaleRefPreflight,
43
51
  } from "./types.js";
44
52
 
53
+ const DIRECT_ANCHOR_DOWNLOAD_MAX_BYTES = 2 * 1024 * 1024;
45
54
  const SCREENSHOT_VALUE_FLAGS = new Set(["--screenshot-dir", "--screenshot-format", "--screenshot-quality"]);
46
55
  const SCREENSHOT_IMAGE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]);
47
56
 
@@ -110,6 +119,143 @@ export function getScreenshotPathTokenIndex(commandTokens: string[]): number | u
110
119
  return undefined;
111
120
  }
112
121
 
122
+ function getArtifactParentPathTokenIndex(commandTokens: string[]): number | undefined {
123
+ if (commandTokens[0] === "download" && commandTokens.length >= 3) return 2;
124
+ if (commandTokens[0] === "pdf" && commandTokens.length >= 2) return 1;
125
+ if (commandTokens[0] === "state" && commandTokens[1] === "save" && commandTokens.length >= 3) return 2;
126
+ if (commandTokens[0] === "wait") {
127
+ const downloadIndex = commandTokens.findIndex((token) => token === "--download");
128
+ const pathIndex = downloadIndex >= 0 ? downloadIndex + 1 : -1;
129
+ if (pathIndex > 0 && typeof commandTokens[pathIndex] === "string" && !commandTokens[pathIndex].startsWith("-")) return pathIndex;
130
+ }
131
+ return undefined;
132
+ }
133
+
134
+ async function ensureArtifactParentDirectory(commandTokens: string[], cwd: string): Promise<void> {
135
+ const pathIndex = getArtifactParentPathTokenIndex(commandTokens);
136
+ if (pathIndex === undefined) return;
137
+ const requestedPath = commandTokens[pathIndex];
138
+ if (!requestedPath) return;
139
+ await mkdir(dirname(resolve(cwd, requestedPath)), { recursive: true });
140
+ }
141
+
142
+ function getDirectDownloadRequest(commandTokens: string[]): { path: string; selector: string } | undefined {
143
+ if (commandTokens[0] !== "download" || commandTokens.length !== 3) return undefined;
144
+ const selector = commandTokens[1];
145
+ const path = commandTokens[2];
146
+ if (!selector || !path || selector.startsWith("@")) return undefined;
147
+ return { path, selector };
148
+ }
149
+
150
+ function buildAnchorDownloadProbe(selector: string): string {
151
+ return `(async () => {\n const selector = ${JSON.stringify(selector)};\n const maxBytes = ${DIRECT_ANCHOR_DOWNLOAD_MAX_BYTES};\n const isLoopbackHttpUrl = (url) => (url.protocol === "http:" || url.protocol === "https:") && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]");\n const element = document.querySelector(selector);\n const anchor = element?.closest?.("a[href]");\n const pageUrl = location.href;\n const page = new URL(pageUrl);\n if (!anchor) return { status: "no-anchor", pageUrl };\n const href = anchor.href;\n const anchorUrl = new URL(href, pageUrl);\n if (!isLoopbackHttpUrl(page)) return { download: anchor.getAttribute("download") || "", href, pageUrl, status: "not-loopback-page" };\n if (anchorUrl.origin !== page.origin) return { download: anchor.getAttribute("download") || "", href, pageUrl, status: "not-same-origin" };\n if (!isLoopbackHttpUrl(anchorUrl)) return { download: anchor.getAttribute("download") || "", href, pageUrl, status: "not-loopback-href" };\n const response = await fetch(anchorUrl.href, { credentials: "include", redirect: "manual" });\n if (!response.ok) return { download: anchor.getAttribute("download") || "", href, pageUrl, responseUrl: response.url, status: "fetch-failed", statusCode: response.status };\n const responseUrl = new URL(response.url);\n if (!isLoopbackHttpUrl(responseUrl) || responseUrl.origin !== page.origin) return { download: anchor.getAttribute("download") || "", href, pageUrl, responseUrl: response.url, status: "not-loopback-response" };\n const buffer = await response.arrayBuffer();\n if (buffer.byteLength > maxBytes) return { download: anchor.getAttribute("download") || "", href, pageUrl, responseUrl: response.url, sizeBytes: buffer.byteLength, status: "too-large" };\n const bytes = new Uint8Array(buffer);\n let binary = "";\n for (let index = 0; index < bytes.length; index += 32768) binary += String.fromCharCode(...bytes.subarray(index, index + 32768));\n return { bodyBase64: btoa(binary), contentType: response.headers.get("content-type") || "", download: anchor.getAttribute("download") || "", href, pageUrl, responseUrl: response.url, sizeBytes: buffer.byteLength, status: "fetched-anchor" };\n})()`;
152
+ }
153
+
154
+ function isLoopbackHttpUrl(url: URL): boolean {
155
+ return (url.protocol === "http:" || url.protocol === "https:") && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]");
156
+ }
157
+
158
+ async function tryDirectAnchorDownload(options: {
159
+ commandTokens: string[];
160
+ compatibilityWorkaround?: CompatibilityWorkaround;
161
+ cwd: string;
162
+ effectiveArgs: string[];
163
+ redactedArgs: string[];
164
+ sessionMode: "auto" | "fresh";
165
+ sessionName?: string;
166
+ signal?: AbortSignal;
167
+ usedImplicitSession: boolean;
168
+ }): Promise<AgentBrowserToolResult | undefined> {
169
+ const request = getDirectDownloadRequest(options.commandTokens);
170
+ if (!request || !options.sessionName) return undefined;
171
+ try {
172
+ const probeData = await runSessionCommandData({
173
+ args: ["eval", "--stdin"],
174
+ cwd: options.cwd,
175
+ sessionName: options.sessionName,
176
+ signal: options.signal,
177
+ stdin: buildAnchorDownloadProbe(request.selector),
178
+ });
179
+ const probe = isRecord(probeData) && isRecord(probeData.result) ? probeData.result : probeData;
180
+ if (!isRecord(probe) || probe.status !== "fetched-anchor" || typeof probe.href !== "string" || typeof probe.pageUrl !== "string" || typeof probe.bodyBase64 !== "string") return undefined;
181
+ const href = new URL(probe.href);
182
+ const pageUrl = new URL(probe.pageUrl);
183
+ const responseUrl = typeof probe.responseUrl === "string" ? new URL(probe.responseUrl) : href;
184
+ if (!isLoopbackHttpUrl(pageUrl) || !isLoopbackHttpUrl(href) || !isLoopbackHttpUrl(responseUrl) || href.origin !== pageUrl.origin || responseUrl.origin !== pageUrl.origin) return undefined;
185
+ const body = Buffer.from(probe.bodyBase64, "base64");
186
+ if (body.byteLength > DIRECT_ANCHOR_DOWNLOAD_MAX_BYTES) return undefined;
187
+ if (typeof probe.sizeBytes === "number" && probe.sizeBytes !== body.byteLength) return undefined;
188
+ const absolutePath = resolve(options.cwd, request.path);
189
+ await mkdir(dirname(absolutePath), { recursive: true });
190
+ await writeFile(absolutePath, body);
191
+ const fileStat = await stat(absolutePath);
192
+ const mediaType = typeof probe.contentType === "string" && probe.contentType.length > 0 ? probe.contentType : undefined;
193
+ const artifact = {
194
+ absolutePath,
195
+ artifactType: "download" as const,
196
+ command: "download",
197
+ cwd: options.cwd,
198
+ exists: true,
199
+ kind: "download" as const,
200
+ mediaType,
201
+ path: absolutePath,
202
+ requestedPath: request.path,
203
+ session: options.sessionName,
204
+ sizeBytes: fileStat.size,
205
+ status: "saved" as const,
206
+ };
207
+ const artifactVerification = {
208
+ artifacts: [{
209
+ absolutePath,
210
+ exists: true,
211
+ kind: "download" as const,
212
+ mediaType,
213
+ path: absolutePath,
214
+ requestedPath: request.path,
215
+ sizeBytes: fileStat.size,
216
+ state: "verified" as const,
217
+ status: "saved" as const,
218
+ }],
219
+ missingCount: 0,
220
+ pendingCount: 0,
221
+ unverifiedCount: 0,
222
+ verified: true,
223
+ verifiedCount: 1,
224
+ };
225
+ const savedFile = { command: "download" as const, kind: "download" as const, metadata: { download: probe.download, href: redactSensitiveText(href.href), method: "direct-anchor-fetch" }, path: absolutePath };
226
+ return {
227
+ content: [{
228
+ type: "text",
229
+ text: [
230
+ `Download completed: ${absolutePath}`,
231
+ `Requested path: ${request.path}`,
232
+ `Source: ${redactSensitiveText(href.href)}`,
233
+ `Size: ${fileStat.size} bytes`,
234
+ "Method: direct anchor fetch before upstream download fallback.",
235
+ ].join("\n"),
236
+ }],
237
+ details: {
238
+ args: options.redactedArgs,
239
+ artifacts: [artifact],
240
+ artifactVerification,
241
+ command: "download",
242
+ compatibilityWorkaround: options.compatibilityWorkaround,
243
+ downloadRecovery: { href: redactSensitiveText(href.href), method: "direct-anchor-fetch", selector: request.selector },
244
+ effectiveArgs: options.effectiveArgs,
245
+ savedFile,
246
+ savedFilePath: absolutePath,
247
+ sessionMode: options.sessionMode,
248
+ ...buildAgentBrowserResultCategoryDetails({ artifacts: [artifact], args: options.effectiveArgs, command: "download", savedFile, succeeded: true }),
249
+ ...buildSessionDetailFields(options.sessionName, options.usedImplicitSession),
250
+ summary: `Download completed: ${absolutePath}`,
251
+ },
252
+ isError: false,
253
+ };
254
+ } catch {
255
+ return undefined;
256
+ }
257
+ }
258
+
113
259
  async function normalizeScreenshotPathInTokens(commandTokens: string[], cwd: string): Promise<{
114
260
  request?: ScreenshotPathRequest;
115
261
  tokens: string[];
@@ -152,7 +298,11 @@ async function prepareBatchScreenshotPaths(args: string[], stdin: string | undef
152
298
  let changed = false;
153
299
  const batchScreenshotPathRequests: Array<ScreenshotPathRequest | undefined> = [];
154
300
  const preparedSteps = await Promise.all(parsed.steps.map(async (step, index) => {
155
- if (!Array.isArray(step) || !step.every((item) => typeof item === "string") || step[0] !== "screenshot") {
301
+ if (!Array.isArray(step) || !step.every((item) => typeof item === "string")) {
302
+ return step;
303
+ }
304
+ await ensureArtifactParentDirectory(step, cwd);
305
+ if (step[0] !== "screenshot") {
156
306
  return step;
157
307
  }
158
308
  const normalized = await normalizeScreenshotPathInTokens(step, cwd);
@@ -179,6 +329,7 @@ export async function prepareAgentBrowserArgs(args: string[], stdin: string | un
179
329
  }
180
330
 
181
331
  const commandTokens = extractCommandTokens(args);
332
+ await ensureArtifactParentDirectory(commandTokens, cwd);
182
333
  const normalized = await normalizeScreenshotPathInTokens(commandTokens, cwd);
183
334
  if (!normalized.request) {
184
335
  return { args };
@@ -191,15 +342,6 @@ export async function prepareAgentBrowserArgs(args: string[], stdin: string | un
191
342
  };
192
343
  }
193
344
 
194
- async function pathExists(path: string): Promise<boolean> {
195
- try {
196
- await stat(path);
197
- return true;
198
- } catch {
199
- return false;
200
- }
201
- }
202
-
203
345
  async function repairScreenshotData(options: {
204
346
  cwd: string;
205
347
  data: Record<string, unknown>;
@@ -289,6 +431,503 @@ export function validateWaitIpcTimeoutContract(commandTokens: string[], stdin: s
289
431
  return undefined;
290
432
  }
291
433
 
434
+ const DIALOG_COMMAND_PROCESS_TIMEOUT_MS = 5_000;
435
+ const DIALOG_COMMAND_PROCESS_TIMEOUT_ENV = "PI_AGENT_BROWSER_DIALOG_PROCESS_TIMEOUT_MS";
436
+ const LIKELY_DIALOG_TRIGGER_PROCESS_TIMEOUT_MS = 8_000;
437
+ const LIKELY_DIALOG_TRIGGER_PROCESS_TIMEOUT_ENV = "PI_AGENT_BROWSER_DIALOG_TRIGGER_PROCESS_TIMEOUT_MS";
438
+ const DIALOG_TRIGGER_TEXT_PATTERN = /\b(?:alert|confirm|dialog|prompt)\b/i;
439
+
440
+ function getPositiveIntegerEnv(name: string): number | undefined {
441
+ const value = process.env[name];
442
+ if (!value || !/^\d+$/.test(value.trim())) return undefined;
443
+ const parsed = Number(value.trim());
444
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
445
+ }
446
+
447
+ function getRefIdsFromDirectCommand(commandTokens: string[]): string[] {
448
+ return [...new Set(getGuardedRefUsage(commandTokens))];
449
+ }
450
+
451
+ function commandTextLooksLikeDialogTrigger(commandTokens: string[], refSnapshot?: SessionRefSnapshot): boolean {
452
+ if (commandTokens.some((token) => DIALOG_TRIGGER_TEXT_PATTERN.test(token))) return true;
453
+ for (const refId of getRefIdsFromDirectCommand(commandTokens)) {
454
+ const ref = refSnapshot?.refs?.[refId];
455
+ if (ref && DIALOG_TRIGGER_TEXT_PATTERN.test(`${ref.role} ${ref.name}`)) return true;
456
+ }
457
+ return false;
458
+ }
459
+
460
+ function getDialogAwareProcessTimeoutMs(commandTokens: string[], refSnapshot?: SessionRefSnapshot, stdin?: string): number | undefined {
461
+ const command = commandTokens[0];
462
+ if (command === "dialog") return getPositiveIntegerEnv(DIALOG_COMMAND_PROCESS_TIMEOUT_ENV) ?? DIALOG_COMMAND_PROCESS_TIMEOUT_MS;
463
+ if (command === "eval" && typeof stdin === "string" && DIALOG_TRIGGER_TEXT_PATTERN.test(stdin)) return getPositiveIntegerEnv(LIKELY_DIALOG_TRIGGER_PROCESS_TIMEOUT_ENV) ?? LIKELY_DIALOG_TRIGGER_PROCESS_TIMEOUT_MS;
464
+ if ((command === "click" || command === "tap" || (command === "find" && commandTokens.includes("click"))) && commandTextLooksLikeDialogTrigger(commandTokens, refSnapshot)) return getPositiveIntegerEnv(LIKELY_DIALOG_TRIGGER_PROCESS_TIMEOUT_ENV) ?? LIKELY_DIALOG_TRIGGER_PROCESS_TIMEOUT_MS;
465
+ return undefined;
466
+ }
467
+
468
+ function describeRef(refSnapshot: SessionRefSnapshot | undefined, refId: string): string {
469
+ const ref = refSnapshot?.refs?.[refId];
470
+ return ref ? `${ref.role} ${JSON.stringify(ref.name)}` : "not present";
471
+ }
472
+
473
+ function getSamePageFreshnessPreflightFailure(options: {
474
+ commandTokens: string[];
475
+ currentSnapshot: SessionRefSnapshot;
476
+ previousSnapshot: SessionRefSnapshot;
477
+ }): { message: string; refIds: string[] } | undefined {
478
+ if (options.commandTokens[0] === "batch") return undefined;
479
+ const refIds = getRefIdsFromDirectCommand(options.commandTokens);
480
+ if (refIds.length === 0) return undefined;
481
+ const previousUrl = options.previousSnapshot.target?.url;
482
+ const currentUrl = options.currentSnapshot.target?.url;
483
+ if (!previousUrl || !currentUrl || previousUrl !== currentUrl || currentUrl === "about:blank") return undefined;
484
+ const mismatchedRefs = refIds.filter((refId) => {
485
+ const previous = options.previousSnapshot.refs?.[refId];
486
+ const current = options.currentSnapshot.refs?.[refId];
487
+ if (!options.currentSnapshot.refIds.includes(refId)) return true;
488
+ if (!previous || !current) return previous !== current;
489
+ return previous.role !== current.role || previous.name !== current.name;
490
+ });
491
+ if (mismatchedRefs.length === 0) return undefined;
492
+ const refText = mismatchedRefs.map((refId) => `@${refId}`).join(", ");
493
+ const evidence = mismatchedRefs.map((refId) => `@${refId}: previous ${describeRef(options.previousSnapshot, refId)}, current ${describeRef(options.currentSnapshot, refId)}`).join("; ");
494
+ return {
495
+ message: `Ref ${refText} no longer matches the latest same-page snapshot. The page likely rerendered after the previous snapshot; run snapshot -i and retry with current refs. Evidence: ${evidence}.`,
496
+ refIds: mismatchedRefs,
497
+ };
498
+ }
499
+
500
+ async function collectSamePageRefFreshnessPreflight(options: {
501
+ commandTokens: string[];
502
+ cwd: string;
503
+ currentTarget?: SessionTabTarget;
504
+ previousSnapshot?: SessionRefSnapshot;
505
+ sessionName?: string;
506
+ signal?: AbortSignal;
507
+ }): Promise<StaleRefPreflight | undefined> {
508
+ if (!options.previousSnapshot || !options.sessionName || options.commandTokens[0] === "batch" || getRefIdsFromDirectCommand(options.commandTokens).length === 0) return undefined;
509
+ const previousUrl = options.previousSnapshot.target?.url;
510
+ const currentTargetUrl = options.currentTarget?.url;
511
+ if (currentTargetUrl === "about:blank" || (previousUrl && currentTargetUrl && previousUrl !== currentTargetUrl)) return undefined;
512
+ const snapshotData = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal });
513
+ const currentSnapshot = extractRefSnapshotFromData(snapshotData);
514
+ if (!currentSnapshot) return undefined;
515
+ const snapshotWithTarget = { ...currentSnapshot, target: currentSnapshot.target ?? options.currentTarget };
516
+ const mismatch = getSamePageFreshnessPreflightFailure({ commandTokens: options.commandTokens, currentSnapshot: snapshotWithTarget, previousSnapshot: options.previousSnapshot });
517
+ if (!mismatch) return undefined;
518
+ return { message: mismatch.message, refIds: mismatch.refIds, snapshot: snapshotWithTarget };
519
+ }
520
+
521
+ const SCROLL_CONTAINER_DIRECTIONS = new Set(["down", "left", "right", "up"]);
522
+
523
+ function getContainerScrollRequest(commandTokens: string[]): { amount?: string; direction: string; selector: string } | undefined {
524
+ if (commandTokens[0] !== "scroll" || commandTokens.length < 3) return undefined;
525
+ const selector = commandTokens[1];
526
+ const direction = commandTokens[2]?.toLowerCase();
527
+ if (!selector || selector.startsWith("-") || selector.startsWith("@") || SCROLL_CONTAINER_DIRECTIONS.has(selector.toLowerCase())) return undefined;
528
+ if (!SCROLL_CONTAINER_DIRECTIONS.has(direction)) return undefined;
529
+ return { amount: commandTokens[3], direction, selector };
530
+ }
531
+
532
+ function buildContainerScrollScript(request: { amount?: string; direction: string; selector: string }): string {
533
+ return `(() => {
534
+ const selector = ${JSON.stringify(request.selector)};
535
+ const direction = ${JSON.stringify(request.direction)};
536
+ const amountToken = ${JSON.stringify(request.amount ?? "")};
537
+ let element;
538
+ try { element = document.querySelector(selector); } catch (error) { return { status: "invalid-selector", selector, error: String(error && error.message || error) }; }
539
+ if (!(element instanceof HTMLElement)) return { status: "not-found", selector };
540
+ const axis = direction === "left" || direction === "right" ? "x" : "y";
541
+ const before = { scrollLeft: element.scrollLeft, scrollTop: element.scrollTop, scrollHeight: element.scrollHeight, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, clientWidth: element.clientWidth };
542
+ const parseAmount = () => {
543
+ const token = String(amountToken || "").trim().toLowerCase();
544
+ const extent = axis === "x" ? element.clientWidth : element.clientHeight;
545
+ if (!token) return Math.max(1, Math.floor(extent * 0.8));
546
+ if (token.endsWith("%")) {
547
+ const value = Number(token.slice(0, -1));
548
+ return Number.isFinite(value) ? Math.max(1, Math.floor(extent * value / 100)) : Math.max(1, Math.floor(extent * 0.8));
549
+ }
550
+ const pixels = Number(token.replace(/px$/, ""));
551
+ return Number.isFinite(pixels) && pixels > 0 ? Math.floor(pixels) : Math.max(1, Math.floor(extent * 0.8));
552
+ };
553
+ const delta = parseAmount() * (direction === "up" || direction === "left" ? -1 : 1);
554
+ if (axis === "x") element.scrollLeft += delta;
555
+ else element.scrollTop += delta;
556
+ const after = { scrollLeft: element.scrollLeft, scrollTop: element.scrollTop, scrollHeight: element.scrollHeight, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, clientWidth: element.clientWidth };
557
+ const moved = before.scrollLeft !== after.scrollLeft || before.scrollTop !== after.scrollTop;
558
+ return { status: moved ? "scrolled" : "no-movement", selector, direction, amount: amountToken || undefined, before, after };
559
+ })()`;
560
+ }
561
+
562
+ function buildScrollResult(options: {
563
+ command: "scroll";
564
+ compatibilityWorkaround?: CompatibilityWorkaround;
565
+ effectiveArgs: string[];
566
+ message: string;
567
+ redactedArgs: string[];
568
+ result: Record<string, unknown>;
569
+ scrollField: "scrollContainer" | "scrollPage";
570
+ scrollValue: unknown;
571
+ sessionMode: "auto" | "fresh";
572
+ sessionName?: string;
573
+ succeeded: boolean;
574
+ usedImplicitSession: boolean;
575
+ }): AgentBrowserToolResult {
576
+ return {
577
+ content: [{ type: "text", text: options.message }],
578
+ details: {
579
+ args: options.redactedArgs,
580
+ command: options.command,
581
+ compatibilityWorkaround: options.compatibilityWorkaround,
582
+ data: options.result,
583
+ effectiveArgs: options.effectiveArgs,
584
+ nextActions: options.succeeded ? undefined : buildScrollNoopNextActions(options.sessionName),
585
+ [options.scrollField]: options.scrollValue,
586
+ sessionMode: options.sessionMode,
587
+ ...buildAgentBrowserResultCategoryDetails({ args: options.effectiveArgs, command: options.command, errorText: options.succeeded ? undefined : options.message, succeeded: options.succeeded, validationError: options.succeeded ? undefined : options.message }),
588
+ ...buildSessionDetailFields(options.sessionName, options.usedImplicitSession),
589
+ summary: options.message,
590
+ validationError: options.succeeded ? undefined : options.message,
591
+ },
592
+ isError: !options.succeeded,
593
+ };
594
+ }
595
+
596
+ async function tryContainerScroll(options: {
597
+ commandTokens: string[];
598
+ compatibilityWorkaround?: CompatibilityWorkaround;
599
+ cwd: string;
600
+ effectiveArgs: string[];
601
+ redactedArgs: string[];
602
+ sessionMode: "auto" | "fresh";
603
+ sessionName?: string;
604
+ signal?: AbortSignal;
605
+ usedImplicitSession: boolean;
606
+ }): Promise<AgentBrowserToolResult | undefined> {
607
+ const request = getContainerScrollRequest(options.commandTokens);
608
+ if (!request || !options.sessionName) return undefined;
609
+ const data = await runSessionCommandData({ args: ["eval", "--stdin"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, stdin: buildContainerScrollScript(request) });
610
+ const result = isRecord(data) && isRecord(data.result) ? data.result : data;
611
+ if (!isRecord(result) || typeof result.status !== "string") return undefined;
612
+ const succeeded = result.status === "scrolled";
613
+ const message = succeeded
614
+ ? `Scrolled container ${request.selector} ${request.direction}${request.amount ? ` by ${request.amount}` : ""}.`
615
+ : `Scroll container ${request.selector} did not move (${result.status}).`;
616
+ return buildScrollResult({ ...options, command: "scroll", message, result, scrollField: "scrollContainer", scrollValue: { request, result }, succeeded });
617
+ }
618
+
619
+ function getPageScrollToRequest(commandTokens: string[]): { target: "end" | "top" } | undefined {
620
+ if (commandTokens[0] !== "scroll" || commandTokens[1]?.toLowerCase() !== "to") return undefined;
621
+ const target = commandTokens[2]?.toLowerCase();
622
+ return target === "end" || target === "top" ? { target } : undefined;
623
+ }
624
+
625
+ function buildPageScrollToScript(request: { target: "end" | "top" }): string {
626
+ return `(() => {
627
+ const target = ${JSON.stringify(request.target)};
628
+ const scroller = document.scrollingElement || document.documentElement || document.body;
629
+ if (!scroller) return { status: "no-scroller", target };
630
+ const before = { scrollLeft: scroller.scrollLeft, scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, scrollWidth: scroller.scrollWidth, clientHeight: scroller.clientHeight, clientWidth: scroller.clientWidth };
631
+ const nextTop = target === "top" ? 0 : Math.max(0, scroller.scrollHeight - scroller.clientHeight);
632
+ const nextLeft = scroller.scrollLeft;
633
+ scroller.scrollTop = nextTop;
634
+ window.scrollTo(nextLeft, nextTop);
635
+ const after = { scrollLeft: scroller.scrollLeft, scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, scrollWidth: scroller.scrollWidth, clientHeight: scroller.clientHeight, clientWidth: scroller.clientWidth };
636
+ const moved = before.scrollLeft !== after.scrollLeft || before.scrollTop !== after.scrollTop;
637
+ return { status: moved ? "scrolled" : "no-movement", target, before, after };
638
+ })()`;
639
+ }
640
+
641
+ async function tryPageScrollTo(options: {
642
+ commandTokens: string[];
643
+ compatibilityWorkaround?: CompatibilityWorkaround;
644
+ cwd: string;
645
+ effectiveArgs: string[];
646
+ redactedArgs: string[];
647
+ sessionMode: "auto" | "fresh";
648
+ sessionName?: string;
649
+ signal?: AbortSignal;
650
+ usedImplicitSession: boolean;
651
+ }): Promise<AgentBrowserToolResult | undefined> {
652
+ const request = getPageScrollToRequest(options.commandTokens);
653
+ if (!request || !options.sessionName) return undefined;
654
+ const data = await runSessionCommandData({ args: ["eval", "--stdin"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, stdin: buildPageScrollToScript(request) });
655
+ const result = isRecord(data) && isRecord(data.result) ? data.result : data;
656
+ if (!isRecord(result) || typeof result.status !== "string") return undefined;
657
+ const succeeded = result.status === "scrolled";
658
+ const message = succeeded ? `Scrolled page to ${request.target}.` : `Scroll to ${request.target} completed with no observed movement (${result.status}).`;
659
+ return buildScrollResult({ ...options, command: "scroll", message, result, scrollField: "scrollPage", scrollValue: { request, result }, succeeded });
660
+ }
661
+
662
+ interface SnapshotFilterRequest {
663
+ cleanArgs: string[];
664
+ diff?: boolean;
665
+ role?: string;
666
+ search?: string;
667
+ viewport?: boolean;
668
+ }
669
+
670
+ function parseSnapshotFilterRequest(commandTokens: string[]): SnapshotFilterRequest | undefined {
671
+ if (commandTokens[0] !== "snapshot") return undefined;
672
+ const cleanArgs: string[] = [];
673
+ let role: string | undefined;
674
+ let search: string | undefined;
675
+ for (let index = 0; index < commandTokens.length; index += 1) {
676
+ const token = commandTokens[index];
677
+ if (token === "--viewport") continue;
678
+ if (token === "--diff") continue;
679
+ if (token === "--search") {
680
+ const value = commandTokens[index + 1];
681
+ if (typeof value === "string" && !value.startsWith("-")) {
682
+ search = value;
683
+ index += 1;
684
+ continue;
685
+ }
686
+ }
687
+ if (token === "--filter") {
688
+ const value = commandTokens[index + 1];
689
+ if (typeof value === "string" && !value.startsWith("-")) {
690
+ const roleMatch = /^role=(.+)$/i.exec(value.trim());
691
+ if (roleMatch?.[1]) role = roleMatch[1].trim().toLowerCase();
692
+ index += 1;
693
+ continue;
694
+ }
695
+ }
696
+ cleanArgs.push(token);
697
+ }
698
+ const viewport = commandTokens.includes("--viewport");
699
+ const diff = commandTokens.includes("--diff");
700
+ if (!search && !role && !viewport && !diff) return undefined;
701
+ return { cleanArgs, diff, role, search, viewport };
702
+ }
703
+
704
+ interface SnapshotDiffSummary {
705
+ addedRefs: string[];
706
+ changedRefs: string[];
707
+ removedRefs: string[];
708
+ summary: string;
709
+ unchangedRefs: number;
710
+ }
711
+
712
+ function buildSnapshotDiff(previous: SessionRefSnapshot | undefined, current: SessionRefSnapshot | undefined): SnapshotDiffSummary | undefined {
713
+ if (!current) return undefined;
714
+ const currentRefs = current.refs ?? {};
715
+ const previousRefs = previous?.refs ?? {};
716
+ if (!previous) return { addedRefs: Object.keys(currentRefs), changedRefs: [], removedRefs: [], summary: `Snapshot diff: no previous snapshot; ${Object.keys(currentRefs).length} current refs recorded.`, unchangedRefs: 0 };
717
+ const addedRefs: string[] = [];
718
+ const removedRefs: string[] = [];
719
+ const changedRefs: string[] = [];
720
+ let unchangedRefs = 0;
721
+ for (const refId of Object.keys(currentRefs)) {
722
+ const currentRef = currentRefs[refId];
723
+ const previousRef = previousRefs[refId];
724
+ if (!previousRef) {
725
+ addedRefs.push(refId);
726
+ continue;
727
+ }
728
+ if (previousRef.role !== currentRef.role || previousRef.name !== currentRef.name) changedRefs.push(refId);
729
+ else unchangedRefs += 1;
730
+ }
731
+ for (const refId of Object.keys(previousRefs)) if (!currentRefs[refId]) removedRefs.push(refId);
732
+ return { addedRefs, changedRefs, removedRefs, summary: `Snapshot diff: +${addedRefs.length} / -${removedRefs.length} / Δ${changedRefs.length} refs versus previous snapshot.`, unchangedRefs };
733
+ }
734
+
735
+ function filterSnapshotData(data: unknown, request: SnapshotFilterRequest): { data: Record<string, unknown>; matchedRefs: number; totalRefs: number; totalLines: number; visibleLines: number } | undefined {
736
+ if (!isRecord(data)) return undefined;
737
+ const refs = isRecord(data.refs) ? data.refs : {};
738
+ const snapshot = typeof data.snapshot === "string" ? data.snapshot : "";
739
+ const normalizedSearch = request.search?.trim().toLowerCase();
740
+ const matchingRefIds = new Set<string>();
741
+ for (const [refId, refValue] of Object.entries(refs)) {
742
+ if (!isRecord(refValue)) continue;
743
+ const role = typeof refValue.role === "string" ? refValue.role.toLowerCase() : "";
744
+ const name = typeof refValue.name === "string" ? refValue.name : "";
745
+ const roleMatches = request.role ? role === request.role : true;
746
+ const searchMatches = normalizedSearch ? `${role} ${name}`.toLowerCase().includes(normalizedSearch) : true;
747
+ if (roleMatches && searchMatches) matchingRefIds.add(refId);
748
+ }
749
+ const lines = snapshot.split(/\r?\n/);
750
+ const visibleLines = lines.filter((line) => {
751
+ const normalizedLine = line.toLowerCase();
752
+ if (normalizedSearch && normalizedLine.includes(normalizedSearch)) return true;
753
+ return [...matchingRefIds].some((refId) => line.includes(`[ref=${refId}]`) || line.includes(`ref=${refId}`));
754
+ });
755
+ const filteredRefs = Object.fromEntries(Object.entries(refs).filter(([refId]) => matchingRefIds.has(refId)));
756
+ const description = [request.role ? `role=${request.role}` : undefined, request.search ? `search=${JSON.stringify(request.search)}` : undefined].filter((part): part is string => part !== undefined).join(", ");
757
+ const filteredSnapshot = visibleLines.length > 0 ? visibleLines.join("\n") : `(no snapshot lines matched ${description})`;
758
+ return {
759
+ data: { ...data, refs: filteredRefs, snapshot: filteredSnapshot },
760
+ matchedRefs: Object.keys(filteredRefs).length,
761
+ totalRefs: Object.keys(refs).length,
762
+ totalLines: lines.filter((line) => line.length > 0).length,
763
+ visibleLines: visibleLines.length,
764
+ };
765
+ }
766
+
767
+ async function trySnapshotFilter(options: {
768
+ commandTokens: string[];
769
+ compatibilityWorkaround?: CompatibilityWorkaround;
770
+ cwd: string;
771
+ effectiveArgs: string[];
772
+ redactedArgs: string[];
773
+ previousRefSnapshot?: SessionRefSnapshot;
774
+ sessionMode: "auto" | "fresh";
775
+ sessionName?: string;
776
+ sessionPageState: BrowserRunOptions["state"]["sessionPageState"];
777
+ sessionPageStateUpdate: ReturnType<BrowserRunOptions["state"]["sessionPageState"]["beginUpdate"]>;
778
+ signal?: AbortSignal;
779
+ usedImplicitSession: boolean;
780
+ }): Promise<AgentBrowserToolResult | undefined> {
781
+ const request = parseSnapshotFilterRequest(options.commandTokens);
782
+ if (!request || !options.sessionName) return undefined;
783
+ const snapshotData = await runSessionCommandData({ args: request.cleanArgs, cwd: options.cwd, sessionName: options.sessionName, signal: options.signal });
784
+ const filtered = request.role || request.search ? filterSnapshotData(snapshotData, request) : isRecord(snapshotData) ? { data: snapshotData, matchedRefs: isRecord(snapshotData.refs) ? Object.keys(snapshotData.refs).length : 0, totalLines: typeof snapshotData.snapshot === "string" ? snapshotData.snapshot.split(/\r?\n/).filter((line) => line.length > 0).length : 0, totalRefs: isRecord(snapshotData.refs) ? Object.keys(snapshotData.refs).length : 0, visibleLines: typeof snapshotData.snapshot === "string" ? snapshotData.snapshot.split(/\r?\n/).filter((line) => line.length > 0).length : 0 } : undefined;
785
+ if (!filtered) return undefined;
786
+ const viewport = request.viewport ? await collectScrollPositionSnapshot({ cwd: options.cwd, sessionName: options.sessionName, signal: options.signal }) : undefined;
787
+ const fullSnapshot = extractRefSnapshotFromData(snapshotData);
788
+ const diff = request.diff ? buildSnapshotDiff(options.previousRefSnapshot, fullSnapshot) : undefined;
789
+ if (fullSnapshot) options.sessionPageState.applyRefSnapshot({ sessionName: options.sessionName, snapshot: fullSnapshot, update: options.sessionPageStateUpdate });
790
+ const presentation = await buildSnapshotPresentation(filtered.data);
791
+ const summary = request.role || request.search
792
+ ? `Snapshot filter: ${filtered.matchedRefs}/${filtered.totalRefs} refs matched${request.role ? ` role=${request.role}` : ""}${request.search ? ` search ${JSON.stringify(request.search)}` : ""}.`
793
+ : request.diff
794
+ ? diff?.summary ?? "Snapshot diff unavailable."
795
+ : "Snapshot viewport metadata collected.";
796
+ const viewportText = viewport ? `Viewport: ${viewport.innerWidth}×${viewport.innerHeight}, scroll ${viewport.scrollX},${viewport.scrollY}, document ${viewport.scrollWidth}×${viewport.scrollHeight}, sampled scroll containers ${viewport.containers.length}/${viewport.containerCount}.` : undefined;
797
+ const diffText = diff && (request.role || request.search) ? diff.summary : undefined;
798
+ const prefix = [summary, diffText, viewportText].filter((line): line is string => line !== undefined).join("\n");
799
+ if (presentation.content[0]?.type === "text") presentation.content[0] = { ...presentation.content[0], text: `${prefix}\n\n${presentation.content[0].text}` };
800
+ return {
801
+ content: presentation.content,
802
+ details: {
803
+ args: options.redactedArgs,
804
+ command: "snapshot",
805
+ compatibilityWorkaround: options.compatibilityWorkaround,
806
+ data: presentation.data,
807
+ effectiveArgs: options.effectiveArgs,
808
+ refSnapshot: fullSnapshot,
809
+ sessionMode: options.sessionMode,
810
+ snapshotDiff: diff,
811
+ snapshotFilter: request.role || request.search ? { cleanArgs: request.cleanArgs, matchedRefs: filtered.matchedRefs, role: request.role, search: request.search, totalLines: filtered.totalLines, totalRefs: filtered.totalRefs, visibleLines: filtered.visibleLines } : undefined,
812
+ snapshotViewport: viewport,
813
+ ...buildAgentBrowserResultCategoryDetails({ args: options.effectiveArgs, command: "snapshot", succeeded: true }),
814
+ ...buildSessionDetailFields(options.sessionName, options.usedImplicitSession),
815
+ summary,
816
+ },
817
+ isError: false,
818
+ };
819
+ }
820
+
821
+ interface NetworkRequestsPageFilterRequest {
822
+ cleanArgs: string[];
823
+ mode: "origin" | "url";
824
+ }
825
+
826
+ function parseNetworkRequestsPageFilterRequest(commandTokens: string[]): NetworkRequestsPageFilterRequest | undefined {
827
+ if (commandTokens[0] !== "network" || commandTokens[1] !== "requests") return undefined;
828
+ const cleanArgs: string[] = [];
829
+ let mode: NetworkRequestsPageFilterRequest["mode"] | undefined;
830
+ for (const token of commandTokens) {
831
+ if (token === "--current-page" || token === "--current-origin") {
832
+ mode = "origin";
833
+ continue;
834
+ }
835
+ if (token === "--current-url") {
836
+ mode = "url";
837
+ continue;
838
+ }
839
+ cleanArgs.push(token);
840
+ }
841
+ if (!mode) return undefined;
842
+ return { cleanArgs, mode };
843
+ }
844
+
845
+ function extractCurrentUrl(data: unknown): string | undefined {
846
+ if (typeof data === "string") return data;
847
+ if (!isRecord(data)) return undefined;
848
+ const candidates = [data.url, data.currentUrl, data.href, data.result];
849
+ for (const candidate of candidates) if (typeof candidate === "string" && candidate.length > 0) return candidate;
850
+ return undefined;
851
+ }
852
+
853
+ function getRequestUrl(row: unknown): string | undefined {
854
+ if (!isRecord(row)) return undefined;
855
+ const candidate = row.url ?? row.requestUrl ?? row.href;
856
+ return typeof candidate === "string" ? candidate : undefined;
857
+ }
858
+
859
+ function requestMatchesCurrentPage(row: unknown, currentUrl: string, mode: NetworkRequestsPageFilterRequest["mode"]): boolean {
860
+ const requestUrl = getRequestUrl(row);
861
+ if (!requestUrl) return false;
862
+ try {
863
+ const current = new URL(currentUrl);
864
+ const request = new URL(requestUrl, current);
865
+ if (mode === "origin") return current.origin === request.origin;
866
+ const currentComparable = `${current.origin}${current.pathname}`;
867
+ const requestComparable = `${request.origin}${request.pathname}`;
868
+ return requestComparable === currentComparable;
869
+ } catch {
870
+ return mode === "url" ? requestUrl === currentUrl : requestUrl.startsWith(currentUrl);
871
+ }
872
+ }
873
+
874
+ function filterNetworkRequestsData(data: unknown, currentUrl: string, request: NetworkRequestsPageFilterRequest): { data: Record<string, unknown>; matchedRows: number; totalRows: number; rows: unknown[] } | undefined {
875
+ if (!isRecord(data)) return undefined;
876
+ const requestRows = Array.isArray(data.requests) ? data.requests : Array.isArray(data.items) ? data.items : Array.isArray(data.entries) ? data.entries : undefined;
877
+ if (!requestRows) return undefined;
878
+ const rows = requestRows.filter((row) => requestMatchesCurrentPage(row, currentUrl, request.mode));
879
+ const key = Array.isArray(data.requests) ? "requests" : Array.isArray(data.items) ? "items" : "entries";
880
+ return { data: { ...data, [key]: rows }, matchedRows: rows.length, rows, totalRows: requestRows.length };
881
+ }
882
+
883
+ function formatNetworkRequestRow(row: unknown): string {
884
+ if (!isRecord(row)) return redactSensitiveText(String(row));
885
+ const status = row.status ?? row.statusCode ?? row.responseStatus ?? "?";
886
+ const method = typeof row.method === "string" ? row.method : typeof row.requestMethod === "string" ? row.requestMethod : "?";
887
+ const id = typeof row.id === "string" ? ` id=${row.id}` : typeof row.requestId === "string" ? ` id=${row.requestId}` : "";
888
+ const url = getRequestUrl(row) ?? "(no url)";
889
+ return redactSensitiveText(`- ${status} ${method}${id} ${url}`);
890
+ }
891
+
892
+ async function tryNetworkRequestsPageFilter(options: {
893
+ commandTokens: string[];
894
+ compatibilityWorkaround?: CompatibilityWorkaround;
895
+ cwd: string;
896
+ effectiveArgs: string[];
897
+ redactedArgs: string[];
898
+ sessionMode: "auto" | "fresh";
899
+ sessionName?: string;
900
+ signal?: AbortSignal;
901
+ usedImplicitSession: boolean;
902
+ }): Promise<AgentBrowserToolResult | undefined> {
903
+ const request = parseNetworkRequestsPageFilterRequest(options.commandTokens);
904
+ if (!request || !options.sessionName) return undefined;
905
+ const currentUrl = extractCurrentUrl(await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal }));
906
+ if (!currentUrl) return undefined;
907
+ const networkData = await runSessionCommandData({ args: request.cleanArgs, cwd: options.cwd, sessionName: options.sessionName, signal: options.signal });
908
+ const filtered = filterNetworkRequestsData(networkData, currentUrl, request);
909
+ if (!filtered) return undefined;
910
+ const summary = `Network requests filtered to current ${request.mode === "origin" ? "origin" : "URL"}: ${filtered.matchedRows}/${filtered.totalRows} rows matched.`;
911
+ const preview = filtered.rows.slice(0, 12).map(formatNetworkRequestRow);
912
+ const omitted = filtered.rows.length > preview.length ? [`- …${filtered.rows.length - preview.length} more matching rows omitted`] : [];
913
+ return {
914
+ content: [{ type: "text", text: [redactSensitiveText(summary), `Current page: ${redactSensitiveText(currentUrl)}`, ...preview, ...omitted].join("\n") }],
915
+ details: {
916
+ args: options.redactedArgs,
917
+ command: "network",
918
+ compatibilityWorkaround: options.compatibilityWorkaround,
919
+ data: filtered.data,
920
+ effectiveArgs: options.effectiveArgs,
921
+ networkRequestsPageFilter: { cleanArgs: request.cleanArgs, currentUrl: redactSensitiveText(currentUrl), matchedRows: filtered.matchedRows, mode: request.mode, totalRows: filtered.totalRows },
922
+ sessionMode: options.sessionMode,
923
+ ...buildAgentBrowserResultCategoryDetails({ args: options.effectiveArgs, command: "network", succeeded: true }),
924
+ ...buildSessionDetailFields(options.sessionName, options.usedImplicitSession),
925
+ summary,
926
+ },
927
+ isError: false,
928
+ };
929
+ }
930
+
292
931
  function isPasswordStdinAuthSave(options: { command?: string; commandTokens: string[] }): boolean {
293
932
  return options.command === "auth" && options.commandTokens[1] === "save" && options.commandTokens.includes("--password-stdin");
294
933
  }
@@ -514,24 +1153,6 @@ export async function prepareBrowserRun(options: BrowserRunOptions): Promise<Pre
514
1153
  ? { ...semanticActionVisibleRefResolution.snapshot, target: semanticActionVisibleRefResolution.snapshot.target ?? priorSessionTabTarget }
515
1154
  : undefined;
516
1155
  const promptRefSnapshot = resolvedSemanticActionRefSnapshot ?? priorRefSnapshotState;
517
- const stopBoundaryViolation = findStopBoundaryViolation({ commandTokens, promptPolicy: options.promptPolicy, refSnapshot: promptRefSnapshot, stdin: runtimeToolStdin });
518
- if (stopBoundaryViolation) {
519
- return { kind: "early-result", statePatch, result: {
520
- content: [{ type: "text", text: stopBoundaryViolation.message }],
521
- details: {
522
- args: redactedArgs,
523
- command: executionPlan.commandInfo.command,
524
- compatibilityWorkaround,
525
- effectiveArgs: redactedEffectiveArgs,
526
- promptGuard: stopBoundaryViolation,
527
- sessionMode,
528
- ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: stopBoundaryViolation.message, failureCategory: "policy-blocked", succeeded: false, validationError: stopBoundaryViolation.message }),
529
- validationError: stopBoundaryViolation.message,
530
- ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
531
- },
532
- isError: true,
533
- } };
534
- }
535
1156
  const requestedArtifactCloseViolation = await findRequestedArtifactCloseViolation({ artifactManifest: state.artifactManifest, command: executionPlan.commandInfo.command, cwd, promptPolicy: options.promptPolicy });
536
1157
  if (requestedArtifactCloseViolation) {
537
1158
  return { kind: "early-result", statePatch, result: {
@@ -576,6 +1197,35 @@ export async function prepareBrowserRun(options: BrowserRunOptions): Promise<Pre
576
1197
  isError: true,
577
1198
  } };
578
1199
  }
1200
+ const samePageRefFreshnessPreflight = await collectSamePageRefFreshnessPreflight({
1201
+ commandTokens,
1202
+ cwd,
1203
+ currentTarget: priorSessionTabTarget,
1204
+ previousSnapshot: resolvedSemanticActionRefSnapshot ? undefined : priorRefSnapshotState,
1205
+ sessionName: executionPlan.sessionName,
1206
+ signal,
1207
+ });
1208
+ if (samePageRefFreshnessPreflight) {
1209
+ if (samePageRefFreshnessPreflight.snapshot && executionPlan.sessionName) {
1210
+ sessionPageState.applyRefSnapshot({ fallbackTarget: priorSessionTabTarget, sessionName: executionPlan.sessionName, snapshot: samePageRefFreshnessPreflight.snapshot, update: options.sessionPageStateUpdate });
1211
+ }
1212
+ return { kind: "early-result", statePatch, result: {
1213
+ content: [{ type: "text", text: samePageRefFreshnessPreflight.message }],
1214
+ details: {
1215
+ args: redactedArgs,
1216
+ command: executionPlan.commandInfo.command,
1217
+ compatibilityWorkaround,
1218
+ effectiveArgs: redactedEffectiveArgs,
1219
+ nextActions: buildSessionAwareStaleRefNextActions(executionPlan.sessionName),
1220
+ refIds: samePageRefFreshnessPreflight.refIds,
1221
+ refSnapshot: samePageRefFreshnessPreflight.snapshot,
1222
+ sessionMode,
1223
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: samePageRefFreshnessPreflight.message, failureCategory: "stale-ref", succeeded: false }),
1224
+ ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
1225
+ },
1226
+ isError: true,
1227
+ } };
1228
+ }
579
1229
 
580
1230
  if (compiledQaPreset?.checks.attached) {
581
1231
  const qaAttachedPrecondition = await validateQaAttachedPrecondition({
@@ -602,6 +1252,73 @@ export async function prepareBrowserRun(options: BrowserRunOptions): Promise<Pre
602
1252
  }
603
1253
  }
604
1254
 
1255
+ const snapshotFilter = await trySnapshotFilter({
1256
+ commandTokens,
1257
+ compatibilityWorkaround,
1258
+ cwd,
1259
+ effectiveArgs: redactedEffectiveArgs,
1260
+ previousRefSnapshot: priorRefSnapshotState,
1261
+ redactedArgs,
1262
+ sessionMode,
1263
+ sessionName: executionPlan.sessionName,
1264
+ sessionPageState,
1265
+ sessionPageStateUpdate: options.sessionPageStateUpdate,
1266
+ signal,
1267
+ usedImplicitSession: executionPlan.usedImplicitSession,
1268
+ });
1269
+ if (snapshotFilter) return { kind: "early-result", statePatch, result: snapshotFilter };
1270
+
1271
+ const networkRequestsPageFilter = await tryNetworkRequestsPageFilter({
1272
+ commandTokens,
1273
+ compatibilityWorkaround,
1274
+ cwd,
1275
+ effectiveArgs: redactedEffectiveArgs,
1276
+ redactedArgs,
1277
+ sessionMode,
1278
+ sessionName: executionPlan.sessionName,
1279
+ signal,
1280
+ usedImplicitSession: executionPlan.usedImplicitSession,
1281
+ });
1282
+ if (networkRequestsPageFilter) return { kind: "early-result", statePatch, result: networkRequestsPageFilter };
1283
+
1284
+ const containerScroll = await tryContainerScroll({
1285
+ commandTokens,
1286
+ compatibilityWorkaround,
1287
+ cwd,
1288
+ effectiveArgs: redactedEffectiveArgs,
1289
+ redactedArgs,
1290
+ sessionMode,
1291
+ sessionName: executionPlan.sessionName,
1292
+ signal,
1293
+ usedImplicitSession: executionPlan.usedImplicitSession,
1294
+ });
1295
+ if (containerScroll) return { kind: "early-result", statePatch, result: containerScroll };
1296
+ const pageScrollTo = await tryPageScrollTo({
1297
+ commandTokens,
1298
+ compatibilityWorkaround,
1299
+ cwd,
1300
+ effectiveArgs: redactedEffectiveArgs,
1301
+ redactedArgs,
1302
+ sessionMode,
1303
+ sessionName: executionPlan.sessionName,
1304
+ signal,
1305
+ usedImplicitSession: executionPlan.usedImplicitSession,
1306
+ });
1307
+ if (pageScrollTo) return { kind: "early-result", statePatch, result: pageScrollTo };
1308
+
1309
+ const directAnchorDownload = await tryDirectAnchorDownload({
1310
+ commandTokens,
1311
+ compatibilityWorkaround,
1312
+ cwd,
1313
+ effectiveArgs: redactedEffectiveArgs,
1314
+ redactedArgs,
1315
+ sessionMode,
1316
+ sessionName: executionPlan.sessionName,
1317
+ signal,
1318
+ usedImplicitSession: executionPlan.usedImplicitSession,
1319
+ });
1320
+ if (directAnchorDownload) return { kind: "early-result", statePatch, result: directAnchorDownload };
1321
+
605
1322
  let pinnedBatchUnwrapMode: PreparedBrowserRun["pinnedBatchUnwrapMode"];
606
1323
  let includePinnedNavigationSummary = false;
607
1324
  let sessionTabCorrection: PreparedBrowserRun["sessionTabCorrection"];
@@ -701,6 +1418,7 @@ export async function prepareBrowserRun(options: BrowserRunOptions): Promise<Pre
701
1418
  const clickDispatchProbe = pinnedBatchUnwrapMode === undefined && compiledElectron === undefined
702
1419
  ? await prepareClickDispatchProbe({ commandTokens, cwd, refSnapshot: promptRefSnapshot, sessionName: executionPlan.sessionName, signal })
703
1420
  : undefined;
1421
+ const processTimeoutMs = options.params.timeoutMs ?? getDialogAwareProcessTimeoutMs(commandTokens, promptRefSnapshot, processStdin);
704
1422
  const redactedProcessArgs = redactInvocationArgs(processArgs);
705
1423
  const shouldProbeScrollNoop = executionPlan.commandInfo.command === "scroll" && executionPlan.startupScopedFlags.length === 0;
706
1424
  const scrollPositionBefore = shouldProbeScrollNoop
@@ -738,6 +1456,7 @@ export async function prepareBrowserRun(options: BrowserRunOptions): Promise<Pre
738
1456
  priorSessionTabTarget,
739
1457
  processArgs,
740
1458
  processStdin,
1459
+ processTimeoutMs,
741
1460
  redactedArgs,
742
1461
  redactedCompiledElectron,
743
1462
  redactedCompiledJob,