pi-agent-browser-native 0.2.44 → 0.2.45

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 (64) hide show
  1. package/CHANGELOG.md +26 -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 +29 -445
  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 +17 -7
  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/orchestration/browser-run/click-dispatch.ts +82 -11
  25. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +153 -30
  26. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +53 -2
  27. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +1 -0
  28. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +751 -32
  29. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +38 -7
  30. package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +0 -46
  31. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +10 -1
  32. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +28 -1
  33. package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +1 -6
  34. package/extensions/agent-browser/lib/orchestration/input-plan.ts +15 -3
  35. package/extensions/agent-browser/lib/orchestration/output-file.ts +86 -0
  36. package/extensions/agent-browser/lib/pi-tool-rendering.ts +231 -0
  37. package/extensions/agent-browser/lib/playbook.ts +26 -26
  38. package/extensions/agent-browser/lib/process.ts +1 -1
  39. package/extensions/agent-browser/lib/prompt-policy.ts +1 -18
  40. package/extensions/agent-browser/lib/results/artifact-manifest.ts +1 -4
  41. package/extensions/agent-browser/lib/results/artifact-state.ts +7 -3
  42. package/extensions/agent-browser/lib/results/contracts.ts +6 -2
  43. package/extensions/agent-browser/lib/results/envelope.ts +11 -2
  44. package/extensions/agent-browser/lib/results/network-routes.ts +7 -4
  45. package/extensions/agent-browser/lib/results/network.ts +7 -1
  46. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +88 -20
  47. package/extensions/agent-browser/lib/results/presentation/batch.ts +84 -12
  48. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +81 -26
  49. package/extensions/agent-browser/lib/results/presentation/errors.ts +13 -0
  50. package/extensions/agent-browser/lib/results/presentation/registry.ts +60 -0
  51. package/extensions/agent-browser/lib/results/presentation.ts +10 -1
  52. package/extensions/agent-browser/lib/results/snapshot-high-value-controls.ts +16 -5
  53. package/extensions/agent-browser/lib/results/snapshot.ts +2 -0
  54. package/extensions/agent-browser/lib/runtime.ts +10 -1
  55. package/extensions/agent-browser/lib/session-page-state.ts +15 -6
  56. package/extensions/agent-browser/lib/web-search.ts +1 -1
  57. package/package.json +2 -2
  58. package/platform-smoke.config.mjs +5 -2
  59. package/scripts/platform-smoke/build-ubuntu-image.mjs +25 -0
  60. package/scripts/platform-smoke/crabbox-runner.mjs +5 -1
  61. package/scripts/platform-smoke/doctor.mjs +6 -2
  62. package/scripts/platform-smoke/linux-image/Dockerfile +3 -5
  63. package/scripts/platform-smoke/targets.mjs +2 -1
  64. package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +0 -154
@@ -3,6 +3,7 @@ import { isAbsolute, resolve } from "node:path";
3
3
 
4
4
  import { isCloseCommand, isOpenNavigationCommand } from "../../command-taxonomy.js";
5
5
  import type { ElectronLaunchRecord } from "../../electron/launch.js";
6
+ import { boundElectronProbeString } from "../../electron/text.js";
6
7
  import { executableExistsOnPath } from "../../executable-path.js";
7
8
  import type { AgentBrowserSourceLookupAnalysis, CompiledAgentBrowserJob, CompiledAgentBrowserSemanticAction } from "../../input-modes.js";
8
9
  import { isHttpOrHttpsUrl } from "../../input-modes/job.js";
@@ -10,7 +11,7 @@ import type { AgentBrowserNextAction } from "../../results.js";
10
11
  import { formatSessionArtifactRetentionSummary } from "../../results/artifact-manifest.js";
11
12
  import { buildNextToolAction, withOptionalSessionArgs } from "../../results/next-actions.js";
12
13
  import { buildVisibleRefFallbackDiagnosticFromSnapshot, getVisibleRefFallbackTarget, type VisibleRefFallbackDiagnostic } from "../../results/selector-recovery.js";
13
- import { extractRefSnapshotFromData, normalizeComparableUrl, type SessionTabTarget } from "../../session-page-state.js";
14
+ import { extractRefSnapshotFromData, normalizeComparableUrl, type SessionRefSnapshot, type SessionTabTarget } from "../../session-page-state.js";
14
15
  import { redactInvocationArgs, redactSensitiveText, type CommandInfo } from "../../runtime.js";
15
16
  import { isRecord } from "../../parsing.js";
