pi-agent-browser-native 0.2.44 → 0.2.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +20 -15
  3. package/docs/ARCHITECTURE.md +12 -10
  4. package/docs/COMMAND_REFERENCE.md +49 -27
  5. package/docs/ELECTRON.md +1 -1
  6. package/docs/RELEASE.md +6 -5
  7. package/docs/REQUIREMENTS.md +6 -3
  8. package/docs/SUPPORT_MATRIX.md +17 -13
  9. package/docs/TOOL_CONTRACT.md +87 -46
  10. package/docs/platform-smoke.md +4 -3
  11. package/extensions/agent-browser/index.ts +43 -450
  12. package/extensions/agent-browser/lib/bash-guard.ts +205 -0
  13. package/extensions/agent-browser/lib/electron/cdp.ts +69 -0
  14. package/extensions/agent-browser/lib/electron/cleanup.ts +5 -58
  15. package/extensions/agent-browser/lib/electron/discovery.ts +2 -9
  16. package/extensions/agent-browser/lib/electron/launch.ts +11 -65
  17. package/extensions/agent-browser/lib/electron/text.ts +13 -0
  18. package/extensions/agent-browser/lib/fs-utils.ts +18 -0
  19. package/extensions/agent-browser/lib/input-modes/job.ts +207 -21
  20. package/extensions/agent-browser/lib/input-modes/params.ts +28 -11
  21. package/extensions/agent-browser/lib/input-modes/semantic-action.ts +22 -2
  22. package/extensions/agent-browser/lib/input-modes/types.ts +5 -1
  23. package/extensions/agent-browser/lib/input-modes.ts +1 -0
  24. package/extensions/agent-browser/lib/json-schema.ts +73 -0
  25. package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +82 -11
  26. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +159 -30
  27. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +53 -2
  28. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +1 -0
  29. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +751 -32
  30. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +38 -7
  31. package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +0 -46
  32. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +10 -1
  33. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +28 -1
  34. package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +1 -6
  35. package/extensions/agent-browser/lib/orchestration/input-plan.ts +15 -3
  36. package/extensions/agent-browser/lib/orchestration/output-file.ts +86 -0
  37. package/extensions/agent-browser/lib/pi-tool-rendering.ts +252 -0
  38. package/extensions/agent-browser/lib/playbook.ts +26 -26
  39. package/extensions/agent-browser/lib/process.ts +1 -1
  40. package/extensions/agent-browser/lib/prompt-policy.ts +1 -18
  41. package/extensions/agent-browser/lib/results/artifact-manifest.ts +1 -4
  42. package/extensions/agent-browser/lib/results/artifact-state.ts +7 -3
  43. package/extensions/agent-browser/lib/results/contracts.ts +6 -2
  44. package/extensions/agent-browser/lib/results/envelope.ts +11 -2
  45. package/extensions/agent-browser/lib/results/network-routes.ts +7 -4
  46. package/extensions/agent-browser/lib/results/network.ts +7 -1
  47. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +88 -20
  48. package/extensions/agent-browser/lib/results/presentation/batch.ts +84 -12
  49. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +81 -26
  50. package/extensions/agent-browser/lib/results/presentation/errors.ts +13 -0
  51. package/extensions/agent-browser/lib/results/presentation/registry.ts +60 -0
  52. package/extensions/agent-browser/lib/results/presentation.ts +10 -1
  53. package/extensions/agent-browser/lib/results/snapshot-high-value-controls.ts +16 -5
  54. package/extensions/agent-browser/lib/results/snapshot.ts +2 -0
  55. package/extensions/agent-browser/lib/runtime.ts +10 -1
  56. package/extensions/agent-browser/lib/session-page-state.ts +15 -6
  57. package/extensions/agent-browser/lib/string-enum-schema.ts +20 -0
  58. package/extensions/agent-browser/lib/web-search.ts +31 -13
  59. package/package.json +2 -2
  60. package/platform-smoke.config.mjs +5 -2
  61. package/scripts/platform-smoke/build-ubuntu-image.mjs +25 -0
  62. package/scripts/platform-smoke/crabbox-runner.mjs +5 -1
  63. package/scripts/platform-smoke/doctor.mjs +6 -2
  64. package/scripts/platform-smoke/linux-image/Dockerfile +3 -5
  65. package/scripts/platform-smoke/targets.mjs +2 -1
  66. package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +0 -154
