pi-agent-browser-native 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-09-26
4
+
5
+ ### Breaking Changes
6
+
7
+ - Require Node.js 24.21.0 or newer.
8
+
9
+ ### Changed
10
+
11
+ - Update the official Pi validation graph to 0.87.1, adopt TypeScript 7, npm 12, and the latest compatible development tooling, and remove stale transitive dependency overrides.
12
+ - Qualify official Pi, the forked Pi host, package smoke, native checkpoints, and platform builds on Node 24.
13
+
14
+ ### Fixed
15
+
16
+ - Keep lifecycle sentinel replacement byte-stable outside its generated block without retaining the removed TypeScript compiler API.
17
+
18
+ ## 0.7.1 - 2026-09-22
19
+
20
+ ### Fixed
21
+
22
+ - Defer the native replay helper import until awaited session restoration, preserving fast package registration and the complete restored tool surface. Public browser commands and saved-session behavior remain unchanged.
23
+
3
24
  ## 0.7.0 - 2026-09-22
4
25
 
5
26
  ### Changed
package/README.md CHANGED
@@ -6,7 +6,7 @@ It is for Pi users who want agents to browse sites, inspect pages, click through
6
6
 
7
7
  ## Pi release qualification
8
8
 
9
- Development host dependencies are pinned to official Pi **0.87.0**, with wildcard runtime peers. `npm run check:compat` checks the installed candidate SDK/CLI identity, typechecks, runs the existing offline tests (including the native Pi pipeline with a controlled provider and fake browser), and tests a packed runtime-only installation through both the SDK and the actual bundled RPC CLI. The fork lane additionally requires the native idle checkpoint hook. It does not substitute a fork SDK while leaving official types or child CLI installed.
9
+ Development host dependencies are pinned to official Pi **0.87.1**, with wildcard runtime peers. `npm run check:compat` checks the installed candidate SDK/CLI identity, typechecks, runs the existing offline tests (including the native Pi pipeline with a controlled provider and fake browser), and tests a packed runtime-only installation through both the SDK and the actual bundled RPC CLI. The fork lane additionally requires the native idle checkpoint hook. It does not substitute a fork SDK while leaving official types or child CLI installed.
10
10
 
11
11
  Run in an empty HOME with a **short** TMPDIR outside your real home. Long temporary paths can exceed macOS's 103-byte Unix socket limit; keep the fixture's socket safety checks intact. Real-browser/checkpoint controls, live upstream help sampling, lifecycle dogfood, and platform qualification remain the separate gates documented below. A Pi-only compatibility run uses no live browser profiles, credentials, or agent-browser fork, and does not certify those external integrations.
12
12
 
@@ -84,7 +84,7 @@ The result is optimized for agent work:
84
84
  | Agents build fragile shell commands or repeat browser calls for loops and branches | Exposes compact `agent_browser` for native commands and batch, `agent_browser_code` for fresh JavaScript against the same persistent browser, and discoverable action, QA, Electron, and source tools | `extensions/agent-browser/index.ts`, `extensions/agent-browser/lib/input-modes/`, [`docs/TOOL_CONTRACT.md`](docs/TOOL_CONTRACT.md) |
85
85
  | Page snapshots are too large or viewport-blind | Shows compact, main-content-first summaries, surfaces an `Omitted high-value controls` section (plus `details.data.highValueControlRefIds`) when dense pages or desktop host screens hide editables, named surfaces/tabs, primary action buttons, and high-signal named links such as repository results from the trimmed ref lists, supports wrapper-side `snapshot -i --search <text>` / `--filter role=<role>` to trim dense pages while preserving full `details.refSnapshot`, supports `snapshot --viewport` for scroll/viewport metadata, supports `snapshot --diff` for quick ref-map deltas versus the prior tracked snapshot, and stores full redacted output in spill files when needed | `extensions/agent-browser/lib/results/snapshot.ts`, `extensions/agent-browser/lib/orchestration/browser-run/prepare.ts`, `test/agent-browser.presentation.test.ts`, `test/agent-browser.extension-validation.test.ts` |
