pi-agent-browser-native 0.6.7 → 0.6.9

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 (26) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -2
  3. package/dist/extensions/agent-browser/lib/electron/cleanup.js +5 -5
  4. package/dist/extensions/agent-browser/lib/electron/launch.js +77 -23
  5. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +2 -2
  6. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +26 -3
  7. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +3 -1
  8. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +3 -1
  9. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +2 -1
  10. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +4 -1
  11. package/dist/extensions/agent-browser/lib/playbook.js +1 -1
  12. package/dist/extensions/agent-browser/lib/process.js +5 -106
  13. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +5 -2
  14. package/dist/extensions/agent-browser/lib/results/envelope.js +5 -3
  15. package/dist/extensions/agent-browser/lib/results/next-actions.js +8 -0
  16. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +2 -1
  17. package/dist/extensions/agent-browser/lib/results/presentation/errors.js +10 -2
  18. package/dist/extensions/agent-browser/lib/results/presentation.js +9 -2
  19. package/dist/extensions/agent-browser/lib/temp.js +14 -0
  20. package/docs/ARCHITECTURE.md +2 -2
  21. package/docs/COMMAND_REFERENCE.md +7 -3
  22. package/docs/ELECTRON.md +8 -4
  23. package/docs/RELEASE.md +2 -0
  24. package/docs/SUPPORT_MATRIX.md +6 -3
  25. package/docs/TOOL_CONTRACT.md +9 -7
  26. package/package.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.9 - 2026-09-08