16
17
  import {
@@ -41,6 +42,7 @@ import type {
41
42
  SelectorTextVisibilityDiagnostic,
42
43
  TimeoutArtifactEvidence,
43
44
  TimeoutPartialProgress,
45
+ TimeoutProgressStep,
44
46
  } from "./types.js";
45
47
  import type { SessionArtifactManifest } from "../../results/contracts.js";
46
48
 
@@ -50,12 +52,6 @@ export function sleepMs(ms: number): Promise<void> {
50
52
  return new Promise((resolve) => setTimeout(resolve, ms));
51
53
  }
52
54
 
53
- function boundElectronProbeString(value: string | undefined, maxLength = 240): string | undefined {
54
- const trimmed = value?.trim();
55
- if (!trimmed) return undefined;
56
- return trimmed.length > maxLength ? `${trimmed.slice(0, Math.max(0, maxLength - 3))}...` : trimmed;
57
- }
58
-
59
55
  export async function collectNavigationSummary(options: {
60
56
  cwd: string;
61
57
  sessionName?: string;
@@ -288,16 +284,22 @@ export function buildOverlayBlockerNextActions(options: { diagnostic: OverlayBlo
288
284
  return [{ id: "inspect-overlay-state", params: { args: withOptionalSessionArgs(options.sessionName, ["snapshot", "-i"]) }, reason: "Refresh interactive refs and inspect whether an overlay, banner, modal, or dialog is blocking the intended click.", safety: "Read-only inspection; use current refs from this snapshot before interacting.", tool: "agent_browser" }, ...options.diagnostic.candidates.map((candidate, index) => ({ id: `try-overlay-blocker-candidate-${index + 1}`, params: { args: withOptionalSessionArgs(options.sessionName, candidate.args) }, reason: candidate.reason, safety: "Only click this if the candidate is clearly a close/dismiss control for an overlay that blocks the intended workflow.", tool: "agent_browser" as const }))];
289
285
  }
290
286
 
287
+ export function collectSnapshotOverlayBlockerDiagnostic(data: unknown): OverlayBlockerDiagnostic | undefined {
288
+ const candidates = getOverlayBlockerCandidates(data);
289
+ const snapshot = extractRefSnapshotFromData(data);
290
+ if (candidates.length === 0 || !snapshot) return undefined;
291
+ return { candidates, snapshot, summary: "Snapshot contains dialog/modal context plus likely close or dismiss controls; treat covered controls as potentially obstructed until the overlay state is resolved." };
292
+ }
293
+
291
294
  export async function collectOverlayBlockerDiagnostic(options: { command?: string; cwd: string; data: unknown; navigationSummary?: NavigationSummary; priorTarget?: SessionTabTarget; sessionName?: string; signal?: AbortSignal }): Promise<OverlayBlockerDiagnostic | undefined> {
292
295
  if (options.command !== "click" || !isRecord(options.data) || typeof options.data.clicked !== "string") return undefined;
293
296
  const priorUrl = normalizeComparableUrl(options.priorTarget?.url);
294
297
  const currentUrl = normalizeComparableUrl(options.navigationSummary?.url);
295
298
  if (!priorUrl || !currentUrl || priorUrl !== currentUrl) return undefined;
296
299
  const snapshotData = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal });
297
- const candidates = getOverlayBlockerCandidates(snapshotData);
298
- const snapshot = extractRefSnapshotFromData(snapshotData);
299
- if (candidates.length === 0 || !snapshot) return undefined;
300
- return { candidates, snapshot, summary: `Click completed but the page stayed on ${currentUrl}; a fresh snapshot contains likely overlay close/dismiss controls.` };
300
+ const diagnostic = collectSnapshotOverlayBlockerDiagnostic(snapshotData);
301
+ if (!diagnostic) return undefined;
302
+ return { ...diagnostic, summary: `Click completed but the page stayed on ${currentUrl}; a fresh snapshot contains likely overlay close/dismiss controls.` };
301
303
  }
302
304
 
303
305
  const SELECTOR_TEXT_VISIBILITY_CANDIDATE_LIMIT = 8;
@@ -605,17 +607,25 @@ export async function validateQaAttachedPrecondition(options: {
605
607
  return undefined;
606
608
  }
607
609
 
608
- function getTopLevelFillInvocation(commandTokens: string[]): { expected: string; selector: string } | undefined {
610
+ function getTopLevelFillInvocation(commandTokens: string[]): { expected: string; refId?: string; selector: string } | undefined {
609
611
  if (commandTokens[0] !== "fill" || commandTokens.length < 3) return undefined;
610
612
  const selector = commandTokens[1];
611
613
  const expected = commandTokens.slice(2).join(" ");
612
- return selector && expected.length > 0 ? { expected, selector } : undefined;
614
+ const refId = selector?.match(/^@?(e\d+)$/)?.[1];
615
+ return selector && expected.length > 0 ? { expected, ...(refId ? { refId } : {}), selector } : undefined;
616
+ }
617
+
618
+ function shouldVerifyContenteditableFill(fill: { refId?: string } | undefined, refSnapshot?: SessionRefSnapshot): boolean {
619
+ if (!fill?.refId) return false;
620
+ const ref = refSnapshot?.refs?.[fill.refId];
621
+ if (!ref) return false;
622
+ return ref.isContentEditable === true && (ref.role === "generic" || ref.role === "unknown" || ref.role === "textbox");
613
623
  }
614
624
 
615
625
  export function buildFillVerificationNextActions(diagnostic: FillVerificationDiagnostic, sessionName: string | undefined): AgentBrowserNextAction[] {
616
626
  return [
617
- { id: "inspect-after-fill-verification", params: { args: withOptionalSessionArgs(sessionName, ["snapshot", "-i"]) }, reason: "Refresh the UI after a fill that reported success but did not appear to update the input value.", safety: "Read-only snapshot; use current refs before retrying.", tool: "agent_browser" },
618
- { id: "verify-filled-value", params: { args: withOptionalSessionArgs(sessionName, ["get", "value", diagnostic.selector]) }, reason: "Check the target input value directly before submitting or creating files.", safety: "Read-only value check; selector may still be stale if the Electron UI rerendered.", tool: "agent_browser" },
627
+ { id: "inspect-after-fill-verification", params: { args: withOptionalSessionArgs(sessionName, ["snapshot", "-i"]) }, reason: "Refresh the UI after a fill that reported success but did not appear to update the target.", safety: "Read-only snapshot; use current refs before retrying.", tool: "agent_browser" },
628
+ { id: "verify-filled-value", params: { args: withOptionalSessionArgs(sessionName, ["get", diagnostic.method, diagnostic.selector]) }, reason: `Check the target ${diagnostic.method} directly before submitting or creating files.`, safety: "Read-only check; selector may still be stale if the UI rerendered.", tool: "agent_browser" },
619
629
  ];
620
630
  }
621
631
 
@@ -627,22 +637,30 @@ function extractFillVerificationValue(data: unknown): string | undefined {
627
637
  return undefined;
628
638
  }
629
639
 
630
- export async function collectFillVerificationDiagnostic(options: { commandTokens: string[]; cwd: string; sessionName?: string; signal?: AbortSignal }): Promise<FillVerificationDiagnostic | undefined> {
640
+ export async function collectFillVerificationDiagnostic(options: { commandTokens: string[]; cwd: string; forceValueVerification?: boolean; refSnapshot?: SessionRefSnapshot; sessionName?: string; signal?: AbortSignal }): Promise<FillVerificationDiagnostic | undefined> {
631
641
  const fill = getTopLevelFillInvocation(options.commandTokens);
632
642
  if (!fill || !options.sessionName) return undefined;
643
+ const contenteditable = shouldVerifyContenteditableFill(fill, options.refSnapshot);
644
+ if (!contenteditable && !options.forceValueVerification) return undefined;
645
+ const method = contenteditable ? "text" : "value";
633
646
  let valueData: unknown | undefined;
634
- try { valueData = await runSessionCommandData({ args: ["get", "value", fill.selector], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, timeoutMs: ELECTRON_FILL_VERIFICATION_TIMEOUT_MS }); } catch { return undefined; }
647
+ try { valueData = await runSessionCommandData({ args: ["get", method, fill.selector], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, timeoutMs: ELECTRON_FILL_VERIFICATION_TIMEOUT_MS }); } catch { return undefined; }
635
648
  const actual = extractFillVerificationValue(valueData);
636
649
  if (actual === undefined || actual === fill.expected) return undefined;
637
- const diagnostic: FillVerificationDiagnostic = { actual: actual.length > 0 ? boundElectronProbeString(actual, 160) : "", expected: boundElectronProbeString(fill.expected, 160) ?? fill.expected, nextActionIds: [], selector: fill.selector, status: "mismatch", summary: `Fill verification warning: fill ${fill.selector} reported success, but get value returned ${actual.length > 0 ? `"${boundElectronProbeString(actual, 80)}"` : "an empty value"}.` };
650
+ const reason = contenteditable ? "contenteditable-fill-mismatch" : "value-fill-mismatch";
651
+ const actualPreview = actual.length > 0 ? `"${boundElectronProbeString(actual, 80)}"` : `an empty ${method}`;
652
+ const diagnostic: FillVerificationDiagnostic = { actual: actual.length > 0 ? boundElectronProbeString(actual, 160) : "", expected: boundElectronProbeString(fill.expected, 160) ?? fill.expected, method, nextActionIds: [], reason, selector: fill.selector, status: "mismatch", summary: `Fill verification warning: fill ${fill.selector} reported success, but get ${method} returned ${actualPreview}.` };
638
653
  diagnostic.nextActionIds = buildFillVerificationNextActions(diagnostic, options.sessionName).map((action) => action.id);
639
654
  return diagnostic;
640
655
  }
641
656
 
642
657
  export function formatFillVerificationText(diagnostic: FillVerificationDiagnostic | undefined): string | undefined {
643
658
  if (!diagnostic) return undefined;
644
- const actual = diagnostic.actual !== undefined ? `actual "${diagnostic.actual}"` : "actual value unavailable";
645
- return `${diagnostic.summary}\nExpected: "${diagnostic.expected}"; ${actual}.\nNext: re-run snapshot -i, then prefer click/focus plus keyboard type for custom Electron quick-input controls before submitting.`;
659
+ const actual = diagnostic.actual !== undefined ? `actual "${diagnostic.actual}"` : `actual ${diagnostic.method} unavailable`;
660
+ const recovery = diagnostic.reason === "contenteditable-fill-mismatch"
661
+ ? "Contenteditable fill may append or prepend instead of replacing. Re-run snapshot -i, then prefer focus/click plus keyboard shortcut selection or direct keyboard insertion only after verifying the editor state."
662
+ : "Re-run snapshot -i, then prefer click/focus plus keyboard type for custom quick-input controls before submitting.";
663
+ return `${diagnostic.summary}\nExpected: "${diagnostic.expected}"; ${actual}.\nNext: ${recovery}`;
646
664
  }
647
665
 
648
666
  export async function collectVisibleRefFallbackDiagnostic(options: { commandTokens: string[]; compiledSemanticAction?: CompiledAgentBrowserSemanticAction; cwd: string; sessionName?: string; signal?: AbortSignal }): Promise<VisibleRefFallbackDiagnostic | undefined> {
@@ -669,8 +687,8 @@ export async function collectElectronHandoff(options: { cwd: string; handoff: "c
669
687
  return { handoff: "snapshot", refSnapshot, snapshot, ...(snapshotRetryCount > 0 ? { snapshotRetryCount } : {}), tabs };
670
688
  }
671
689
 
672
- function getTimeoutProgressSteps(compiledJob: CompiledAgentBrowserJob | undefined, command: string | undefined, stdin: string | undefined): Array<{ args: string[]; index: number }> {
673
- if (compiledJob) return compiledJob.steps.map((step, index) => ({ args: step.args, index: index + 1 }));
690
+ function getTimeoutProgressSteps(compiledJob: CompiledAgentBrowserJob | undefined, command: string | undefined, stdin: string | undefined): Array<{ args: string[]; generatedFrom?: string; index: number }> {
691
+ if (compiledJob) return compiledJob.steps.map((step, index) => ({ args: step.args, generatedFrom: step.generatedFrom, index: index + 1 }));
674
692
  if (command !== "batch" || !stdin) return [];
675
693
  return parseValidBatchStepEntries(stdin).map(({ index, step }) => ({ args: step, index: index + 1 }));
676
694
  }
@@ -721,8 +739,8 @@ async function collectTimeoutArtifactEvidence(cwd: string, steps: Array<{ args:
721
739
  const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
722
740
  const artifact = await statTimeoutArtifactPath(absolutePath);
723
741
  evidence.push(artifact.exists
724
- ? { absolutePath, exists: true, path, sizeBytes: artifact.sizeBytes, stepIndex: step.index }
725
- : { absolutePath, exists: false, path, stepIndex: step.index });
742
+ ? { absolutePath, exists: true, path, sizeBytes: artifact.sizeBytes, state: "verified", stepIndex: step.index }
743
+ : { absolutePath, exists: false, path, state: "missing", stepIndex: step.index });
726
744
  }
727
745
  return evidence;
728
746
  }
@@ -735,18 +753,116 @@ function getPlannedCurrentPageUrl(steps: Array<{ args: string[]; index: number }
735
753
  return undefined;
736
754
  }
737
755
 
756
+ const TIMEOUT_RETRYABLE_COMMANDS = new Set([
757
+ "console",
758
+ "diff",
759
+ "errors",
760
+ "get",
761
+ "goto",
762
+ "navigate",
763
+ "network",
764
+ "open",
765
+ "pdf",
766
+ "pushstate",
767
+ "screenshot",
768
+ "snapshot",
769
+ "tab",
770
+ "vitals",
771
+ "wait",
772
+ ]);
773
+
774
+ function getTimeoutStepRetry(step: { args: string[] }): { args: string[] } | undefined {
775
+ const command = step.args[0];
776
+ return command && TIMEOUT_RETRYABLE_COMMANDS.has(command) ? { args: step.args } : undefined;
777
+ }
778
+
779
+ function normalizeUrlForTimeoutComparison(url: string | undefined): URL | undefined {
780
+ if (!url) return undefined;
781
+ try {
782
+ return new URL(url);
783
+ } catch {
784
+ return undefined;
785
+ }
786
+ }
787
+
788
+ function currentUrlMatchesNavigationStep(currentUrl: string | undefined, plannedUrl: string | undefined): boolean {
789
+ if (!currentUrl || !plannedUrl) return false;
790
+ if (currentUrl === plannedUrl) return true;
791
+ const current = normalizeUrlForTimeoutComparison(currentUrl);
792
+ const planned = normalizeUrlForTimeoutComparison(plannedUrl);
793
+ if (!current || !planned || current.origin !== planned.origin) return false;
794
+ const plannedPath = planned.pathname.endsWith("/") ? planned.pathname : `${planned.pathname}/`;
795
+ const currentPath = current.pathname.endsWith("/") ? current.pathname : `${current.pathname}/`;
796
+ return planned.pathname === "/" || currentPath.startsWith(plannedPath);
797
+ }
798
+
799
+ function buildTimeoutProgressSteps(options: {
800
+ artifacts: TimeoutArtifactEvidence[];
801
+ currentPageSource?: "live" | "planned";
802
+ currentPageUrl?: string;
803
+ steps: Array<{ args: string[]; generatedFrom?: string; index: number }>;
804
+ }): { openedButPostOpenTimedOut?: boolean; retryStep?: TimeoutProgressStep; steps: TimeoutProgressStep[] } {
805
+ let retryStep: TimeoutProgressStep | undefined;
806
+ let lastCompletedNavigationIndex: number | undefined;
807
+ const progressSteps = options.steps.map((step): TimeoutProgressStep => {
808
+ const stepArtifacts = options.artifacts.filter((artifact) => artifact.stepIndex === step.index);
809
+ const command = step.args[0];
810
+ const navigationUrl = isOpenNavigationCommand(command) || command === "pushstate" ? getLastPositionalToken(step.args) : undefined;
811
+ if (stepArtifacts.some((artifact) => artifact.exists)) {
812
+ return { ...step, reason: "Declared artifact exists on disk after timeout.", status: "completed" };
813
+ }
814
+ if (options.currentPageSource === "live" && currentUrlMatchesNavigationStep(options.currentPageUrl, navigationUrl)) {
815
+ lastCompletedNavigationIndex = step.index;
816
+ return { ...step, reason: "Live page URL was recovered after timeout.", status: "completed" };
817
+ }
818
+ return { ...step, reason: stepArtifacts.length > 0 ? "Declared artifact was not present when the watchdog fired." : undefined, status: "unknown" };
819
+ });
820
+ const highestCompletedIndex = Math.max(0, ...progressSteps.filter((step) => step.status === "completed").map((step) => step.index));
821
+ for (const step of progressSteps) {
822
+ if (step.status === "unknown" && step.index < highestCompletedIndex) {
823
+ step.status = "completed";
824
+ step.reason = "Later step completion evidence indicates the batch advanced past this step before timeout.";
825
+ }
826
+ }
827
+ for (const step of progressSteps) {
828
+ if (step.status === "completed") continue;
829
+ if (!retryStep) {
830
+ const retry = getTimeoutStepRetry(step);
831
+ retryStep = {
832
+ ...step,
833
+ reason: step.reason ?? (retry ? "Likely active when the wrapper watchdog fired." : "Likely active when the wrapper watchdog fired; executable retry omitted because this step may have already mutated page state."),
834
+ retry,
835
+ status: "failed",
836
+ };
837
+ Object.assign(step, retryStep);
838
+ continue;
839
+ }
840
+ step.status = "pending";
841
+ step.reason = step.reason ?? `Pending behind timed-out step ${retryStep.index}.`;
842
+ }
843
+ return {
844
+ openedButPostOpenTimedOut: lastCompletedNavigationIndex !== undefined && retryStep !== undefined && retryStep.index > lastCompletedNavigationIndex,
845
+ retryStep,
846
+ steps: progressSteps,
847
+ };
848
+ }
849
+
738
850
  export async function collectTimeoutPartialProgress(options: { command?: string; compiledJob?: CompiledAgentBrowserJob; cwd: string; sessionName?: string; stdin?: string }): Promise<TimeoutPartialProgress | undefined> {
739
- const steps = getTimeoutProgressSteps(options.compiledJob, options.command, options.stdin);
740
- const artifacts = await collectTimeoutArtifactEvidence(options.cwd, steps);
851
+ const rawSteps = getTimeoutProgressSteps(options.compiledJob, options.command, options.stdin);
852
+ const artifacts = await collectTimeoutArtifactEvidence(options.cwd, rawSteps);
741
853
  const [urlData, titleData] = await Promise.all([runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, sessionName: options.sessionName }), runSessionCommandData({ args: ["get", "title"], cwd: options.cwd, sessionName: options.sessionName })]);
742
854
  const recoveredUrl = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
743
855
  const title = extractStringResultField(titleData, "result") ?? extractStringResultField(titleData, "title");
744
- const plannedUrl = recoveredUrl ? undefined : getPlannedCurrentPageUrl(steps);
856
+ const plannedUrl = recoveredUrl ? undefined : getPlannedCurrentPageUrl(rawSteps);
745
857
  const url = recoveredUrl ?? plannedUrl;
746
- if (steps.length === 0 && artifacts.length === 0 && !url && !title) return undefined;
858
+ const currentPageSource = recoveredUrl ? "live" as const : plannedUrl ? "planned" as const : title ? "live" as const : undefined;
859
+ const stepProgress = buildTimeoutProgressSteps({ artifacts, currentPageSource: recoveredUrl ? "live" : undefined, currentPageUrl: recoveredUrl, steps: rawSteps });
860
+ if (rawSteps.length === 0 && artifacts.length === 0 && !url && !title) return undefined;
747
861
  const foundArtifacts = artifacts.filter((artifact) => artifact.exists).length;
862
+ const completedSteps = stepProgress.steps.filter((step) => step.status === "completed").length;
748
863
  const pageStateSummary = recoveredUrl || title ? " and current page state" : plannedUrl ? " and planned page URL" : "";
749
- return { artifacts, currentPage: url || title ? { title, url } : undefined, steps: steps.length > 0 ? steps : undefined, summary: `Timed out before upstream returned final results; recovered ${foundArtifacts}/${artifacts.length} declared artifact path${artifacts.length === 1 ? "" : "s"}${pageStateSummary}.` };
864
+ const retrySummary = stepProgress.retryStep ? ` Retry step ${stepProgress.retryStep.index} is the first incomplete step.` : "";
865
+ return { artifacts, currentPage: url || title ? { source: currentPageSource, title, url } : undefined, liveUrlRecovered: recoveredUrl !== undefined, openedButPostOpenTimedOut: stepProgress.openedButPostOpenTimedOut, retryStep: stepProgress.retryStep, steps: stepProgress.steps.length > 0 ? stepProgress.steps : undefined, summary: `Timed out before upstream returned final results; recovered ${completedSteps}/${rawSteps.length} planned step state${rawSteps.length === 1 ? "" : "s"} and ${foundArtifacts}/${artifacts.length} declared artifact path${artifacts.length === 1 ? "" : "s"}${pageStateSummary}.${retrySummary}` };
750
866
  }
751
867
 
752
868
  function redactSensitivePathSegmentsForDiagnostic(path: string): string {
@@ -775,9 +891,16 @@ export function formatTimeoutPartialProgressText(progress: TimeoutPartialProgres
775
891
  if (progress.steps && progress.steps.length > 0) {
776
892
  const shownSteps = progress.steps.slice(0, 6);
777
893
  lines.push("Planned steps:");
778
- for (const step of shownSteps) lines.push(`- Step ${step.index}: ${redactSensitivePathSegmentsForDiagnostic(redactInvocationArgs(step.args).join(" "))}`);
894
+ for (const step of shownSteps) {
895
+ const commandText = redactSensitivePathSegmentsForDiagnostic(redactInvocationArgs(step.args).join(" "));
896
+ const generatedFrom = step.generatedFrom ? `, generated from ${step.generatedFrom}` : "";
897
+ lines.push(`- Step ${step.index} [${step.status}${generatedFrom}]: ${commandText}${step.reason ? ` — ${redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(step.reason))}` : ""}`);
898
+ }
779
899
  if (progress.steps.length > shownSteps.length) lines.push(`- ... ${progress.steps.length - shownSteps.length} more step${progress.steps.length - shownSteps.length === 1 ? "" : "s"} omitted`);
780
900
  }
901
+ if (progress.retryStep?.retry?.args) {
902
+ lines.push(`Retry failed step: ${JSON.stringify({ args: redactInvocationArgs(progress.retryStep.retry.args) })}`);
903
+ }
781
904
  for (const artifact of progress.artifacts) lines.push(`Artifact from step ${artifact.stepIndex}: ${redactSensitivePathSegmentsForDiagnostic(artifact.path)} (${artifact.exists ? `exists${typeof artifact.sizeBytes === "number" ? `, ${artifact.sizeBytes} bytes` : ""}` : "missing"})`);
782
905
  return lines.join("\n");
783
906
  }
@@ -19,6 +19,7 @@ import {
19
19
  AgentBrowserNextActionCollector,
20
20
  alignPageChangeSummaryNextActionIds,
21
21
  isStandaloneSnapshotNextAction,
22
+ withOptionalSessionArgs,
22
23
  } from "../../results/next-actions.js";
23
24
  import {
24
25
  buildConnectedSessionNextActions,
@@ -282,7 +283,8 @@ export async function prepareFinalResultRecoveryState(options: {
282
283
  let visibleRefFallbackDiagnostic: FinalRecoveryState["visibleRefFallbackDiagnostic"];
283
284
  const visibleRefFallbackSessionName = options.executionPlan.sessionName ?? extractExplicitSessionName(options.runtimeToolArgs);
284
285
  if (categoryDetails.failureCategory === "selector-not-found") {
285
- visibleRefFallbackDiagnostic = await collectVisibleRefFallbackDiagnostic({ commandTokens: options.commandTokens, compiledSemanticAction: options.compiledSemanticAction, cwd: options.cwd, sessionName: visibleRefFallbackSessionName, signal: options.signal });
286
+ const selectorRecoveryCommandTokens = options.presentation.batchFailure?.failedStep.command ?? options.commandTokens;
287
+ visibleRefFallbackDiagnostic = await collectVisibleRefFallbackDiagnostic({ commandTokens: selectorRecoveryCommandTokens, compiledSemanticAction: options.compiledSemanticAction, cwd: options.cwd, sessionName: visibleRefFallbackSessionName, signal: options.signal });
286
288
  if (visibleRefFallbackDiagnostic && visibleRefFallbackSessionName) {
287
289
  const refUpdate = options.sessionPageState.applyRefSnapshot({ fallbackTarget: options.currentSessionTabTarget, sessionName: visibleRefFallbackSessionName, snapshot: visibleRefFallbackDiagnostic.snapshot, update: options.sessionPageStateUpdate });
288
290
  currentRefSnapshot = refUpdate.refSnapshot;
@@ -299,6 +301,51 @@ export async function prepareFinalResultRecoveryState(options: {
299
301
  return { categoryDetails, currentRefSnapshot, currentRefSnapshotInvalidation, noActivePageSnapshotFailure, richInputRecoveryDiagnostic, visibleRefFallbackDiagnostic, visibleRefFallbackSessionName };
300
302
  }
301
303
 
304
+ function buildTimeoutPartialProgressNextActions(options: FinalResultInput): AgentBrowserNextAction[] {
305
+ const retryArgs = options.timeoutPartialProgress?.retryStep?.retry?.args;
306
+ if (!retryArgs) return [];
307
+ const stepIndex = options.timeoutPartialProgress?.retryStep?.index;
308
+ const freshSessionAbandoned = options.sessionMode === "fresh" && options.timeoutPartialProgress?.liveUrlRecovered !== true;
309
+ return [{
310
+ id: "retry-timeout-step",
311
+ params: freshSessionAbandoned
312
+ ? { args: retryArgs, sessionMode: "fresh" }
313
+ : { args: withOptionalSessionArgs(options.executionPlan.sessionName, retryArgs) },
314
+ reason: freshSessionAbandoned
315
+ ? `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} in a fresh browser session because the timed-out fresh session was not proven live.`
316
+ : `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} against the current browser session.`,
317
+ safety: "Only read-only or idempotent timeout steps get executable retry args; inspect current page/artifact state before using the action.",
318
+ tool: "agent_browser" as const,
319
+ }];
320
+ }
321
+
322
+ function buildDialogTimeoutNextActions(options: { command?: string; sessionName?: string }): AgentBrowserNextAction[] {
323
+ if (options.command !== "dialog" && options.command !== "click" && options.command !== "tap" && options.command !== "find" && options.command !== "eval") return [];
324
+ return [
325
+ {
326
+ id: "inspect-dialog-after-timeout",
327
+ params: { args: withOptionalSessionArgs(options.sessionName, ["dialog", "status"]) },
328
+ reason: "Check whether a blocking JavaScript dialog is pending after the timed-out interaction.",
329
+ safety: "Read-only dialog status; this wrapper bounds dialog commands so recovery attempts do not wait for the full default watchdog.",
330
+ tool: "agent_browser" as const,
331
+ },
332
+ {
333
+ id: "dismiss-dialog-after-timeout",
334
+ params: { args: withOptionalSessionArgs(options.sessionName, ["dialog", "dismiss"]) },
335
+ reason: "Dismiss a pending alert/confirm/prompt when the workflow can safely abandon the dialog.",
336
+ safety: "Only run when dismissing/canceling the dialog is acceptable for the user flow.",
337
+ tool: "agent_browser" as const,
338
+ },
339
+ {
340
+ id: "recover-fresh-session-after-dialog-timeout",
341
+ params: { args: ["open", "about:blank"], sessionMode: "fresh" as const },
342
+ reason: "Start a clean browser session if the current session remains blocked behind a JavaScript dialog.",
343
+ safety: "Replace about:blank with the intended recovery URL; this abandons the blocked managed session.",
344
+ tool: "agent_browser" as const,
345
+ },
346
+ ];
347
+ }
348
+
302
349
  function buildResultNextActions(options: FinalResultInput): AgentBrowserNextAction[] | undefined {
303
350
  const nextActionCollector = new AgentBrowserNextActionCollector(options.presentation.nextActions);
304
351
  if (options.categoryDetails.resultCategory === "success" && options.executionPlan.commandInfo.command === "connect" && !options.electronLaunchRecord) nextActionCollector.appendUnique(buildConnectedSessionNextActions(options.executionPlan.sessionName));
@@ -322,10 +369,14 @@ function buildResultNextActions(options: FinalResultInput): AgentBrowserNextActi
322
369
  if (options.selectorTextVisibilityDiagnostics.length > 0) nextActionCollector.append(buildSelectorTextVisibilityNextActions({ diagnostics: options.selectorTextVisibilityDiagnostics, sessionName: options.executionPlan.sessionName }));
323
370
  if (options.electronBroadGetTextScopeDiagnostics.length > 0) nextActionCollector.append(buildElectronBroadGetTextScopeNextActions({ diagnostics: options.electronBroadGetTextScopeDiagnostics, sessionName: options.executionPlan.sessionName }));
324
371
  if (options.sourceLookup?.electronContext) nextActionCollector.appendUnique(buildSourceLookupElectronNextActions(options.sourceLookup));
325
- if (options.clickDispatchDiagnostic) nextActionCollector.append(buildClickDispatchNextActions({ commandTokens: options.commandTokens, sessionName: options.executionPlan.sessionName }));
372
+ if (options.clickDispatchDiagnostic) nextActionCollector.append(buildClickDispatchNextActions({ commandTokens: options.commandTokens, diagnostic: options.clickDispatchDiagnostic, sessionName: options.executionPlan.sessionName }));
326
373
  if (options.scrollNoopDiagnostic) nextActionCollector.append(buildScrollNoopNextActions(options.executionPlan.sessionName));
327
374
  if (options.comboboxFocusDiagnostic) nextActionCollector.append(buildComboboxFocusNextActions(options.executionPlan.sessionName));
328
375
  if (options.managedSessionOutcome) nextActionCollector.appendUnique(buildManagedSessionFreshFailureNextActions(options.managedSessionOutcome));
376
+ if (options.categoryDetails.failureCategory === "timeout" && options.processResult.timedOut) {
377
+ nextActionCollector.appendUnique(buildTimeoutPartialProgressNextActions(options));
378
+ nextActionCollector.appendUnique(buildDialogTimeoutNextActions({ command: options.executionPlan.commandInfo.command, sessionName: options.executionPlan.sessionName }));
379
+ }
329
380
  if (options.categoryDetails.failureCategory === "stale-ref" && options.redactedCompiledSemanticAction && isCompiledSemanticActionFindCommand(options.compiledSemanticAction)) nextActionCollector.append([{ id: "retry-semantic-action-after-stale-ref", params: { args: options.redactedCompiledSemanticAction.args }, reason: "Retry the same semantic target via its compiled find command after the upstream stale-ref failure proves the prior action did not execute.", safety: "Use only for the same intended target; direct stale @refs still require a fresh snapshot or stable locator before retrying.", tool: "agent_browser" as const }]);
330
381
  if (options.electronLaunchRecord) nextActionCollector.append(buildAgentBrowserNextActions({ electron: { launchId: options.electronLaunchRecord.launchId, sessionName: options.electronLaunchRecord.sessionName, status: options.electronLaunchRecord.cleanupState }, failureCategory: options.categoryDetails.failureCategory, resultCategory: options.categoryDetails.resultCategory, successCategory: options.categoryDetails.successCategory }));
331
382
  return nextActionCollector.toArray();
@@ -24,6 +24,7 @@ export async function runAgentBrowserTool(options: BrowserRunOptions): Promise<A
24
24
  env: prepared.executionPlan.managedSessionName ? { AGENT_BROWSER_IDLE_TIMEOUT_MS: options.implicitSessionIdleTimeoutMs } : undefined,
25
25
  signal: options.signal,
26
26
  stdin: prepared.processStdin,
27
+ timeoutMs: prepared.processTimeoutMs,
27
28
  });
28
29
 
29
30
  const missingBinaryResult = await buildMissingBinaryFailureResult({