86
86
  | Screenshots/downloads get lost in text | Normalizes artifact paths, creates missing parent directories, leaves click/download execution to native download, and reports existence, size, cwd, session, and repair status | [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#download-screenshot-and-pdf-files) |
87
- | Profile restores and tab drift confuse agents | Tracks managed sessions, keeps every upstream helper probe on the same idle-timeout launch configuration so the background browser is not restarted between a snapshot and action, re-selects target tabs after observed drift, live-verifies the active target and refreshes its title after successful tab selection or `tab close` (while retaining deliberate `about:blank` selections and post-close blank targets), rehydrates branch-backed session state on Pi session-tree changes, and pins later commands only for sessions with drift/restored-session risk | generated tab-recovery notes below; `test/agent-browser.extension-tab-recovery.test.ts` (drift and about:blank recovery), `test/agent-browser.extension-tabs.test.ts` (post-close target), `test/agent-browser.extension-ref-guards.test.ts` (snapshot/action environment and session-tree rehydration), `test/agent-browser.resume-state.test.ts` (persisted session / resume planning) |
87
+ | Profile restores and tab drift confuse agents | Tracks managed sessions, keeps every upstream helper probe on the same idle-timeout launch configuration so the background browser is not restarted between a snapshot and action, retains native CDP target IDs so same-address tabs remain distinct after resume, re-selects target tabs after observed drift, live-verifies the active target and refreshes its title after successful tab selection or `tab close` (while retaining new blank tabs, deliberate `about:blank` selections, and post-close blank targets, including batch transitions), rehydrates branch-backed session state on Pi session-tree changes, and pins later commands only for sessions with drift/restored-session risk | generated tab-recovery notes below; `test/agent-browser.extension-tab-recovery.test.ts` (drift and about:blank recovery), `test/agent-browser.extension-tabs.test.ts` (post-close target), `test/agent-browser.extension-ref-guards.test.ts` (snapshot/action environment and session-tree rehydration), `test/agent-browser.resume-state.test.ts` (persisted session / resume planning) |
88
88
  | Auth/profile workflows can leak secrets | Supports `auth save --password-stdin`, redacts sensitive args, SAML/OAuth-bearing URLs, stdout/stderr, details, and snapshot spills, and discards malformed oversized stdout instead of persisting a parse-failure spill | `test/agent-browser.extension-security-redaction.test.ts` |
89
89
  | Stateful cookies/storage/auth output bloats or leaks context | Presentation layer redacts `details.data` for cookies and credential-like storage values while keeping low-risk local QA values such as `theme: dark` readable; recursively scrubs other structured upstream JSON (network, diff, trace/profiler, stream, dashboard, chat, auth, dialog, frame, state, and similar) using sensitive key names plus string heuristics; masks sensitive argv flags and positionals, including raw batch rows and ordered code journals; scrubs secrets from failed batch step errors; and exposes a compact redacted `batch` matrix on top-level `details.data` | `extensions/agent-browser/lib/results/presentation.ts`, `extensions/agent-browser/lib/results/presentation/diagnostics.ts`, `extensions/agent-browser/lib/runtime.ts`, `test/agent-browser.presentation-diagnostics.test.ts` |
90
90
  | Stale `@eN` refs fail mysteriously | Records per-session `details.refSnapshot`, rejects mismatched URLs / unknown refs / unsafe `batch` stdin ordering before spawn, adds recovery guidance to rerun `snapshot -i` or use stable `find` locators | `extensions/agent-browser/index.ts`, `extensions/agent-browser/lib/session-page-state.ts`, `test/agent-browser.session-page-state.test.ts`, `test/agent-browser.results.test.ts`, `test/agent-browser.extension-ref-guards.test.ts`, `test/agent-browser.extension-semantic-recovery.test.ts` |
@@ -102,7 +102,7 @@ Artifact results show known requested paths separately from reported/resolved lo
102
102
 
103
103
  ## Fastest way to try it
104
104
 
105
- Use Pi 0.87.0 or newer. This package keeps optional Pi core imports as wildcard `peerDependencies` because Pi package docs require the host Pi install to provide those packages, pins its direct Pi validation dependencies to 0.87.0, and makes hosts below 0.87.0 a setup failure through `pi-agent-browser-doctor`. There are no compatibility shims for older Pi releases.
105
+ Use Node 24.21.0 or newer and Pi 0.87.0 or newer. This package keeps optional Pi core imports as wildcard `peerDependencies` because Pi package docs require the host Pi install to provide those packages, pins its direct Pi validation dependencies to 0.87.1, and makes Pi hosts below 0.87.0 a setup failure through `pi-agent-browser-doctor`. There are no compatibility shims for older Pi releases.
106
106
 
107
107
  Install upstream `agent-browser` first and make sure it is on `PATH`:
108
108
 
@@ -492,7 +492,7 @@ Use native `batch --bail` for known sequences. Add an explicit destination/text
492
492
  }
493
493
  ```
494
494
 
495
- Use the exact returned recovery payload after errors. Timeout partial progress distinguishes observed results from planned or unknown steps; it never proves an unobserved mutation did not run. For paced typing, native batch can interleave `keyboard type` characters and positional `wait <ms>` rows; see [the migration map](docs/TOOL_CONTRACT.md#07-migration). Do not assume a native `--delay` option reproduces the removed job behavior.
495
+ Use the exact returned recovery payload after errors. Timeout partial progress reports current page and file observations separately from planned steps, whose outcomes remain unknown without upstream results. An existing artifact or matching URL does not prove any step ran. Multi-step timeouts offer inspection rather than guessing a retry position; a single retryable step may offer a retry candidate after inspection. For paced typing, native batch can interleave `keyboard type` characters and positional `wait <ms>` rows; see [the migration map](docs/TOOL_CONTRACT.md#07-migration). Do not assume a native `--delay` option reproduces the removed job behavior.
496
496
 
497
497
  Both JSON and prose results expose a bounded canonical observation with recovery actions and artifact receipts. Oversized observations name an `observationPath` for the complete redacted result; audit-only `details` is not the model's sole recovery channel. Inner code calls avoid full model rendering, spills, and image encoding until output is selected.
498
498
 
@@ -720,7 +720,7 @@ Install upstream `agent-browser`, then install dependencies:
720
720
  npm install
721
721
  ```
722
722
 