4
+
5
+ ### Fixed
6
+
7
+ - Include redacted stdout/stderr tails in failed Electron startup diagnostics and visible errors (#128). Capture uses private regular files inside the isolated profile, with the last 4096 bytes read per stream; this is not a lifetime disk limit. Preserve the profile and logs when failed-startup process cleanup cannot finish, without changing normal quit cleanup.
8
+
9
+ - Restore ordinary browser access to a tracked Electron app after Pi reload or resume by checking its live debug endpoint and the named upstream connection. Keep the app, profile, and session intact; unrelated or replaced connections still fail verification.
10
+
11
+ ### Validation
12
+
13
+ - Verified startup output, reload continuity and quit cleanup with genuine Electron through the official Pi SDK on native macOS and Ubuntu. Native Windows was waived and not run; permanent release gates are unchanged.
14
+
15
+ ## 0.6.8 - 2026-09-07
16
+
17
+ ### Fixed
18
+
19
+ - Add session- and namespace-aware `inspect-overlay-state` recovery for direct, semantic, raw `find` (including `nth` and default-click), and batched/job clicks that upstream rejects because another element covers the target's click point. These failures remain `upstream-error`; the wrapper recommends refreshing refs for inspection without retrying the blocked click or guessing a dismiss control. Thanks to @MDGChamomile for #147.
20
+
21
+ - Replace Windows PowerShell argument forwarding with `cross-spawn` at the shared process boundary, preserving empty operands, literal doublequotes, the selected `PATH` shim and upstream architecture selection. Remove command reordering and the old empty-argument/namespace workarounds; POSIX keeps native Node `spawn`. Thanks to @MaartenDMT (#109) and @dagve11 (#134) for their reports and proposed fixes.
22
+
23
+ ### Documentation
24
+
25
+ - Clarify that recording destinations are reserved within one Pi process; concurrent processes must use unique paths because different sessions can overwrite the same file (#110).
26
+
27
+ ### Validation
28
+
29
+ - Retain runnable Windows argv and failure/lifecycle contracts in the local and platform-target gates. Native macOS and Ubuntu validate the POSIX process path; PowerShell Legacy diagnostics reproduced both old corruptions but do not validate the replacement Windows transport. Native Windows/`cmd.exe` was unavailable and was not run under the task-specific waiver; permanent release requirements remain intact.
30
+
3
31
  ## 0.6.7 - 2026-09-07
4
32
 
5
33
  ### Fixed
package/README.md CHANGED
@@ -135,6 +135,8 @@ Then install this Pi package:
135
135
  pi install npm:pi-agent-browser-native
136
136
  ```
137
137
 
138
+ After updating `pi-agent-browser-native`, fully quit and restart Pi before using the updated tools. `/reload` can retain previously loaded compiled JavaScript even after `dist/` is rebuilt, so it is not a reliable way to pick up package updates.
139
+
138
140
  Start Pi and ask for a browser action:
139
141
 
140
142
  ```text
@@ -449,7 +451,7 @@ Typical pitfalls:
449
451
  - If the failure is `selector-not-found`, the wrapper may take one fresh snapshot and add `Current snapshot ref fallback` when that snapshot has exact visible role/name matches for the failed `find` / `semanticAction` target. Non-fill targets can include direct `try-current-visible-ref*` next actions, and semantic click misses can still add bounded `Agent-browser candidate fallbacks` such as `button`/`link` role retries for `text` clicks. `semanticAction` does not expose `uncheck` while upstream `find ... uncheck` is not runtime-supported; use raw `args: ["uncheck", <selector-or-ref>]` after a stable selector or fresh snapshot ref. For semantic `fill` misses on desktop or host-controlled rich inputs, prefer `details.richInputRecovery`: refresh refs, choose the current editable `@ref`, focus or click it, then use `keyboard type` for framework-controlled editors that require real key events. `keyboard inserttext` is paste-like and can change a DOM value without updating application state, so use it only with separate application-state verification. Direct contenteditable fills are verified with `get text` when snapshot metadata proves the target is contenteditable; if replacement did not happen, `details.fillVerification` warns before any submit step. Those recovery nextActions do not copy the fill text and do not press `Enter` or submit; only submit when the user flow explicitly calls for it (same contract link).
450
452
  - A successful upstream `click` is not proof that the web app handled the event or changed state. For top-level non-Electron direct clicks on `xpath=` targets and eligible current `@e…` refs, the wrapper may fail the tool with `details.clickDispatch` and a `Click dispatch diagnostic` line when upstream reported success but no trusted DOM event reached the resolved target. Raw `find … click` locator calls are not probed because the wrapper has no concrete element before upstream resolves the locator, and document-level probes can falsely fail frame-scoped clicks. `@e…` ref click probes are limited to current snapshot refs with accessible role `button`, `checkbox`, `menuitem`, `radio`, `switch`, or `tab`, with a unique role/name in both the saved snapshot and the live candidates. Duplicate-name refs remain upstream-owned; snapshot order is not target identity. Use the suggested `inspect-click-dispatch-miss` / `retry-click-after-dispatch-miss` next actions instead of assuming the click mutated the page; when `details.clickDispatch.scrollContainer` is present, use `scroll-target-into-view-after-dispatch-miss` first. When the task depends on a mutation, follow `inspect-after-mutation` / `pageChangeSummary` evidence with a wait, URL/text check, or fresh snapshot before trusting the result; if the target still did not change, retry with a current visible ref or stable selector and report the workflow issue instead of silently continuing. For static local fixtures where the user only needs to exercise app code, an explicit `eval --stdin` programmatic click such as `document.querySelector("#demo").click()` can be a diagnostic workaround, but treat it as an untrusted scripted activation rather than proof a real user click works, and never use it to bypass user instructions. Respect explicit user stop boundaries yourself: if the user says to stop before order/post/purchase/submit, gather evidence on that page and do not click the final action. The wrapper does not parse broad prompt text into business-intent action blocks; `details.promptGuard` is reserved for concrete artifact-before-close checks.
451
453
  - A successful upstream `click` can deliver no input at all on some sites. Reproduced against `https://www.saucedemo.com/` with upstream `agent-browser` 0.34.0 and no wrapper involved: when every CLI invocation in the flow is spaced ~0.6s apart, the React add-to-cart click reports success while **zero** DOM events reach a capture-phase listener and the cart badge never updates (0/5 runs succeeded, versus 4/5 with no spacing). A single idle gap is harmless (5/5); only sustained spacing across the flow triggers it. In that state a scripted `document.querySelector(...).click()` still works and updates the badge, so the page and its handlers are fine and the input never arrives; retries, `scrollintoview`, headed mode, and re-navigation do not recover it. This looks site-specific — React TodoMVC and react.dev were unaffected under the same pacing and command count. Wrapper calls do more per-command work than raw CLI calls, so they sit in the slow regime more often. Running the interaction steps as one `batch` keeps them inside a single upstream process and succeeded 5/5; prefer `batch` for consecutive real-app click flows, and treat a click whose `pageChangeSummary`/`navigationSummary` shows no change as unproven. When a `wait --url` or `assertUrl` times out after a suspected missed click, use the `fresh-session-after-url-wait-timeout` next action (`sessionMode: "fresh"` + `open about:blank`): replace about:blank with the target URL and replay the flow as one batch in a fresh session instead of retrying the wait.
452
- - A successful `snapshot -i` can surface `Possible overlay blockers` immediately when refs already contain strong dialog/alertdialog evidence plus close/dismiss controls. If a **top-level** `@e…`/`ref=` click succeeds (unified command `click`, not a `batch` step), upstream reports `data.clicked`, and `details.navigationSummary.url` stays on the same tab URL under the same normalization as ref preflight (fragment-insensitive), the wrapper may take one extra `snapshot -i` and add `Possible overlay blockers` with `details.overlayBlockers` (`candidates`, `summary`, optional `snapshot` refresh for refs) plus session-aware `inspect-overlay-state` / bounded `try-overlay-blocker-candidate-*` next actions when that snapshot shows strong modal context (`dialog` / `alertdialog`) and close/dismiss-like controls. Page-wide words like privacy, sign in, or banner alone do not trigger this diagnostic. The unchanged-URL check compares the prior pinned tab target with `details.navigationSummary.url`; CSS selector clicks do not run this overlay probe. Also skipped when tab correction or about-blank recovery already ran on that result.
454
+ - A successful `snapshot -i` can surface `Possible overlay blockers` immediately when refs already contain strong dialog/alertdialog evidence plus close/dismiss controls. If a **top-level** `@e…`/`ref=` click succeeds (unified command `click`, not a `batch` step), upstream reports `data.clicked`, and `details.navigationSummary.url` stays on the same tab URL under the same normalization as ref preflight (fragment-insensitive), the wrapper may take one extra `snapshot -i` and add `Possible overlay blockers` with `details.overlayBlockers` (`candidates`, `summary`, optional `snapshot` refresh for refs) plus session-aware `inspect-overlay-state` / bounded `try-overlay-blocker-candidate-*` next actions when that snapshot shows strong modal context (`dialog` / `alertdialog`) and close/dismiss-like controls. Page-wide words like privacy, sign in, or banner alone do not trigger this diagnostic. The unchanged-URL check compares the prior pinned tab target with `details.navigationSummary.url`; CSS selector clicks do not run this overlay probe. Also skipped when tab correction or about-blank recovery already ran on that result. Separately, when upstream rejects a click because another element covers the target's click point (`is covered by` … `at its click point`), the wrapper keeps `failureCategory: "upstream-error"` and adds only the session-aware `inspect-overlay-state` snapshot action; it does not retry the blocked click or guess a dismiss control without fresh snapshot evidence. This covers direct `click`, `semanticAction` click, raw `find` clicks (including `nth` and an omitted action that defaults to click), and failed `batch`/`job` rows; it does not diagnose silent input-dispatch failures.
453
455
  - If `get text <selector>` reads a non-ref, non-simple-id CSS selector with multiple matches or a hidden first match while visible matches exist, including successful `batch` steps, the wrapper may add `Selector text visibility warning`, `details.selectorTextVisibility` (plus `selectorTextVisibilityAll` for multiple batched warnings), and `inspect-visible-text-candidates` next actions; the warning names the matching `details.nextActions` id. Prefer a visible `@ref`, a scoped selector, or a targeted `eval --stdin` over hidden tab content.
454
456
  - In wrapper-tracked attached Electron sessions, broad selectors such as `body`, `html`, `main`, or `[role=application]` may read the whole app shell. The wrapper may add `Broad Electron get text selector warning`, `details.electronGetTextScopeWarning`, and `snapshot-for-electron-text-scope`; ordinary browser pages do not qualify without Electron launch provenance. Prefer `snapshot -i`, a current `@ref`, or a narrower panel selector.
455
457
 
@@ -505,7 +507,7 @@ For desktop Electron apps, use top-level `electron` to avoid hand-building the d
505
507
  { "electron": { "action": "cleanup", "launchId": "electron-…" } }
506
508
  ```
507
509
 
508
- `electron.list` has no configurable timeout. Other Electron actions accept nested `electron.timeoutMs`; `electron.probe.timeoutMs` bounds each underlying read subprocess when dense desktop apps need a shorter or longer probe budget (omit for the normal tool subprocess default). `electron.cleanup.timeoutMs` applies separately to upstream `close` and the initial process-exit wait, not the whole teardown, and defaults to the implicit session close budget unless overridden; if the managed-session close step succeeds but host cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. `electron.status.timeoutMs` only tightens managed-session title/url reads used for mismatch checks. Pass `electron.probe.launchId` when you want the probe tied to a wrapper-tracked launch instead of only the current managed session. Launch/status/probe results show both `launchId` (for status/cleanup/probe) and `sessionName` (for browser `snapshot`/`tab` commands); if the managed session drifts to `about:blank` while wrapper status still sees a live renderer, Electron-specific mismatch warnings and `status`/`probe`/`reattach`/`snapshot` next actions replace generic tab guidance. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. If the app process/debug port dies after a successful-looking mutation, the wrapper reports `details.electronPostCommandHealth` and fails with `tab-drift` instead of quietly continuing on `about:blank`. Launch timeouts expose `details.electron.failure.diagnostics` for PID, profile, DevToolsActivePort, and timing evidence.
510
+ `electron.list` has no configurable timeout. Other Electron actions accept nested `electron.timeoutMs`; `electron.probe.timeoutMs` bounds each underlying read subprocess when dense desktop apps need a shorter or longer probe budget (omit for the normal tool subprocess default). `electron.cleanup.timeoutMs` applies separately to upstream `close` and the initial process-exit wait, not the whole teardown, and defaults to the implicit session close budget unless overridden; if the managed-session close step succeeds but host cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. `electron.status.timeoutMs` applies to managed-session title/URL reads and any `get cdp-url` read needed to verify a restored connection. Pass `electron.probe.launchId` when you want the probe tied to a wrapper-tracked launch instead of only the current managed session. Launch/status/probe results show both `launchId` (for status/cleanup/probe) and `sessionName` (for browser `snapshot`/`tab` commands); if the managed session drifts to `about:blank` while wrapper status still sees a live renderer, Electron-specific mismatch warnings and `status`/`probe`/`reattach`/`snapshot` next actions replace generic tab guidance. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches. First reuse after reload/resume checks the live app's saved debug endpoint and the exact named upstream connection without reconnecting or resetting page refs; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. If the app process/debug port dies after a successful-looking mutation, the wrapper reports `details.electronPostCommandHealth` and fails with `tab-drift` instead of quietly continuing on `about:blank`. Failed startups expose redacted stdout/stderr tails in visible errors and `details.electron.failure.diagnostics`, alongside PID, profile, DevToolsActivePort, and timing evidence. Each tail reads at most 4096 source bytes; private capture files follow the profile lifecycle, not a lifetime disk cap. See [`docs/ELECTRON.md`](docs/ELECTRON.md#failure-categories-and-recovery).
509
511
 
510
512
  Explicit-ID `electron.status` labels historical cleaned launch records; default and `all: true` selection exclude them. Current PID/port liveness stays independent of cleanup history. `details.electron.statuses[].userDataDirState` freshly measures only the tracked profile path: `present` (including dangling symlinks), `absent` (ENOENT), or `unknown` (other `lstat` errors), not whether all app residue is gone.
511
513
 
@@ -24,15 +24,15 @@ function isPidAlive(pid) {
24
24
  return code === "EPERM" ? true : false;
25
25
  }
26
26
  }
27
- async function isPortAlive(port) {
28
- const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`));
27
+ async function isPortAlive(port, signal) {
28
+ const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`, signal));
29
29
  if (!version)
30
30
  return { targets: [] };
31
- const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`));
31
+ const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`, signal));
32
32
  return { targets, version };
33
33
  }
34
- export async function inspectElectronLaunchStatus(record) {
35
- const cdp = await isPortAlive(record.port);
34
+ export async function inspectElectronLaunchStatus(record, signal) {
35
+ const cdp = await isPortAlive(record.port, signal);
36
36
  let userDataDirState;
37
37
  try {
38
38
  await lstat(record.userDataDir);
@@ -1,10 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFile, rm } from "node:fs/promises";
4
- import { dirname } from "node:path";
3
+ import { open, readFile, rm } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
5
  import { fetchCdpJson, parseCdpTargets, parseCdpVersion, } from "./cdp.js";
6
6
  import { discoverElectronApps, inspectElectronAppPath, inspectElectronExecutablePath, } from "./discovery.js";
7
- import { createSecureTempDirectory } from "../temp.js";
7
+ import { createSecureTempDirectory, preserveSecureTempDirectory } from "../temp.js";
8
8
  export const ELECTRON_LAUNCH_RECORD_VERSION = 1;
9
9
  export const ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS = 15_000;
10
10
  export const ELECTRON_LAUNCH_MAX_TIMEOUT_MS = 120_000;
@@ -12,6 +12,8 @@ const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
12
12
  export const ELECTRON_PROFILE_DIR_PREFIX = "electron-profile-";
13
13
  const ELECTRON_DEFAULT_APP_ARGS = ["--disable-extensions", "--no-first-run", "--no-default-browser-check"];
14
14
  const ELECTRON_DEVTOOLS_POLL_INTERVAL_MS = 100;
15
+ // ponytail: bound failure reads, not lifetime log growth; noisy long-lived apps need rotation if that becomes a problem.
16
+ const ELECTRON_OUTPUT_TAIL_BYTES = 4096;
15
17
  function normalizeTimeoutMs(timeoutMs) {
16
18
  if (!Number.isSafeInteger(timeoutMs) || (timeoutMs ?? 0) <= 0)
17
19
  return ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS;
@@ -284,40 +286,90 @@ export async function launchElectronApp(options) {
284
286
  let exitCode = null;
285
287
  let exitSignal = null;
286
288
  const args = buildLaunchArgs(userDataDir, appArgs);
287
- const child = spawn(target.executablePath, args, {
288
- cwd: dirname(target.executablePath),
289
- detached: process.platform !== "win32",
290
- stdio: "ignore",
291
- });
292
- child.once("error", (error) => {
293
- spawnError = error;
294
- });
295
- child.once("exit", (code, signal) => {
296
- exitCode = code;
297
- exitSignal = signal;
298
- });
299
- child.unref();
289
+ let child;
290
+ let outputCaptured = false;
291
+ const outputFiles = [];
292
+ try {
293
+ for (const stream of ["stdout", "stderr"])
294
+ outputFiles.push(await open(join(userDataDir, `${stream}.log`), "wx", 0o600));
295
+ options.signal?.throwIfAborted();
296
+ child = spawn(target.executablePath, args, {
297
+ cwd: dirname(target.executablePath),
298
+ detached: process.platform !== "win32",
299
+ stdio: ["ignore", outputFiles[0].fd, outputFiles[1].fd],
300
+ });
301
+ outputCaptured = true;
302
+ child.once("error", (error) => {
303
+ spawnError = error;
304
+ });
305
+ child.once("exit", (code, signal) => {
306
+ exitCode = code;
307
+ exitSignal = signal;
308
+ });
309
+ child.unref();
310
+ }
311
+ catch (error) {
312
+ spawnError = error instanceof Error ? error : new Error(String(error));
313
+ }
314
+ finally {
315
+ for (const file of outputFiles) {
316
+ await file.close().catch((error) => {
317
+ cleanupError = [cleanupError, `Output handle close failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("; ");
318
+ });
319
+ }
320
+ }
300
321
  const buildFailureDiagnostics = (options = {}) => ({
301
322
  cdpVersionReached: options.cdpVersionReached,
302
323
  devToolsActivePort: options.devToolsActivePort,
303
324
  elapsedMs: Math.max(0, Date.now() - startedAtMs),
304
325
  exitCode,
305
326
  exitSignal,
306
- outputCaptured: false,
307
- pid: child.pid,
308
- pidAlive: isLaunchChildPidAlive(child),
327
+ outputCaptured,
328
+ pid: child?.pid,
329
+ pidAlive: child ? isLaunchChildPidAlive(child) : undefined,
309
330
  port: options.port ?? options.devToolsActivePort?.port,
310
331
  timeoutMs,
311
332
  userDataDir,
312
333
  });
