pi-agent-browser-native 0.2.24 → 0.2.26

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.
@@ -6,7 +6,7 @@
6
6
  * Invariants/Assumptions: agent-browser is installed separately on PATH, the wrapper targets the current locally installed upstream version only, and no backward-compatibility shims are provided.
7
7
  */
8
8
 
9
- import { copyFile, mkdir, readFile, rm, stat } from "node:fs/promises";
9
+ import { copyFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
10
10
  import { dirname, extname, isAbsolute, join, resolve } from "node:path";
11
11
 
12
12
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -27,11 +27,14 @@ import {
27
27
  } from "./lib/playbook.js";
28
28
  import { SAFE_AGENT_BROWSER_OPERATION_TIMEOUT_MS, runAgentBrowserProcess } from "./lib/process.js";
29
29
  import {
30
+ buildAgentBrowserNextActions,
31
+ buildAgentBrowserResultCategoryDetails,
30
32
  buildToolPresentation,
31
33
  getAgentBrowserErrorText,
32
34
  parseAgentBrowserEnvelope,
33
35
  type AgentBrowserBatchResult,
34
36
  type AgentBrowserEnvelope,
37
+ type AgentBrowserNextAction,
35
38
  } from "./lib/results.js";
36
39
  import {
37
40
  buildExecutionPlan,
@@ -54,6 +57,7 @@ import {
54
57
  resolveManagedSessionState,
55
58
  shouldAppendBrowserSystemPrompt,
56
59
  validateToolArgs,
60
+ type CommandInfo,
57
61
  type CompatibilityWorkaround,
58
62
  type OpenResultTabCorrection,
59
63
  } from "./lib/runtime.js";
@@ -70,22 +74,211 @@ import {
70
74
  formatSessionArtifactRetentionSummary,
71
75
  isSessionArtifactManifest,
72
76
  mergeSessionArtifactManifest,
77
+ summarizeNetworkFailures,
73
78
  } from "./lib/results/shared.js";
74
79
 
75
80
  const DEFAULT_SESSION_MODE = "auto" as const;
76
81
  const DIRECT_AGENT_BROWSER_BASH_BYPASS_ENV = "PI_AGENT_BROWSER_ALLOW_DIRECT_BASH";
77
82
  const PACKAGE_NAME = "pi-agent-browser-native";
78
83
 
84
+ const AGENT_BROWSER_SEMANTIC_ACTIONS = ["check", "click", "fill", "select", "uncheck"] as const;
85
+ const AGENT_BROWSER_SEMANTIC_LOCATORS = ["alt", "label", "placeholder", "role", "testid", "text", "title"] as const;
86
+ const AGENT_BROWSER_JOB_STEP_ACTIONS = ["open", "click", "fill", "wait", "assertText", "assertUrl", "waitForDownload", "screenshot"] as const;
87
+ const SOURCE_LOOKUP_WORKSPACE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
88
+ const SOURCE_LOOKUP_IGNORED_DIRECTORIES = new Set([".git", "node_modules", "dist", "build", "coverage", ".next", "out", "tmp", "temp"]);
89
+ const SOURCE_LOOKUP_DEFAULT_MAX_WORKSPACE_FILES = 2_000;
90
+ const SOURCE_LOOKUP_MAX_WORKSPACE_FILES = 5_000;
91
+
92
+ type AgentBrowserSemanticActionName = (typeof AGENT_BROWSER_SEMANTIC_ACTIONS)[number];
93
+ type AgentBrowserSemanticLocator = (typeof AGENT_BROWSER_SEMANTIC_LOCATORS)[number];
94
+ type AgentBrowserJobStepAction = (typeof AGENT_BROWSER_JOB_STEP_ACTIONS)[number];
95
+ type AgentBrowserSourceLookupStatus = "candidates-found" | "no-candidates" | "unsupported";
96
+ type AgentBrowserNetworkSourceLookupStatus = "failed-requests-found" | "no-failed-requests" | "no-candidates";
97
+
98
+ interface AgentBrowserSemanticActionInput {
99
+ action: AgentBrowserSemanticActionName;
100
+ locator: AgentBrowserSemanticLocator;
101
+ value: string;
102
+ text?: string;
103
+ role?: string;
104
+ name?: string;
105
+ session?: string;
106
+ }
107
+
108
+ interface CompiledAgentBrowserSemanticAction {
109
+ action: AgentBrowserSemanticActionName;
110
+ locator: AgentBrowserSemanticLocator;
111
+ args: string[];
112
+ }
113
+
114
+ interface CompiledAgentBrowserJobStep {
115
+ action: AgentBrowserJobStepAction;
116
+ args: string[];
117
+ }
118
+
119
+ interface CompiledAgentBrowserJob {
120
+ args: string[];
121
+ stdin: string;
122
+ steps: CompiledAgentBrowserJobStep[];
123
+ }
124
+
125
+ interface CompiledAgentBrowserQaPreset extends CompiledAgentBrowserJob {
126
+ checks: {
127
+ checkConsole: boolean;
128
+ checkErrors: boolean;
129
+ checkNetwork: boolean;
130
+ expectedText: string[];
131
+ expectedSelector?: string;
132
+ screenshotPath?: string;
133
+ url: string;
134
+ };
135
+ }
136
+
137
+ interface CompiledAgentBrowserSourceLookupStep {
138
+ action: "dom" | "react";
139
+ args: string[];
140
+ }
141
+
142
+ interface CompiledAgentBrowserSourceLookup {
143
+ args: string[];
144
+ stdin: string;
145
+ steps: CompiledAgentBrowserSourceLookupStep[];
146
+ query: {
147
+ componentName?: string;
148
+ includeDomHints: boolean;
149
+ maxWorkspaceFiles: number;
150
+ reactFiberId?: string;
151
+ selector?: string;
152
+ };
153
+ }
154
+
155
+ interface AgentBrowserSourceLookupCandidate {
156
+ column?: number;
157
+ componentName?: string;
158
+ confidence: "high" | "medium" | "low";
159
+ evidence: string[];
160
+ file?: string;
161
+ line?: number;
162
+ source: "react-inspect" | "dom-attribute" | "workspace-search";
163
+ }
164
+
165
+ interface AgentBrowserSourceLookupAnalysis {
166
+ candidates: AgentBrowserSourceLookupCandidate[];
167
+ limitations: string[];
168
+ status: AgentBrowserSourceLookupStatus;
169
+ summary: string;
170
+ }
171
+
172
+ interface CompiledAgentBrowserNetworkSourceLookup {
173
+ args: string[];
174
+ stdin: string;
175
+ steps: Array<{ action: "network"; args: string[] }>;
176
+ query: {
177
+ filter?: string;
178
+ maxWorkspaceFiles: number;
179
+ requestId?: string;
180
+ url?: string;
181
+ };
182
+ }
183
+
184
+ interface AgentBrowserNetworkSourceLookupRequest {
185
+ error?: string;
186
+ method?: string;
187
+ requestId?: string;
188
+ status?: number;
189
+ url?: string;
190
+ }
191
+
192
+ interface AgentBrowserNetworkSourceLookupCandidate {
193
+ confidence: "high" | "medium" | "low";
194
+ evidence: string[];
195
+ file?: string;
196
+ line?: number;
197
+ requestUrl?: string;
198
+ source: "initiator" | "workspace-search";
199
+ }
200
+
201
+ interface AgentBrowserNetworkSourceLookupAnalysis {
202
+ candidates: AgentBrowserNetworkSourceLookupCandidate[];
203
+ failedRequests: AgentBrowserNetworkSourceLookupRequest[];
204
+ limitations: string[];
205
+ status: AgentBrowserNetworkSourceLookupStatus;
206
+ summary: string;
207
+ }
208
+
79
209
  const AGENT_BROWSER_PARAMS = Type.Object({
80
- args: Type.Array(Type.String({ description: "Exact agent-browser CLI arguments, excluding the binary name." }), {
81
- description: "Exact agent-browser CLI arguments, excluding the binary name and any shell operators.",
82
- minItems: 1,
83
- }),
84
- stdin: Type.Optional(Type.String({ description: "Optional raw stdin content; only supported for batch, eval --stdin, and auth save --password-stdin." })),
210
+
211
+ args: Type.Optional(
212
+ Type.Array(Type.String({ description: "Exact agent-browser CLI arguments, excluding the binary name." }), {
213
+ description: "Exact agent-browser CLI arguments, excluding the binary name and any shell operators. Required unless semanticAction, job, qa, sourceLookup, or networkSourceLookup is provided.",
214
+ minItems: 1,
215
+ }),
216
+ ),
217
+ semanticAction: Type.Optional(
218
+ Type.Object({
219
+ action: StringEnum(AGENT_BROWSER_SEMANTIC_ACTIONS, {
220
+ description: "Intent action to compile to an existing agent-browser find command.",
221
+ }),
222
+ locator: StringEnum(AGENT_BROWSER_SEMANTIC_LOCATORS, {
223
+ description: "Upstream find locator family to use.",
224
+ }),
225
+ value: Type.String({ description: "Locator value, such as visible text, label text, placeholder text, test id, title, alt text, or role." }),
226
+ text: Type.Optional(Type.String({ description: "Text/value argument for fill or select actions." })),
227
+ role: Type.Optional(Type.String({ description: "Role locator value; when set it must match value for locator=role." })),
228
+ name: Type.Optional(Type.String({ description: "Accessible name filter for locator=role; compiles to --name <name>." })),
229
+ session: Type.Optional(Type.String({ description: "Optional upstream session name; prepends --session <name> before the compiled find command." })),
230
+ }),
231
+ ),
232
+ qa: Type.Optional(
233
+ Type.Object({
234
+ url: Type.String({ description: "URL to open for a lightweight QA preset." }),
235
+ expectedText: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Text that must appear on the page." })),
236
+ expectedSelector: Type.Optional(Type.String({ description: "Selector or @ref that must appear on the page." })),
237
+ screenshotPath: Type.Optional(Type.String({ description: "Optional evidence screenshot path captured at the end of the QA preset." })),
238
+ checkConsole: Type.Optional(Type.Boolean({ description: "Whether to fail on console error messages. Defaults to true." })),
239
+ checkErrors: Type.Optional(Type.Boolean({ description: "Whether to fail on page errors. Defaults to true." })),
240
+ checkNetwork: Type.Optional(Type.Boolean({ description: "Whether to inspect network requests and fail on actionable request failures; benign icon misses warn. Defaults to true." })),
241
+ }),
242
+ ),
243
+ sourceLookup: Type.Optional(
244
+ Type.Object({
245
+ selector: Type.Optional(Type.String({ description: "Visible selector or @ref whose DOM metadata should be inspected for source hints." })),
246
+ reactFiberId: Type.Optional(Type.String({ description: "React fiber id to inspect with upstream react inspect. Requires a session opened with --enable react-devtools." })),
247
+ componentName: Type.Optional(Type.String({ description: "Component name to correlate with react tree output and bounded local workspace search." })),
248
+ includeDomHints: Type.Optional(Type.Boolean({ description: "Whether selector lookups should inspect DOM HTML attributes for source-like metadata. Defaults to true." })),
249
+ maxWorkspaceFiles: Type.Optional(Type.Number({ description: "Maximum local source files to scan when componentName is provided. Defaults to 2000 and cannot exceed 5000.", minimum: 1, maximum: SOURCE_LOOKUP_MAX_WORKSPACE_FILES })),
250
+ }),
251
+ ),
252
+ networkSourceLookup: Type.Optional(
253
+ Type.Object({
254
+ filter: Type.Optional(Type.String({ description: "Optional upstream network requests filter pattern." })),
255
+ requestId: Type.Optional(Type.String({ description: "Optional network request id to inspect with network request <id>." })),
256
+ url: Type.Optional(Type.String({ description: "Optional failed request URL or URL fragment to correlate with local source." })),
257
+ maxWorkspaceFiles: Type.Optional(Type.Number({ description: "Maximum local source files to scan for URL literals. Defaults to 2000 and cannot exceed 5000.", minimum: 1, maximum: SOURCE_LOOKUP_MAX_WORKSPACE_FILES })),
258
+ }),
259
+ ),
260
+ job: Type.Optional(
261
+ Type.Object({
262
+ steps: Type.Array(
263
+ Type.Object({
264
+ action: StringEnum(AGENT_BROWSER_JOB_STEP_ACTIONS, {
265
+ description: "Constrained one-call job step compiled to existing upstream batch commands.",
266
+ }),
267
+ url: Type.Optional(Type.String({ description: "URL for open steps, or URL pattern for assertUrl steps." })),
268
+ selector: Type.Optional(Type.String({ description: "Selector or @ref for click/fill/get-like steps." })),
269
+ text: Type.Optional(Type.String({ description: "Text for fill steps or visible text for assertText steps." })),
270
+ path: Type.Optional(Type.String({ description: "Artifact/download path for waitForDownload or screenshot steps." })),
271
+ milliseconds: Type.Optional(Type.Number({ description: "Milliseconds for wait steps." })),
272
+ }),
273
+ { minItems: 1 },
274
+ ),
275
+ }),
276
+ ),
277
+ stdin: Type.Optional(Type.String({ description: "Optional raw stdin content; only supported for batch, eval --stdin, auth save --password-stdin, and is generated internally by job, qa, sourceLookup, or networkSourceLookup mode." })),
85
278
  sessionMode: Type.Optional(
86
279
  StringEnum(["auto", "fresh"] as const, {
87
280
  description:
88
- "Session handling mode. `auto` reuses the extension-managed pi-scoped session when possible. `fresh` switches that managed session to a fresh upstream launch so launch-scoped flags like --profile, --session-name, --cdp, --state, --auto-connect, --init-script, or --enable apply and later auto calls follow the new browser.",
281
+ "Session handling mode. `auto` reuses the extension-managed pi-scoped session when possible. `fresh` switches that managed session to a fresh upstream launch so launch-scoped flags like --profile, --session-name, --cdp, --state, --auto-connect, --init-script, --enable, -p/--provider, or iOS --device apply and later auto calls follow the new browser.",
89
282
  default: DEFAULT_SESSION_MODE,
90
283
  }),
91
284
  ),
@@ -105,6 +298,725 @@ function buildInvocationPreview(effectiveArgs: string[]): string {
105
298
  return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview;
106
299
  }
107
300
 
301
+ function getRequiredJobString(step: Record<string, unknown>, field: "path" | "selector" | "text" | "url", action: AgentBrowserJobStepAction): { value?: string; error?: string } {
302
+ const value = step[field];
303
+ if (typeof value !== "string" || value.trim().length === 0) {
304
+ return { error: `job step ${action} requires a non-empty ${field} string.` };
305
+ }
306
+ return { value };
307
+ }
308
+
309
+ function compileAgentBrowserJob(input: unknown): { compiled?: CompiledAgentBrowserJob; error?: string } {
310
+ if (!isRecord(input)) {
311
+ return { error: "job must be an object." };
312
+ }
313
+ const rawSteps = input.steps;
314
+ if (!Array.isArray(rawSteps) || rawSteps.length === 0) {
315
+ return { error: "job.steps must be a non-empty array." };
316
+ }
317
+ const steps: CompiledAgentBrowserJobStep[] = [];
318
+ for (const [index, rawStep] of rawSteps.entries()) {
319
+ if (!isRecord(rawStep)) {
320
+ return { error: `job.steps[${index}] must be an object.` };
321
+ }
322
+ const action = rawStep.action;
323
+ if (typeof action !== "string" || !AGENT_BROWSER_JOB_STEP_ACTIONS.includes(action as AgentBrowserJobStepAction)) {
324
+ return { error: `job.steps[${index}].action must be one of: ${AGENT_BROWSER_JOB_STEP_ACTIONS.join(", ")}.` };
325
+ }
326
+ const jobAction = action as AgentBrowserJobStepAction;
327
+ let args: string[];
328
+ if (jobAction === "open") {
329
+ const result = getRequiredJobString(rawStep, "url", jobAction);
330
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
331
+ args = ["open", result.value as string];
332
+ } else if (jobAction === "click") {
333
+ const result = getRequiredJobString(rawStep, "selector", jobAction);
334
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
335
+ args = ["click", result.value as string];
336
+ } else if (jobAction === "fill") {
337
+ const selector = getRequiredJobString(rawStep, "selector", jobAction);
338
+ if (selector.error) return { error: `job.steps[${index}]: ${selector.error}` };
339
+ const text = getRequiredJobString(rawStep, "text", jobAction);
340
+ if (text.error) return { error: `job.steps[${index}]: ${text.error}` };
341
+ args = ["fill", selector.value as string, text.value as string];
342
+ } else if (jobAction === "wait") {
343
+ const milliseconds = rawStep.milliseconds;
344
+ if (typeof milliseconds !== "number" || !Number.isInteger(milliseconds) || milliseconds <= 0) {
345
+ return { error: `job.steps[${index}]: job step wait requires a positive integer milliseconds value.` };
346
+ }
347
+ args = ["wait", String(milliseconds)];
348
+ } else if (jobAction === "assertText") {
349
+ const result = getRequiredJobString(rawStep, "text", jobAction);
350
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
351
+ args = ["wait", "--text", result.value as string];
352
+ } else if (jobAction === "assertUrl") {
353
+ const result = getRequiredJobString(rawStep, "url", jobAction);
354
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
355
+ args = ["wait", "--url", result.value as string];
356
+ } else if (jobAction === "waitForDownload") {
357
+ const result = getRequiredJobString(rawStep, "path", jobAction);
358
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
359
+ args = ["wait", "--download", result.value as string];
360
+ } else {
361
+ const result = getRequiredJobString(rawStep, "path", jobAction);
362
+ if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
363
+ args = ["screenshot", result.value as string];
364
+ }
365
+ steps.push({ action: jobAction, args });
366
+ }
367
+ return { compiled: { args: ["batch"], stdin: JSON.stringify(steps.map((step) => step.args)), steps } };
368
+ }
369
+
370
+ interface AgentBrowserQaPresetAnalysis {
371
+ failedChecks: string[];
372
+ passed: boolean;
373
+ summary: string;
374
+ warnings: string[];
375
+ }
376
+
377
+ function getBatchResultItems(data: unknown): Array<Record<string, unknown>> {
378
+ return Array.isArray(data) ? data.filter(isRecord) : [];
379
+ }
380
+
381
+ function getCommandNameFromBatchItem(item: Record<string, unknown>): string | undefined {
382
+ const command = item.command;
383
+ return Array.isArray(command) && typeof command[0] === "string" ? command[0] : undefined;
384
+ }
385
+
386
+ function analyzeQaPresetResults(data: unknown): AgentBrowserQaPresetAnalysis | undefined {
387
+ const items = getBatchResultItems(data);
388
+ if (items.length === 0) return undefined;
389
+ const failedChecks: string[] = [];
390
+ const warnings: string[] = [];
391
+ for (const item of items) {
392
+ if (item.success === false) {
393
+ failedChecks.push(`${getCommandNameFromBatchItem(item) ?? "step"} failed`);
394
+ }
395
+ const result = isRecord(item.result) ? item.result : undefined;
396
+ const commandName = getCommandNameFromBatchItem(item);
397
+ if (commandName === "errors" && Array.isArray(result?.errors) && result.errors.length > 0) {
398
+ failedChecks.push(`${result.errors.length} page error(s)`);
399
+ }
400
+ if (commandName === "console" && Array.isArray(result?.messages)) {
401
+ const errorCount = result.messages.filter((message) => isRecord(message) && /error/i.test(String(message.type ?? message.level ?? ""))).length;
402
+ if (errorCount > 0) failedChecks.push(`${errorCount} console error message(s)`);
403
+ }
404
+ if (commandName === "network" && Array.isArray(result?.requests)) {
405
+ const networkFailures = summarizeNetworkFailures(result.requests);
406
+ if (networkFailures.actionableCount > 0) failedChecks.push(`${networkFailures.actionableCount} actionable failed network request(s)`);
407
+ if (networkFailures.benignCount > 0) warnings.push(`${networkFailures.benignCount} benign network request failure(s) ignored`);
408
+ }
409
+ }
410
+ const uniqueFailures = [...new Set(failedChecks)];
411
+ const uniqueWarnings = [...new Set(warnings)];
412
+ return {
413
+ failedChecks: uniqueFailures,
414
+ passed: uniqueFailures.length === 0,
415
+ summary: uniqueFailures.length === 0
416
+ ? uniqueWarnings.length === 0 ? "QA preset passed." : `QA preset passed with warnings: ${uniqueWarnings.join("; ")}.`
417
+ : `QA preset failed: ${uniqueFailures.join("; ")}.`,
418
+ warnings: uniqueWarnings,
419
+ };
420
+ }
421
+
422
+ function compileAgentBrowserQaPreset(input: unknown): { compiled?: CompiledAgentBrowserQaPreset; error?: string } {
423
+ if (!isRecord(input)) {
424
+ return { error: "qa must be an object." };
425
+ }
426
+ const url = input.url;
427
+ if (typeof url !== "string" || url.trim().length === 0) {
428
+ return { error: "qa.url must be a non-empty string." };
429
+ }
430
+ const expectedText = input.expectedText === undefined
431
+ ? []
432
+ : typeof input.expectedText === "string"
433
+ ? [input.expectedText]
434
+ : Array.isArray(input.expectedText)
435
+ ? input.expectedText
436
+ : undefined;
437
+ if (!expectedText || expectedText.some((text) => typeof text !== "string" || text.trim().length === 0)) {
438
+ return { error: "qa.expectedText must be a non-empty string or array of non-empty strings when provided." };
439
+ }
440
+ const expectedSelector = input.expectedSelector;
441
+ if (expectedSelector !== undefined && (typeof expectedSelector !== "string" || expectedSelector.trim().length === 0)) {
442
+ return { error: "qa.expectedSelector must be a non-empty string when provided." };
443
+ }
444
+ const screenshotPath = input.screenshotPath;
445
+ if (screenshotPath !== undefined && (typeof screenshotPath !== "string" || screenshotPath.trim().length === 0)) {
446
+ return { error: "qa.screenshotPath must be a non-empty string when provided." };
447
+ }
448
+ for (const field of ["checkConsole", "checkErrors", "checkNetwork"] as const) {
449
+ if (input[field] !== undefined && typeof input[field] !== "boolean") {
450
+ return { error: `qa.${field} must be a boolean when provided.` };
451
+ }
452
+ }
453
+ const checkConsole = input.checkConsole !== false;
454
+ const checkErrors = input.checkErrors !== false;
455
+ const checkNetwork = input.checkNetwork !== false;
456
+ const steps: CompiledAgentBrowserJobStep[] = [];
457
+ if (checkNetwork) steps.push({ action: "wait", args: ["network", "requests", "--clear"] });
458
+ if (checkConsole) steps.push({ action: "wait", args: ["console", "--clear"] });
459
+ if (checkErrors) steps.push({ action: "wait", args: ["errors", "--clear"] });
460
+ steps.push(
461
+ { action: "open", args: ["open", url] },
462
+ { action: "wait", args: ["wait", "--load", "networkidle"] },
463
+ );
464
+ for (const text of expectedText) {
465
+ steps.push({ action: "assertText", args: ["wait", "--text", text] });
466
+ }
467
+ if (typeof expectedSelector === "string") {
468
+ steps.push({ action: "wait", args: ["wait", expectedSelector] });
469
+ }
470
+ if (checkNetwork) steps.push({ action: "wait", args: ["network", "requests"] });
471
+ if (checkConsole) steps.push({ action: "wait", args: ["console"] });
472
+ if (checkErrors) steps.push({ action: "wait", args: ["errors"] });
473
+ if (typeof screenshotPath === "string") steps.push({ action: "screenshot", args: ["screenshot", screenshotPath] });
474
+ return {
475
+ compiled: {
476
+ args: ["batch"],
477
+ checks: { checkConsole, checkErrors, checkNetwork, expectedSelector, expectedText, screenshotPath, url },
478
+ stdin: JSON.stringify(steps.map((step) => step.args)),
479
+ steps,
480
+ },
481
+ };
482
+ }
483
+
484
+ function compileAgentBrowserSourceLookup(input: unknown): { compiled?: CompiledAgentBrowserSourceLookup; error?: string } {
485
+ if (!isRecord(input)) {
486
+ return { error: "sourceLookup must be an object." };
487
+ }
488
+ const selector = input.selector;
489
+ const reactFiberId = input.reactFiberId;
490
+ const componentName = input.componentName;
491
+ if (selector !== undefined && (typeof selector !== "string" || selector.trim().length === 0)) {
492
+ return { error: "sourceLookup.selector must be a non-empty string when provided." };
493
+ }
494
+ if (reactFiberId !== undefined && (typeof reactFiberId !== "string" || reactFiberId.trim().length === 0)) {
495
+ return { error: "sourceLookup.reactFiberId must be a non-empty string when provided." };
496
+ }
497
+ if (componentName !== undefined && (typeof componentName !== "string" || componentName.trim().length === 0)) {
498
+ return { error: "sourceLookup.componentName must be a non-empty string when provided." };
499
+ }
500
+ if (selector === undefined && reactFiberId === undefined && componentName === undefined) {
501
+ return { error: "sourceLookup requires selector, reactFiberId, or componentName." };
502
+ }
503
+ if (input.includeDomHints !== undefined && typeof input.includeDomHints !== "boolean") {
504
+ return { error: "sourceLookup.includeDomHints must be a boolean when provided." };
505
+ }
506
+ const rawMaxWorkspaceFiles = input.maxWorkspaceFiles;
507
+ if (rawMaxWorkspaceFiles !== undefined && (typeof rawMaxWorkspaceFiles !== "number" || !Number.isInteger(rawMaxWorkspaceFiles) || rawMaxWorkspaceFiles <= 0)) {
508
+ return { error: "sourceLookup.maxWorkspaceFiles must be a positive integer when provided." };
509
+ }
510
+ if (typeof rawMaxWorkspaceFiles === "number" && rawMaxWorkspaceFiles > SOURCE_LOOKUP_MAX_WORKSPACE_FILES) {
511
+ return { error: `sourceLookup.maxWorkspaceFiles must be ${SOURCE_LOOKUP_MAX_WORKSPACE_FILES} or less.` };
512
+ }
513
+ const includeDomHints = input.includeDomHints !== false;
514
+ const maxWorkspaceFiles = (rawMaxWorkspaceFiles as number | undefined) ?? SOURCE_LOOKUP_DEFAULT_MAX_WORKSPACE_FILES;
515
+ const steps: CompiledAgentBrowserSourceLookupStep[] = [];
516
+ if (typeof selector === "string") {
517
+ steps.push({ action: "dom", args: ["is", "visible", selector] });
518
+ if (includeDomHints) {
519
+ steps.push({ action: "dom", args: ["get", "html", selector] });
520
+ }
521
+ }
522
+ if (typeof reactFiberId === "string") {
523
+ steps.push({ action: "react", args: ["react", "inspect", reactFiberId] });
524
+ }
525
+ if (typeof componentName === "string") {
526
+ steps.push({ action: "react", args: ["react", "tree"] });
527
+ }
528
+ return {
529
+ compiled: {
530
+ args: ["batch"],
531
+ query: { componentName, includeDomHints, maxWorkspaceFiles, reactFiberId, selector },
532
+ stdin: JSON.stringify(steps.map((step) => step.args)),
533
+ steps,
534
+ },
535
+ };
536
+ }
537
+
538
+ function extractStringField(value: Record<string, unknown>, names: string[]): string | undefined {
539
+ for (const name of names) {
540
+ const field = value[name];
541
+ if (typeof field === "string" && field.trim().length > 0) return field;
542
+ }
543
+ return undefined;
544
+ }
545
+
546
+ function extractNumberField(value: Record<string, unknown>, names: string[]): number | undefined {
547
+ for (const name of names) {
548
+ const field = value[name];
549
+ if (typeof field === "number" && Number.isFinite(field)) return field;
550
+ if (typeof field === "string" && /^\d+$/.test(field)) return Number(field);
551
+ }
552
+ return undefined;
553
+ }
554
+
555
+ function candidateKey(candidate: AgentBrowserSourceLookupCandidate): string {
556
+ return [candidate.source, candidate.file ?? "", candidate.line ?? "", candidate.column ?? "", candidate.componentName ?? ""].join(":");
557
+ }
558
+
559
+ function addSourceLookupCandidate(candidates: AgentBrowserSourceLookupCandidate[], candidate: AgentBrowserSourceLookupCandidate): void {
560
+ if (!candidates.some((existing) => candidateKey(existing) === candidateKey(candidate))) {
561
+ candidates.push(candidate);
562
+ }
563
+ }
564
+
565
+ function collectSourceCandidatesFromValue(value: unknown, source: "react-inspect" | "dom-attribute", candidates: AgentBrowserSourceLookupCandidate[], evidence: string[], depth = 0): void {
566
+ if (depth > 6 || value === undefined || value === null) return;
567
+ if (typeof value === "string") {
568
+ const sourcePattern = /([A-Za-z0-9_./@-]+\.(?:tsx|jsx|ts|js))(?:[:#](\d+))?(?:[:#](\d+))?/g;
569
+ for (const match of value.matchAll(sourcePattern)) {
570
+ addSourceLookupCandidate(candidates, {
571
+ column: match[3] ? Number(match[3]) : undefined,
572
+ confidence: source === "react-inspect" ? "high" : "medium",
573
+ evidence,
574
+ file: match[1],
575
+ line: match[2] ? Number(match[2]) : undefined,
576
+ source,
577
+ });
578
+ }
579
+ return;
580
+ }
581
+ if (Array.isArray(value)) {
582
+ for (const item of value) collectSourceCandidatesFromValue(item, source, candidates, evidence, depth + 1);
583
+ return;
584
+ }
585
+ if (!isRecord(value)) return;
586
+ const file = extractStringField(value, ["file", "fileName", "filename", "filePath", "path", "source", "url"]);
587
+ if (file && /\.(?:tsx|jsx|ts|js)(?:$|[:?#])/.test(file)) {
588
+ addSourceLookupCandidate(candidates, {
589
+ column: extractNumberField(value, ["column", "columnNumber", "col"]),
590
+ confidence: source === "react-inspect" ? "high" : "medium",
591
+ evidence,
592
+ file,
593
+ line: extractNumberField(value, ["line", "lineNumber"]),
594
+ source,
595
+ });
596
+ }
597
+ for (const nested of Object.values(value)) {
598
+ collectSourceCandidatesFromValue(nested, source, candidates, evidence, depth + 1);
599
+ }
600
+ }
601
+
602
+ function getHtmlAttributeValue(html: string, name: string): string | undefined {
603
+ const pattern = new RegExp(`${name}=["']([^"']+)["']`, "i");
604
+ return pattern.exec(html)?.[1];
605
+ }
606
+
607
+ function collectDomSourceCandidates(html: unknown, candidates: AgentBrowserSourceLookupCandidate[]): void {
608
+ if (typeof html !== "string") return;
609
+ const file = getHtmlAttributeValue(html, "(?:data-source-file|data-file|data-component-file|data-source)");
610
+ if (file && /\.(?:tsx|jsx|ts|js)$/.test(file)) {
611
+ const line = getHtmlAttributeValue(html, "(?:data-source-line|data-line)");
612
+ const column = getHtmlAttributeValue(html, "(?:data-source-column|data-column)");
613
+ addSourceLookupCandidate(candidates, {
614
+ column: column && /^\d+$/.test(column) ? Number(column) : undefined,
615
+ confidence: "medium",
616
+ evidence: ["selector HTML contained source-like data attributes"],
617
+ file,
618
+ line: line && /^\d+$/.test(line) ? Number(line) : undefined,
619
+ source: "dom-attribute",
620
+ });
621
+ }
622
+ collectSourceCandidatesFromValue(html, "dom-attribute", candidates, ["selector HTML contained source-like text"]);
623
+ }
624
+
625
+ async function walkWorkspaceSourceFiles(root: string, maxFiles: number): Promise<string[]> {
626
+ const files: string[] = [];
627
+ async function visit(directory: string): Promise<void> {
628
+ if (files.length >= maxFiles) return;
629
+ let entries: Array<{ isDirectory: () => boolean; isFile: () => boolean; name: string }>;
630
+ try {
631
+ entries = await readdir(directory, { withFileTypes: true });
632
+ } catch {
633
+ return;
634
+ }
635
+ for (const entry of entries) {
636
+ if (files.length >= maxFiles) return;
637
+ const path = join(directory, entry.name);
638
+ if (entry.isDirectory()) {
639
+ if (!SOURCE_LOOKUP_IGNORED_DIRECTORIES.has(entry.name)) await visit(path);
640
+ } else if (entry.isFile() && SOURCE_LOOKUP_WORKSPACE_EXTENSIONS.has(extname(entry.name))) {
641
+ files.push(path);
642
+ }
643
+ }
644
+ }
645
+ await visit(root);
646
+ return files;
647
+ }
648
+
649
+ function escapeRegExp(value: string): string {
650
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
651
+ }
652
+
653
+ async function collectWorkspaceComponentCandidates(query: CompiledAgentBrowserSourceLookup["query"], cwd: string, candidates: AgentBrowserSourceLookupCandidate[], limitations: string[]): Promise<void> {
654
+ if (!query.componentName) return;
655
+ const files = await walkWorkspaceSourceFiles(cwd, query.maxWorkspaceFiles);
656
+ if (files.length >= query.maxWorkspaceFiles) {
657
+ limitations.push(`Workspace source scan stopped at ${query.maxWorkspaceFiles} files.`);
658
+ }
659
+ const componentPattern = new RegExp(`(?:function|class)\\s+${escapeRegExp(query.componentName)}\\b|(?:const|let|var)\\s+${escapeRegExp(query.componentName)}\\s*=|export\\s+default\\s+function\\s+${escapeRegExp(query.componentName)}\\b`);
660
+ for (const file of files) {
661
+ let text: string;
662
+ try {
663
+ text = await readFile(file, "utf8");
664
+ } catch {
665
+ continue;
666
+ }
667
+ const match = componentPattern.exec(text);
668
+ if (!match) continue;
669
+ const line = text.slice(0, match.index).split("\n").length;
670
+ addSourceLookupCandidate(candidates, {
671
+ componentName: query.componentName,
672
+ confidence: "low",
673
+ evidence: [`local workspace contains a matching ${query.componentName} declaration`],
674
+ file,
675
+ line,
676
+ source: "workspace-search",
677
+ });
678
+ if (candidates.filter((candidate) => candidate.source === "workspace-search").length >= 10) break;
679
+ }
680
+ }
681
+
682
+ function validateLookupMaxWorkspaceFiles(value: unknown, fieldName: string): { value?: number; error?: string } {
683
+ if (value === undefined) return { value: SOURCE_LOOKUP_DEFAULT_MAX_WORKSPACE_FILES };
684
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
685
+ return { error: `${fieldName} must be a positive integer when provided.` };
686
+ }
687
+ if (value > SOURCE_LOOKUP_MAX_WORKSPACE_FILES) {
688
+ return { error: `${fieldName} must be ${SOURCE_LOOKUP_MAX_WORKSPACE_FILES} or less.` };
689
+ }
690
+ return { value };
691
+ }
692
+
693
+ async function analyzeSourceLookupResults(data: unknown, compiled: CompiledAgentBrowserSourceLookup, cwd: string): Promise<AgentBrowserSourceLookupAnalysis> {
694
+ const items = getBatchResultItems(data);
695
+ const candidates: AgentBrowserSourceLookupCandidate[] = [];
696
+ const limitations = [
697
+ "Experimental lookup only reports candidates with evidence; it cannot guarantee a DOM node maps to one source file.",
698
+ "React source hints require the page to be opened with --enable react-devtools and source information from the app build.",
699
+ ];
700
+ let unsupported = false;
701
+ for (const item of items) {
702
+ const command = Array.isArray(item.command) ? item.command : [];
703
+ const result = isRecord(item.result) && "data" in item.result ? item.result.data : item.result;
704
+ if (item.success === false && command[0] === "react") unsupported = true;
705
+ if (command[0] === "react" && command[1] === "inspect") {
706
+ collectSourceCandidatesFromValue(result, "react-inspect", candidates, ["react inspect returned source-like metadata"]);
707
+ }
708
+ if (command[0] === "get" && command[1] === "html") {
709
+ collectDomSourceCandidates(result, candidates);
710
+ }
711
+ }
712
+ await collectWorkspaceComponentCandidates(compiled.query, cwd, candidates, limitations);
713
+ const status: AgentBrowserSourceLookupStatus = candidates.length > 0 ? "candidates-found" : unsupported ? "unsupported" : "no-candidates";
714
+ return {
715
+ candidates,
716
+ limitations,
717
+ status,
718
+ summary: candidates.length > 0
719
+ ? `Source lookup found ${candidates.length} candidate location(s).`
720
+ : unsupported
721
+ ? "Source lookup could not inspect React metadata in this session."
722
+ : "Source lookup found no candidate locations.",
723
+ };
724
+ }
725
+
726
+ function compileAgentBrowserNetworkSourceLookup(input: unknown): { compiled?: CompiledAgentBrowserNetworkSourceLookup; error?: string } {
727
+ if (!isRecord(input)) return { error: "networkSourceLookup must be an object." };
728
+ const filter = input.filter;
729
+ const requestId = input.requestId;
730
+ const url = input.url;
731
+ if (filter !== undefined && (typeof filter !== "string" || filter.trim().length === 0)) return { error: "networkSourceLookup.filter must be a non-empty string when provided." };
732
+ if (requestId !== undefined && (typeof requestId !== "string" || requestId.trim().length === 0)) return { error: "networkSourceLookup.requestId must be a non-empty string when provided." };
733
+ if (url !== undefined && (typeof url !== "string" || url.trim().length === 0)) return { error: "networkSourceLookup.url must be a non-empty string when provided." };
734
+ if (filter === undefined && requestId === undefined && url === undefined) return { error: "networkSourceLookup requires requestId, filter, or url." };
735
+ const maxWorkspaceFiles = validateLookupMaxWorkspaceFiles(input.maxWorkspaceFiles, "networkSourceLookup.maxWorkspaceFiles");
736
+ if (maxWorkspaceFiles.error) return { error: maxWorkspaceFiles.error };
737
+ const steps: Array<{ action: "network"; args: string[] }> = [];
738
+ if (typeof requestId === "string") {
739
+ steps.push({ action: "network", args: ["network", "request", requestId] });
740
+ }
741
+ const effectiveFilter = typeof filter === "string" ? filter : typeof url === "string" ? url : undefined;
742
+ if (effectiveFilter) {
743
+ steps.push({ action: "network", args: ["network", "requests", "--filter", effectiveFilter] });
744
+ }
745
+ return { compiled: { args: ["batch"], query: { filter, maxWorkspaceFiles: maxWorkspaceFiles.value as number, requestId, url }, stdin: JSON.stringify(steps.map((step) => step.args)), steps } };
746
+ }
747
+
748
+ function getResultPayload(item: Record<string, unknown>): unknown {
749
+ return isRecord(item.result) && "data" in item.result ? item.result.data : item.result;
750
+ }
751
+
752
+ function networkRequestMatchesQuery(url: string | undefined, queryText: string | undefined): boolean {
753
+ return queryText === undefined || url === undefined || url.includes(queryText) || queryText.includes(url);
754
+ }
755
+
756
+ function isFailedNetworkRecord(request: Record<string, unknown>): boolean {
757
+ const status = typeof request.status === "number" ? request.status : undefined;
758
+ const error = typeof request.error === "string" ? request.error : undefined;
759
+ return request.failed === true || error !== undefined || (status !== undefined && status >= 400);
760
+ }
761
+
762
+ function getFailedNetworkRequests(data: unknown, queryText?: string): AgentBrowserNetworkSourceLookupRequest[] {
763
+ const failed: AgentBrowserNetworkSourceLookupRequest[] = [];
764
+ for (const item of getBatchResultItems(data)) {
765
+ const payload = getResultPayload(item);
766
+ const requests = isRecord(payload) && Array.isArray(payload.requests) ? payload.requests : Array.isArray(payload) ? payload : isRecord(payload) ? [payload] : [];
767
+ for (const request of requests) {
768
+ if (!isRecord(request)) continue;
769
+ const url = typeof request.url === "string" ? request.url : undefined;
770
+ if (!networkRequestMatchesQuery(url, queryText) || !isFailedNetworkRecord(request)) continue;
771
+ failed.push({
772
+ error: typeof request.error === "string" ? request.error : undefined,
773
+ method: typeof request.method === "string" ? request.method : undefined,
774
+ requestId: typeof request.id === "string" ? request.id : typeof request.requestId === "string" ? request.requestId : undefined,
775
+ status: typeof request.status === "number" ? request.status : undefined,
776
+ url,
777
+ });
778
+ }
779
+ }
780
+ return failed;
781
+ }
782
+
783
+ function addNetworkCandidate(candidates: AgentBrowserNetworkSourceLookupCandidate[], candidate: AgentBrowserNetworkSourceLookupCandidate): void {
784
+ const key = [candidate.source, candidate.file ?? "", candidate.line ?? "", candidate.requestUrl ?? ""].join(":");
785
+ if (!candidates.some((existing) => [existing.source, existing.file ?? "", existing.line ?? "", existing.requestUrl ?? ""].join(":") === key)) candidates.push(candidate);
786
+ }
787
+
788
+ function collectInitiatorCandidates(data: unknown, failedRequests: AgentBrowserNetworkSourceLookupRequest[], candidates: AgentBrowserNetworkSourceLookupCandidate[]): void {
789
+ const failedRequestIds = new Set(failedRequests.map((request) => request.requestId).filter((value): value is string => value !== undefined));
790
+ const failedRequestUrls = new Set(failedRequests.map((request) => request.url).filter((value): value is string => value !== undefined));
791
+ for (const item of getBatchResultItems(data)) {
792
+ const payload = getResultPayload(item);
793
+ const requestValues = isRecord(payload) && Array.isArray(payload.requests) ? payload.requests : [payload];
794
+ for (const value of requestValues) {
795
+ if (!isRecord(value)) continue;
796
+ const requestUrl = typeof value.url === "string" ? value.url : undefined;
797
+ const requestId = typeof value.id === "string" ? value.id : typeof value.requestId === "string" ? value.requestId : undefined;
798
+ const correlatesWithFailedRequest = (requestId !== undefined && failedRequestIds.has(requestId)) || (requestUrl !== undefined && failedRequestUrls.has(requestUrl));
799
+ if (!correlatesWithFailedRequest && !isFailedNetworkRecord(value)) continue;
800
+ for (const field of [value.initiator, value.stack, value.source, value.trace]) {
801
+ const localCandidates: AgentBrowserSourceLookupCandidate[] = [];
802
+ collectSourceCandidatesFromValue(field, "dom-attribute", localCandidates, ["failed network request included source-like initiator metadata"]);
803
+ for (const candidate of localCandidates) {
804
+ addNetworkCandidate(candidates, { confidence: "medium", evidence: candidate.evidence, file: candidate.file, line: candidate.line, requestUrl, source: "initiator" });
805
+ }
806
+ }
807
+ }
808
+ }
809
+ }
810
+
811
+ async function collectWorkspaceRequestCandidates(query: CompiledAgentBrowserNetworkSourceLookup["query"], failedRequests: AgentBrowserNetworkSourceLookupRequest[], cwd: string, candidates: AgentBrowserNetworkSourceLookupCandidate[], limitations: string[]): Promise<void> {
812
+ const needles = [...new Set([query.url, query.filter, ...failedRequests.map((request) => request.url)].filter((value): value is string => typeof value === "string" && value.length > 0).flatMap((value) => {
813
+ try {
814
+ const parsed = new URL(value);
815
+ return [value, parsed.pathname].filter((item) => item && item !== "/");
816
+ } catch {
817
+ return [value];
818
+ }
819
+ }))].slice(0, 8);
820
+ if (needles.length === 0) return;
821
+ const files = await walkWorkspaceSourceFiles(cwd, query.maxWorkspaceFiles);
822
+ if (files.length >= query.maxWorkspaceFiles) limitations.push(`Workspace source scan stopped at ${query.maxWorkspaceFiles} files.`);
823
+ for (const file of files) {
824
+ let text: string;
825
+ try { text = await readFile(file, "utf8"); } catch { continue; }
826
+ for (const needle of needles) {
827
+ const index = text.indexOf(needle);
828
+ if (index === -1) continue;
829
+ addNetworkCandidate(candidates, { confidence: "low", evidence: [`local workspace contains request URL literal ${needle}`], file, line: text.slice(0, index).split("\n").length, requestUrl: needle, source: "workspace-search" });
830
+ if (candidates.filter((candidate) => candidate.source === "workspace-search").length >= 10) return;
831
+ }
832
+ }
833
+ }
834
+
835
+ function redactNetworkSourceLookupUrl(value: string | undefined): string | undefined {
836
+ if (!value) return value;
837
+ try {
838
+ const isRelative = value.startsWith("/");
839
+ const url = new URL(value, isRelative ? "https://redacted.invalid" : undefined);
840
+ url.username = url.username ? "[REDACTED]" : "";
841
+ url.password = url.password ? "[REDACTED]" : "";
842
+ for (const key of [...url.searchParams.keys()]) {
843
+ url.searchParams.set(key, "[REDACTED]");
844
+ }
845
+ if (/(?:token|secret|password|passwd|pwd|key|auth|session|jwt|credential)/i.test(url.hash)) {
846
+ url.hash = "#[REDACTED]";
847
+ }
848
+ return isRelative ? `${url.pathname}${url.search}${url.hash}` : url.toString();
849
+ } catch {
850
+ return redactSensitiveText(value
851
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)\S+:\S+@/gi, "$1[REDACTED]@")
852
+ .replace(/([?&][^=]+)=([^&#\s"'\]]+)/g, "$1=[REDACTED]"));
853
+ }
854
+ }
855
+
856
+ function redactNetworkSourceLookupArgs(args: string[]): string[] {
857
+ return redactInvocationArgs(args).map((arg) => redactNetworkSourceLookupUrl(arg) ?? arg);
858
+ }
859
+
860
+ function redactNetworkSourceLookupSurface(value: unknown): unknown {
861
+ if (typeof value === "string") return redactNetworkSourceLookupUrl(value) ?? value;
862
+ if (Array.isArray(value)) return value.map((item) => redactNetworkSourceLookupSurface(item));
863
+ if (!isRecord(value)) return value;
864
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactNetworkSourceLookupSurface(item)]));
865
+ }
866
+
867
+ function redactNetworkSourceLookupAnalysis(analysis: AgentBrowserNetworkSourceLookupAnalysis): AgentBrowserNetworkSourceLookupAnalysis {
868
+ return {
869
+ ...analysis,
870
+ candidates: analysis.candidates.map((candidate) => ({
871
+ ...candidate,
872
+ evidence: candidate.evidence.map((item) => redactNetworkSourceLookupUrl(item) ?? redactSensitiveText(item)),
873
+ file: redactNetworkSourceLookupUrl(candidate.file),
874
+ requestUrl: redactNetworkSourceLookupUrl(candidate.requestUrl),
875
+ })),
876
+ failedRequests: analysis.failedRequests.map((request) => ({ ...request, error: redactNetworkSourceLookupUrl(request.error), url: redactNetworkSourceLookupUrl(request.url) })),
877
+ };
878
+ }
879
+
880
+ async function analyzeNetworkSourceLookupResults(data: unknown, compiled: CompiledAgentBrowserNetworkSourceLookup, cwd: string): Promise<AgentBrowserNetworkSourceLookupAnalysis> {
881
+ const limitations = [
882
+ "Experimental network source hints report candidates only; failed requests can be triggered indirectly by frameworks, caches, service workers, or third-party scripts.",
883
+ "Initiator/source-map metadata is upstream/browser-build dependent and may be absent.",
884
+ ];
885
+ const failedRequests = getFailedNetworkRequests(data, compiled.query.url ?? compiled.query.filter);
886
+ const candidates: AgentBrowserNetworkSourceLookupCandidate[] = [];
887
+ collectInitiatorCandidates(data, failedRequests, candidates);
888
+ await collectWorkspaceRequestCandidates(compiled.query, failedRequests, cwd, candidates, limitations);
889
+ const status: AgentBrowserNetworkSourceLookupStatus = failedRequests.length === 0 ? "no-failed-requests" : candidates.length > 0 ? "failed-requests-found" : "no-candidates";
890
+ return { candidates, failedRequests, limitations, status, summary: failedRequests.length === 0 ? "Network source lookup found no failed requests." : candidates.length > 0 ? `Network source lookup found ${failedRequests.length} failed request(s) and ${candidates.length} candidate source hint(s).` : `Network source lookup found ${failedRequests.length} failed request(s) but no source candidates.` };
891
+ }
892
+
893
+ function appendSemanticActionTextArg(args: string[], action: string, text: string | undefined): void {
894
+ if ((action === "fill" || action === "select") && text) {
895
+ args.push(text);
896
+ }
897
+ }
898
+
899
+ function getCompiledSemanticActionCommandIndex(compiled: CompiledAgentBrowserSemanticAction): number {
900
+ return compiled.args[0] === "--session" ? 2 : 0;
901
+ }
902
+
903
+ function getCompiledSemanticActionTextArg(compiled: CompiledAgentBrowserSemanticAction): string | undefined {
904
+ if (compiled.action !== "fill" && compiled.action !== "select") return undefined;
905
+ const commandIndex = getCompiledSemanticActionCommandIndex(compiled);
906
+ if (commandIndex < 0) return undefined;
907
+ const markerIndex = compiled.args.indexOf("--name");
908
+ return markerIndex >= 0 ? compiled.args[markerIndex - 1] : compiled.args[commandIndex + 4];
909
+ }
910
+
911
+ function getCompiledSemanticActionSessionPrefix(compiled: CompiledAgentBrowserSemanticAction): string[] {
912
+ const commandIndex = getCompiledSemanticActionCommandIndex(compiled);
913
+ return commandIndex > 0 ? compiled.args.slice(0, commandIndex) : [];
914
+ }
915
+
916
+ const SEMANTIC_ACTION_CANDIDATE_ACTION_IDS = new Set([
917
+ "try-searchbox-name-candidate",
918
+ "try-textbox-name-candidate",
919
+ "try-button-name-candidate",
920
+ "try-link-name-candidate",
921
+ "try-labeled-textbox-candidate",
922
+ ]);
923
+
924
+ function formatSemanticActionCandidateText(actions: AgentBrowserNextAction[]): string | undefined {
925
+ const candidateActions = actions.filter((action) => SEMANTIC_ACTION_CANDIDATE_ACTION_IDS.has(action.id) && action.params?.args);
926
+ if (candidateActions.length === 0) return undefined;
927
+ return [
928
+ "Agent-browser candidate fallbacks:",
929
+ ...candidateActions.map((action) => `- ${action.id}: agent_browser ${JSON.stringify({ args: action.params?.args })} — ${action.reason}`),
930
+ ].join("\n");
931
+ }
932
+
933
+ function buildSemanticActionCandidateActions(compiled: CompiledAgentBrowserSemanticAction): AgentBrowserNextAction[] {
934
+ const commandIndex = getCompiledSemanticActionCommandIndex(compiled);
935
+ if (commandIndex < 0) return [];
936
+ const locator = compiled.args[commandIndex + 1];
937
+ const value = compiled.args[commandIndex + 2];
938
+ if (!locator || !value) return [];
939
+ const text = getCompiledSemanticActionTextArg(compiled);
940
+ const sessionPrefix = getCompiledSemanticActionSessionPrefix(compiled);
941
+ const buildRoleCandidate = (role: string, id: string, reason: string): AgentBrowserNextAction => {
942
+ const args = [...sessionPrefix, "find", "role", role, compiled.action];
943
+ appendSemanticActionTextArg(args, compiled.action, text);
944
+ args.push("--name", value);
945
+ return {
946
+ id,
947
+ params: { args: redactInvocationArgs(args) },
948
+ reason,
949
+ safety: "Candidate locator fallback only; inspect the page if multiple elements could match the same accessible name.",
950
+ tool: "agent_browser" as const,
951
+ };
952
+ };
953
+
954
+ if (locator === "placeholder" && compiled.action === "fill") {
955
+ return [
956
+ buildRoleCandidate("searchbox", "try-searchbox-name-candidate", "Retry against a searchbox with the same accessible name; many search inputs expose names instead of placeholders."),
957
+ buildRoleCandidate("textbox", "try-textbox-name-candidate", "Retry against a textbox with the same accessible name when placeholder lookup misses."),
958
+ ];
959
+ }
960
+ if (locator === "text" && compiled.action === "click") {
961
+ return [
962
+ buildRoleCandidate("button", "try-button-name-candidate", "Retry against a button with the same accessible name when text lookup misses."),
963
+ buildRoleCandidate("link", "try-link-name-candidate", "Retry against a link with the same accessible name when text lookup misses."),
964
+ ];
965
+ }
966
+ if (locator === "label" && compiled.action === "fill") {
967
+ return [buildRoleCandidate("textbox", "try-labeled-textbox-candidate", "Retry against a textbox with the same accessible name when label lookup misses.")];
968
+ }
969
+ return [];
970
+ }
971
+
972
+ function compileAgentBrowserSemanticAction(input: unknown): { compiled?: CompiledAgentBrowserSemanticAction; error?: string } {
973
+ if (!isRecord(input)) {
974
+ return { error: "semanticAction must be an object." };
975
+ }
976
+ const action = input.action;
977
+ const locator = input.locator;
978
+ const value = input.value;
979
+ const text = input.text;
980
+ const role = input.role;
981
+ const name = input.name;
982
+ const session = input.session;
983
+ if (typeof action !== "string" || !AGENT_BROWSER_SEMANTIC_ACTIONS.includes(action as AgentBrowserSemanticActionName)) {
984
+ return { error: `semanticAction.action must be one of: ${AGENT_BROWSER_SEMANTIC_ACTIONS.join(", ")}.` };
985
+ }
986
+ if (typeof locator !== "string" || !AGENT_BROWSER_SEMANTIC_LOCATORS.includes(locator as AgentBrowserSemanticLocator)) {
987
+ return { error: `semanticAction.locator must be one of: ${AGENT_BROWSER_SEMANTIC_LOCATORS.join(", ")}.` };
988
+ }
989
+ if (typeof value !== "string" || value.trim().length === 0) {
990
+ return { error: "semanticAction.value must be a non-empty string." };
991
+ }
992
+ if (text !== undefined && typeof text !== "string") {
993
+ return { error: "semanticAction.text must be a string when provided." };
994
+ }
995
+ if ((action === "fill" || action === "select") && (typeof text !== "string" || text.length === 0)) {
996
+ return { error: `semanticAction.text is required for ${action}.` };
997
+ }
998
+ if (action !== "fill" && action !== "select" && text !== undefined) {
999
+ return { error: `semanticAction.text is only supported for fill and select actions.` };
1000
+ }
1001
+ if (role !== undefined && (locator !== "role" || role !== value)) {
1002
+ return { error: "semanticAction.role is only supported for locator=role and must match value." };
1003
+ }
1004
+ if (name !== undefined && (locator !== "role" || typeof name !== "string" || name.length === 0)) {
1005
+ return { error: "semanticAction.name is only supported as a non-empty string for locator=role." };
1006
+ }
1007
+ if (session !== undefined && (typeof session !== "string" || session.trim().length === 0)) {
1008
+ return { error: "semanticAction.session must be a non-empty string when provided." };
1009
+ }
1010
+ const args = typeof session === "string" ? ["--session", session, "find", locator, value, action] : ["find", locator, value, action];
1011
+ if (action === "fill" || action === "select") {
1012
+ args.push(text as string);
1013
+ }
1014
+ if (locator === "role" && typeof name === "string") {
1015
+ args.push("--name", name);
1016
+ }
1017
+ return { compiled: { action: action as AgentBrowserSemanticActionName, locator: locator as AgentBrowserSemanticLocator, args } };
1018
+ }
1019
+
108
1020
  const TUI_COLLAPSED_OUTPUT_MAX_LINES = 10;
109
1021
  const TUI_INVOCATION_PREVIEW_MAX_CHARS = 120;
110
1022
  const ANSI_CONTROL_SEQUENCE_PATTERN = /\x1B(?:\][^\x07\x1B]*(?:\x07|\x1B\\)|\[[0-?]*[ -/]*[@-~]|P[^\x1B]*(?:\x1B\\)|_[^\x1B]*(?:\x1B\\)|\^[^\x1B]*(?:\x1B\\)|[@-Z\\-_])/g;
@@ -178,7 +1090,15 @@ function formatVisualTruncationNotice(remainingLines: number, totalLines: number
178
1090
 
179
1091
  function formatAgentBrowserRenderCall(args: unknown, theme: Theme): string {
180
1092
  const input = isRecord(args) ? args : {};
181
- const rawArgs = Array.isArray(input.args) ? input.args.filter((value): value is string => typeof value === "string") : [];
1093
+ const semanticAction = compileAgentBrowserSemanticAction(input.semanticAction);
1094
+ const job = compileAgentBrowserJob(input.job);
1095
+ const qa = compileAgentBrowserQaPreset(input.qa);
1096
+ const sourceLookup = compileAgentBrowserSourceLookup(input.sourceLookup);
1097
+ const networkSourceLookup = compileAgentBrowserNetworkSourceLookup(input.networkSourceLookup);
1098
+ const generatedBatch = networkSourceLookup.compiled ?? sourceLookup.compiled ?? job.compiled ?? qa.compiled;
1099
+ const rawArgs = Array.isArray(input.args)
1100
+ ? input.args.filter((value): value is string => typeof value === "string")
1101
+ : (semanticAction.compiled?.args ?? generatedBatch?.args ?? []);
182
1102
  const redactedArgs = redactInvocationArgs(rawArgs);
183
1103
  const invocation = sanitizeDisplayText(redactedArgs.join(" ")).replace(/\s+/g, " ").trim();
184
1104
  const invocationPreview =
@@ -475,6 +1395,72 @@ interface NavigationSummary {
475
1395
  url?: string;
476
1396
  }
477
1397
 
1398
+ interface OverlayBlockerCandidate {
1399
+ args: string[];
1400
+ name?: string;
1401
+ reason: string;
1402
+ ref: string;
1403
+ role?: string;
1404
+ }
1405
+
1406
+ interface OverlayBlockerDiagnostic {
1407
+ candidates: OverlayBlockerCandidate[];
1408
+ snapshot: SessionRefSnapshot;
1409
+ summary: string;
1410
+ }
1411
+
1412
+ interface SelectorTextVisibilityDiagnostic {
1413
+ firstMatchVisible?: boolean;
1414
+ firstVisibleTextPreview?: string;
1415
+ matchCount: number;
1416
+ selector: string;
1417
+ summary: string;
1418
+ visibleCount: number;
1419
+ }
1420
+
1421
+ interface TimeoutArtifactEvidence {
1422
+ absolutePath: string;
1423
+ exists: boolean;
1424
+ path: string;
1425
+ sizeBytes?: number;
1426
+ stepIndex: number;
1427
+ }
1428
+
1429
+ interface TimeoutPartialProgress {
1430
+ artifacts: TimeoutArtifactEvidence[];
1431
+ currentPage?: {
1432
+ title?: string;
1433
+ url?: string;
1434
+ };
1435
+ steps?: Array<{ args: string[]; index: number }>;
1436
+ summary: string;
1437
+ }
1438
+
1439
+ interface EvalStdinHint {
1440
+ reason: string;
1441
+ suggestion: string;
1442
+ }
1443
+
1444
+ interface ArtifactCleanupGuidance {
1445
+ explicitArtifactPaths: string[];
1446
+ note: string;
1447
+ owner: "host-file-tools";
1448
+ summary: string;
1449
+ }
1450
+
1451
+ interface ManagedSessionOutcome {
1452
+ activeAfter: boolean;
1453
+ activeBefore: boolean;
1454
+ attemptedSessionName?: string;
1455
+ currentSessionName: string;
1456
+ previousSessionName: string;
1457
+ replacedSessionName?: string;
1458
+ sessionMode: "auto" | "fresh";
1459
+ status: "abandoned" | "closed" | "created" | "preserved" | "replaced" | "unchanged";
1460
+ succeeded: boolean;
1461
+ summary: string;
1462
+ }
1463
+
478
1464
  function isRecord(value: unknown): value is Record<string, unknown> {
479
1465
  return typeof value === "object" && value !== null;
480
1466
  }
@@ -878,7 +1864,7 @@ function shouldCaptureNavigationSummary(command: string | undefined, data: unkno
878
1864
  );
879
1865
  }
880
1866
 
881
- function extractStringResultField(data: unknown, fieldName: "title" | "url"): string | undefined {
1867
+ function extractStringResultField(data: unknown, fieldName: "result" | "title" | "url"): string | undefined {
882
1868
  if (typeof data === "string") {
883
1869
  const text = data.trim();
884
1870
  return text.length > 0 ? text : undefined;
@@ -915,6 +1901,21 @@ interface OrderedSessionTabTarget {
915
1901
  target: SessionTabTarget;
916
1902
  }
917
1903
 
1904
+ interface SessionRefSnapshot {
1905
+ refIds: string[];
1906
+ target?: SessionTabTarget;
1907
+ }
1908
+
1909
+ interface OrderedSessionRefSnapshot extends SessionRefSnapshot {
1910
+ order: number;
1911
+ }
1912
+
1913
+ interface StaleRefPreflight {
1914
+ message: string;
1915
+ refIds: string[];
1916
+ snapshot?: SessionRefSnapshot;
1917
+ }
1918
+
918
1919
  interface AboutBlankSessionMismatch {
919
1920
  activeUrl: "about:blank";
920
1921
  recoveryApplied: boolean;
@@ -923,7 +1924,7 @@ interface AboutBlankSessionMismatch {
923
1924
  targetUrl: string;
924
1925
  }
925
1926
 
926
- function getLatestSessionTabTargetOrder(targets: Map<string, OrderedSessionTabTarget>): number {
1927
+ function getLatestSessionTabTargetOrder(targets: Map<string, { order: number }>): number {
927
1928
  let latestOrder = 0;
928
1929
  for (const target of targets.values()) {
929
1930
  latestOrder = Math.max(latestOrder, target.order);
@@ -932,7 +1933,7 @@ function getLatestSessionTabTargetOrder(targets: Map<string, OrderedSessionTabTa
932
1933
  }
933
1934
 
934
1935
  function shouldApplySessionTabTargetUpdate(options: {
935
- current?: OrderedSessionTabTarget;
1936
+ current?: { order: number };
936
1937
  updateOrder: number;
937
1938
  }): boolean {
938
1939
  return !options.current || options.updateOrder >= options.current.order;
@@ -1072,10 +2073,70 @@ function restoreSessionTabTargetsFromBranch(branch: unknown[]): Map<string, Orde
1072
2073
  : undefined;
1073
2074
  if (sessionTabTarget) {
1074
2075
  restoredOrder += 1;
1075
- restoredTargets.set(sessionName, { order: restoredOrder, target: sessionTabTarget });
2076
+ restoredTargets.set(sessionName, { order: restoredOrder, target: sessionTabTarget });
2077
+ }
2078
+ }
2079
+ return restoredTargets;
2080
+ }
2081
+
2082
+ function extractRefSnapshotFromData(data: unknown): SessionRefSnapshot | undefined {
2083
+ if (!isRecord(data)) return undefined;
2084
+ const refIds = isRecord(data.refs) ? Object.keys(data.refs).filter((refId) => /^e\d+$/.test(refId)) : [];
2085
+ if (refIds.length === 0) return undefined;
2086
+ return {
2087
+ refIds,
2088
+ target: extractSessionTabTargetFromData(data),
2089
+ };
2090
+ }
2091
+
2092
+ function extractRefSnapshotFromBatchResults(data: unknown): SessionRefSnapshot | undefined {
2093
+ if (!Array.isArray(data)) return undefined;
2094
+ let latestSnapshot: SessionRefSnapshot | undefined;
2095
+ for (const item of data) {
2096
+ if (!isRecord(item) || item.success === false) continue;
2097
+ const [name] = extractBatchResultCommand(item);
2098
+ if (name !== "snapshot") continue;
2099
+ latestSnapshot = extractRefSnapshotFromData(item.result) ?? latestSnapshot;
2100
+ }
2101
+ return latestSnapshot;
2102
+ }
2103
+
2104
+ function restoreSessionRefSnapshotsFromBranch(branch: unknown[]): Map<string, OrderedSessionRefSnapshot> {
2105
+ const restoredSnapshots = new Map<string, OrderedSessionRefSnapshot>();
2106
+ let restoredOrder = 0;
2107
+ for (const entry of branch) {
2108
+ if (!isRecord(entry) || entry.type !== "message") continue;
2109
+ const message = isRecord(entry.message) ? entry.message : undefined;
2110
+ if (!message || message.toolName !== "agent_browser") continue;
2111
+ const details = isRecord(message.details) ? message.details : undefined;
2112
+ if (!details) continue;
2113
+ const sessionName = typeof details.sessionName === "string" ? details.sessionName : undefined;
2114
+ if (!sessionName) continue;
2115
+ const command = typeof details.command === "string" ? details.command : undefined;
2116
+ if (command === "close" && message.isError !== true) {
2117
+ restoredOrder += 1;
2118
+ restoredSnapshots.delete(sessionName);
2119
+ continue;
2120
+ }
2121
+ const refSnapshot = isRecord(details.refSnapshot)
2122
+ ? {
2123
+ refIds: Array.isArray(details.refSnapshot.refIds)
2124
+ ? details.refSnapshot.refIds.filter((refId): refId is string => typeof refId === "string" && /^e\d+$/.test(refId))
2125
+ : [],
2126
+ target: isRecord(details.refSnapshot.target)
2127
+ ? normalizeSessionTabTarget({
2128
+ title: typeof details.refSnapshot.target.title === "string" ? details.refSnapshot.target.title : undefined,
2129
+ url: typeof details.refSnapshot.target.url === "string" ? details.refSnapshot.target.url : undefined,
2130
+ })
2131
+ : undefined,
2132
+ }
2133
+ : undefined;
2134
+ if (refSnapshot && refSnapshot.refIds.length > 0) {
2135
+ restoredOrder += 1;
2136
+ restoredSnapshots.set(sessionName, { ...refSnapshot, order: restoredOrder });
1076
2137
  }
1077
2138
  }
1078
- return restoredTargets;
2139
+ return restoredSnapshots;
1079
2140
  }
1080
2141
 
1081
2142
  function restoreArtifactManifestFromBranch(branch: unknown[]): SessionArtifactManifest | undefined {
@@ -1227,6 +2288,46 @@ function parseUserBatchStdin(stdin: string | undefined): { error?: string; steps
1227
2288
  }
1228
2289
  }
1229
2290
 
2291
+ const REF_INVALIDATING_BATCH_COMMANDS = new Set([
2292
+ "back",
2293
+ "check",
2294
+ "click",
2295
+ "dblclick",
2296
+ "drag",
2297
+ "fill",
2298
+ "forward",
2299
+ "goto",
2300
+ "keyboard",
2301
+ "mouse",
2302
+ "navigate",
2303
+ "open",
2304
+ "press",
2305
+ "reload",
2306
+ "select",
2307
+ "type",
2308
+ "uncheck",
2309
+ "upload",
2310
+ ]);
2311
+
2312
+ const REF_GUARDED_COMMANDS = new Set([
2313
+ "check",
2314
+ "click",
2315
+ "dblclick",
2316
+ "download",
2317
+ "drag",
2318
+ "fill",
2319
+ "focus",
2320
+ "hover",
2321
+ "keyboard",
2322
+ "mouse",
2323
+ "press",
2324
+ "scrollintoview",
2325
+ "select",
2326
+ "type",
2327
+ "uncheck",
2328
+ "upload",
2329
+ ]);
2330
+
1230
2331
  function getStaleRefArgs(commandTokens: string[], stdin?: string): string[] {
1231
2332
  if (commandTokens[0] !== "batch" || stdin === undefined) {
1232
2333
  return commandTokens;
@@ -1238,6 +2339,101 @@ function getStaleRefArgs(commandTokens: string[], stdin?: string): string[] {
1238
2339
  return parsed.steps.flatMap((step) => step);
1239
2340
  }
1240
2341
 
2342
+ function collectRefsFromTokens(tokens: string[]): string[] {
2343
+ return tokens.filter((token) => /^@e\d+\b/.test(token)).map((token) => token.slice(1));
2344
+ }
2345
+
2346
+ function getGuardedRefUsage(commandTokens: string[], stdin?: string): string[] {
2347
+ const collectFromStep = (step: string[]) => REF_GUARDED_COMMANDS.has(step[0] ?? "") ? collectRefsFromTokens(step) : [];
2348
+ if (commandTokens[0] !== "batch" || stdin === undefined) {
2349
+ return collectFromStep(commandTokens);
2350
+ }
2351
+ const parsed = parseUserBatchStdin(stdin);
2352
+ if (parsed.error || parsed.steps === undefined) {
2353
+ return collectFromStep(commandTokens);
2354
+ }
2355
+ const refsBeforeInBatchSnapshot: string[] = [];
2356
+ for (const step of parsed.steps) {
2357
+ if ((step[0] ?? "") === "snapshot") break;
2358
+ refsBeforeInBatchSnapshot.push(...collectFromStep(step));
2359
+ }
2360
+ return refsBeforeInBatchSnapshot;
2361
+ }
2362
+
2363
+ function targetsMatch(left: SessionTabTarget | undefined, right: SessionTabTarget | undefined): boolean {
2364
+ if (!left || !right) return true;
2365
+ return normalizeComparableUrl(left.url) === normalizeComparableUrl(right.url);
2366
+ }
2367
+
2368
+ function getBatchRefInvalidationMessage(commandTokens: string[], stdin?: string): string | undefined {
2369
+ if (commandTokens[0] !== "batch" || stdin === undefined) return undefined;
2370
+ const parsed = parseUserBatchStdin(stdin);
2371
+ if (parsed.error || parsed.steps === undefined) return undefined;
2372
+ let priorStepInvalidatesRefs = false;
2373
+ for (const step of parsed.steps) {
2374
+ if ((step[0] ?? "") === "snapshot") {
2375
+ priorStepInvalidatesRefs = false;
2376
+ }
2377
+ const refIds = collectRefsFromTokens(step);
2378
+ if (refIds.length > 0 && REF_GUARDED_COMMANDS.has(step[0] ?? "") && priorStepInvalidatesRefs) {
2379
+ return `Batch step ${step[0]} uses page-scoped ref ${refIds.map((refId) => `@${refId}`).join(", ")} after an earlier batch step can navigate or mutate the page. Split the batch, run snapshot -i after the page-changing step, then retry with current refs.`;
2380
+ }
2381
+ if (REF_INVALIDATING_BATCH_COMMANDS.has(step[0] ?? "")) {
2382
+ priorStepInvalidatesRefs = true;
2383
+ }
2384
+ }
2385
+ return undefined;
2386
+ }
2387
+
2388
+ function buildStaleRefPreflight(options: {
2389
+ commandTokens: string[];
2390
+ currentTarget?: SessionTabTarget;
2391
+ refSnapshot?: SessionRefSnapshot;
2392
+ stdin?: string;
2393
+ }): StaleRefPreflight | undefined {
2394
+ const usedRefIds = [...new Set(getGuardedRefUsage(options.commandTokens, options.stdin))];
2395
+ const batchInvalidationMessage = getBatchRefInvalidationMessage(options.commandTokens, options.stdin);
2396
+ if (batchInvalidationMessage && usedRefIds.length > 0) {
2397
+ return {
2398
+ message: batchInvalidationMessage,
2399
+ refIds: usedRefIds,
2400
+ snapshot: options.refSnapshot,
2401
+ };
2402
+ }
2403
+ if (usedRefIds.length === 0 || !options.refSnapshot) return undefined;
2404
+ if (!targetsMatch(options.refSnapshot.target, options.currentTarget)) {
2405
+ return {
2406
+ message: `Ref ${usedRefIds.map((refId) => `@${refId}`).join(", ")} came from a snapshot for ${options.refSnapshot.target?.url ?? "a prior page"}, but the current session target is ${options.currentTarget?.url ?? "unknown"}. Run snapshot -i again before using page-scoped refs.`,
2407
+ refIds: usedRefIds,
2408
+ snapshot: options.refSnapshot,
2409
+ };
2410
+ }
2411
+ const knownRefs = new Set(options.refSnapshot.refIds);
2412
+ const missingRefs = usedRefIds.filter((refId) => !knownRefs.has(refId));
2413
+ if (missingRefs.length > 0) {
2414
+ return {
2415
+ message: `Ref ${missingRefs.map((refId) => `@${refId}`).join(", ")} was not present in the latest snapshot for this session. Run snapshot -i again before using page-scoped refs.`,
2416
+ refIds: missingRefs,
2417
+ snapshot: options.refSnapshot,
2418
+ };
2419
+ }
2420
+ return undefined;
2421
+ }
2422
+
2423
+ function sessionPrefixArgs(sessionName: string | undefined, args: string[]): string[] {
2424
+ return sessionName && args[0] !== "--session" ? ["--session", sessionName, ...args] : args;
2425
+ }
2426
+
2427
+ function sessionAwareStaleRefNextActions(sessionName: string | undefined): AgentBrowserNextAction[] {
2428
+ return (buildAgentBrowserNextActions({ failureCategory: "stale-ref", resultCategory: "failure" }) ?? []).map((action) => {
2429
+ const actionArgs = action.params?.args;
2430
+ return {
2431
+ ...action,
2432
+ params: action.params && actionArgs ? { ...action.params, args: sessionPrefixArgs(sessionName, actionArgs) } : action.params,
2433
+ };
2434
+ });
2435
+ }
2436
+
1241
2437
  function buildPinnedBatchPlan(options: {
1242
2438
  command?: string;
1243
2439
  commandTokens: string[];
@@ -1371,14 +2567,16 @@ async function runSessionCommandData(options: {
1371
2567
  cwd: string;
1372
2568
  sessionName?: string;
1373
2569
  signal?: AbortSignal;
2570
+ stdin?: string;
1374
2571
  }): Promise<unknown | undefined> {
1375
- const { args, cwd, sessionName, signal } = options;
2572
+ const { args, cwd, sessionName, signal, stdin } = options;
1376
2573
  if (!sessionName) return undefined;
1377
2574
 
1378
2575
  const processResult = await runAgentBrowserProcess({
1379
2576
  args: ["--json", "--session", sessionName, ...args],
1380
2577
  cwd,
1381
2578
  signal,
2579
+ stdin,
1382
2580
  });
1383
2581
  try {
1384
2582
  if (processResult.aborted || processResult.spawnError || processResult.exitCode !== 0) {
@@ -1424,6 +2622,401 @@ function mergeNavigationSummaryIntoData(data: unknown, navigationSummary: Naviga
1424
2622
  return { navigationSummary, result: data };
1425
2623
  }
1426
2624
 
2625
+ function getSnapshotRefRecord(data: unknown): Record<string, unknown> | undefined {
2626
+ return isRecord(data) && isRecord(data.refs) ? data.refs : undefined;
2627
+ }
2628
+
2629
+ const OVERLAY_CLOSE_NAME_PATTERN = /(?:\b(?:close|dismiss|no thanks|not now|maybe later|hide|skip|continue without|x)\b|^\s*×\s*$)/i;
2630
+ const OVERLAY_CONTEXT_NAME_PATTERN = /\b(?:banner|modal|dialog|popup|pop-up|overlay|donat(?:e|ion)|subscribe|sign in|login|cookie|privacy|consent)\b/i;
2631
+ const OVERLAY_CONTEXT_ROLES = new Set(["alertdialog", "dialog"]);
2632
+ const OVERLAY_ACTION_ROLES = new Set(["button", "link", "menuitem"]);
2633
+ const OVERLAY_BLOCKER_CANDIDATE_LIMIT = 3;
2634
+
2635
+ function getOverlayBlockerCandidates(snapshotData: unknown): OverlayBlockerCandidate[] {
2636
+ const refs = getSnapshotRefRecord(snapshotData);
2637
+ if (!refs) return [];
2638
+ const hasOverlayContext = Object.values(refs).some((entry) => {
2639
+ if (!isRecord(entry)) return false;
2640
+ const role = typeof entry.role === "string" ? entry.role : "";
2641
+ const name = typeof entry.name === "string" ? entry.name : "";
2642
+ return OVERLAY_CONTEXT_ROLES.has(role.toLowerCase()) || OVERLAY_CONTEXT_NAME_PATTERN.test(name);
2643
+ });
2644
+ if (!hasOverlayContext) return [];
2645
+ const candidates: OverlayBlockerCandidate[] = [];
2646
+ for (const [ref, entry] of Object.entries(refs)) {
2647
+ if (!/^e\d+$/.test(ref) || !isRecord(entry)) continue;
2648
+ const role = typeof entry.role === "string" ? entry.role : undefined;
2649
+ const name = typeof entry.name === "string" ? entry.name : undefined;
2650
+ if (!role || !OVERLAY_ACTION_ROLES.has(role.toLowerCase()) || !name || !OVERLAY_CLOSE_NAME_PATTERN.test(name)) continue;
2651
+ candidates.push({
2652
+ args: ["click", `@${ref}`],
2653
+ name,
2654
+ reason: `Visible ${role} ${JSON.stringify(name)} appears in a snapshot that also contains overlay/banner/dialog context.`,
2655
+ ref: `@${ref}`,
2656
+ role,
2657
+ });
2658
+ if (candidates.length >= OVERLAY_BLOCKER_CANDIDATE_LIMIT) break;
2659
+ }
2660
+ return candidates;
2661
+ }
2662
+
2663
+ function formatOverlayBlockerText(diagnostic: OverlayBlockerDiagnostic): string {
2664
+ return [
2665
+ "Possible overlay blockers:",
2666
+ ...diagnostic.candidates.map((candidate) => `- ${candidate.ref}${candidate.role ? ` ${candidate.role}` : ""}${candidate.name ? ` ${JSON.stringify(candidate.name)}` : ""}: ${candidate.reason}`),
2667
+ ].join("\n");
2668
+ }
2669
+
2670
+ function buildOverlayBlockerNextActions(options: { diagnostic: OverlayBlockerDiagnostic; sessionName?: string }): AgentBrowserNextAction[] {
2671
+ return [
2672
+ {
2673
+ id: "inspect-overlay-state",
2674
+ params: { args: sessionPrefixArgs(options.sessionName, ["snapshot", "-i"]) },
2675
+ reason: "Refresh interactive refs and inspect whether an overlay, banner, modal, or dialog is blocking the intended click.",
2676
+ safety: "Read-only inspection; use current refs from this snapshot before interacting.",
2677
+ tool: "agent_browser" as const,
2678
+ },
2679
+ ...options.diagnostic.candidates.map((candidate, index) => ({
2680
+ id: `try-overlay-blocker-candidate-${index + 1}`,
2681
+ params: { args: sessionPrefixArgs(options.sessionName, candidate.args) },
2682
+ reason: candidate.reason,
2683
+ safety: "Only click this if the candidate is clearly a close/dismiss control for an overlay that blocks the intended workflow.",
2684
+ tool: "agent_browser" as const,
2685
+ })),
2686
+ ];
2687
+ }
2688
+
2689
+ function buildVisibleTextProbeScript(selector: string): string {
2690
+ return `(() => {\n const selector = ${JSON.stringify(selector)};\n const isVisible = (element) => {\n const style = window.getComputedStyle(element);\n if (!style || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || Number(style.opacity) === 0) return false;\n return Array.from(element.getClientRects()).some((rect) => rect.width > 0 && rect.height > 0);\n };\n let matches = [];\n try {\n matches = Array.from(document.querySelectorAll(selector));\n } catch (error) {\n return JSON.stringify({ selector, error: error instanceof Error ? error.message : String(error) });\n }\n const visible = matches.filter(isVisible);\n const trim = (value) => typeof value === 'string' ? value.trim().replace(/\\s+/g, ' ').slice(0, 200) : undefined;\n return JSON.stringify({\n selector,\n matchCount: matches.length,\n visibleCount: visible.length,\n firstMatchVisible: matches[0] ? isVisible(matches[0]) : undefined,\n firstTextPreview: trim(matches[0]?.textContent),\n firstVisibleTextPreview: trim(visible[0]?.textContent),\n });\n})()`;
2691
+ }
2692
+
2693
+ function parseSelectorTextVisibilityProbe(data: unknown, selector: string): Omit<SelectorTextVisibilityDiagnostic, "summary"> | undefined {
2694
+ const result = extractStringResultField(data, "result");
2695
+ if (!result) return undefined;
2696
+ let parsed: unknown;
2697
+ try {
2698
+ parsed = JSON.parse(result);
2699
+ } catch {
2700
+ return undefined;
2701
+ }
2702
+ if (!isRecord(parsed) || typeof parsed.error === "string") return undefined;
2703
+ const matchCount = typeof parsed.matchCount === "number" ? parsed.matchCount : undefined;
2704
+ const visibleCount = typeof parsed.visibleCount === "number" ? parsed.visibleCount : undefined;
2705
+ if (matchCount === undefined || visibleCount === undefined) return undefined;
2706
+ return {
2707
+ firstMatchVisible: typeof parsed.firstMatchVisible === "boolean" ? parsed.firstMatchVisible : undefined,
2708
+ firstVisibleTextPreview: typeof parsed.firstVisibleTextPreview === "string" && parsed.firstVisibleTextPreview.length > 0 ? redactSensitiveText(parsed.firstVisibleTextPreview) : undefined,
2709
+ matchCount,
2710
+ selector,
2711
+ visibleCount,
2712
+ };
2713
+ }
2714
+
2715
+ function selectorMayExposeSensitiveLiteral(selector: string): boolean {
2716
+ return redactSensitiveText(selector) !== selector || /\[[^\]]*[~|^$*]?=\s*(?:"[^"]*"|'[^']*'|[^\]\s]+)\s*(?:[is]\s*)?\]/.test(selector);
2717
+ }
2718
+
2719
+ async function collectSelectorTextVisibilityDiagnosticForSelector(options: {
2720
+ cwd: string;
2721
+ selector: string | undefined;
2722
+ sessionName?: string;
2723
+ signal?: AbortSignal;
2724
+ }): Promise<SelectorTextVisibilityDiagnostic | undefined> {
2725
+ const { selector } = options;
2726
+ if (!selector || /^@e\d+$/.test(selector) || selectorMayExposeSensitiveLiteral(selector)) return undefined;
2727
+ const probe = await runSessionCommandData({
2728
+ args: ["eval", "--stdin"],
2729
+ cwd: options.cwd,
2730
+ sessionName: options.sessionName,
2731
+ signal: options.signal,
2732
+ stdin: buildVisibleTextProbeScript(selector),
2733
+ });
2734
+ const parsed = parseSelectorTextVisibilityProbe(probe, selector);
2735
+ if (!parsed || parsed.matchCount <= 1 && parsed.firstMatchVisible !== false) return undefined;
2736
+ if (parsed.visibleCount === 0) return undefined;
2737
+ const visibleMatchNoun = `visible match${parsed.visibleCount === 1 ? "" : "es"}`;
2738
+ const visibleMatchVerb = parsed.visibleCount === 1 ? "exists" : "exist";
2739
+ const summary = parsed.firstMatchVisible === false
2740
+ ? `Selector ${JSON.stringify(selector)} matched ${parsed.matchCount} elements; the first match is hidden while ${parsed.visibleCount} ${visibleMatchNoun} ${visibleMatchVerb}.`
2741
+ : `Selector ${JSON.stringify(selector)} matched ${parsed.matchCount} elements; get text reads the first upstream match, which may not be the intended visible tab/panel.`;
2742
+ return { ...parsed, summary };
2743
+ }
2744
+
2745
+ function getBatchGetTextSelectors(data: unknown): string[] {
2746
+ if (!Array.isArray(data)) return [];
2747
+ return data.flatMap((item) => {
2748
+ if (!isRecord(item) || item.success === false) return [];
2749
+ const [command, subcommand, selector] = extractBatchResultCommand(item);
2750
+ return command === "get" && subcommand === "text" && selector ? [selector] : [];
2751
+ });
2752
+ }
2753
+
2754
+ async function collectSelectorTextVisibilityDiagnostics(options: {
2755
+ commandInfo: CommandInfo;
2756
+ commandTokens: string[];
2757
+ cwd: string;
2758
+ data: unknown;
2759
+ sessionName?: string;
2760
+ signal?: AbortSignal;
2761
+ }): Promise<SelectorTextVisibilityDiagnostic[]> {
2762
+ const selectors = options.commandInfo.command === "get" && options.commandInfo.subcommand === "text"
2763
+ ? [options.commandTokens[2]]
2764
+ : options.commandInfo.command === "batch"
2765
+ ? getBatchGetTextSelectors(options.data)
2766
+ : [];
2767
+ const diagnostics: SelectorTextVisibilityDiagnostic[] = [];
2768
+ for (const selector of selectors) {
2769
+ const diagnostic = await collectSelectorTextVisibilityDiagnosticForSelector({
2770
+ cwd: options.cwd,
2771
+ selector,
2772
+ sessionName: options.sessionName,
2773
+ signal: options.signal,
2774
+ });
2775
+ if (diagnostic) diagnostics.push(diagnostic);
2776
+ }
2777
+ return diagnostics.sort((left, right) => Number(right.firstMatchVisible === false) - Number(left.firstMatchVisible === false));
2778
+ }
2779
+
2780
+ function formatSelectorTextVisibilityText(diagnostics: SelectorTextVisibilityDiagnostic[]): string | undefined {
2781
+ if (diagnostics.length === 0) return undefined;
2782
+ return diagnostics.flatMap((diagnostic) => {
2783
+ const lines = [`Selector text visibility warning: ${diagnostic.summary}`];
2784
+ if (diagnostic.firstVisibleTextPreview) lines.push(`First visible text preview: ${JSON.stringify(diagnostic.firstVisibleTextPreview)}`);
2785
+ return lines;
2786
+ }).join("\n");
2787
+ }
2788
+
2789
+ function looksLikeFunctionEvalStdin(stdin: string | undefined): boolean {
2790
+ const trimmed = stdin?.trim();
2791
+ if (!trimmed) return false;
2792
+ return /^(?:async\s+)?function\b/.test(trimmed) || /^(?:async\s*)?\([^)]*\)\s*=>/.test(trimmed) || /^(?:async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(trimmed);
2793
+ }
2794
+
2795
+ function isEmptyRecord(value: unknown): boolean {
2796
+ return isRecord(value) && Object.keys(value).length === 0;
2797
+ }
2798
+
2799
+ function getEvalStdinHint(options: { command?: string; data: unknown; stdin?: string }): EvalStdinHint | undefined {
2800
+ if (options.command !== "eval" || !looksLikeFunctionEvalStdin(options.stdin) || !isRecord(options.data)) return undefined;
2801
+ const result = options.data.result;
2802
+ if (!isEmptyRecord(result)) return undefined;
2803
+ return {
2804
+ reason: "eval --stdin received a function-shaped snippet and the upstream JSON result was an empty object, which often means the function itself was returned or serialized instead of invoked.",
2805
+ suggestion: "Pass a plain expression such as `({ title: document.title })`, or invoke the function explicitly, for example `(() => ({ title: document.title }))()`.",
2806
+ };
2807
+ }
2808
+
2809
+ function formatEvalStdinHintText(hint: EvalStdinHint | undefined): string | undefined {
2810
+ return hint ? `Eval stdin hint: ${hint.reason} ${hint.suggestion}` : undefined;
2811
+ }
2812
+
2813
+ function getArtifactCleanupGuidance(options: { command?: string; manifest?: SessionArtifactManifest; succeeded: boolean }): ArtifactCleanupGuidance | undefined {
2814
+ if (!options.succeeded || options.command !== "close" || !options.manifest || options.manifest.entries.length === 0) return undefined;
2815
+ const explicitArtifactPaths = options.manifest.entries
2816
+ .filter((entry) => entry.storageScope === "explicit-path")
2817
+ .map((entry) => entry.path)
2818
+ .filter((path, index, paths) => paths.indexOf(path) === index)
2819
+ .slice(0, 10);
2820
+ return {
2821
+ explicitArtifactPaths,
2822
+ note: "Closing the browser session does not delete explicit screenshots, downloads, PDFs, traces, HAR files, or recordings; clean those paths with host file tools when no longer needed.",
2823
+ owner: "host-file-tools",
2824
+ summary: formatSessionArtifactRetentionSummary(options.manifest),
2825
+ };
2826
+ }
2827
+
2828
+ function formatArtifactCleanupGuidanceText(guidance: ArtifactCleanupGuidance | undefined): string | undefined {
2829
+ if (!guidance) return undefined;
2830
+ const lines = [
2831
+ "Artifact lifecycle:",
2832
+ `- ${guidance.summary}`,
2833
+ `- ${guidance.note}`,
2834
+ ];
2835
+ if (guidance.explicitArtifactPaths.length > 0) {
2836
+ lines.push(`- Explicit artifact paths to review: ${guidance.explicitArtifactPaths.join(", ")}`);
2837
+ }
2838
+ return lines.join("\n");
2839
+ }
2840
+
2841
+ function buildSelectorTextVisibilityNextActions(options: { diagnostics: SelectorTextVisibilityDiagnostic[]; sessionName?: string }): AgentBrowserNextAction[] {
2842
+ return options.diagnostics.map((diagnostic, index) => ({
2843
+ id: index === 0 ? "inspect-visible-text-candidates" : `inspect-visible-text-candidates-${index + 1}`,
2844
+ params: {
2845
+ args: sessionPrefixArgs(options.sessionName, ["eval", "--stdin"]),
2846
+ stdin: buildVisibleTextProbeScript(diagnostic.selector),
2847
+ },
2848
+ reason: "Inspect selector match count and visible text before trusting get text on tabbed or hidden DOM content.",
2849
+ safety: "Read-only DOM inspection; use a more specific visible selector or current @ref before acting on hidden-tab text.",
2850
+ tool: "agent_browser" as const,
2851
+ }));
2852
+ }
2853
+
2854
+ function getTimeoutProgressSteps(compiledJob: CompiledAgentBrowserJob | undefined, command: string | undefined, stdin: string | undefined): Array<{ args: string[]; index: number }> {
2855
+ if (compiledJob) return compiledJob.steps.map((step, index) => ({ args: step.args, index: index + 1 }));
2856
+ if (command !== "batch" || !stdin) return [];
2857
+ try {
2858
+ const parsed = JSON.parse(stdin) as unknown;
2859
+ if (!Array.isArray(parsed)) return [];
2860
+ return parsed.flatMap((step, index) => Array.isArray(step) && step.every((token) => typeof token === "string") ? [{ args: step as string[], index: index + 1 }] : []);
2861
+ } catch {
2862
+ return [];
2863
+ }
2864
+ }
2865
+
2866
+ function getLastPositionalToken(args: string[], startIndex = 1): string | undefined {
2867
+ for (let index = args.length - 1; index >= startIndex; index -= 1) {
2868
+ const token = args[index];
2869
+ if (token && !token.startsWith("-")) return token;
2870
+ }
2871
+ return undefined;
2872
+ }
2873
+
2874
+ function getTimeoutStepArtifactPath(args: string[]): string | undefined {
2875
+ const [command] = args;
2876
+ if (command === "screenshot") {
2877
+ const index = getScreenshotPathTokenIndex(args);
2878
+ return index === undefined ? undefined : args[index];
2879
+ }
2880
+ if (command === "pdf") return getLastPositionalToken(args);
2881
+ if (command === "download") return getLastPositionalToken(args, 2);
2882
+ if (command === "wait") {
2883
+ const inlineDownload = args.find((token) => token.startsWith("--download="));
2884
+ if (inlineDownload) return inlineDownload.slice("--download=".length) || undefined;
2885
+ const downloadIndex = args.indexOf("--download");
2886
+ const downloadPath = downloadIndex >= 0 ? args[downloadIndex + 1] : undefined;
2887
+ if (downloadPath && !downloadPath.startsWith("-")) return downloadPath;
2888
+ }
2889
+ return undefined;
2890
+ }
2891
+
2892
+ async function collectTimeoutArtifactEvidence(cwd: string, steps: Array<{ args: string[]; index: number }>): Promise<TimeoutArtifactEvidence[]> {
2893
+ const evidence: TimeoutArtifactEvidence[] = [];
2894
+ for (const step of steps) {
2895
+ const path = getTimeoutStepArtifactPath(step.args);
2896
+ if (!path) continue;
2897
+ const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
2898
+ try {
2899
+ const stats = await stat(absolutePath);
2900
+ evidence.push({ absolutePath, exists: true, path, sizeBytes: stats.size, stepIndex: step.index });
2901
+ } catch {
2902
+ evidence.push({ absolutePath, exists: false, path, stepIndex: step.index });
2903
+ }
2904
+ }
2905
+ return evidence;
2906
+ }
2907
+
2908
+ function getPlannedCurrentPageUrl(steps: Array<{ args: string[]; index: number }>): string | undefined {
2909
+ for (let index = steps.length - 1; index >= 0; index -= 1) {
2910
+ const args = steps[index]?.args ?? [];
2911
+ if (args[0] === "open" || args[0] === "navigate" || args[0] === "pushstate") {
2912
+ return getLastPositionalToken(args);
2913
+ }
2914
+ }
2915
+ return undefined;
2916
+ }
2917
+
2918
+ async function collectTimeoutPartialProgress(options: {
2919
+ command?: string;
2920
+ compiledJob?: CompiledAgentBrowserJob;
2921
+ cwd: string;
2922
+ sessionName?: string;
2923
+ stdin?: string;
2924
+ }): Promise<TimeoutPartialProgress | undefined> {
2925
+ const steps = getTimeoutProgressSteps(options.compiledJob, options.command, options.stdin);
2926
+ const artifacts = await collectTimeoutArtifactEvidence(options.cwd, steps);
2927
+ const [urlData, titleData] = await Promise.all([
2928
+ runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, sessionName: options.sessionName }),
2929
+ runSessionCommandData({ args: ["get", "title"], cwd: options.cwd, sessionName: options.sessionName }),
2930
+ ]);
2931
+ const recoveredUrl = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
2932
+ const title = extractStringResultField(titleData, "result") ?? extractStringResultField(titleData, "title");
2933
+ const plannedUrl = recoveredUrl ? undefined : getPlannedCurrentPageUrl(steps);
2934
+ const url = recoveredUrl ?? plannedUrl;
2935
+ if (steps.length === 0 && artifacts.length === 0 && !url && !title) return undefined;
2936
+ const foundArtifacts = artifacts.filter((artifact) => artifact.exists).length;
2937
+ const pageStateSummary = recoveredUrl || title ? " and current page state" : plannedUrl ? " and planned page URL" : "";
2938
+ return {
2939
+ artifacts,
2940
+ currentPage: url || title ? { title, url } : undefined,
2941
+ steps: steps.length > 0 ? steps : undefined,
2942
+ summary: `Timed out before upstream returned final results; recovered ${foundArtifacts}/${artifacts.length} declared artifact path${artifacts.length === 1 ? "" : "s"}${pageStateSummary}.`,
2943
+ };
2944
+ }
2945
+
2946
+ function redactSensitivePathSegmentsForDiagnostic(path: string): string {
2947
+ return path.split(/([/\\]+)/).map((segment) => {
2948
+ if (segment === "/" || segment === "\\" || /^[/\\]+$/.test(segment)) return segment;
2949
+ return redactSensitiveText(segment) !== segment || /(?:secret|token|password|passwd|credential|auth|api[-_]?key|bearer)/i.test(segment) ? "[REDACTED]" : segment;
2950
+ }).join("");
2951
+ }
2952
+
2953
+ function sanitizeCurrentPageUrlForTimeoutDiagnostic(url: string): string {
2954
+ try {
2955
+ const parsedUrl = new URL(url);
2956
+ parsedUrl.pathname = parsedUrl.pathname.split("/").map((segment) => redactSensitivePathSegmentsForDiagnostic(segment)).join("/");
2957
+ for (const [key, value] of parsedUrl.searchParams.entries()) {
2958
+ if (redactSensitiveText(key) !== key || redactSensitiveText(value) !== value || /(?:secret|token|password|passwd|credential|auth|api[-_]?key|bearer)/i.test(`${key} ${value}`)) {
2959
+ parsedUrl.searchParams.set(key, "[REDACTED]");
2960
+ }
2961
+ }
2962
+ if (parsedUrl.hash) {
2963
+ parsedUrl.hash = redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(parsedUrl.hash));
2964
+ }
2965
+ return redactSensitiveText(parsedUrl.toString());
2966
+ } catch {
2967
+ return redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(url));
2968
+ }
2969
+ }
2970
+
2971
+ function formatTimeoutPartialProgressText(progress: TimeoutPartialProgress): string {
2972
+ const lines = [`Timeout partial progress: ${progress.summary}`];
2973
+ const currentPageTitle = progress.currentPage?.title ? redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(progress.currentPage.title)) : undefined;
2974
+ const currentPageUrl = progress.currentPage?.url ? sanitizeCurrentPageUrlForTimeoutDiagnostic(progress.currentPage.url) : undefined;
2975
+ if (currentPageTitle || currentPageUrl) {
2976
+ lines.push(`Current page: ${[currentPageTitle, currentPageUrl].filter(Boolean).join(" — ")}`);
2977
+ }
2978
+ if (progress.steps && progress.steps.length > 0) {
2979
+ const shownSteps = progress.steps.slice(0, 6);
2980
+ lines.push("Planned steps:");
2981
+ for (const step of shownSteps) {
2982
+ const command = redactSensitivePathSegmentsForDiagnostic(redactInvocationArgs(step.args).join(" "));
2983
+ lines.push(`- Step ${step.index}: ${command}`);
2984
+ }
2985
+ if (progress.steps.length > shownSteps.length) {
2986
+ lines.push(`- ... ${progress.steps.length - shownSteps.length} more step${progress.steps.length - shownSteps.length === 1 ? "" : "s"} omitted`);
2987
+ }
2988
+ }
2989
+ for (const artifact of progress.artifacts) {
2990
+ const path = redactSensitivePathSegmentsForDiagnostic(artifact.path);
2991
+ lines.push(`Artifact from step ${artifact.stepIndex}: ${path} (${artifact.exists ? `exists${typeof artifact.sizeBytes === "number" ? `, ${artifact.sizeBytes} bytes` : ""}` : "missing"})`);
2992
+ }
2993
+ return lines.join("\n");
2994
+ }
2995
+
2996
+ async function collectOverlayBlockerDiagnostic(options: {
2997
+ command?: string;
2998
+ cwd: string;
2999
+ data: unknown;
3000
+ navigationSummary?: NavigationSummary;
3001
+ priorTarget?: SessionTabTarget;
3002
+ sessionName?: string;
3003
+ signal?: AbortSignal;
3004
+ }): Promise<OverlayBlockerDiagnostic | undefined> {
3005
+ if (options.command !== "click" || !isRecord(options.data) || typeof options.data.clicked !== "string") return undefined;
3006
+ const priorUrl = normalizeComparableUrl(options.priorTarget?.url);
3007
+ const currentUrl = normalizeComparableUrl(options.navigationSummary?.url);
3008
+ if (!priorUrl || !currentUrl || priorUrl !== currentUrl) return undefined;
3009
+ const snapshotData = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal });
3010
+ const candidates = getOverlayBlockerCandidates(snapshotData);
3011
+ const snapshot = extractRefSnapshotFromData(snapshotData);
3012
+ if (candidates.length === 0 || !snapshot) return undefined;
3013
+ return {
3014
+ candidates,
3015
+ snapshot,
3016
+ summary: `Click completed but the page stayed on ${currentUrl}; a fresh snapshot contains likely overlay close/dismiss controls.`,
3017
+ };
3018
+ }
3019
+
1427
3020
  async function collectOpenResultTabCorrection(options: {
1428
3021
  cwd: string;
1429
3022
  sessionName?: string;
@@ -1489,6 +3082,68 @@ function buildSessionDetailFields(sessionName: string | undefined, usedImplicitS
1489
3082
  return sessionName ? { sessionName, usedImplicitSession } : {};
1490
3083
  }
1491
3084
 
3085
+ function buildManagedSessionOutcome(options: {
3086
+ activeAfter: boolean;
3087
+ activeBefore: boolean;
3088
+ attemptedSessionName?: string;
3089
+ command?: string;
3090
+ currentSessionName: string;
3091
+ previousSessionName: string;
3092
+ replacedSessionName?: string;
3093
+ sessionMode: "auto" | "fresh";
3094
+ succeeded: boolean;
3095
+ }): ManagedSessionOutcome | undefined {
3096
+ const { activeAfter, activeBefore, attemptedSessionName, command, currentSessionName, previousSessionName, replacedSessionName, sessionMode, succeeded } = options;
3097
+ if (!attemptedSessionName) return undefined;
3098
+ let status: ManagedSessionOutcome["status"];
3099
+ let summary: string;
3100
+ if (command === "close") {
3101
+ status = succeeded ? "closed" : activeBefore ? "preserved" : "abandoned";
3102
+ summary = succeeded
3103
+ ? `Managed session ${attemptedSessionName} was closed.`
3104
+ : activeBefore
3105
+ ? `Managed session close failed; previous managed session ${previousSessionName} remains current.`
3106
+ : `Managed session close failed; no managed session is active.`;
3107
+ } else if (succeeded) {
3108
+ if (replacedSessionName) {
3109
+ status = "replaced";
3110
+ summary = `Managed session ${replacedSessionName} was replaced by ${currentSessionName}.`;
3111
+ } else if (!activeBefore && activeAfter) {
3112
+ status = "created";
3113
+ summary = `Managed session ${currentSessionName} is now current.`;
3114
+ } else {
3115
+ status = "unchanged";
3116
+ summary = `Managed session ${currentSessionName} remains current.`;
3117
+ }
3118
+ } else if (activeBefore) {
3119
+ status = "preserved";
3120
+ summary = sessionMode === "fresh" && attemptedSessionName !== previousSessionName
3121
+ ? `Fresh managed session ${attemptedSessionName} failed before becoming current; previous managed session ${previousSessionName} was preserved.`
3122
+ : `Managed session call failed; previous managed session ${previousSessionName} was preserved.`;
3123
+ } else {
3124
+ status = "abandoned";
3125
+ summary = sessionMode === "fresh"
3126
+ ? `Fresh managed session ${attemptedSessionName} failed before becoming current; no previous managed session was active, so no managed session is current.`
3127
+ : `Managed session call failed before any managed session became current.`;
3128
+ }
3129
+ return {
3130
+ activeAfter,
3131
+ activeBefore,
3132
+ attemptedSessionName,
3133
+ currentSessionName,
3134
+ previousSessionName,
3135
+ replacedSessionName,
3136
+ sessionMode,
3137
+ status,
3138
+ succeeded,
3139
+ summary,
3140
+ };
3141
+ }
3142
+
3143
+ function formatManagedSessionOutcomeText(outcome: ManagedSessionOutcome | undefined): string | undefined {
3144
+ return outcome && !outcome.succeeded && outcome.sessionMode === "fresh" ? `Managed session outcome: ${outcome.summary}` : undefined;
3145
+ }
3146
+
1492
3147
  function getPersistentSessionArtifactStore(ctx: {
1493
3148
  sessionManager: {
1494
3149
  getSessionDir?: () => string;
@@ -1640,6 +3295,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1640
3295
  let managedSessionCwd = process.cwd();
1641
3296
  let freshSessionOrdinal = 0;
1642
3297
  let sessionTabTargets = new Map<string, OrderedSessionTabTarget>();
3298
+ let sessionRefSnapshots = new Map<string, OrderedSessionRefSnapshot>();
1643
3299
  let sessionTabTargetUpdateOrder = 0;
1644
3300
  let traceOwners = new Map<string, TraceOwner>();
1645
3301
  let artifactManifest: SessionArtifactManifest | undefined;
@@ -1653,7 +3309,8 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1653
3309
  managedSessionCwd = ctx.cwd;
1654
3310
  freshSessionOrdinal = restoredState.freshSessionOrdinal;
1655
3311
  sessionTabTargets = restoreSessionTabTargetsFromBranch(ctx.sessionManager.getBranch());
1656
- sessionTabTargetUpdateOrder = getLatestSessionTabTargetOrder(sessionTabTargets);
3312
+ sessionRefSnapshots = restoreSessionRefSnapshotsFromBranch(ctx.sessionManager.getBranch());
3313
+ sessionTabTargetUpdateOrder = Math.max(getLatestSessionTabTargetOrder(sessionTabTargets), getLatestSessionTabTargetOrder(sessionRefSnapshots));
1657
3314
  artifactManifest = restoreArtifactManifestFromBranch(ctx.sessionManager.getBranch());
1658
3315
  });
1659
3316
 
@@ -1670,6 +3327,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1670
3327
  }
1671
3328
  managedSessionActive = false;
1672
3329
  sessionTabTargets = new Map<string, OrderedSessionTabTarget>();
3330
+ sessionRefSnapshots = new Map<string, OrderedSessionRefSnapshot>();
1673
3331
  sessionTabTargetUpdateOrder = 0;
1674
3332
  traceOwners = new Map<string, TraceOwner>();
1675
3333
  artifactManifest = undefined;
@@ -1723,17 +3381,77 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1723
3381
  return component;
1724
3382
  },
1725
3383
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1726
- const redactedArgs = redactInvocationArgs(params.args);
1727
- const validationError = validateToolArgs(params.args) ?? getBatchAnnotateValidationError(params.args, params.stdin);
3384
+ const semanticActionResult = params.semanticAction === undefined ? {} : compileAgentBrowserSemanticAction(params.semanticAction);
3385
+ const jobResult = params.job === undefined ? {} : compileAgentBrowserJob(params.job);
3386
+ const qaResult = params.qa === undefined ? {} : compileAgentBrowserQaPreset(params.qa);
3387
+ const sourceLookupResult = params.sourceLookup === undefined ? {} : compileAgentBrowserSourceLookup(params.sourceLookup);
3388
+ const networkSourceLookupResult = params.networkSourceLookup === undefined ? {} : compileAgentBrowserNetworkSourceLookup(params.networkSourceLookup);
3389
+ const hasExplicitArgs = Array.isArray(params.args);
3390
+ const explicitInputModes = [hasExplicitArgs, Boolean(semanticActionResult.compiled), Boolean(jobResult.compiled), Boolean(qaResult.compiled), Boolean(sourceLookupResult.compiled), Boolean(networkSourceLookupResult.compiled)].filter(Boolean).length;
3391
+ const semanticActionError = semanticActionResult.error;
3392
+ const jobError = jobResult.error;
3393
+ const qaError = qaResult.error;
3394
+ const sourceLookupError = sourceLookupResult.error;
3395
+ const networkSourceLookupError = networkSourceLookupResult.error;
3396
+ const inputModeError = explicitInputModes !== 1
3397
+ ? "Provide exactly one of args, semanticAction, job, qa, sourceLookup, or networkSourceLookup."
3398
+ : undefined;
3399
+ const compiledSemanticAction = semanticActionResult.compiled;
3400
+ const compiledQaPreset = qaResult.compiled;
3401
+ const compiledSourceLookup = sourceLookupResult.compiled;
3402
+ const compiledNetworkSourceLookup = networkSourceLookupResult.compiled;
3403
+ const compiledJob = jobResult.compiled ?? compiledQaPreset;
3404
+ const compiledGeneratedBatch = compiledNetworkSourceLookup ?? compiledSourceLookup ?? compiledJob;
3405
+ const toolArgs = compiledSemanticAction?.args ?? compiledGeneratedBatch?.args ?? params.args ?? [];
3406
+ const toolStdin = compiledGeneratedBatch?.stdin ?? params.stdin;
3407
+ const redactedArgs = redactInvocationArgs(toolArgs);
3408
+ const generatedStdinError = compiledGeneratedBatch && params.stdin !== undefined ? "Do not provide stdin with job, qa, sourceLookup, or networkSourceLookup; those modes generate their own batch stdin." : undefined;
3409
+ const validationError = semanticActionError ?? jobError ?? qaError ?? sourceLookupError ?? networkSourceLookupError ?? inputModeError ?? generatedStdinError ?? validateToolArgs(toolArgs) ?? getBatchAnnotateValidationError(toolArgs, toolStdin);
3410
+ const redactedCompiledSemanticAction = compiledSemanticAction
3411
+ ? { ...compiledSemanticAction, args: redactInvocationArgs(compiledSemanticAction.args) }
3412
+ : undefined;
3413
+ const redactedCompiledJobSteps = compiledJob?.steps.map((step) => ({ ...step, args: redactInvocationArgs(step.args) }));
3414
+ const redactedCompiledJob = compiledJob && redactedCompiledJobSteps
3415
+ ? { ...compiledJob, stdin: JSON.stringify(redactedCompiledJobSteps.map((step) => step.args)), steps: redactedCompiledJobSteps }
3416
+ : undefined;
3417
+ const redactedCompiledQaPreset = compiledQaPreset && redactedCompiledJob
3418
+ ? { ...redactedCompiledJob, checks: compiledQaPreset.checks }
3419
+ : undefined;
3420
+ const redactedCompiledSourceLookupSteps = compiledSourceLookup?.steps.map((step) => ({ ...step, args: redactInvocationArgs(step.args) }));
3421
+ const redactedCompiledSourceLookup = compiledSourceLookup && redactedCompiledSourceLookupSteps
3422
+ ? { ...compiledSourceLookup, stdin: JSON.stringify(redactedCompiledSourceLookupSteps.map((step) => step.args)), steps: redactedCompiledSourceLookupSteps }
3423
+ : undefined;
3424
+ const redactedCompiledNetworkSourceLookupSteps = compiledNetworkSourceLookup?.steps.map((step) => ({ ...step, args: redactNetworkSourceLookupArgs(step.args) }));
3425
+ const redactedCompiledNetworkSourceLookup = compiledNetworkSourceLookup && redactedCompiledNetworkSourceLookupSteps
3426
+ ? {
3427
+ ...compiledNetworkSourceLookup,
3428
+ query: {
3429
+ ...compiledNetworkSourceLookup.query,
3430
+ filter: redactNetworkSourceLookupUrl(compiledNetworkSourceLookup.query.filter),
3431
+ url: redactNetworkSourceLookupUrl(compiledNetworkSourceLookup.query.url),
3432
+ },
3433
+ stdin: JSON.stringify(redactedCompiledNetworkSourceLookupSteps.map((step) => step.args)),
3434
+ steps: redactedCompiledNetworkSourceLookupSteps,
3435
+ }
3436
+ : undefined;
1728
3437
  if (validationError) {
1729
3438
  return {
1730
3439
  content: [{ type: "text", text: validationError }],
1731
- details: { args: redactedArgs, validationError },
3440
+ details: {
3441
+ args: redactedArgs,
3442
+ compiledJob: redactedCompiledJob,
3443
+ compiledQaPreset: redactedCompiledQaPreset,
3444
+ compiledSourceLookup: redactedCompiledSourceLookup,
3445
+ compiledNetworkSourceLookup: redactedCompiledNetworkSourceLookup,
3446
+ compiledSemanticAction: redactedCompiledSemanticAction,
3447
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedArgs, errorText: validationError, succeeded: false, validationError }),
3448
+ validationError,
3449
+ },
1732
3450
  isError: true,
1733
3451
  };
1734
3452
  }
1735
- const preparedArgs = await prepareAgentBrowserArgs(params.args, params.stdin, ctx.cwd);
1736
- const userRequestedJson = params.args.includes("--json");
3453
+ const preparedArgs = await prepareAgentBrowserArgs(toolArgs, toolStdin, ctx.cwd);
3454
+ const userRequestedJson = toolArgs.includes("--json");
1737
3455
 
1738
3456
  const tabTargetUpdateOrder = ++sessionTabTargetUpdateOrder;
1739
3457
  const runTool = async (): Promise<AgentBrowserToolResult> => {
@@ -1757,10 +3475,15 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1757
3475
  content: [{ type: "text", text: executionPlan.validationError }],
1758
3476
  details: {
1759
3477
  args: redactedArgs,
3478
+ compiledJob: redactedCompiledJob,
3479
+ compiledQaPreset: redactedCompiledQaPreset,
3480
+ compiledSourceLookup: redactedCompiledSourceLookup,
3481
+ compiledNetworkSourceLookup: redactedCompiledNetworkSourceLookup,
1760
3482
  invalidValueFlag: executionPlan.invalidValueFlag,
1761
3483
  sessionMode,
1762
3484
  sessionRecoveryHint: redactedRecoveryHint,
1763
3485
  startupScopedFlags: executionPlan.startupScopedFlags,
3486
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedArgs, command: executionPlan.commandInfo.command, errorText: executionPlan.validationError, succeeded: false, validationError: executionPlan.validationError }),
1764
3487
  validationError: executionPlan.validationError,
1765
3488
  },
1766
3489
  isError: true,
@@ -1771,7 +3494,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1771
3494
  const exactSensitiveValues = getExactSensitiveStdinValues({
1772
3495
  command: executionPlan.commandInfo.command,
1773
3496
  commandTokens,
1774
- stdin: params.stdin,
3497
+ stdin: toolStdin,
1775
3498
  });
1776
3499
  const traceOwnerGuardMessage = getTraceOwnerGuardMessage({
1777
3500
  command: executionPlan.commandInfo.command,
@@ -1788,6 +3511,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1788
3511
  compatibilityWorkaround,
1789
3512
  effectiveArgs: redactedEffectiveArgs,
1790
3513
  sessionMode,
3514
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: traceOwnerGuardMessage, succeeded: false, validationError: traceOwnerGuardMessage }),
1791
3515
  validationError: traceOwnerGuardMessage,
1792
3516
  ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
1793
3517
  },
@@ -1797,7 +3521,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1797
3521
  const stdinValidationError = validateStdinCommandContract({
1798
3522
  command: executionPlan.commandInfo.command,
1799
3523
  commandTokens,
1800
- stdin: params.stdin,
3524
+ stdin: toolStdin,
1801
3525
  });
1802
3526
  if (stdinValidationError) {
1803
3527
  return {
@@ -1808,13 +3532,14 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1808
3532
  compatibilityWorkaround,
1809
3533
  effectiveArgs: redactedEffectiveArgs,
1810
3534
  sessionMode,
3535
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: stdinValidationError, succeeded: false, validationError: stdinValidationError }),
1811
3536
  validationError: stdinValidationError,
1812
3537
  ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
1813
3538
  },
1814
3539
  isError: true,
1815
3540
  };
1816
3541
  }
1817
- const waitIpcTimeoutError = validateWaitIpcTimeoutContract(commandTokens, params.stdin);
3542
+ const waitIpcTimeoutError = validateWaitIpcTimeoutContract(commandTokens, toolStdin);
1818
3543
  if (waitIpcTimeoutError) {
1819
3544
  return {
1820
3545
  content: [{ type: "text", text: waitIpcTimeoutError }],
@@ -1824,6 +3549,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1824
3549
  compatibilityWorkaround,
1825
3550
  effectiveArgs: redactedEffectiveArgs,
1826
3551
  sessionMode,
3552
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: waitIpcTimeoutError, succeeded: false, timedOut: true, validationError: waitIpcTimeoutError }),
1827
3553
  validationError: waitIpcTimeoutError,
1828
3554
  ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
1829
3555
  },
@@ -1833,18 +3559,43 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1833
3559
 
1834
3560
  const priorSessionTabTargetState = executionPlan.sessionName ? sessionTabTargets.get(executionPlan.sessionName) : undefined;
1835
3561
  const priorSessionTabTarget = priorSessionTabTargetState?.target;
3562
+ const priorRefSnapshotState = executionPlan.sessionName ? sessionRefSnapshots.get(executionPlan.sessionName) : undefined;
3563
+ const staleRefPreflight = buildStaleRefPreflight({
3564
+ commandTokens,
3565
+ currentTarget: priorSessionTabTarget,
3566
+ refSnapshot: priorRefSnapshotState,
3567
+ stdin: toolStdin,
3568
+ });
3569
+ if (staleRefPreflight) {
3570
+ return {
3571
+ content: [{ type: "text", text: staleRefPreflight.message }],
3572
+ details: {
3573
+ args: redactedArgs,
3574
+ command: executionPlan.commandInfo.command,
3575
+ compatibilityWorkaround,
3576
+ effectiveArgs: redactedEffectiveArgs,
3577
+ nextActions: sessionAwareStaleRefNextActions(executionPlan.sessionName),
3578
+ refIds: staleRefPreflight.refIds,
3579
+ refSnapshot: staleRefPreflight.snapshot,
3580
+ sessionMode,
3581
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: staleRefPreflight.message, failureCategory: "stale-ref", succeeded: false }),
3582
+ ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
3583
+ },
3584
+ isError: true,
3585
+ };
3586
+ }
1836
3587
  let pinnedBatchUnwrapMode: PinnedBatchUnwrapMode | undefined;
1837
3588
  let includePinnedNavigationSummary = false;
1838
3589
  let sessionTabCorrection: OpenResultTabCorrection | undefined;
1839
3590
  let processArgs = executionPlan.effectiveArgs;
1840
- let processStdin = preparedArgs.stdin ?? params.stdin;
3591
+ let processStdin = preparedArgs.stdin ?? toolStdin;
1841
3592
  if (
1842
3593
  priorSessionTabTarget &&
1843
3594
  shouldPinSessionTabForCommand({
1844
3595
  command: executionPlan.commandInfo.command,
1845
3596
  commandTokens,
1846
3597
  sessionName: executionPlan.sessionName,
1847
- stdin: params.stdin,
3598
+ stdin: toolStdin,
1848
3599
  })
1849
3600
  ) {
1850
3601
  const plannedSessionTabSelection = await collectSessionTabSelection({
@@ -1854,7 +3605,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1854
3605
  target: priorSessionTabTarget,
1855
3606
  });
1856
3607
  if (plannedSessionTabSelection && executionPlan.sessionName) {
1857
- if (executionPlan.commandInfo.command === "eval" && params.stdin !== undefined) {
3608
+ if (executionPlan.commandInfo.command === "eval" && toolStdin !== undefined) {
1858
3609
  const appliedSessionTabSelection = await applyOpenResultTabCorrection({
1859
3610
  correction: plannedSessionTabSelection,
1860
3611
  cwd: ctx.cwd,
@@ -1872,6 +3623,8 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1872
3623
  effectiveArgs: redactedEffectiveArgs,
1873
3624
  sessionMode,
1874
3625
  sessionTabCorrection: plannedSessionTabSelection,
3626
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: error, failureCategory: "tab-drift", succeeded: false, tabDrift: true, validationError: error }),
3627
+ nextActions: buildAgentBrowserNextActions({ failureCategory: "tab-drift", resultCategory: "failure" }),
1875
3628
  validationError: error,
1876
3629
  ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
1877
3630
  },
@@ -1884,7 +3637,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1884
3637
  command: executionPlan.commandInfo.command,
1885
3638
  commandTokens,
1886
3639
  selectedTab: plannedSessionTabSelection.selectedTab,
1887
- stdin: params.stdin,
3640
+ stdin: toolStdin,
1888
3641
  });
1889
3642
  if (pinnedBatchPlan && "error" in pinnedBatchPlan) {
1890
3643
  return {
@@ -1896,6 +3649,8 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1896
3649
  effectiveArgs: redactedEffectiveArgs,
1897
3650
  sessionMode,
1898
3651
  sessionTabCorrection: plannedSessionTabSelection,
3652
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: pinnedBatchPlan.error, failureCategory: "tab-drift", succeeded: false, tabDrift: true, validationError: pinnedBatchPlan.error }),
3653
+ nextActions: buildAgentBrowserNextActions({ failureCategory: "tab-drift", resultCategory: "failure" }),
1899
3654
  validationError: pinnedBatchPlan.error,
1900
3655
  ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
1901
3656
  },
@@ -1935,14 +3690,27 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1935
3690
 
1936
3691
  if (processResult.spawnError?.message.includes("ENOENT")) {
1937
3692
  const errorText = buildMissingBinaryMessage();
3693
+ const managedSessionOutcome = buildManagedSessionOutcome({
3694
+ activeAfter: managedSessionActive,
3695
+ activeBefore: managedSessionActive,
3696
+ attemptedSessionName: executionPlan.managedSessionName,
3697
+ command: executionPlan.commandInfo.command,
3698
+ currentSessionName: managedSessionName,
3699
+ previousSessionName: managedSessionName,
3700
+ sessionMode,
3701
+ succeeded: false,
3702
+ });
3703
+ const managedSessionOutcomeText = formatManagedSessionOutcomeText(managedSessionOutcome);
1938
3704
  return {
1939
- content: [{ type: "text", text: errorText }],
3705
+ content: [{ type: "text", text: managedSessionOutcomeText ? `${errorText}\n\n${managedSessionOutcomeText}` : errorText }],
1940
3706
  details: {
1941
3707
  args: redactedArgs,
1942
3708
  compatibilityWorkaround,
1943
3709
  effectiveArgs: redactedProcessArgs,
3710
+ managedSessionOutcome,
1944
3711
  sessionMode,
1945
3712
  sessionTabCorrection,
3713
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedProcessArgs, command: executionPlan.commandInfo.command, errorText, failureCategory: "missing-binary", spawnError: processResult.spawnError.message, succeeded: false }),
1946
3714
  spawnError: processResult.spawnError.message,
1947
3715
  },
1948
3716
  isError: true,
@@ -1997,7 +3765,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1997
3765
  const plainTextInspection = executionPlan.plainTextInspection && processSucceeded;
1998
3766
  const parseSucceeded = plainTextInspection || parseError === undefined;
1999
3767
  const envelopeSuccess = plainTextInspection ? true : presentationEnvelope?.success !== false;
2000
- const succeeded = processSucceeded && parseSucceeded && envelopeSuccess;
3768
+ let succeeded = processSucceeded && parseSucceeded && envelopeSuccess;
2001
3769
  const inspectionText = plainTextInspection ? processResult.stdout.trim() : undefined;
2002
3770
  updateTraceOwnerState({
2003
3771
  command: executionPlan.commandInfo.command,
@@ -2020,12 +3788,13 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2020
3788
  data: mergeNavigationSummaryIntoData(presentationEnvelope.data, navigationSummary),
2021
3789
  };
2022
3790
  }
3791
+ let overlayBlockerDiagnostic: OverlayBlockerDiagnostic | undefined;
2023
3792
 
2024
3793
  let openResultTabCorrection: OpenResultTabCorrection | undefined;
2025
3794
  if (
2026
3795
  succeeded &&
2027
3796
  executionPlan.sessionName &&
2028
- hasLaunchScopedTabCorrectionFlag(params.args) &&
3797
+ hasLaunchScopedTabCorrectionFlag(toolArgs) &&
2029
3798
  (executionPlan.commandInfo.command === "goto" ||
2030
3799
  executionPlan.commandInfo.command === "navigate" ||
2031
3800
  executionPlan.commandInfo.command === "open")
@@ -2123,33 +3892,91 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2123
3892
  }
2124
3893
  }
2125
3894
  }
3895
+ let selectorTextVisibilityDiagnostics: SelectorTextVisibilityDiagnostic[] = [];
3896
+ const timeoutPartialProgress = processResult.timedOut ? await collectTimeoutPartialProgress({
3897
+ command: executionPlan.commandInfo.command,
3898
+ compiledJob,
3899
+ cwd: ctx.cwd,
3900
+ sessionName: executionPlan.sessionName,
3901
+ stdin: toolStdin,
3902
+ }) : undefined;
3903
+ if (succeeded && !sessionTabCorrection && !aboutBlankSessionMismatch) {
3904
+ overlayBlockerDiagnostic = await collectOverlayBlockerDiagnostic({
3905
+ command: executionPlan.commandInfo.command,
3906
+ cwd: ctx.cwd,
3907
+ data: presentationEnvelope?.data,
3908
+ navigationSummary,
3909
+ priorTarget: priorSessionTabTarget,
3910
+ sessionName: executionPlan.sessionName,
3911
+ signal,
3912
+ });
3913
+ }
3914
+ if (succeeded) {
3915
+ selectorTextVisibilityDiagnostics = await collectSelectorTextVisibilityDiagnostics({
3916
+ commandInfo: executionPlan.commandInfo,
3917
+ commandTokens,
3918
+ cwd: ctx.cwd,
3919
+ data: presentationEnvelope?.data,
3920
+ sessionName: executionPlan.sessionName,
3921
+ signal,
3922
+ });
3923
+ }
3924
+ let currentRefSnapshot: SessionRefSnapshot | undefined;
2126
3925
  if (executionPlan.sessionName) {
2127
3926
  const activeSessionTabTargetState = sessionTabTargets.get(executionPlan.sessionName);
2128
3927
  if (shouldApplySessionTabTargetUpdate({ current: activeSessionTabTargetState, updateOrder: tabTargetUpdateOrder })) {
2129
3928
  if (executionPlan.commandInfo.command === "close" && succeeded) {
2130
3929
  sessionTabTargets.delete(executionPlan.sessionName);
3930
+ sessionRefSnapshots.delete(executionPlan.sessionName);
2131
3931
  } else if (currentSessionTabTarget) {
2132
3932
  sessionTabTargets.set(executionPlan.sessionName, { order: tabTargetUpdateOrder, target: currentSessionTabTarget });
2133
3933
  }
2134
3934
  }
3935
+ const refSnapshot = succeeded
3936
+ ? executionPlan.commandInfo.command === "snapshot"
3937
+ ? extractRefSnapshotFromData(presentationEnvelope?.data)
3938
+ : executionPlan.commandInfo.command === "batch"
3939
+ ? extractRefSnapshotFromBatchResults(presentationEnvelope?.data)
3940
+ : overlayBlockerDiagnostic?.snapshot
3941
+ : undefined;
3942
+ if (refSnapshot && shouldApplySessionTabTargetUpdate({ current: sessionRefSnapshots.get(executionPlan.sessionName), updateOrder: tabTargetUpdateOrder })) {
3943
+ currentRefSnapshot = { ...refSnapshot, target: refSnapshot.target ?? currentSessionTabTarget };
3944
+ sessionRefSnapshots.set(executionPlan.sessionName, { ...currentRefSnapshot, order: tabTargetUpdateOrder });
3945
+ } else {
3946
+ currentRefSnapshot = sessionRefSnapshots.get(executionPlan.sessionName);
3947
+ }
2135
3948
  }
2136
3949
 
3950
+ const priorManagedSessionActive = managedSessionActive;
2137
3951
  const priorManagedSessionCwd = managedSessionCwd;
3952
+ const priorManagedSessionName = managedSessionName;
2138
3953
  const managedSessionState = resolveManagedSessionState({
2139
3954
  command: executionPlan.commandInfo.command,
2140
3955
  managedSessionName: executionPlan.managedSessionName,
2141
- priorActive: managedSessionActive,
2142
- priorSessionName: managedSessionName,
3956
+ priorActive: priorManagedSessionActive,
3957
+ priorSessionName: priorManagedSessionName,
2143
3958
  succeeded,
2144
3959
  });
2145
3960
  const replacedManagedSessionName = managedSessionState.replacedSessionName;
2146
3961
  managedSessionActive = managedSessionState.active;
2147
3962
  managedSessionName = managedSessionState.sessionName;
3963
+ let managedSessionOutcome = buildManagedSessionOutcome({
3964
+ activeAfter: managedSessionActive,
3965
+ activeBefore: priorManagedSessionActive,
3966
+ attemptedSessionName: executionPlan.managedSessionName,
3967
+ command: executionPlan.commandInfo.command,
3968
+ currentSessionName: managedSessionName,
3969
+ previousSessionName: priorManagedSessionName,
3970
+ replacedSessionName: replacedManagedSessionName,
3971
+ sessionMode,
3972
+ succeeded,
3973
+ });
2148
3974
  if (executionPlan.managedSessionName && succeeded) {
2149
3975
  managedSessionCwd = ctx.cwd;
2150
3976
  }
2151
3977
  if (replacedManagedSessionName) {
2152
3978
  sessionTabTargets.delete(replacedManagedSessionName);
3979
+ sessionRefSnapshots.delete(replacedManagedSessionName);
2153
3980
  await closeManagedSession({
2154
3981
  cwd: priorManagedSessionCwd,
2155
3982
  sessionName: replacedManagedSessionName,
@@ -2165,7 +3992,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2165
3992
  exitCode: processResult.exitCode,
2166
3993
  parseError,
2167
3994
  plainTextInspection,
2168
- staleRefArgs: getStaleRefArgs(commandTokens, params.stdin),
3995
+ staleRefArgs: getStaleRefArgs(commandTokens, toolStdin),
2169
3996
  spawnError: processResult.spawnError,
2170
3997
  stderr: processResult.stderr,
2171
3998
  timedOut: processResult.timedOut,
@@ -2189,6 +4016,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2189
4016
  summary: `${redactedArgs.join(" ")} completed`,
2190
4017
  }
2191
4018
  : await buildToolPresentation({
4019
+ args: redactedProcessArgs,
2192
4020
  artifactManifest,
2193
4021
  artifactRequest: screenshotArtifactRequest,
2194
4022
  batchArtifactRequests: batchScreenshotArtifactRequests,
@@ -2220,6 +4048,45 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2220
4048
  if (presentation.artifactManifest) {
2221
4049
  artifactManifest = presentation.artifactManifest;
2222
4050
  }
4051
+ const qaPreset = compiledQaPreset ? analyzeQaPresetResults(presentationEnvelope?.data) : undefined;
4052
+ const sourceLookup = compiledSourceLookup ? await analyzeSourceLookupResults(presentationEnvelope?.data, compiledSourceLookup, ctx.cwd) : undefined;
4053
+ const networkSourceLookup = compiledNetworkSourceLookup ? redactNetworkSourceLookupAnalysis(await analyzeNetworkSourceLookupResults(presentationEnvelope?.data, compiledNetworkSourceLookup, ctx.cwd)) : undefined;
4054
+ if (networkSourceLookup && presentation.content[0]?.type === "text") {
4055
+ presentation.content[0] = { ...presentation.content[0], text: `${networkSourceLookup.summary}\n\n${presentation.content[0].text}` };
4056
+ } else if (networkSourceLookup) {
4057
+ presentation.content.unshift({ type: "text", text: networkSourceLookup.summary });
4058
+ }
4059
+ if (sourceLookup && presentation.content[0]?.type === "text") {
4060
+ presentation.content[0] = { ...presentation.content[0], text: `${sourceLookup.summary}\n\n${presentation.content[0].text}` };
4061
+ } else if (sourceLookup) {
4062
+ presentation.content.unshift({ type: "text", text: sourceLookup.summary });
4063
+ }
4064
+ if (qaPreset && (!qaPreset.passed || qaPreset.warnings.length > 0)) {
4065
+ if (!qaPreset.passed) {
4066
+ succeeded = false;
4067
+ presentation.failureCategory = "qa-failure";
4068
+ }
4069
+ presentation.summary = qaPreset.summary;
4070
+ if (presentation.content[0]?.type === "text") {
4071
+ presentation.content[0] = { ...presentation.content[0], text: `${qaPreset.summary}\n\n${presentation.content[0].text}` };
4072
+ } else {
4073
+ presentation.content.unshift({ type: "text", text: qaPreset.summary });
4074
+ }
4075
+ }
4076
+ if (managedSessionOutcome && managedSessionOutcome.succeeded !== succeeded) {
4077
+ managedSessionOutcome = { ...managedSessionOutcome, succeeded };
4078
+ }
4079
+ const evalStdinHint = getEvalStdinHint({
4080
+ command: executionPlan.commandInfo.command,
4081
+ data: presentationEnvelope?.data,
4082
+ stdin: toolStdin,
4083
+ });
4084
+ const resultArtifactManifest = presentation.artifactManifest ?? artifactManifest;
4085
+ const artifactCleanup = getArtifactCleanupGuidance({
4086
+ command: executionPlan.commandInfo.command,
4087
+ manifest: resultArtifactManifest,
4088
+ succeeded,
4089
+ });
2223
4090
  const warningText = aboutBlankSessionMismatch ? buildAboutBlankWarning(aboutBlankSessionMismatch) : undefined;
2224
4091
  const contentWithSessionWarnings = userRequestedJson && !plainTextInspection
2225
4092
  ? buildJsonVisibleContent({
@@ -2248,20 +4115,69 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2248
4115
  ? { ...item, text: exactRedactedText }
2249
4116
  : { ...item, text: redactSensitiveText(exactRedactedText) };
2250
4117
  });
4118
+ const categoryDetails = buildAgentBrowserResultCategoryDetails({
4119
+ artifacts: presentation.artifacts,
4120
+ args: redactedProcessArgs,
4121
+ command: executionPlan.commandInfo.command,
4122
+ confirmationRequired: presentation.summary.startsWith("Confirmation required"),
4123
+ errorText: errorText ?? presentation.summary,
4124
+ failureCategory: presentation.failureCategory ?? presentation.batchFailure?.failedStep.failureCategory,
4125
+ inspection: plainTextInspection,
4126
+ parseError,
4127
+ savedFile: presentation.savedFile,
4128
+ spawnError: processResult.spawnError?.message,
4129
+ succeeded,
4130
+ tabDrift: !succeeded && (aboutBlankSessionMismatch !== undefined || sessionTabCorrection !== undefined),
4131
+ timedOut: processResult.timedOut,
4132
+ validationError: undefined,
4133
+ });
4134
+ let nextActions = presentation.nextActions ? [...presentation.nextActions] : undefined;
4135
+ if (categoryDetails.failureCategory === "stale-ref") {
4136
+ nextActions = sessionAwareStaleRefNextActions(executionPlan.sessionName);
4137
+ }
4138
+ if (categoryDetails.failureCategory === "selector-not-found" && redactedCompiledSemanticAction) {
4139
+ const candidateActions = buildSemanticActionCandidateActions(redactedCompiledSemanticAction);
4140
+ if (candidateActions.length > 0) {
4141
+ (nextActions ??= []).push(...candidateActions);
4142
+ }
4143
+ }
4144
+ if (overlayBlockerDiagnostic) {
4145
+ (nextActions ??= []).push(...buildOverlayBlockerNextActions({ diagnostic: overlayBlockerDiagnostic, sessionName: executionPlan.sessionName }));
4146
+ }
4147
+ if (selectorTextVisibilityDiagnostics.length > 0) {
4148
+ (nextActions ??= []).push(...buildSelectorTextVisibilityNextActions({ diagnostics: selectorTextVisibilityDiagnostics, sessionName: executionPlan.sessionName }));
4149
+ }
4150
+ if (categoryDetails.failureCategory === "stale-ref" && redactedCompiledSemanticAction) {
4151
+ (nextActions ??= []).push({
4152
+ id: "retry-semantic-action-after-stale-ref",
4153
+ params: { args: redactedCompiledSemanticAction.args },
4154
+ reason: "Retry the same semantic target via its compiled find command after the upstream stale-ref failure proves the prior action did not execute.",
4155
+ safety: "Use only for the same intended target; direct stale @refs still require a fresh snapshot or stable locator before retrying.",
4156
+ tool: "agent_browser" as const,
4157
+ });
4158
+ }
2251
4159
  const details = {
2252
4160
  args: redactedArgs,
2253
- artifactManifest: presentation.artifactManifest,
2254
- artifactRetentionSummary: presentation.artifactRetentionSummary,
4161
+ compiledJob: redactedCompiledJob,
4162
+ compiledQaPreset: redactedCompiledQaPreset,
4163
+ compiledSourceLookup: redactedCompiledSourceLookup,
4164
+ compiledNetworkSourceLookup: redactedCompiledNetworkSourceLookup,
4165
+ artifactManifest: resultArtifactManifest,
4166
+ artifactRetentionSummary: presentation.artifactRetentionSummary ?? (resultArtifactManifest ? formatSessionArtifactRetentionSummary(resultArtifactManifest) : undefined),
4167
+ artifactCleanup,
4168
+ artifactVerification: presentation.artifactVerification,
2255
4169
  artifacts: presentation.artifacts,
2256
4170
  batchFailure: presentation.batchFailure,
2257
4171
  batchSteps: presentation.batchSteps,
2258
4172
  command: executionPlan.commandInfo.command,
4173
+ compiledSemanticAction: redactedCompiledSemanticAction,
2259
4174
  compatibilityWorkaround,
2260
4175
  subcommand: executionPlan.commandInfo.subcommand,
2261
4176
  data: presentation.data,
2262
4177
  error: plainTextInspection ? undefined : presentationEnvelope?.error,
2263
4178
  inspection: plainTextInspection || undefined,
2264
4179
  navigationSummary,
4180
+ ...categoryDetails,
2265
4181
  aboutBlankSessionMismatch,
2266
4182
  openResultTabCorrection,
2267
4183
  effectiveArgs: redactedProcessArgs,
@@ -2269,14 +4185,26 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2269
4185
  fullOutputPath: parseFailureOutput.fullOutputPath ?? presentation.fullOutputPath,
2270
4186
  fullOutputPaths: presentation.fullOutputPaths,
2271
4187
  fullOutputUnavailable: parseFailureOutput.fullOutputUnavailable,
4188
+ managedSessionOutcome,
2272
4189
  imagePath: presentation.imagePath,
2273
4190
  imagePaths: presentation.imagePaths,
4191
+ nextActions,
4192
+ pageChangeSummary: presentation.pageChangeSummary,
4193
+ overlayBlockers: overlayBlockerDiagnostic,
4194
+ qaPreset,
4195
+ selectorTextVisibility: selectorTextVisibilityDiagnostics[0],
4196
+ selectorTextVisibilityAll: selectorTextVisibilityDiagnostics.length > 1 ? selectorTextVisibilityDiagnostics : undefined,
4197
+ evalStdinHint,
4198
+ timeoutPartialProgress,
2274
4199
  parseError: plainTextInspection ? undefined : parseError,
2275
4200
  savedFile: presentation.savedFile,
2276
4201
  savedFilePath: presentation.savedFilePath,
4202
+ sourceLookup,
4203
+ networkSourceLookup,
2277
4204
  sessionMode,
2278
4205
  sessionTabCorrection,
2279
4206
  sessionTabTarget: currentSessionTabTarget,
4207
+ refSnapshot: currentRefSnapshot,
2280
4208
  ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
2281
4209
  sessionRecoveryHint: redactedRecoveryHint,
2282
4210
  startupScopedFlags: executionPlan.startupScopedFlags,
@@ -2287,11 +4215,28 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2287
4215
  timeoutMs: processResult.timeoutMs,
2288
4216
  };
2289
4217
 
2290
- return {
2291
- content: redactedContent,
4218
+ const semanticActionCandidateText = nextActions ? formatSemanticActionCandidateText(nextActions) : undefined;
4219
+ const overlayBlockerText = overlayBlockerDiagnostic ? formatOverlayBlockerText(overlayBlockerDiagnostic) : undefined;
4220
+ const selectorTextVisibilityText = formatSelectorTextVisibilityText(selectorTextVisibilityDiagnostics);
4221
+ const evalStdinHintText = formatEvalStdinHintText(evalStdinHint);
4222
+ const artifactCleanupText = formatArtifactCleanupGuidanceText(artifactCleanup);
4223
+ const timeoutPartialProgressText = timeoutPartialProgress ? formatTimeoutPartialProgressText(timeoutPartialProgress) : undefined;
4224
+ const managedSessionOutcomeText = formatManagedSessionOutcomeText(managedSessionOutcome);
4225
+ const rawAppendedDiagnosticText = [semanticActionCandidateText, overlayBlockerText, selectorTextVisibilityText, evalStdinHintText, artifactCleanupText, timeoutPartialProgressText, managedSessionOutcomeText].filter((item): item is string => item !== undefined).join("\n\n");
4226
+ const appendedDiagnosticText = redactSensitiveText(redactExactSensitiveText(rawAppendedDiagnosticText, exactSensitiveValues));
4227
+ const shouldAppendDiagnosticText = appendedDiagnosticText.length > 0 && (!userRequestedJson || plainTextInspection);
4228
+ const content = shouldAppendDiagnosticText && redactedContent[0]?.type === "text"
4229
+ ? [
4230
+ { ...redactedContent[0], text: `${redactedContent[0].text}\n\n${appendedDiagnosticText}` },
4231
+ ...redactedContent.slice(1),
4232
+ ]
4233
+ : redactedContent;
4234
+ const result = {
4235
+ content,
2292
4236
  details: redactToolDetails(details, exactSensitiveValues),
2293
4237
  isError: !succeeded,
2294
4238
  };
4239
+ return compiledNetworkSourceLookup ? redactNetworkSourceLookupSurface(result) as typeof result : result;
2295
4240
  } finally {
2296
4241
  if (processResult.stdoutSpillPath) {
2297
4242
  await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
@@ -2299,7 +4244,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
2299
4244
  }
2300
4245
  };
2301
4246
 
2302
- return extractExplicitSessionName(params.args)
4247
+ return extractExplicitSessionName(toolArgs)
2303
4248
  ? runTool()
2304
4249
  : managedSessionExecutionQueue.run(runTool);
2305
4250
  },