723
- Use the npm version declared in `package.json` `packageManager` when refreshing `package-lock.json` (for example `npx -y npm@11.14.0 install`) so optional-platform lockfile metadata does not drift. Use Pi 0.87.0 or newer for lifecycle and interactive browser smokes; the pinned Pi devDependencies are validation fixtures, not an exact-version requirement for the host CLI. See [Environment and automation pitfalls](docs/RELEASE.md#environment-and-automation-pitfalls) in `docs/RELEASE.md`.
723
+ Use the npm version declared in `package.json` `packageManager` when refreshing `package-lock.json` (for example `npx -y npm@12.1.0 install`) so optional-platform lockfile metadata does not drift. Use Node 24.21.0 and Pi 0.87.0 or newer for lifecycle and interactive browser smokes; the pinned Pi 0.87.1 devDependencies are validation fixtures, not an exact-version requirement for the host CLI. See [Environment and automation pitfalls](docs/RELEASE.md#environment-and-automation-pitfalls) in `docs/RELEASE.md`.
724
724
 
725
725
  Checkout-only extension smoke test:
726
726
 
@@ -768,7 +768,7 @@ These calls return plain text and stay stateless: the extension does not inject
768
768
  - After open/goto/navigate calls with --profile, --restore, --session-name, or --state, agent_browser best-effort re-selects the tab whose URL matches the returned page when restored tabs steal focus during launch or reconnect.
769
769
  - After confirmed shutdown of an automatically restored managed session, the wrapper retains its complete recorded URL, including the fragment, until the first current-page operation (including get url and reload). Non-page calls such as tab list may start a daemon without fulfilling that reopen; explicit URL reads leave the managed browser and pending reopen untouched. The wrapper uses native open once, verifies the observed tab, and discards old refs/frame scope; it does not restore unsaved forms, JavaScript memory, or history. Explicit navigation, caller-owned/attached sessions, and restore-disabled sessions are not auto-reopened.
770
770
  - For a still-live browser after tab drift or resume, the wrapper verifies/selects the intended tab before ref/semantic helpers and page commands; failed selection stops the call without navigating. Local commands, read <url>, URL a11y/vitals, diff url, window new, and explicit tab/navigation/connection/state recovery do not require the prior tab. Batch checks follow effective rows past non-page prefixes and stop at explicit context changes, preserving caller argv/stdin and continue-on-error behavior. Same-tab reselection is avoided because it clears refs. Use exact batch --bail for fail-fast, not --bail=<value>. Routine same-session calls skip tab-list preflights.
771
- - For sessions with observed tab-drift risk, after a successful command on a known target tab, agent_browser also best-effort restores that intended tab if a restored/background tab steals focus after the command completes. Routine same-session commands skip this post-command tab-list probe.
771
+ - For sessions with observed tab-drift risk, after a successful command on a known target tab, agent_browser also best-effort restores that intended tab if a restored/background tab steals focus after the command completes. Routine same-session commands skip focus correction; observed pages without a native targetId use tab list to establish their identity.
772
772
  - If a known session target unexpectedly reports about:blank, agent_browser best-effort re-selects the prior intended target when it still exists; if recovery fails, it records the observed about:blank target and reports exact recovery guidance instead of treating the prior page as active.
773
773
  - If upstream reports tab_gone, the pinned bound tab is gone; use details.nextActions (tab list / tab new) instead of assuming another tab is yours.
774
774
  <!-- agent-browser-playbook:end wrapper-tab-recovery -->
@@ -2032,11 +2032,6 @@ export default function agentBrowserExtension(pi, { beforeExecute } = {}) {
2032
2032
  return finalizeObservation(result, params, ctx);
2033
2033
  },
2034
2034
  executionMode: beforeExecute ? "sequential" : undefined,
2035
- renderCodeCall(args, theme, context) {
2036
- const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
2037
- text.setText(formatAgentBrowserRenderCall(args, theme, context.expanded));
2038
- return text;
2039
- },
2040
2035
  promptGuidelines: toolPromptGuidelines,
2041
2036
  renderCall: agentBrowserTool.renderCall, renderResult: agentBrowserTool.renderResult,
2042
2037
  });
@@ -784,81 +784,6 @@ function getTimeoutStepRetry(step) {
784
784
  const command = step.args[0];
785
785
  return command && TIMEOUT_RETRYABLE_COMMANDS.has(command) ? { args: ["batch"], stdin: JSON.stringify([step.args]) } : undefined;
786
786
  }