313
334
  const fail = async (reason, detail, diagnosticOptions) => {
314
335
  const diagnostics = buildFailureDiagnostics(diagnosticOptions);
315
- const processCleanupError = await terminateLaunchChild(child);
336
+ const processCleanupError = child ? await terminateLaunchChild(child) : undefined;
337
+ const outputLines = [];
338
+ if (outputCaptured) {
339
+ for (const stream of ["stdout", "stderr"]) {
340
+ let file;
341
+ try {
342
+ file = await open(join(userDataDir, `${stream}.log`), "r");
343
+ const { size } = await file.stat();
344
+ const buffer = Buffer.alloc(Math.min(size, ELECTRON_OUTPUT_TAIL_BYTES));
345
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length));
346
+ diagnostics[`${stream}Tail`] = buffer.subarray(0, bytesRead).toString("utf8");
347
+ diagnostics[`${stream}Truncated`] = size > buffer.length;
348
+ }
349
+ catch (error) {
350
+ diagnostics[`${stream}Error`] = error instanceof Error ? error.message : String(error);
351
+ }
352
+ finally {
353
+ await file?.close().catch((error) => {
354
+ diagnostics[`${stream}Error`] = [diagnostics[`${stream}Error`], `Output reader close failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("; ");
355
+ });
356
+ }
357
+ const tail = diagnostics[`${stream}Tail`];
358
+ if (tail !== undefined)
359
+ outputLines.push(`App ${stream}${diagnostics[`${stream}Truncated`] ? ` (last ${ELECTRON_OUTPUT_TAIL_BYTES} bytes)` : ""}: ${tail || "(empty)"}`);
360
+ if (diagnostics[`${stream}Error`])
361
+ outputLines.push(`App ${stream} capture error: ${diagnostics[`${stream}Error`]}`);
362
+ }
363
+ }
316
364
  try {
317
- await rm(userDataDir, { force: true, recursive: true });
365
+ if (processCleanupError)
366
+ await preserveSecureTempDirectory(userDataDir);
367
+ else
368
+ await rm(userDataDir, { force: true, recursive: true });
318
369
  }
319
370
  catch (error) {
320
- cleanupError = error instanceof Error ? error.message : String(error);
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ cleanupError = [cleanupError, processCleanupError ? `Profile preservation failed: ${message}` : message].filter(Boolean).join("; ");
321
373
  }
322
374
  cleanupError = [processCleanupError, cleanupError].filter((value) => value !== undefined).join("; ") || undefined;
323
375
  return {
@@ -326,13 +378,15 @@ export async function launchElectronApp(options) {
326
378
  appArgs,
327
379
  cleanupError,
328
380
  diagnostics,
329
- error: launchFailureMessage(reason, target, detail),
381
+ error: [launchFailureMessage(reason, target, detail), ...outputLines].join("\n"),
330
382
  reason,
331
383
  target,
332
384
  userDataDir,
333
385
  },
334
386
  };
335
387
  };
388
+ if (!child)
389
+ return fail(options.signal?.aborted ? "aborted" : "spawn-error", spawnError?.message);
336
390
  const portResult = await pollDevToolsActivePort({
337
391
  deadlineMs,
338
392
  getChildExit: () => ({ code: exitCode, signal: exitSignal }),
@@ -4,7 +4,7 @@ import { isCloseCommand, isOpenNavigationCommand } from "../../command-taxonomy.
4
4
  import { boundElectronProbeString } from "../../electron/cdp.js";
5
5
  import { executableExistsOnPath } from "../../executable-path.js";
6
6
  import { formatSessionArtifactRetentionSummary } from "../../results/artifact-manifest.js";
7
- import { buildNextToolAction, withOptionalSessionArgs } from "../../results/next-actions.js";
7
+ import { buildInspectOverlayStateAction, buildNextToolAction, withOptionalSessionArgs } from "../../results/next-actions.js";
8
8
  import { buildVisibleRefFallbackDiagnosticFromSnapshot, getVisibleRefFallbackTarget } from "../../results/selector-recovery.js";
9
9
  import { extractRefSnapshotFromData, isAboutBlankUrl, normalizeComparableUrl } from "../../session-page-state.js";
10
10
  import { extractUpstreamCommandTokens, parseWaitCommandTokens, redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
@@ -266,7 +266,7 @@ export function formatOverlayBlockerText(diagnostic) {
266
266
  return ["Possible overlay blockers:", ...diagnostic.candidates.map((candidate) => `- ${candidate.ref}${candidate.role ? ` ${candidate.role}` : ""}${candidate.name ? ` ${JSON.stringify(candidate.name)}` : ""}: ${candidate.reason}`)].join("\n");
267
267
  }
268
268
  export function buildOverlayBlockerNextActions(options) {
269
- 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" }))];
269
+ return [buildInspectOverlayStateAction(options.sessionName), ...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" }))];
270
270
  }
271
271
  export function collectSnapshotOverlayBlockerDiagnostic(data) {
272
272
  const candidates = getOverlayBlockerCandidates(data);
@@ -1,12 +1,15 @@
1
1
  import { rm } from "node:fs/promises";
2
+ import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
3
+ import { inspectElectronLaunchStatus } from "../../electron/cleanup.js";
2
4
  import { acquireManagedSessionPolicyLock } from "../../managed-session-policy-lock.js";
3
- import { pruneOwnedManagedSessionRestoreSnapshots, resolveExplicitAutosaveInterval, } from "../../managed-session-restore.js";
5
+ import { pruneOwnedManagedSessionRestoreSnapshots, resolveExplicitAutosaveInterval, withOwnedManagedSessionContext, } from "../../managed-session-restore.js";
4
6
  import { isManagedSessionRestoreKey } from "../../managed-session-storage.js";
5
7
  import { isRecord } from "../../parsing.js";
6
8
  import { getAgentBrowserProcessEnvironment } from "../../process-environment.js";
7
- import { runAgentBrowserProcess } from "../../process.js";
9
+ import { runAgentBrowserProcess, withAttachedBrowserSessionContext } from "../../process.js";
8
10
  import { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
9
11
  import { redactInvocationArgs } from "../../runtime.js";
12
+ import { runSessionCommandData } from "./session-state.js";
10
13
  const MANAGED_SESSION_DAEMON_INSPECTION_TIMEOUT_MS = 35_000;
11
14
  const RUNNING_HEADED_AUTOSAVE_POLICY_CHANGE_ERROR = "AGENT_BROWSER_AUTOSAVE_INTERVAL_MS cannot change a running wrapper-owned headed session's launch-time periodic autosave interval. Close that session first, then retry with sessionMode: \"fresh\" so the new daemon starts with the requested interval.";
12
15
  export function getRunningHeadedAutosavePolicyChangeError(recordedInterval, closeCommand = false) {
@@ -49,6 +52,23 @@ export async function inspectManagedSessionDaemon(options) {
49
52
  await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
50
53
  }
51
54
  }
55
+ async function verifyRestoredElectronAttachment(context, record, signal, timeoutMs) {
56
+ const cwd = context.cwd;
57
+ if (!cwd || !record?.webSocketDebuggerUrl || !record.sessionName || record.cleanupState === "cleaned"
58
+ || getAgentBrowserSessionIdentityKey(record.sessionName, record.namespace) !== getAgentBrowserSessionIdentityKey(context.sessionName, context.namespace))
59
+ return false;
60
+ const status = await inspectElectronLaunchStatus(record, signal);
61
+ if (signal?.aborted || status.pidAlive !== true || status.userDataDirState !== "present"
62
+ || status.version?.webSocketDebuggerUrl !== record.webSocketDebuggerUrl)
63
+ return false;
64
+ // This metadata probe must not grant daemon provenance just by spawning.
65
+ const connection = await withAttachedBrowserSessionContext(true, () => withOwnedManagedSessionContext(undefined, () => runSessionCommandData({
66
+ args: ["get", "cdp-url"], cwd, env: getHeadedManagedAutosaveEnv(context.headedManagedAutosaveInterval), namespace: context.namespace, pinNamespace: true,
67
+ sessionName: context.sessionName, signal, timeoutMs,
68
+ })));
69
+ return !signal?.aborted && isRecord(connection) && typeof connection.cdpUrl === "string"
70
+ && (connection.cdpUrl === record.webSocketDebuggerUrl || status.targets.some((target) => target.webSocketDebuggerUrl === connection.cdpUrl));
71
+ }
52
72
  export async function acquireOwnedManagedSessionDaemonPolicy(options) {
53
73
  const { context, signal } = options;
54
74
  if (!context.cwd)
@@ -81,7 +101,7 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
81
101
  return { daemonStatus: daemon.status, lock };
82
102
  }
83
103
  const stickyDisabled = context.restoreState.isDisabled(context.sessionName, context.namespace);
84
- const hasKnownDaemonRestoreKey = context.restoreState.hasDaemonRestoreKey(context.sessionName, context.namespace);
104
+ let hasKnownDaemonRestoreKey = context.restoreState.hasDaemonRestoreKey(context.sessionName, context.namespace);
85
105
  const knownDaemonRestoreKey = context.restoreState.getDaemonRestoreKey(context.sessionName, context.namespace);
86
106
  const requestedDaemonRestoreKey = context.restoreDecision === "enabled" && stickyDisabled
87
107
  ? knownDaemonRestoreKey ?? null
@@ -93,6 +113,9 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
93
113
  };
94
114
  }
95
115
  const restoreDisabledPolicyNeedsProvenance = stickyDisabled || context.restoreDecision !== "enabled";
116
+ if (daemon.status === "active" && restoreDisabledPolicyNeedsProvenance && !hasKnownDaemonRestoreKey && daemon.restoreKey === requestedDaemonRestoreKey) {
117
+ hasKnownDaemonRestoreKey = await verifyRestoredElectronAttachment(context, options.electronLaunchRecord, signal, options.electronVerificationTimeoutMs);
118
+ }
96
119
  const activePolicyMatches = daemon.status === "active"
97
120
  && (!restoreDisabledPolicyNeedsProvenance || hasKnownDaemonRestoreKey)
98
121
  && daemon.restoreKey === requestedDaemonRestoreKey;
@@ -22,7 +22,7 @@ import { buildOwnedManagedSessionRestoreContext, resolveExplicitAutosaveInterval
22
22
  import { getAgentBrowserProcessEnvironment } from "../../process-environment.js";
23
23
  import { getExplicitSessionPageVerificationRequirement, getPageTargetValidationError, } from "../../page-target-validation.js";
24
24
  import { acquireOwnedManagedSessionDaemonPolicy, getRunningHeadedAutosavePolicyChangeError } from "./managed-session-daemon-policy.js";
25
- import { buildManagedSessionOutcome, buildSessionDetailFields, buildStaleRefPreflight, getSessionContextKey, extractStringResultField, ensureSessionTabTarget, getGuardedRefUsage, getTraceOwnerGuardMessage, runSessionCommandData, shouldPinSessionTabForCommand, } from "./session-state.js";
25
+ import { buildManagedSessionOutcome, buildSessionDetailFields, buildStaleRefPreflight, getSessionContextKey, findElectronLaunchRecordForSession, extractStringResultField, ensureSessionTabTarget, getGuardedRefUsage, getTraceOwnerGuardMessage, runSessionCommandData, shouldPinSessionTabForCommand, } from "./session-state.js";
26
26
  import { getUpstreamEffectiveBatchSteps, parseBatchStdinJsonArray } from "../batch-stdin.js";
27
27
  import { buildElectronHostFailureResult, formatAgentBrowserNextActionsText, getElectronLaunchFailureCategory, redactRecoveryHint } from "./final-result.js";
28
28
  import { prepareClickDispatchProbe } from "./click-dispatch.js";
@@ -508,6 +508,8 @@ export async function prepareBrowserRun(options) {
508
508
  const closeCommand = isCloseCommand(executionPlan.commandInfo.command);
509
509
  const policy = await acquireOwnedManagedSessionDaemonPolicy({
510
510
  context: ownedManagedSession,
511
+ electronLaunchRecord: findElectronLaunchRecordForSession(executionPlan.sessionName, state.electronLaunchRecords, executionPlan.namespace),
512
+ electronVerificationTimeoutMs: params.timeoutMs,
511
513
  mode: closeCommand ? "close" : "reuse",
512
514
  signal,
513
515
  });
@@ -12,7 +12,7 @@ import { analyzeQaPresetResults, analyzeQaPresetTimeout, buildQaCompactFailureTe
12
12
  import { applyNetworkRouteRecords, buildNetworkRouteDiagnostics } from "../../results/network-routes.js";
13
13
  import { buildToolPresentation } from "../../results/presentation.js";
14
14
  import { compactLargePresentationOutput } from "../../results/presentation/large-output.js";
15
- import { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
15
+ import { extractEnvelopeErrorText, getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
16
16
  import { omitUpstreamLifecycle } from "../../results/presentation/common.js";
17
17
  import { getClipboardWritePayloadCandidates, redactClipboardPermissionEcho, redactClipboardPermissionErrorValue } from "../../results/presentation/errors.js";
18
18
  import { shouldCaptureSemanticActionNavigationSummary } from "../../results/presentation/semantic-action.js";
@@ -637,6 +637,8 @@ export async function processBrowserOutput(input) {
637
637
  }
638
638
  }
639
639
  let errorText = getAgentBrowserErrorText({ aborted: processResult.aborted, command: prepared.executionPlan.commandInfo.command, effectiveArgs: prepared.redactedProcessArgs, envelope: presentationEnvelope, exitCode: processResult.exitCode, parseError, plainTextInspection, staleRefArgs: getStaleRefArgs(prepared.commandTokens, prepared.runtimeToolStdin), spawnError: processResult.spawnError, stderr: processResult.stderr, timedOut: processResult.timedOut, timeoutMs: processResult.timeoutMs, wrapperRecoveryHint: buildWrapperRecoveryHint({ sessionTabCorrection }) });
640
+ if (errorText && presentationEnvelope?.success === false && extractEnvelopeErrorText(presentationEnvelope.error) === undefined)
641
+ presentationEnvelope = { ...presentationEnvelope, error: errorText };
640
642
  if (errorText) {
641
643
  const clipboardWritePayloadCandidates = getClipboardWritePayloadCandidates(prepared.commandTokens);
642
644
  errorText = redactClipboardPermissionEcho(prepared.executionPlan.commandInfo, errorText);
@@ -483,12 +483,13 @@ function selectAnySessionTargetTab(options) {
483
483
  return selection ? { ...selection, ...(targetTitle ? { targetTitle } : {}), targetUrl } : undefined;
484
484
  }
485
485
  export async function runSessionCommandData(options) {
486
- const { args, cwd, namespace, pinNamespace, sessionName, signal, stdin, throwOnFailure, timeoutMs } = options;
486
+ const { args, cwd, env, namespace, pinNamespace, sessionName, signal, stdin, throwOnFailure, timeoutMs } = options;
487
487
  if (!sessionName)
488
488
  return undefined;
489
489
  const processResult = await runAgentBrowserProcess({
490
490
  args: ["--json", ...(namespace !== undefined || pinNamespace ? ["--namespace", namespace ?? ""] : []), "--session", sessionName, ...args],
491
491
  cwd,
492
+ env,
492
493
  signal,
493
494
  stdin,
494
495
  timeoutMs,
@@ -414,7 +414,7 @@ async function withOwnedElectronManagedSessionPolicy(options, run) {
414
414
  throw new ElectronManagedSessionPolicyError("Electron helper could not establish wrapper ownership for its managed session.");
415
415
  let policy;
416
416
  try {
417
- policy = await acquireOwnedManagedSessionDaemonPolicy({ context, signal: options.signal });
417
+ policy = await acquireOwnedManagedSessionDaemonPolicy({ context, electronLaunchRecord: options.electronLaunchRecord, electronVerificationTimeoutMs: options.timeoutMs, signal: options.signal });
418
418
  }
419
419
  catch (error) {
420
420
  throw new ElectronManagedSessionPolicyError(error instanceof Error ? error.message : String(error), { cause: error });
@@ -675,6 +675,7 @@ async function handleElectronHostInputInContext(options) {
675
675
  const sessionKey = getSessionPageStateKey(record.sessionName, record.namespace) ?? record.sessionName;
676
676
  return collectOwnedElectronManagedSessionTarget({
677
677
  cwd,
678
+ electronLaunchRecord: record,
678
679
  headedManagedAutosaveDisabled: ownedManagedSessions.get(sessionKey)?.headedManagedAutosaveDisabled,
679
680
  headedManagedAutosaveInterval: ownedManagedSessions.get(sessionKey)?.headedManagedAutosaveInterval,
680
681
  namespace: record.namespace,
@@ -750,12 +751,14 @@ async function handleElectronHostInputInContext(options) {
750
751
  const probe = await withOwnedElectronManagedSessionPolicy({
751
752
  args: ["snapshot", "-i"],
752
753
  cwd,
754
+ electronLaunchRecord: launchRecord,
753
755
  headedManagedAutosaveDisabled,
754
756
  headedManagedAutosaveInterval,
755
757
  namespace: probeNamespace,
756
758
  restoreState: managedSessionRestoreState,
757
759
  sessionName: probeSessionName,
758
760
  signal,
761
+ timeoutMs: compiledElectron.timeoutMs,
759
762
  }, async () => await collectElectronProbe({ cwd, namespace: probeNamespace, sessionName: probeSessionName, signal, timeoutMs: compiledElectron.timeoutMs }));
760
763
  const managedSession = {
761
764
  sessionName: probe.sessionName,
@@ -53,7 +53,7 @@ export const SHARED_BROWSER_PLAYBOOK_GUIDELINES = [
53
53
  "On dashboards with nested scroll containers, verify scroll with a screenshot or fresh snapshot -i; if the viewport did not move, details.data.scrolled may be false/noMovement true and you should prefer scrollintoview <@ref> or target the actual scrollable region with scroll <selector> <dir> [px|percent]. For native selects, use select <selector> <value...> (or semanticAction/job select) instead of clicking option refs; for custom comboboxes, a click/semanticAction may only focus the field, so re-snapshot and use keyboard type <text> for focused input, press ArrowDown or press Enter, or visible option refs. Raw type requires both <selector> and <text>.",
54
54
  "When using eval --stdin, scope checks and actions to the target element or route whenever possible instead of relying on broad page-wide text heuristics.",
55
55
  "When using eval --stdin for extraction, pass the JavaScript through the native tool stdin field, not as an extra args token after --stdin, and return the value you want instead of relying on console.log as the primary result channel. Prefer plain expressions like ({ title: document.title }) or explicitly invoked functions like (() => ({ title: document.title }))(); use outputPath when the eval/get/snapshot data should be saved as a durable local file, but never reuse a screenshot, download, recording, or other browser artifact destination as outputPath. If a function-shaped snippet returns {}, details.evalStdinHint may warn that the function was serialized instead of called. Local file pages and caller-selected output paths are supported when upstream allows them. If get text on a broad CSS selector surfaces details.selectorTextVisibility or selectorTextVisibilityAll, prefer a visible @ref, a more specific selector, or the inspect-visible-text-candidates nextAction over hidden tab content.",
56
- "When details.pageChangeSummary is present, use changeType and summary as a compact signal for navigation, DOM mutation, confirmations, or artifacts; when nextActionIds is set, match those ids to entries in details.nextActions (or per-step nextActions inside batch) for concrete follow-up payloads instead of inferring from prose alone. If details.clickDispatch reports a click-dispatch miss, refresh/inspect/retry the real click first; for static local fixtures only, an explicit eval --stdin programmatic .click() can exercise app handlers, but treat it as an untrusted scripted workaround and never use it to bypass stop-before-submit/order/purchase boundaries. If a no-navigation click surfaces details.overlayBlockers, inspect the fresh snapshot evidence before using a close/dismiss candidate nextAction; ordinary page chrome without dialog/alertdialog evidence should not trigger this diagnostic.",
56
+ "When details.pageChangeSummary is present, use changeType and summary as a compact signal for navigation, DOM mutation, confirmations, or artifacts; when nextActionIds is set, match those ids to entries in details.nextActions (or per-step nextActions inside batch) for concrete follow-up payloads instead of inferring from prose alone. If details.clickDispatch reports a click-dispatch miss, refresh/inspect/retry the real click first; for static local fixtures only, an explicit eval --stdin programmatic .click() can exercise app handlers, but treat it as an untrusted scripted workaround and never use it to bypass stop-before-submit/order/purchase boundaries. If an upstream click failure says the target is covered by another element at the target's click point, use the inspect-overlay-state nextAction to refresh refs and inspect the blocker before deciding whether to retry; do not blindly repeat the blocked click. If a no-navigation click surfaces details.overlayBlockers, inspect the fresh snapshot evidence before using a close/dismiss candidate nextAction; ordinary page chrome without dialog/alertdialog evidence should not trigger this diagnostic.",
57
57
  "When commands save or spill files (screenshots, downloads, PDFs, traces, recordings, HAR, large snapshot spills), use the user's exact requested paths when given and treat paths as provisional until details.artifactVerification shows every row verified: branch on missingCount, pendingCount, unverifiedCount, per-entry state, and optional limitation before downstream file use or PASS/FAIL reporting.",
58
58
  "For evidence-only screenshots, QA captures, or other audit artifacts, save to an explicit path and branch on details.artifactVerification plus details.artifacts before reporting PASS/FAIL; do not require vision review of inline image attachments unless the user asked for visual inspection.",
59
59
  "Respect explicit user stop boundaries yourself. When the surrounding authenticated employee or automation context is explicitly unattended/auto-approved, ordinary non-destructive form submissions within the requested flow may proceed without separate confirmation. Still require explicit authorization for purchases, production-control actions, destructive or irreversible actions, and account, security, or privacy changes. The wrapper does not infer broad business intent from prompt text; details.promptGuard is reserved for concrete artifact-before-close checks.",
@@ -3,9 +3,9 @@ import { spawn } from "node:child_process";
3
3
  import { lstat, mkdir, readdir, readlink, stat } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join } from "node:path";
5
5
  import { env as processEnv, platform as processPlatform } from "node:process";
6
+ import { spawn as crossSpawn } from "cross-spawn";
6
7
  import { parseArgvDescriptor } from "./argv-descriptor.js";
7
- import { isKnownCommandToken } from "./command-taxonomy.js";
8
- import { extractExplicitSessionName, getFlagName, GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, GLOBAL_VALUE_FLAGS, optionalGlobalValueFlagConsumesNext, resolveAgentBrowserNamespace, } from "./argv-grammar.js";
8
+ import { extractExplicitSessionName, resolveAgentBrowserNamespace } from "./argv-grammar.js";
9
9
  import { commitManagedSessionRestoreSuppression, getManagedSessionRestoreEnv, getManagedSessionRestoreProtectedEnv, getOwnedManagedSessionCompatibilityEnv, getOwnedManagedSessionNamespaceEnv, isOwnedManagedSessionTarget, validateManagedSessionRestoreContextForSpawn, } from "./managed-session-restore.js";
10
10
  import { getPageTargetValidationError, } from "./page-target-validation.js";
11
11
  import { getImplicitSessionIdleTimeoutMs } from "./runtime.js";
@@ -26,114 +26,19 @@ export const SAFE_AGENT_BROWSER_OPERATION_TIMEOUT_MS = 25_000;
26
26
  const DEFAULT_AGENT_BROWSER_PROCESS_TIMEOUT_MS = 35_000;
27
27
  /** Grace period after `exit` before resolving when `close` is delayed by inherited stdio handles. */
28
28
  const EXIT_STDIO_GRACE_MS = 100;
29
- const WINDOWS_AGENT_BROWSER_MISSING_MARKER = "PI_AGENT_BROWSER_COMMAND_NOT_FOUND:agent-browser.cmd";
30
29
  const attachedBrowserSessionContext = new AsyncLocalStorage();
31
- const WINDOWS_COMMANDS_WITH_ADJACENT_SUBCOMMAND = new Set([
32
- "auth", "clipboard", "cookies", "dashboard", "device", "dialog", "diff", "find", "get", "is", "keyboard",
33
- "mouse", "network", "plugin", "profiler", "react", "record", "session", "set", "skills", "state", "storage",
34
- "stream", "tab", "trace", "webmcp", "window",
35
- ]);
36
30
  export function withAttachedBrowserSessionContext(preserve, run) {
37
31
  return attachedBrowserSessionContext.run(preserve || attachedBrowserSessionContext.getStore() === true, run);
38
32
  }
39
- export function getWindowsExplicitDefaultNamespaceEnv(args, parentNamespace, platform = processPlatform) {
40
- return platform === "win32" && resolveAgentBrowserNamespace(args, parentNamespace) === ""
41
- ? { AGENT_BROWSER_NAMESPACE: "" }
42
- : {};
43
- }
44
33
  function appendTail(text, addition, maxChars) {
45
34
  const combined = text + addition;
46
35
  return combined.length <= maxChars ? combined : combined.slice(combined.length - maxChars);
47
36
  }
48
- function quoteWindowsPowerShellArg(value) {
49
- return `'${value.replace(/'/g, "''")}'`;
50
- }
51
- /** Exported for unit tests that lock Windows launcher argv ordering. */
52
- export function reorderWindowsLeadingGlobalArgs(args) {
53
- const leadingGlobals = [];
54
- for (let index = 0; index < args.length; index += 1) {
55
- const token = args[index];
56
- if (isKnownCommandToken(token)) {
57
- if (index === 0)
58
- return args;
59
- const firstPositional = args[index + 1];
60
- return WINDOWS_COMMANDS_WITH_ADJACENT_SUBCOMMAND.has(token) && firstPositional && !firstPositional.startsWith("-")
61
- ? [token, firstPositional, ...leadingGlobals, ...args.slice(index + 2)]
62
- : [token, ...leadingGlobals, ...args.slice(index + 1)];
63
- }
64
- if (!token.startsWith("-"))
65
- return args;
66
- if (token.startsWith("--restore=")) {
67
- leadingGlobals.push(token);
68
- continue;
69
- }
70
- if (token === "--restore") {
71
- const value = args[index + 1];
72
- if (optionalGlobalValueFlagConsumesNext(token, value)) {
73
- leadingGlobals.push(`--restore=${value}`);
74
- index += 1;
75
- }
76
- else {
77
- leadingGlobals.push(token);
78
- }
79
- continue;
80
- }
81
- if (token.includes("="))
82
- return args;
83
- const flag = getFlagName(token);
84
- if (GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES.has(flag)) {
85
- leadingGlobals.push(token);
86
- if (["true", "false"].includes(args[index + 1] ?? "")) {
87
- leadingGlobals.push(args[index + 1]);
88
- index += 1;
89
- }
90
- continue;
91
- }
92
- if (GLOBAL_VALUE_FLAGS.includes(flag)) {
93
- const value = args[index + 1];
94
- if (value === undefined)
95
- return args;
96
- // PowerShell -> .cmd drops empty argv values. Planning rejects empty
97
- // caller --args; keep this defensive skip so an unexpected empty value
98
- // cannot turn the next flag into its accidental value on native Windows.
99
- if (value === "" && (flag === "--args" || flag === "--namespace")) {
100
- index += 1;
101
- continue;
102
- }
103
- leadingGlobals.push(token, value);
104
- index += 1;
105
- continue;
106
- }
107
- return args;
108
- }
109
- return args;
110
- }
111
37
  export function prepareAgentBrowserSpawnArgs(args, wrapperCompatibilityUserAgent, preserveAttachedBrowserSession = false) {
112
38
  if (preserveAttachedBrowserSession || !wrapperCompatibilityUserAgent)
113
39
  return args;
114
40
  return ["--args", `--user-agent=${wrapperCompatibilityUserAgent.replaceAll(/[\r\n,]/g, "")}`, ...args];
115
41
  }
116
- export function buildAgentBrowserSpawnCommand(args, platform = processPlatform) {
117
- if (platform !== "win32") {
118
- return { command: "agent-browser", args };
119
- }
120
- const invocationArgs = reorderWindowsLeadingGlobalArgs(args).map(quoteWindowsPowerShellArg).join(" ");
121
- const commandLine = [
122
- "$agentBrowser = Get-Command agent-browser.cmd -ErrorAction SilentlyContinue;",
123
- `if (-not $agentBrowser) { [Console]::Error.WriteLine('${WINDOWS_AGENT_BROWSER_MISSING_MARKER}'); exit 127 };`,
124
- `& $agentBrowser.Source ${invocationArgs}`.trimEnd(),
125
- ].join(" ");
126
- return { command: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", commandLine] };
127
- }
128
- export function isWindowsAgentBrowserCommandMissing(stderr) {
129
- const normalized = stderr.toLowerCase();
130
- return normalized.includes(WINDOWS_AGENT_BROWSER_MISSING_MARKER.toLowerCase()) || (normalized.includes("agent-browser.cmd") && (normalized.includes("commandnotfoundexception") ||
131
- normalized.includes("not recognized as the name of a cmdlet") ||
132
- normalized.includes("not recognized as an internal or external command")));
133
- }
134
- export function shouldCommitManagedRestoreAfterWindowsProcess(input) {
135
- return !input.spawnError && !(input.exitCode !== 0 && isWindowsAgentBrowserCommandMissing(input.stderr));
136
- }
137
42
  function terminateSpawnedChild(child, signal) {
138
43
  if (processPlatform === "win32" && child.pid) {
139
44
  const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
@@ -431,7 +336,6 @@ export async function runAgentBrowserProcess(options) {
431
336
  ...env,
432
337
  ...getManagedSessionRestoreProtectedEnv(managedSessionRestoreOptions, managedSessionRestoreEnv),
433
338
  ...getOwnedManagedSessionNamespaceEnv(managedSessionRestoreOptions),
434
- ...getWindowsExplicitDefaultNamespaceEnv(args, parentEnv.AGENT_BROWSER_NAMESPACE),
435
339
  ...ownedManagedSessionCompatibilityEnv,
436
340
  };
437
341
  const explicitSocketDir = processOverrides[AGENT_BROWSER_SOCKET_DIR_ENV];
@@ -536,13 +440,8 @@ export async function runAgentBrowserProcess(options) {
536
440
  if (stdoutSpillHandle) {
537
441
  await stdoutSpillHandle.close().catch(() => undefined);
538
442
  }
539
- const windowsMissingBinary = processPlatform === "win32" && exitCode !== 0 && isWindowsAgentBrowserCommandMissing(stderr);
540
- if (processPlatform === "win32" && !windowsMissingBinary && !spawnError)
443
+ if (processPlatform === "win32" && !spawnError) {
541
444
  agentBrowserStarted = true;
542
- if (windowsMissingBinary && !spawnError) {
543
- spawnError = Object.assign(new Error("spawn agent-browser ENOENT"), { code: "ENOENT" });
544
- }
545
- else if (processPlatform === "win32" && shouldCommitManagedRestoreAfterWindowsProcess({ exitCode, spawnError, stderr })) {
546
445
  commitManagedSessionRestoreSuppression(managedSessionRestoreOptions);
547
446
  }
548
447
  if (!spawnError && stdoutSpillError) {
@@ -569,8 +468,8 @@ export async function runAgentBrowserProcess(options) {
569
468
  resolve({ aborted: false, agentBrowserStarted: false, exitCode: 1, spawnError: new Error(spawnPolicyError), stderr: "", stdout: "", timedOut: false });
570
469
  return;
571
470
  }
572
- const spawnCommand = buildAgentBrowserSpawnCommand(prepareAgentBrowserSpawnArgs(args, ownedManagedSessionCompatibilityEnv.AGENT_BROWSER_USER_AGENT, preserveAttachedBrowserSession));
573
- const child = spawn(spawnCommand.command, spawnCommand.args, {
471
+ const spawnBrowser = processPlatform === "win32" ? crossSpawn : spawn;
472
+ const child = spawnBrowser("agent-browser", prepareAgentBrowserSpawnArgs(args, ownedManagedSessionCompatibilityEnv.AGENT_BROWSER_USER_AGENT, preserveAttachedBrowserSession), {
574
473
  cwd,
575
474
  env: childEnv,
576
475
  stdio: ["pipe", "pipe", "pipe"],