@@ -7,6 +7,7 @@ import { getAllowedDomainsViolation, parseAllowedDomainsPolicyFromArgs } from ".
7
7
  import {
8
8
  analyzeNetworkSourceLookupResults,
9
9
  analyzeQaPresetResults,
10
+ analyzeQaPresetTimeout,
10
11
  analyzeSourceLookupResults,
11
12
  buildQaCompactPassText,
12
13
  extractQaPageContext,
@@ -78,6 +79,7 @@ import {
78
79
  collectNavigationSummary,
79
80
  collectOverlayBlockerDiagnostic,
80
81
  collectQaAttachedTarget,
82
+ collectSnapshotOverlayBlockerDiagnostic,
81
83
  collectRecordingDependencyWarning,
82
84
  collectScrollPositionSnapshot,
83
85
  collectSelectorTextVisibilityDiagnostics,
@@ -159,6 +161,15 @@ function isStreamEnableAlreadyEnabledNoop(options: { command: string | undefined
159
161
  return message === "streaming is already enabled for this session" || message === "streaming is already enabled" || message === "stream already enabled";
160
162
  }
161
163
 
164
+ function batchStartedManagedBrowser(data: unknown): boolean {
165
+ if (!Array.isArray(data)) return false;
166
+ return data.some((entry) => {
167
+ if (!isRecord(entry) || entry.success !== true || !Array.isArray(entry.command)) return false;
168
+ const command = typeof entry.command[0] === "string" ? entry.command[0] : undefined;
169
+ return command === "connect" || command === "goto" || command === "navigate" || isOpenNavigationCommand(command);
170
+ });
171
+ }
172
+
162
173
  function setNetworkRouteState(options: { routes?: NetworkRouteRecord[]; routesBySession: Map<string, NetworkRouteRecord[]>; sessionName: string | undefined }): Map<string, NetworkRouteRecord[]> {
163
174
  if (!options.sessionName) return options.routesBySession;
164
175
  const previousRoutes = options.routesBySession.get(options.sessionName);
@@ -351,11 +362,17 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
351
362
  let selectorTextVisibilityDiagnostics: Awaited<ReturnType<typeof collectSelectorTextVisibilityDiagnostics>> = [];
352
363
  let electronBroadGetTextScopeDiagnostics: ReturnType<typeof collectElectronBroadGetTextScopeDiagnostics> = [];
353
364
  const timeoutPartialProgress = processResult.timedOut ? await collectTimeoutPartialProgress({ command: prepared.executionPlan.commandInfo.command, compiledJob: prepared.compiledJob, cwd, sessionName: prepared.executionPlan.sessionName, stdin: prepared.runtimeToolStdin }) : undefined;
365
+ if (succeeded) {
366
+ const fillRefSnapshot = prepared.resolvedSemanticActionRefSnapshot ?? prepared.priorRefSnapshotState;
367
+ fillVerificationDiagnostic = await collectFillVerificationDiagnostic({ commandTokens: prepared.commandTokens, cwd, forceValueVerification: electronRecordForCommand !== undefined, refSnapshot: fillRefSnapshot, sessionName: prepared.executionPlan.sessionName, signal });
368
+ }
354
369
  if (succeeded && electronRecordForCommand) {
355
- fillVerificationDiagnostic = await collectFillVerificationDiagnostic({ commandTokens: prepared.commandTokens, cwd, sessionName: prepared.executionPlan.sessionName, signal });
356
370
  electronRefFreshnessDiagnostic = buildElectronRefFreshnessDiagnostic({ command: prepared.executionPlan.commandInfo.command, commandTokens: prepared.commandTokens, record: electronRecordForCommand, sessionName: prepared.executionPlan.sessionName, stdin: prepared.runtimeToolStdin });
357
371
  }
358
- if (succeeded && !sessionTabCorrection && !aboutBlankSessionMismatch && !electronRecordForCommand && !clickDispatchDiagnostic) overlayBlockerDiagnostic = await collectOverlayBlockerDiagnostic({ command: prepared.executionPlan.commandInfo.command, cwd, data: presentationEnvelope?.data, navigationSummary, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName, signal });
372
+ if (succeeded && prepared.executionPlan.commandInfo.command === "snapshot") {
373
+ overlayBlockerDiagnostic = collectSnapshotOverlayBlockerDiagnostic(presentationEnvelope?.data);
374
+ }
375
+ if (succeeded && !overlayBlockerDiagnostic && !sessionTabCorrection && !aboutBlankSessionMismatch && !electronRecordForCommand && !clickDispatchDiagnostic) overlayBlockerDiagnostic = await collectOverlayBlockerDiagnostic({ command: prepared.executionPlan.commandInfo.command, cwd, data: presentationEnvelope?.data, navigationSummary, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName, signal });
359
376
  if (succeeded) {
360
377
  selectorTextVisibilityDiagnostics = await collectSelectorTextVisibilityDiagnostics({ commandInfo: prepared.executionPlan.commandInfo, commandTokens: prepared.commandTokens, cwd, data: presentationEnvelope?.data, sessionName: prepared.executionPlan.sessionName, signal });
361
378
  if (electronRecordForCommand) electronBroadGetTextScopeDiagnostics = collectElectronBroadGetTextScopeDiagnostics({ commandInfo: prepared.executionPlan.commandInfo, commandTokens: prepared.commandTokens, currentTarget: currentSessionTabTarget, data: presentationEnvelope?.data, electronLaunchRecords, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName });
@@ -403,7 +420,9 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
403
420
  ? prepared.executionPlan.sessionName
404
421
  : prepared.executionPlan.managedSessionName;
405
422
  const policyBlockedFreshManagedSession = allowedDomainsViolation !== undefined && prepared.sessionMode === "fresh" && prepared.executionPlan.managedSessionName === prepared.executionPlan.sessionName;
406
- const managedTransitionSucceeded = succeeded || policyBlockedFreshManagedSession;
423
+ const postLaunchBatchFailure = !succeeded && processSucceeded && parseSucceeded && prepared.sessionMode === "fresh" && prepared.executionPlan.commandInfo.command === "batch" && batchStartedManagedBrowser(presentationEnvelope?.data);
424
+ const postLaunchTimeoutWithPage = !succeeded && processResult.timedOut && prepared.sessionMode === "fresh" && prepared.executionPlan.commandInfo.command === "batch" && timeoutPartialProgress?.liveUrlRecovered === true;
425
+ const managedTransitionSucceeded = succeeded || policyBlockedFreshManagedSession || postLaunchBatchFailure || postLaunchTimeoutWithPage;
407
426
  const managedSessionState = resolveManagedSessionState({ command: prepared.executionPlan.commandInfo.command, managedSessionName: managedCloseSessionName, priorActive: priorManagedSessionActive, priorSessionName: priorManagedSessionName, succeeded: managedTransitionSucceeded });
408
427
  const replacedManagedSessionName = managedSessionState.replacedSessionName;
409
428
  managedSessionActive = managedSessionState.active;
@@ -462,10 +481,16 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
462
481
  }
463
482
  const presentation = plainTextInspection ? { artifacts: undefined, batchFailure: undefined, batchSteps: undefined, content: [{ type: "text" as const, text: inspectionText ?? "" }], data: undefined, fullOutputPath: undefined, fullOutputPaths: undefined, imagePath: undefined, imagePaths: undefined, savedFile: undefined, savedFilePath: undefined, summary: `${prepared.redactedArgs.join(" ")} completed` } : await buildToolPresentation({ args: prepared.redactedProcessArgs, artifactManifest, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: prepared.executionPlan.commandInfo, compiledSemanticAction: prepared.compiledSemanticAction, cwd, envelope: presentationEnvelope, errorText, networkRouteDiagnostics, networkRoutes: activeNetworkRoutes, persistentArtifactStore, sessionName: prepared.executionPlan.sessionName });
464
483
  networkRoutesBySession = applyBatchNetworkRouteState({ data: presentationEnvelope?.data, routesBySession: networkRoutesBySession, sessionName: prepared.executionPlan.sessionName, succeeded });
465
- if (presentation.failureCategory === "artifact-missing") {
484
+ if (presentation.resultCategory === "failure" && succeeded) {
466
485
  succeeded = false;
467
486
  presentationEnvelope = { ...(presentationEnvelope ?? {}), error: presentation.summary, success: false };
468
487
  }
488
+ if (scrollNoopDiagnostic) {
489
+ presentation.summary = "Scroll completed with no observed movement.";
490
+ if (isRecord(presentation.data)) presentation.data = { ...presentation.data, noMovement: true, scrolled: false };
491
+ if (presentation.content[0]?.type === "text") presentation.content[0] = { ...presentation.content[0], text: `Scroll completed with no observed movement.\n\n${presentation.content[0].text}` };
492
+ else presentation.content.unshift({ type: "text", text: "Scroll completed with no observed movement." });
493
+ }
469
494
  if (parseFailureOutput.artifactManifest) { presentation.artifactManifest = parseFailureOutput.artifactManifest; presentation.artifactRetentionSummary = parseFailureOutput.artifactRetentionSummary; }
470
495
  if (parseFailureOutput.fullOutputPath || parseFailureOutput.fullOutputUnavailable) {
471
496
  const existingText = presentation.content[0]?.type === "text" ? presentation.content[0].text : "";
@@ -474,7 +499,9 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
474
499
  presentation.content[0] = { type: "text", text: existingText.length > 0 ? `${existingText}\n\n${notice}` : notice };
475
500
  }
476
501
  if (presentation.artifactManifest) artifactManifest = presentation.artifactManifest;
477
- const qaPreset = prepared.compiledQaPreset ? analyzeQaPresetResults(presentationEnvelope?.data) : undefined;
502
+ const qaPreset = prepared.compiledQaPreset
503
+ ? (processResult.timedOut ? analyzeQaPresetTimeout(prepared.compiledQaPreset) ?? analyzeQaPresetResults(presentationEnvelope?.data, prepared.compiledQaPreset) : analyzeQaPresetResults(presentationEnvelope?.data, prepared.compiledQaPreset))
504
+ : undefined;
478
505
  let qaAttachedTarget = prepared.compiledQaPreset?.checks.attached
479
506
  ? await collectQaAttachedTarget({ currentTarget: currentSessionTabTarget ?? prepared.priorSessionTabTarget, cwd, sessionName: prepared.executionPlan.sessionName, signal })
480
507
  : undefined;
@@ -508,9 +535,13 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
508
535
  presentation.content = [{ type: "text", text: compactText }, ...nonTextContent];
509
536
  }
510
537
  const qaAttachedTargetText = formatQaAttachedTargetText(qaAttachedTarget);
538
+ const qaAttachedDiagnosticsText = prepared.compiledQaPreset?.checks.attached && prepared.compiledQaPreset.checks.diagnosticsResetAtStart === false && (prepared.compiledQaPreset.checks.checkNetwork || prepared.compiledQaPreset.checks.checkConsole || prepared.compiledQaPreset.checks.checkErrors)
539
+ ? "Attached diagnostics: existing upstream session console/network/error buffers were preserved; rows may include events from before qa.attached started."
540
+ : undefined;
541
+ const qaAttachedBannerText = [qaAttachedTargetText, qaAttachedDiagnosticsText].filter((part): part is string => typeof part === "string" && part.length > 0).join("\n");
511
542
  const skipAttachedTargetBanner = qaPreset?.passed && prepared.compiledQaPreset?.checks.attached;
512
- if (!skipAttachedTargetBanner && qaAttachedTargetText && presentation.content[0]?.type === "text") presentation.content[0] = { ...presentation.content[0], text: `${qaAttachedTargetText}\n\n${presentation.content[0].text}` };
513
- else if (!skipAttachedTargetBanner && qaAttachedTargetText) presentation.content.unshift({ type: "text", text: qaAttachedTargetText });
543
+ if (!skipAttachedTargetBanner && qaAttachedBannerText && presentation.content[0]?.type === "text") presentation.content[0] = { ...presentation.content[0], text: `${qaAttachedBannerText}\n\n${presentation.content[0].text}` };
544
+ else if (!skipAttachedTargetBanner && qaAttachedBannerText) presentation.content.unshift({ type: "text", text: qaAttachedBannerText });
514
545
  if (managedSessionOutcome && managedSessionOutcome.succeeded !== succeeded) managedSessionOutcome = { ...managedSessionOutcome, succeeded };
515
546
  const evalNavigationSummary = navigationSummary ?? extractNavigationSummaryFromData(presentationEnvelope?.data);
516
547
  const evalSessionTabUrl = prepared.executionPlan.sessionName ? sessionPageState.get(prepared.executionPlan.sessionName).tabTarget?.url : undefined;
@@ -4,17 +4,6 @@ import { isCloseCommand } from "../../command-taxonomy.js";
4
4
  import { executableExistsOnPath } from "../../executable-path.js";
5
5
  import type { SessionArtifactManifest } from "../../results/contracts.js";
6
6
  import type { PromptPolicy, PromptRequestedArtifact } from "../../prompt-policy.js";
7
- import type { SessionRefSnapshot } from "../../session-page-state.js";
8
- import { findBlockedFinalizingAction, STOP_BOUNDARY_GUARD_SCOPE, type BrowserFinalizingAction } from "./browser-action-model.js";
9
-
10
- export interface StopBoundaryViolation {
11
- action: BrowserFinalizingAction;
12
- command: string[];
13
- message: string;
14
- reason: "explicit-user-stop-boundary";
15
- stepIndex?: number;
16
- target?: string;
17
- }
18
7
 
19
8
  export interface RequestedArtifactCloseViolation {
20
9
  message: string;
@@ -22,41 +11,6 @@ export interface RequestedArtifactCloseViolation {
22
11
  reason: "requested-artifacts-missing-before-close";
23
12
  }
24
13
 
25
- function formatStopBoundaryActionPhrase(action: BrowserFinalizingAction): string {
26
- if (action.kind === "keyboard-submit") return "keyboard submit (Enter/Return)";
27
- return "click-like action";
28
- }
29
-
30
- export function findStopBoundaryViolation(options: { commandTokens: string[]; promptPolicy: PromptPolicy; refSnapshot?: SessionRefSnapshot; stdin?: string }): StopBoundaryViolation | undefined {
31
- if (!options.promptPolicy.stopBoundary) return undefined;
32
- const blocked = findBlockedFinalizingAction({
33
- commandTokens: options.commandTokens,
34
- refSnapshot: options.refSnapshot,
35
- stdin: options.stdin,
36
- });
37
- if (!blocked) return undefined;
38
- const target = blocked.targetLabel;
39
- const actionPhrase = formatStopBoundaryActionPhrase(blocked);
40
- const scopeNote = `Best-effort guard scope covers ${STOP_BOUNDARY_GUARD_SCOPE.covered.join(", ")}; it does not block ${STOP_BOUNDARY_GUARD_SCOPE.excluded.join(", ")}.`;
41
- if (blocked.stepIndex === undefined) {
42
- return {
43
- action: blocked,
44
- command: blocked.command,
45
- message: `Blocked likely final submit/order ${actionPhrase} (${target}) because the latest user prompt set an explicit stop boundary. Gather evidence on the current page instead of activating the final action. ${scopeNote}`,
46
- reason: "explicit-user-stop-boundary",
47
- target,
48
- };
49
- }
50
- return {
51
- action: blocked,
52
- command: blocked.command,
53
- message: `Blocked likely final submit/order ${actionPhrase} in batch step ${blocked.stepIndex + 1} (${target}) because the latest user prompt set an explicit stop boundary. Gather evidence on the current page instead of activating the final action. ${scopeNote}`,
54
- reason: "explicit-user-stop-boundary",
55
- stepIndex: blocked.stepIndex,
56
- target,
57
- };
58
- }
59
-
60
14
  function resolveArtifactPath(cwd: string, path: string): string {
61
15
  return isAbsolute(path) ? path : resolve(cwd, path);
62
16
  }
@@ -157,7 +157,15 @@ function formatManagedSessionOutcomeRecoveryGuidance(outcome: ManagedSessionOutc
157
157
  }
158
158
 
159
159
  export function formatManagedSessionOutcomeText(outcome: ManagedSessionOutcome | undefined): string | undefined {
160
- if (!outcome || outcome.succeeded || outcome.sessionMode !== "fresh") return undefined;
160
+ if (!outcome) return undefined;
161
+ if (outcome.status === "closed" && outcome.succeeded) {
162
+ return [
163
+ "Managed session outcome: The current wrapper-managed browser session was closed.",
164
+ "Next sessionMode auto call will start or attach a managed session as needed. If upstream session list still shows rows, they are separate saved/upstream sessions; use close --all only when full cleanup is intended.",
165
+ "Full session names and transition details remain in details.managedSessionOutcome.",
166
+ ].join("\n");
167
+ }
168
+ if (outcome.succeeded || outcome.sessionMode !== "fresh") return undefined;
161
169
  return [formatManagedSessionOutcomeHeadline(outcome), formatManagedSessionOutcomeRecoveryGuidance(outcome)].join("\n");
162
170
  }
163
171
 
@@ -334,6 +342,7 @@ function isSafeSameSnapshotFormBatchStep(step: string[], refSnapshot: SessionRef
334
342
  const roles = refIds.map((refId) => getSnapshotRefRole(refSnapshot, refId));
335
343
  if (roles.some((role) => role === undefined)) return false;
336
344
  if (command === "check" || command === "uncheck") return roles.every((role) => role === "checkbox" || role === "radio");
345
+ if (command === "click" || command === "tap") return roles.every((role) => role === "checkbox" || role === "radio");
337
346
  if (command === "select") return roles.every((role) => role === "combobox");
338
347
  return false;
339
348
  }
@@ -150,9 +150,17 @@ export interface ClickDispatchProbe {
150
150
  target: ClickDispatchProbeTarget;
151
151
  }
152
152
 
153
+ export interface ClickDispatchScrollContainerDiagnostic {
154
+ selector?: string;
155
+ summary: string;
156
+ targetOutsideContainer?: boolean;
157
+ targetOutsideViewport?: boolean;
158
+ }
159
+
153
160
  export interface ClickDispatchDiagnostic {
154
161
  nativeEventCount: number;
155
162
  reason: "native-click-produced-no-target-dom-event";
163
+ scrollContainer?: ClickDispatchScrollContainerDiagnostic;
156
164
  status: "no-native-event-observed";
157
165
  summary: string;
158
166
  target: ClickDispatchProbeTarget;
@@ -202,16 +210,32 @@ export interface TimeoutArtifactEvidence {
202
210
  exists: boolean;
203
211
  path: string;
204
212
  sizeBytes?: number;
213
+ state: "missing" | "verified";
205
214
  stepIndex: number;
206
215
  }
207
216
 
217
+ export type TimeoutProgressStepStatus = "completed" | "failed" | "pending" | "unknown";
218
+
219
+ export interface TimeoutProgressStep {
220
+ args: string[];
221
+ generatedFrom?: string;
222
+ index: number;
223
+ reason?: string;
224
+ retry?: { args: string[] };
225
+ status: TimeoutProgressStepStatus;
226
+ }
227
+
208
228
  export interface TimeoutPartialProgress {
209
229
  artifacts: TimeoutArtifactEvidence[];
210
230
  currentPage?: {
231
+ source?: "live" | "planned";
211
232
  title?: string;
212
233
  url?: string;
213
234
  };
214
- steps?: Array<{ args: string[]; index: number }>;
235
+ liveUrlRecovered?: boolean;
236
+ openedButPostOpenTimedOut?: boolean;
237
+ retryStep?: TimeoutProgressStep;
238
+ steps?: TimeoutProgressStep[];
215
239
  summary: string;
216
240
  }
217
241
 
@@ -374,7 +398,9 @@ export interface ElectronPostCommandHealthDiagnostic {
374
398
  export interface FillVerificationDiagnostic {
375
399
  actual?: string;
376
400
  expected: string;
401
+ method: "text" | "value";
377
402
  nextActionIds: string[];
403
+ reason: "contenteditable-fill-mismatch" | "value-fill-mismatch";
378
404
  selector: string;
379
405
  status: "mismatch";
380
406
  summary: string;
@@ -411,6 +437,7 @@ export interface PreparedBrowserRun {
411
437
  priorSessionTabTarget?: SessionTabTarget;
412
438
  processArgs: string[];
413
439
  processStdin?: string;
440
+ processTimeoutMs?: number;
414
441
  redactedArgs: string[];
415
442
  redactedCompiledElectron?: CompiledAgentBrowserElectron;
416
443
  redactedCompiledJob?: CompiledAgentBrowserJob;
@@ -9,6 +9,7 @@ import type { ChildProcess } from "node:child_process";
9
9
  import { cleanupElectronLaunchResources, inspectElectronLaunchStatus, type ElectronCleanupResult, type ElectronLaunchStatus } from "../../electron/cleanup.js";
10
10
  import { discoverElectronApps, type ElectronDiscoveryResult } from "../../electron/discovery.js";
11
11
  import type { ElectronCdpTarget, ElectronLaunchRecord } from "../../electron/launch.js";
12
+ import { boundElectronProbeString } from "../../electron/text.js";
12
13
  import type { CompiledAgentBrowserElectron } from "../../input-modes.js";
13
14
  import { isRecord } from "../../parsing.js";
14
15
  import { buildAgentBrowserNextActions, buildAgentBrowserResultCategoryDetails } from "../../results.js";
@@ -368,12 +369,6 @@ const ELECTRON_FOCUSED_ELEMENT_EVAL = `(() => {
368
369
  return { focusedElement: describeElement(document.activeElement) };
369
370
  })()`;
370
371
 
371
- function boundElectronProbeString(value: string | undefined, maxLength = 240): string | undefined {
372
- const trimmed = value?.trim();
373
- if (!trimmed) return undefined;
374
- return trimmed.length > maxLength ? `${trimmed.slice(0, Math.max(0, maxLength - 3))}...` : trimmed;
375
- }
376
-
377
372
  function getTrimmedString(value: unknown): string | undefined {
378
373
  return typeof value === "string" ? boundElectronProbeString(value) : undefined;
379
374
  }
@@ -23,11 +23,13 @@ export interface AgentBrowserExecuteParams {
23
23
  electron?: unknown;
24
24
  job?: unknown;
25
25
  networkSourceLookup?: unknown;
26
+ outputPath?: string;
26
27
  qa?: unknown;
27
28
  semanticAction?: unknown;
28
29
  sessionMode?: "auto" | "fresh";
29
30
  sourceLookup?: unknown;
30
31
  stdin?: string;
32
+ timeoutMs?: number;
31
33
  }
32
34
 
33
35
  export type ResolvedAgentBrowserInputKind = "args" | "electron" | "job" | "networkSourceLookup" | "qa" | "semanticAction" | "sourceLookup";
@@ -175,11 +177,11 @@ function normalizeExplicitEvalStdinArgs(args: string[], stdin: string | undefine
175
177
  }
176
178
 
177
179
  export function resolveAgentBrowserInput(options: {
178
- getBatchAnnotateValidationError: (args: string[], stdin: string | undefined) => string | undefined;
180
+ getBatchPreflightValidationError: (args: string[], stdin: string | undefined) => string | undefined;
179
181
  managedSessionActive: boolean;
180
182
  params: AgentBrowserExecuteParams;
181
183
  }): ResolvedAgentBrowserInput {
182
- const { getBatchAnnotateValidationError, managedSessionActive, params } = options;
184
+ const { getBatchPreflightValidationError, managedSessionActive, params } = options;
183
185
  const semanticActionResult = params.semanticAction === undefined ? {} : compileAgentBrowserSemanticAction(params.semanticAction);
184
186
  const jobResult = params.job === undefined ? {} : compileAgentBrowserJob(params.job);
185
187
  const qaResult = params.qa === undefined ? {} : compileAgentBrowserQaPreset(params.qa);
@@ -219,6 +221,14 @@ export function resolveAgentBrowserInput(options: {
219
221
  ? "Do not provide stdin with electron; electron mode is host-only or manages its own input."
220
222
  : undefined
221
223
  : undefined;
224
+ const outputPathError = params.outputPath !== undefined && (typeof params.outputPath !== "string" || params.outputPath.trim().length === 0)
225
+ ? "outputPath must be a non-empty string when provided."
226
+ : undefined;
227
+ const timeoutMsError = params.timeoutMs !== undefined && (typeof params.timeoutMs !== "number" || !Number.isSafeInteger(params.timeoutMs) || params.timeoutMs <= 0)
228
+ ? "timeoutMs must be a positive integer when provided."
229
+ : compiledElectron && params.timeoutMs !== undefined
230
+ ? "Use electron.timeoutMs for electron actions; top-level timeoutMs applies only to browser CLI subprocess calls."
231
+ : undefined;
222
232
  const attachedQaSessionError = compiledQaPreset?.checks.attached
223
233
  ? params.sessionMode === "fresh"
224
234
  ? "qa.attached cannot be used with sessionMode=fresh; attach or launch a session first, then run qa.attached with the current session."
@@ -234,8 +244,10 @@ export function resolveAgentBrowserInput(options: {
234
244
  ?? electronResult.error
235
245
  ?? inputModeError
236
246
  ?? generatedStdinError
247
+ ?? outputPathError
248
+ ?? timeoutMsError
237
249
  ?? attachedQaSessionError
238
- ?? (compiledElectron ? undefined : validateToolArgs(toolArgs) ?? getBatchAnnotateValidationError(toolArgs, toolStdin));
250
+ ?? (compiledElectron ? undefined : validateToolArgs(toolArgs) ?? getBatchPreflightValidationError(toolArgs, toolStdin));
239
251
  const redactedCompiledJob = redactCompiledJob(compiledJob);
240
252
  const redactedCompiledSemanticAction = compiledSemanticAction
241
253
  ? { ...compiledSemanticAction, args: redactInvocationArgs(compiledSemanticAction.args) }
@@ -0,0 +1,86 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
+
4
+ import { isRecord } from "../parsing.js";
5
+ import type { AgentBrowserToolResult } from "./browser-run/types.js";
6
+
7
+ export interface AgentBrowserOutputFileDetails {
8
+ absolutePath: string;
9
+ bytes?: number;
10
+ error?: string;
11
+ path: string;
12
+ source: "content.text" | "details.data";
13
+ status: "failed" | "saved";
14
+ }
15
+
16
+ function normalizeRequestedOutputPath(path: string): string {
17
+ return path.startsWith("@") ? path.slice(1) : path;
18
+ }
19
+
20
+ function getTextContent(result: AgentBrowserToolResult): string {
21
+ return result.content
22
+ ?.filter((item): item is { text: string; type: "text" } => item.type === "text")
23
+ .map((item) => item.text)
24
+ .join("\n\n") ?? "";
25
+ }
26
+
27
+ function getOutputPayload(result: AgentBrowserToolResult): { source: AgentBrowserOutputFileDetails["source"]; value: unknown } {
28
+ const details = isRecord(result.details) ? result.details : undefined;
29
+ if (details && details.data !== undefined) return { source: "details.data", value: details.data };
30
+ return { source: "content.text", value: getTextContent(result) };
31
+ }
32
+
33
+ function serializeOutputPayload(value: unknown): string {
34
+ return typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`;
35
+ }
36
+
37
+ function appendOutputFileNotice(result: AgentBrowserToolResult, message: string): AgentBrowserToolResult["content"] {
38
+ const content = [...(result.content ?? [])] as AgentBrowserToolResult["content"];
39
+ if (content[0]?.type === "text") {
40
+ content[0] = { ...content[0], text: `${content[0].text}\n\n${message}` };
41
+ return content;
42
+ }
43
+ return [{ type: "text", text: message }, ...content];
44
+ }
45
+
46
+ export async function applyAgentBrowserOutputPath(options: {
47
+ cwd: string;
48
+ outputPath?: string;
49
+ preserveTextContent?: boolean;
50
+ result: AgentBrowserToolResult;
51
+ }): Promise<AgentBrowserToolResult> {
52
+ if (!options.outputPath) return options.result;
53
+ if (options.result.isError || (isRecord(options.result.details) && options.result.details.resultCategory === "failure")) return options.result;
54
+ const requestedPath = normalizeRequestedOutputPath(options.outputPath);
55
+ const absolutePath = isAbsolute(requestedPath) ? requestedPath : resolve(options.cwd, requestedPath);
56
+ const payload = getOutputPayload(options.result);
57
+ try {
58
+ const serialized = serializeOutputPayload(payload.value);
59
+ await mkdir(dirname(absolutePath), { recursive: true });
60
+ await writeFile(absolutePath, serialized, "utf8");
61
+ const bytes = Buffer.byteLength(serialized, "utf8");
62
+ const outputFile: AgentBrowserOutputFileDetails = { absolutePath, bytes, path: requestedPath, source: payload.source, status: "saved" };
63
+ const details = isRecord(options.result.details) ? { ...options.result.details, outputFile } : { outputFile };
64
+ return {
65
+ ...options.result,
66
+ content: options.preserveTextContent ? options.result.content : appendOutputFileNotice(options.result, `Output file: ${requestedPath} (${bytes} bytes from ${payload.source}).`),
67
+ details,
68
+ };
69
+ } catch (error) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ const outputFile: AgentBrowserOutputFileDetails = { absolutePath, error: message, path: requestedPath, source: payload.source, status: "failed" };
72
+ const details = isRecord(options.result.details)
73
+ ? (() => {
74
+ const rest = { ...options.result.details };
75
+ delete rest.successCategory;
76
+ return { ...rest, failureCategory: rest.failureCategory ?? "upstream-error", outputFile, resultCategory: "failure" };
77
+ })()
78
+ : { failureCategory: "upstream-error", outputFile, resultCategory: "failure" };
79
+ return {
80
+ ...options.result,
81
+ content: options.preserveTextContent ? options.result.content : appendOutputFileNotice(options.result, `Output file failed: ${requestedPath} (${message}).`),
82
+ details,
83
+ isError: true,
84
+ };
85
+ }
86
+ }