787
- function normalizeUrlForTimeoutComparison(url) {
788
- if (!url)
789
- return undefined;
790
- try {
791
- return new URL(url);
792
- }
793
- catch {
794
- return undefined;
795
- }
796
- }
797
- function currentUrlMatchesNavigationStep(currentUrl, plannedUrl) {
798
- if (!currentUrl || !plannedUrl)
799
- return false;
800
- if (currentUrl === plannedUrl)
801
- return true;
802
- const current = normalizeUrlForTimeoutComparison(currentUrl);
803
- const planned = normalizeUrlForTimeoutComparison(plannedUrl);
804
- if (!current || !planned || current.origin !== planned.origin)
805
- return false;
806
- const plannedPath = planned.pathname.endsWith("/") ? planned.pathname : `${planned.pathname}/`;
807
- const currentPath = current.pathname.endsWith("/") ? current.pathname : `${current.pathname}/`;
808
- return planned.pathname === "/" || currentPath.startsWith(plannedPath);
809
- }
810
- function buildTimeoutProgressSteps(options) {
811
- let retryStep;
812
- let lastCompletedNavigationIndex;
813
- const progressSteps = options.steps.map((step) => {
814
- const stepArtifacts = options.artifacts.filter((artifact) => artifact.stepIndex === step.index);
815
- const command = step.args[0];
816
- const navigationUrl = isOpenNavigationCommand(command) || command === "pushstate" ? getLastPositionalToken(step.args) : undefined;
817
- if (stepArtifacts.some((artifact) => artifact.exists)) {
818
- return { ...step, reason: "Declared artifact exists on disk after timeout.", status: "completed" };
819
- }
820
- if (options.currentPageSource === "live" && currentUrlMatchesNavigationStep(options.currentPageUrl, navigationUrl)) {
821
- lastCompletedNavigationIndex = step.index;
822
- return { ...step, reason: "Live page URL was recovered after timeout.", status: "completed" };
823
- }
824
- return { ...step, reason: stepArtifacts.length > 0 ? "Declared artifact was not present when the watchdog fired." : undefined, status: "unknown" };
825
- });
826
- const highestCompletedIndex = Math.max(0, ...progressSteps.filter((step) => step.status === "completed").map((step) => step.index));
827
- for (const step of progressSteps) {
828
- if (step.status === "unknown" && step.index < highestCompletedIndex) {
829
- step.status = "completed";
830
- step.reason = "Later step completion evidence indicates the batch advanced past this step before timeout.";
831
- }
832
- }
833
- for (const step of progressSteps) {
834
- const command = step.args[0];
835
- if (step.status === "completed" && (isOpenNavigationCommand(command) || command === "pushstate")) {
836
- lastCompletedNavigationIndex = Math.max(lastCompletedNavigationIndex ?? 0, step.index);
837
- }
838
- }
839
- for (const step of progressSteps) {
840
- if (step.status === "completed")
841
- continue;
842
- if (!retryStep) {
843
- const retry = getTimeoutStepRetry(step);
844
- retryStep = {
845
- ...step,
846
- 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."),
847
- retry,
848
- status: "failed",
849
- };
850
- Object.assign(step, retryStep);
851
- continue;
852
- }
853
- step.status = "pending";
854
- step.reason = step.reason ?? `Pending behind timed-out step ${retryStep.index}.`;
855
- }
856
- return {
857
- openedButPostOpenTimedOut: lastCompletedNavigationIndex !== undefined && retryStep !== undefined && retryStep.index > lastCompletedNavigationIndex,
858
- retryStep,
859
- steps: progressSteps,
860
- };
861
- }
862
787
  export async function collectTimeoutPartialProgress(options) {
863
788
  if ((options.commandTokens[0] === "session" && options.commandTokens[1] === "info") || isBrowserIndependentRead(options.commandTokens, options.stdin))
864
789
  return undefined;
@@ -873,14 +798,16 @@ export async function collectTimeoutPartialProgress(options) {
873
798
  const plannedUrl = recoveredUrl ? undefined : getPlannedCurrentPageUrl(rawSteps);
874
799
  const url = recoveredUrl ?? plannedUrl;
875
800
  const currentPageSource = recoveredUrl ? "live" : plannedUrl ? "planned" : title ? "live" : undefined;
876
- const stepProgress = buildTimeoutProgressSteps({ artifacts, currentPageSource: recoveredUrl ? "live" : undefined, currentPageUrl: recoveredUrl, steps: rawSteps });
801
+ // A matching URL or existing file is current state, not an execution receipt.
802
+ const steps = rawSteps.map(step => ({ ...step, status: "unknown", reason: "Upstream did not report this step's outcome before timeout." }));
803
+ const singleStep = steps.length === 1 ? steps[0] : undefined;
804
+ const retry = singleStep ? getTimeoutStepRetry(singleStep) : undefined;
805
+ const retryStep = singleStep && retry ? { ...singleStep, retry } : undefined;
877
806
  if (rawSteps.length === 0 && artifacts.length === 0 && !url && !title)
878
807
  return undefined;
879
808
  const foundArtifacts = artifacts.filter((artifact) => artifact.exists).length;
880
- const completedSteps = stepProgress.steps.filter((step) => step.status === "completed").length;
881
809
  const pageStateSummary = recoveredUrl || title ? " and current page state" : plannedUrl ? " and planned page URL" : "";
882
- const retrySummary = stepProgress.retryStep ? ` Retry step ${stepProgress.retryStep.index} is the first incomplete step.` : "";
883
- 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}` };
810
+ return { artifacts, currentPage: url || title ? { source: currentPageSource, title, url } : undefined, liveUrlRecovered: recoveredUrl !== undefined, retryStep, steps: steps.length > 0 ? steps : undefined, summary: `Timed out before upstream returned final results; ${steps.length} planned step outcome${steps.length === 1 ? " is" : "s are"} unknown. Found ${foundArtifacts}/${artifacts.length} declared artifact path${artifacts.length === 1 ? "" : "s"}${pageStateSummary}; these observations do not prove step execution.` };
884
811
  }
885
812
  function redactSensitivePathSegmentsForDiagnostic(path) {
886
813
  return path.split(/([/\\]+)/).map((segment) => segment === "/" || segment === "\\" || /^[/\\]+$/.test(segment) ? segment : redactSensitiveText(segment) !== segment || /(?:secret|token|password|passwd|credential|auth|api[-_]?key|bearer)/i.test(segment) ? "[REDACTED]" : segment).join("");
@@ -934,7 +861,7 @@ export function formatTimeoutPartialProgressText(progress, pageTargetUnknown = f
934
861
  const payload = JSON.stringify({ ...progress.retryStep.retry, stdin: JSON.stringify([redactInvocationArgs(progress.retryStep.args)]) });
935
862
  lines.push(pageTargetUnknown
936
863
  ? `Retry candidate for step ${progress.retryStep.index}: ${payload}. Verify the current URL before running it.`
937
- : `Retry failed step: ${payload}`);
864
+ : `Retry candidate for step ${progress.retryStep.index} (outcome unknown): ${payload}`);
938
865
  }
939
866
  for (const artifact of progress.artifacts)
940
867
  lines.push(`Artifact from step ${artifact.stepIndex}: ${redactSensitivePathSegmentsForDiagnostic(artifact.path)} (${artifact.exists ? `exists${typeof artifact.sizeBytes === "number" ? `, ${artifact.sizeBytes} bytes` : ""}` : "missing"})`);
@@ -217,7 +217,7 @@ function buildTimeoutPartialProgressNextActions(options) {
217
217
  args: withOptionalSessionArgs(options.executionPlan.sessionName, ["batch", "--bail"]),
218
218
  stdin: JSON.stringify([["get", "url"], ["snapshot", "-i"]]),
219
219
  },
220
- reason: `Verify the current URL, then inspect the page after timeout${stepIndex === undefined ? "" : ` before resuming from incomplete step ${stepIndex}`}.`,
220
+ reason: `Verify the current URL, then inspect the page after timeout${stepIndex === undefined ? "" : ` before considering a retry of step ${stepIndex}`}.`,
221
221
  safety: "Fail-fast read-only recovery: snapshot runs only after get url succeeds, satisfying the wrapper page-target guard without trusting the planned URL.",
222
222
  tool: "agent_browser",
223
223
  }];
@@ -229,8 +229,8 @@ function buildTimeoutPartialProgressNextActions(options) {
229
229
  ? { ...retry, sessionMode: "fresh" }
230
230
  : { ...retry, args: withOptionalSessionArgs(options.executionPlan.sessionName, retry.args) },
231
231
  reason: freshSessionAbandoned
232
- ? `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.`
233
- : `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} against the current browser session.`,
232
+ ? `Consider retrying the single timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} in a fresh browser session because the timed-out fresh session was not proven live.`
233
+ : `Consider retrying the single timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} against the current browser session.`,
234
234
  safety: "Only read-only or idempotent timeout steps get executable retry args; inspect current page/artifact state before using the action.",
235
235
  tool: "agent_browser",
236
236
  }];
@@ -240,7 +240,7 @@ function buildTimeoutPartialProgressNextActions(options) {
240
240
  return [{
241
241
  id: "inspect-current-page-after-timeout",
242
242
  params: { args: withOptionalSessionArgs(options.executionPlan.sessionName, ["snapshot", "-i"]) },
243
- reason: `Inspect the current page after timeout before deciding how to resume${stepIndex === undefined ? "" : ` from incomplete step ${stepIndex}`}.`,
243
+ reason: `Inspect the current page after timeout before deciding how to resume${stepIndex === undefined ? "" : ` with step ${stepIndex}`}.`,
244
244
  safety: "Read details.timeoutPartialProgress first. Do not blindly retry mutating steps such as clicks, fills, key presses, selects, or checks; split the remaining flow into shorter batches around the next navigation or DOM mutation boundary.",
245
245
  tool: "agent_browser",
246
246
  }];
@@ -8,6 +8,7 @@ import { prepareAgentBrowserSpawnArgs, withChromeStartupArgs } from "../../proce
8
8
  import { cleanupElectronLaunchResources } from "../../electron/cleanup.js";
9
9
  import { launchElectronApp } from "../../electron/launch.js";
10
10
  import { pathExists } from "../../fs-utils.js";
11
+ import { isRecord } from "../../parsing.js";
11
12
  import { getCompiledSemanticActionSessionPrefix } from "../../input-modes/semantic-action.js";
12
13
  import { tryNetworkRequestsPageFilter } from "./prepare/network-page-filter.js";
13
14
  import { tryContainerScroll, tryPageScrollTo } from "./prepare/scroll-shims.js";
@@ -599,14 +600,20 @@ export async function prepareBrowserRun(options) {
599
600
  });
600
601
  if (!executionPlan.validationError && !executionPlan.plainTextInspection && !knownStaleRef && !invalidStdin && priorSessionTabTarget && pinSessionTab) {
601
602
  signal?.throwIfAborted();
602
- const reopened = !coldManagedSession || await runSessionCommandData({
603
+ const reopenedData = coldManagedSession ? await runSessionCommandData({
603
604
  args: ["open", priorSessionTabTarget.url], cwd, namespace: executionPlan.namespace, sessionName: executionPlan.sessionName, signal, timeoutMs: params.timeoutMs,
604
605
  onProcessResult: ({ agentBrowserStarted }) => {
605
606
  // A started open may have navigated even if its CLI was aborted before replying.
606
607
  if (agentBrowserStarted && sessionStateKey)
607
608
  sessionPageState.setTabReopenPending({ pending: false, sessionName: sessionStateKey, update: options.sessionPageStateUpdate });
608
609
  },
609
- }) !== undefined;
610
+ }) : undefined;
611
+ const reopened = !coldManagedSession || reopenedData !== undefined;
612
+ if (coldManagedSession && reopened)
613
+ priorSessionTabTarget = {
614
+ ...priorSessionTabTarget,
615
+ targetId: isRecord(reopenedData) && typeof reopenedData.targetId === "string" ? reopenedData.targetId : undefined,
616
+ };
610
617
  const selection = signal?.aborted ? undefined : reopened
611
618
  ? await ensureSessionTabTarget({ cwd, namespace: executionPlan.namespace, sessionName: executionPlan.sessionName, signal, target: priorSessionTabTarget })
612
619
  : { error: "agent-browser could not reopen the remembered URL after the managed browser shut down. Navigate explicitly, then run snapshot -i before retrying." };
@@ -4,7 +4,7 @@ import { parseArgvDescriptor } from "../../argv-descriptor.js";
4
4
  import { isBrowserIndependentRead, needsManagedSession } from "../../command-policy.js";
5
5
  import { deleteIdentityKeysInNamespace, getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "../../argv-grammar.js";
6
6
  import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "../../batch-lifecycle.js";
7
- import { isCloseAllCommand, isCloseCommand, isOpenNavigationCommand, isPageMutationCommand, isRecordPageTransitionCommand, isUnverifiedPageTransitionCommand, isWindowOrDiffPageTransitionCommand } from "../../command-taxonomy.js";
7
+ import { isCloseAllCommand, isCloseCommand, isNavigationObservableCommandName, isOpenNavigationCommand, isPageMutationCommand, isRecordPageTransitionCommand, isUnverifiedPageTransitionCommand, isWindowOrDiffPageTransitionCommand } from "../../command-taxonomy.js";
8
8
  import { OPEN_RESULT_TAB_CORRECTION_FLAGS } from "../../launch-scoped-flags.js";
9
9
  import { cleanupElectronLaunchResources, inspectElectronLaunchStatus } from "../../electron/cleanup.js";
10
10
  import { getResultingPageTargetState, commandRequiresLivePageVerification } from "../../page-target-validation.js";
@@ -26,7 +26,7 @@ import { isManagedSessionRestoreKey } from "../../managed-session-storage.js";
26
26
  import { createFreshSessionName, extractUpstreamCommandTokens, resolveManagedSessionState } from "../../runtime.js";
27
27
  import { getUpstreamEffectiveBatchSteps } from "../batch-stdin.js";
28
28
  import { closeManagedSession, inspectManagedSessionDaemon } from "./managed-session-daemon-policy.js";
29
- import { applyOpenResultTabCorrection, buildAboutBlankRecoveryHint, buildAboutBlankWarning, buildElectronPostCommandHealthDiagnostic, buildElectronRefFreshnessDiagnostic, buildElectronSessionMismatch, buildManagedSessionOutcome, collectOpenResultTabCorrection, collectSessionTabSelection, commandChoosesSessionTabTarget, extractNavigationSummaryFromData, extractStringResultField, findElectronLaunchRecordForSession, formatElectronPostCommandHealthText, formatElectronSessionMismatchText, getSessionContextKey, getStaleRefArgs, mergeNavigationSummaryIntoData, runSessionCommandData, shouldCaptureNavigationSummary, shouldCorrectSessionTabAfterCommand, shouldInspectElectronPostCommandHealth, updateTraceOwnerState, } from "./session-state.js";
29
+ import { applyOpenResultTabCorrection, buildAboutBlankRecoveryHint, buildAboutBlankWarning, buildElectronPostCommandHealthDiagnostic, buildElectronRefFreshnessDiagnostic, buildElectronSessionMismatch, buildManagedSessionOutcome, collectOpenResultTabCorrection, collectSessionTabSelection, collectSessionTabTarget, commandChoosesSessionTabTarget, extractNavigationSummaryFromData, extractStringResultField, findElectronLaunchRecordForSession, formatElectronPostCommandHealthText, formatElectronSessionMismatchText, getSessionContextKey, getStaleRefArgs, mergeNavigationSummaryIntoData, runSessionCommandData, shouldCaptureNavigationSummary, shouldCorrectSessionTabAfterCommand, shouldInspectElectronPostCommandHealth, updateTraceOwnerState, } from "./session-state.js";
30
30
  import { collectClickDispatchDiagnostic } from "./click-dispatch.js";
31
31
  import { buildScrollNoopDiagnostic, collectComboboxFocusDiagnostic, collectElectronBroadGetTextScopeDiagnostics, collectElectronHandoff, collectFillVerificationDiagnostic, collectNavigationSummary, collectOverlayBlockerDiagnostic, collectQaAttachedTarget, collectSnapshotOverlayBlockerDiagnostic, collectRecordingDependencyWarning, collectScrollPositionSnapshot, collectSelectorTextVisibilityDiagnostics, collectTimeoutPartialProgress, sleepMs, formatQaAttachedTargetText, getArtifactCleanupGuidance, getEvalResultWarning, getEvalStdinHint, getSourceLookupElectronContext, } from "./diagnostics.js";
32
32
  import { repairScreenshotData } from "./prepare.js";
@@ -296,7 +296,10 @@ export async function processBrowserOutput(input) {
296
296
  presentationEnvelope = { ...(presentationEnvelope ?? {}), error: clickDispatchDiagnostic.summary, success: false };
297
297
  }
298
298
  }
299
- const tabTransition = prepared.executionPlan.commandInfo.command === "tab" && prepared.executionPlan.commandInfo.subcommand !== undefined && !["list", "new"].includes(prepared.executionPlan.commandInfo.subcommand);
299
+ const tabTransition = dispatchedCommands.some((step) => {
300
+ const [command, subcommand] = extractUpstreamCommandTokens(step);
301
+ return command === "tab" && subcommand !== undefined && subcommand !== "list";
302
+ });
300
303
  // Non-page rows (including a failed prefix) cannot retire a cold target for a navigation that never ran.
301
304
  const resultingPageState = sessionPageState.get(sessionStateKey).tabReopenPending === true
302
305
  ? { currentPageUrl: prepared.priorSessionTabTarget?.url, pageTargetMayHaveChanged: false, pageUrlUnknown: prepared.priorSessionTabTargetUnknown === true }
@@ -306,11 +309,12 @@ export async function processBrowserOutput(input) {
306
309
  currentPageUrl: prepared.priorSessionTabTarget?.url,
307
310
  pageUrlUnknown: prepared.priorSessionTabTargetUnknown === true,
308
311
  });
309
- if (succeeded &&
312
+ if (succeeded && !nestedBatchClosed &&
310
313
  (shouldCaptureNavigationSummary(prepared.executionPlan.commandInfo.command, presentationEnvelope?.data, prepared.executionPlan.commandInfo.subcommand) ||
314
+ (prepared.executionPlan.commandInfo.command === "batch" && dispatchedCommands.some(([command, subcommand]) => isNavigationObservableCommandName(command, subcommand))) ||
311
315
  shouldCaptureSemanticActionNavigationSummary(prepared.compiledSemanticAction, presentationEnvelope?.data) ||
312
316
  commandRequiresLivePageVerification(prepared.executionPlan.effectiveArgs, prepared.runtimeToolStdin) ||
313
- (destinationTransition && !nestedBatchClosed) || tabTransition)) {
317
+ destinationTransition || tabTransition)) {
314
318
  navigationSummary = await collectNavigationSummary({ cwd, namespace: prepared.executionPlan.namespace, priorTarget: prepared.priorSessionTabTarget, reusePriorTitle: !tabTransition, sessionName: prepared.executionPlan.sessionName, signal });
315
319
  }
316
320
  // Failed transitions may already have changed the page; keep only a live observed URL.
@@ -319,8 +323,11 @@ export async function processBrowserOutput(input) {
319
323
  !processResult.aborted &&
320
324
  !processResult.timedOut &&
321
325
  !nestedBatchClosed &&
322
- (destinationTransition || (prepared.executionPlan.commandInfo.command !== "batch" &&
323
- isUnverifiedPageTransitionCommand(prepared.executionPlan.commandInfo.command, prepared.executionPlan.commandInfo.subcommand)))) {
326
+ dispatchedCommands.some((step) => {
327
+ const [command, subcommand] = extractUpstreamCommandTokens(step);
328
+ return (prepared.executionPlan.commandInfo.command === "batch" && isOpenNavigationCommand(command))
329
+ || isUnverifiedPageTransitionCommand(command, subcommand);
330
+ })) {
324
331
  navigationSummary = await collectNavigationSummary({ cwd, namespace: prepared.executionPlan.namespace, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName, signal });
325
332
  // Re-verifying a failed transition's URL does not make its prior refs valid.
326
333
  failedTransitionReverification = navigationSummary !== undefined;
@@ -342,11 +349,21 @@ export async function processBrowserOutput(input) {
342
349
  const failedWebMcpSettlement = prepared.priorSessionTabTargetUnknown === true && ((!succeeded && isWebMcpSettlementCommand(prepared.executionPlan.commandInfo.command, prepared.executionPlan.commandInfo.subcommand))
343
350
  || (prepared.executionPlan.commandInfo.command === "batch" && batchHasFailedWebMcpSettlement(presentationEnvelope?.data)));
344
351
  const unsettledWebMcpMutation = pendingWebMcpMutation || failedWebMcpSettlement;
345
- const observedSessionTabTarget = unsettledWebMcpMutation || (unobservedMutation && !failedTransitionReverification)
352
+ let observedSessionTabTarget = unsettledWebMcpMutation || (unobservedMutation && !failedTransitionReverification)
346
353
  ? undefined
347
354
  : normalizeSessionTabTarget(navigationSummary)
348
355
  ?? (trustsReportedPageTarget ? extractSessionTabTargetFromBatchResults(presentationEnvelope?.data) : undefined)
349
356
  ?? (succeeded && trustsReportedPageTarget ? extractSessionTabTargetFromCommandData(prepared.commandTokens, presentationEnvelope?.data) : undefined);
357
+ if (observedSessionTabTarget && !browserIndependentRead && !nestedBatchClosed && !isCloseCommand(prepared.executionPlan.commandInfo.command)) {
358
+ const reportedTarget = prepared.executionPlan.commandInfo.command === "batch"
359
+ ? extractSessionTabTargetFromBatchResults(presentationEnvelope?.data)
360
+ : prepared.commandTokens[0] === "tab" && prepared.commandTokens[1] === "close" ? undefined
361
+ : extractSessionTabTargetFromCommandData(prepared.commandTokens, presentationEnvelope?.data);
362
+ if (reportedTarget?.targetId)
363
+ observedSessionTabTarget.targetId = reportedTarget.targetId;
364
+ else
365
+ observedSessionTabTarget = await collectSessionTabTarget({ cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, signal, target: observedSessionTabTarget });
366
+ }
350
367
  const safeObservedSessionTabTarget = observedSessionTabTarget;
351
368
  let currentSessionTabTarget = safeObservedSessionTabTarget;
352
369
  if (!currentSessionTabTarget && nestedBatchClose === undefined && !unobservedMutation) {
@@ -459,7 +476,7 @@ export async function processBrowserOutput(input) {
459
476
  : processResult.agentBrowserStarted && (directTransitionInvalidation || plannedBatchTransitionInvalidation)
460
477
  ? directTransitionInvalidation ?? plannedBatchTransitionInvalidation
461
478
  : failedTransitionReverification
462
- ? buildPageTransitionRefSnapshotInvalidation("A failed eval/back/forward/reload/connect/state-load/tab command may still have changed the page, so the prior snapshot refs were invalidated. Run snapshot -i before using page-scoped refs.")
479
+ ? buildPageTransitionRefSnapshotInvalidation("A failed page transition may still have changed the page, so the prior snapshot refs were invalidated. Run snapshot -i before using page-scoped refs.")
463
480
  : batchRefSnapshotState?.invalidation?.reason === "page-transition"
464
481
  ? batchRefSnapshotState.invalidation
465
482
  : undefined;
@@ -460,6 +460,8 @@ export function shouldCorrectSessionTabAfterCommand(options) {
460
460
  !isSessionTabPostCommandCorrectionExcludedCommand(options.command));
461
461
  }
462
462
  function getTabSelection(tab) {
463
+ if (tab.targetId)
464
+ return { selectedTab: tab.targetId, selectionKind: "targetId" };
463
465
  if (typeof tab.tabId === "string" && tab.tabId.trim().length > 0)
464
466
  return { selectedTab: tab.tabId.trim(), selectionKind: "tabId" };
465
467
  if (typeof tab.label === "string" && tab.label.trim().length > 0)
@@ -467,6 +469,10 @@ function getTabSelection(tab) {
467
469
  return typeof tab.index === "number" ? { selectedTab: String(tab.index), selectionKind: "index" } : undefined;
468
470
  }
469
471
  function selectSessionTargetTab(options) {
472
+ if (options.target.targetId) {
473
+ const target = selectAnySessionTargetTab(options);
474
+ return target && options.tabs.find((tab) => tab.targetId === options.target.targetId)?.active !== true ? target : undefined;
475
+ }
470
476
  return chooseOpenResultTabCorrection({
471
477
  tabs: options.tabs,
472
478
  targetTitle: options.target.title,
@@ -477,7 +483,9 @@ function selectAnySessionTargetTab(options) {
477
483
  const targetUrl = typeof options.target.url === "string" ? normalizeComparableUrl(options.target.url) : undefined;
478
484
  if (!targetUrl)
479
485
  return undefined;
480
- const matchingTabs = options.tabs.filter((tab) => normalizeComparableUrl(tab.url ?? "") === targetUrl);
486
+ const matchingTabs = options.tabs.filter((tab) => options.target.targetId
487
+ ? tab.targetId === options.target.targetId
488
+ : normalizeComparableUrl(tab.url ?? "") === targetUrl);
481
489
  const targetTitle = options.target.title?.trim() ?? "";
482
490
  const titledTabs = targetTitle ? matchingTabs.filter((tab) => tab.title?.trim() === targetTitle) : [];
483
491
  const selectedTab = titledTabs.find((tab) => tab.active) ?? titledTabs[0] ?? matchingTabs.find((tab) => tab.active) ?? matchingTabs[0];
@@ -538,6 +546,7 @@ export async function collectOpenResultTabCorrection(options) {
538
546
  index: typeof tab.index === "number" ? tab.index : index,
539
547
  label: typeof tab.label === "string" ? tab.label : undefined,
540
548
  tabId: typeof tab.tabId === "string" ? tab.tabId : undefined,
549
+ targetId: typeof tab.targetId === "string" ? tab.targetId : undefined,
541
550
  title: typeof tab.title === "string" ? tab.title : undefined,
542
551
  url: typeof tab.url === "string" ? tab.url : undefined,
543
552
  }));
@@ -551,10 +560,21 @@ function mapTabData(tabData) {
551
560
  index: typeof tab.index === "number" ? tab.index : index,
552
561
  label: typeof tab.label === "string" ? tab.label : undefined,
553
562
  tabId: typeof tab.tabId === "string" ? tab.tabId : undefined,
563
+ targetId: typeof tab.targetId === "string" ? tab.targetId : undefined,
554
564
  title: typeof tab.title === "string" ? tab.title : undefined,
555
565
  url: typeof tab.url === "string" ? tab.url : undefined,
556
566
  }));
557
567
  }
568
+ export async function collectSessionTabTarget(options) {
569
+ const tabs = mapTabData(await runSessionCommandData({ ...options, args: ["tab", "list"] }));
570
+ const active = tabs?.find((tab) => tab.active);
571
+ const observedUrl = normalizeComparableUrl(options.target.url);
572
+ if (normalizeComparableUrl(active?.url) !== observedUrl && tabs?.some((tab) => normalizeComparableUrl(tab.url) === observedUrl))
573
+ return options.target;
574
+ return active?.targetId
575
+ ? { ...options.target, targetId: active.targetId }
576
+ : options.target;
577
+ }
558
578
  export async function collectSessionTabSelection(options) {
559
579
  const { cwd, namespace, sessionName, signal, target } = options;
560
580
  const tabData = await runSessionCommandData({ args: ["tab", "list"], cwd, namespace, sessionName, signal });
@@ -569,13 +589,20 @@ export async function ensureSessionTabTarget(options) {
569
589
  const error = "agent-browser could not re-select and verify the intended tab before running the command. Run tab list and select the intended tab, then snapshot -i before retrying.";
570
590
  if (!correction)
571
591
  return { error };
592
+ const verifyUrl = async (tab) => {
593
+ if (normalizeComparableUrl(tab.url) === normalizeComparableUrl(options.target.url))
594
+ return true;
595
+ // Native tab metadata can retain the attempted URL while get url reports Chrome's error page.
596
+ const data = await runSessionCommandData({ ...options, args: ["get", "url"] });
597
+ return normalizeComparableUrl(extractStringResultField(data, "url")) === normalizeComparableUrl(options.target.url);
598
+ };
572
599
  // Native tab selection clears refs and frame scope even when selecting the current tab.
573
600
  if (active && getTabSelection(active)?.selectedTab === correction.selectedTab)
574
- return {};
601
+ return await verifyUrl(active) ? {} : { error };
575
602
  if (!await applyOpenResultTabCorrection({ ...options, correction }))
576
603
  return { correction, error };
577
604
  const selected = (await readTabs())?.find((tab) => tab.active);
578
- return selected && getTabSelection(selected)?.selectedTab === correction.selectedTab && normalizeComparableUrl(selected.url ?? "") === normalizeComparableUrl(options.target.url)
605
+ return selected && getTabSelection(selected)?.selectedTab === correction.selectedTab && await verifyUrl(selected)
579
606
  ? { correction }
580
607
  : { correction, error };
581
608
  }
@@ -92,7 +92,7 @@ export const WRAPPER_TAB_RECOVERY_BEHAVIOR = [
92
92
  "After open/goto/navigate calls with --profile, --restore, --session-name, or --state, agent_browser best-effort re-selects the tab whose URL matches the returned page when restored tabs steal focus during launch or reconnect.",
93
93
  "After confirmed shutdown of an automatically restored managed session, the wrapper retains its complete recorded URL, including the fragment, until the first current-page operation (including get url and reload). Non-page calls such as tab list may start a daemon without fulfilling that reopen; explicit URL reads leave the managed browser and pending reopen untouched. The wrapper uses native open once, verifies the observed tab, and discards old refs/frame scope; it does not restore unsaved forms, JavaScript memory, or history. Explicit navigation, caller-owned/attached sessions, and restore-disabled sessions are not auto-reopened.",
94
94
  "For a still-live browser after tab drift or resume, the wrapper verifies/selects the intended tab before ref/semantic helpers and page commands; failed selection stops the call without navigating. Local commands, read <url>, URL a11y/vitals, diff url, window new, and explicit tab/navigation/connection/state recovery do not require the prior tab. Batch checks follow effective rows past non-page prefixes and stop at explicit context changes, preserving caller argv/stdin and continue-on-error behavior. Same-tab reselection is avoided because it clears refs. Use exact batch --bail for fail-fast, not --bail=<value>. Routine same-session calls skip tab-list preflights.",
95
- "For sessions with observed tab-drift risk, after a successful command on a known target tab, agent_browser also best-effort restores that intended tab if a restored/background tab steals focus after the command completes. Routine same-session commands skip this post-command tab-list probe.",
95
+ "For sessions with observed tab-drift risk, after a successful command on a known target tab, agent_browser also best-effort restores that intended tab if a restored/background tab steals focus after the command completes. Routine same-session commands skip focus correction; observed pages without a native targetId use tab list to establish their identity.",
96
96
  "If a known session target unexpectedly reports about:blank, agent_browser best-effort re-selects the prior intended target when it still exists; if recovery fails, it records the observed about:blank target and reports exact recovery guidance instead of treating the prior page as active.",
97
97
  "If upstream reports tab_gone, the pinned bound tab is gone; use details.nextActions (tab list / tab new) instead of assuming another tab is yours.",
98
98
  ];