pi-agent-browser-native 0.6.8 → 0.6.10

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 (34) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +16 -14
  3. package/dist/extensions/agent-browser/index.js +8 -21
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +6 -7
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +6 -0
  6. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +4 -8
  7. package/dist/extensions/agent-browser/lib/command-taxonomy.js +15 -2
  8. package/dist/extensions/agent-browser/lib/electron/cleanup.js +5 -5
  9. package/dist/extensions/agent-browser/lib/electron/launch.js +77 -23
  10. package/dist/extensions/agent-browser/lib/managed-session-restore.js +2 -2
  11. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +3 -5
  12. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +6 -14
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +11 -25
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +4 -4
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +26 -3
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +16 -18
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +5 -11
  18. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +4 -3
  19. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +4 -1
  20. package/dist/extensions/agent-browser/lib/playbook.js +4 -4
  21. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +17 -29
  22. package/dist/extensions/agent-browser/lib/results/presentation/common.js +5 -5
  23. package/dist/extensions/agent-browser/lib/session-page-state.js +1 -1
  24. package/dist/extensions/agent-browser/lib/temp.js +14 -0
  25. package/dist/scripts/agent-browser-target.mjs +1 -1
  26. package/docs/ARCHITECTURE.md +5 -4
  27. package/docs/COMMAND_REFERENCE.md +38 -20
  28. package/docs/ELECTRON.md +8 -4
  29. package/docs/RELEASE.md +8 -4
  30. package/docs/SUPPORT_MATRIX.md +19 -15
  31. package/docs/TOOL_CONTRACT.md +15 -13
  32. package/package.json +1 -1
  33. package/scripts/agent-browser-capability-baseline.mjs +10 -3
  34. package/scripts/agent-browser-target.mjs +1 -1
@@ -2,8 +2,7 @@ import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { foldAgentBrowserFilesystemIdentity } from "../../argv-grammar.js";
4
4
  import { parseWaitCommandTokens } from "../../argv-descriptor.js";
5
- const SCREENSHOT_BOOLEAN_FLAGS = new Set(["--annotate", "--full", "-f"]);
6
- const SCREENSHOT_VALUE_FLAGS = new Set(["--screenshot-dir", "--screenshot-format", "--screenshot-quality"]);
5
+ import { getRecordCommandOperands } from "../../command-taxonomy.js";
7
6
  const SCREENSHOT_IMAGE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".webp"];
8
7
  function isSingleScreenshotPathToken(token) {
9
8
  const explicitlyRelative = token.startsWith("./") || token.startsWith("../");
@@ -17,11 +16,7 @@ function getScreenshotPositionalIndices(commandTokens) {
17
16
  const positionalIndices = [];
18
17
  for (let index = 1; index < commandTokens.length; index += 1) {
19
18
  const token = commandTokens[index];
20
- if (SCREENSHOT_VALUE_FLAGS.has(token)) {
21
- index += 1;
22
- continue;
23
- }
24
- if (SCREENSHOT_BOOLEAN_FLAGS.has(token))
19
+ if (token === "--full" || token === "-f")
25
20
  continue;
26
21
  positionalIndices.push(index);
27
22
  }
@@ -54,9 +49,6 @@ function getDiffScreenshotOutputPath(commandTokens) {
54
49
  }
55
50
  return outputPath;
56
51
  }
57
- function foldArtifactPath(path, platform) {
58
- return foldAgentBrowserFilesystemIdentity(path, platform);
59
- }
60
52
  function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
61
53
  let cursor = absolutePath;
62
54
  const suffix = [];
@@ -71,7 +63,7 @@ function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
71
63
  catch {
72
64
  // The destination does not exist yet; canonical ancestry still catches aliases.
73
65
  }
74
- return foldArtifactPath(canonicalPath, platform);
66
+ return foldAgentBrowserFilesystemIdentity(canonicalPath, platform);
75
67
  }
76
68
  catch {
77
69
  let symlinkTarget;
@@ -90,7 +82,7 @@ function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
90
82
  }
91
83
  const parent = dirname(cursor);
92
84
  if (parent === cursor)
93
- return foldArtifactPath(absolutePath, platform);
85
+ return foldAgentBrowserFilesystemIdentity(absolutePath, platform);
94
86
  suffix.unshift(basename(cursor));
95
87
  cursor = parent;
96
88
  }
@@ -120,7 +112,7 @@ export function getExplicitArtifactDestination(commandTokens) {
120
112
  return commandTokens[3];
121
113
  if ((command === "trace" || command === "profiler") && subcommand === "stop")
122
114
  return commandTokens[2];
123
- if (command === "record" && (subcommand === "start" || subcommand === "restart"))
124
- return commandTokens[2];
115
+ if (command === "record")
116
+ return getRecordCommandOperands(commandTokens).path;
125
117
  return undefined;
126
118
  }
@@ -7,11 +7,11 @@ import { formatSessionArtifactRetentionSummary } from "../../results/artifact-ma
7
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
- import { extractUpstreamCommandTokens, parseWaitCommandTokens, redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
10
+ import { redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
11
11
  import { isRecord } from "../../parsing.js";
12
12
  import { extractBatchResultCommand, extractNavigationSummaryFromData, extractStringResultField, findElectronLaunchRecordForSession, runSessionCommandData, } from "./session-state.js";
13
- import { parseValidBatchStepEntries } from "../batch-stdin.js";
14
- import { getScreenshotPathTokenIndex } from "./artifact-paths.js";
13
+ import { getUpstreamEffectiveBatchSteps } from "../batch-stdin.js";
14
+ import { getExplicitArtifactDestination } from "./artifact-paths.js";
15
15
  const ELECTRON_FILL_VERIFICATION_TIMEOUT_MS = 2_000;
16
16
  export function sleepMs(ms) {
17
17
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -227,7 +227,7 @@ export async function collectRecordingDependencyWarning(options) {
227
227
  return undefined;
228
228
  if (await executableExistsOnPath("ffmpeg"))
229
229
  return undefined;
230
- return { command: recordCommand, dependency: "ffmpeg", message: `${recordCommand} can begin recording, but record stop needs ffmpeg on PATH to encode the WebM output.`, reason: "ffmpeg-missing-for-recording", recommendations: ["Install ffmpeg before relying on this recording workflow; on macOS with Homebrew, brew install ffmpeg or brew install ffmpeg-full.", "If ffmpeg was just installed, restart pi or ensure the PATH visible to pi includes the ffmpeg binary before running record stop."] };
230
+ return { command: recordCommand, dependency: "ffmpeg", message: `${recordCommand} reported a pending recording, but ffmpeg is not on PATH. Its output is unverified; install ffmpeg before starting a new recording.`, reason: "ffmpeg-missing-for-recording", recommendations: ["Install ffmpeg before recording; on macOS with Homebrew, brew install ffmpeg or brew install ffmpeg-full.", "Stop this recording, check the result, and start a new recording after ensuring Pi can find ffmpeg on PATH."] };
231
231
  }
232
232
  export function formatRecordingDependencyWarningText(warning) {
233
233
  if (!warning)
@@ -711,12 +711,10 @@ export async function collectElectronHandoff(options) {
711
711
  }
712
712
  return { handoff: "snapshot", refSnapshot, snapshot, ...(snapshotRetryCount > 0 ? { snapshotRetryCount } : {}), tabs };
713
713
  }
714
- function getTimeoutProgressSteps(compiledJob, command, stdin) {
714
+ function getTimeoutProgressSteps(compiledJob, commandTokens, stdin) {
715
715
  if (compiledJob)
716
716
  return compiledJob.steps.map((step, index) => ({ args: step.args, generatedFrom: step.generatedFrom, index: index + 1 }));
717
- if (command !== "batch" || !stdin)
718
- return [];
719
- return parseValidBatchStepEntries(stdin).map(({ index, step }) => ({ args: step, index: index + 1 }));
717
+ return getUpstreamEffectiveBatchSteps(commandTokens, stdin).map((args, index) => ({ args, index: index + 1 }));
720
718
  }
721
719
  function getLastPositionalToken(args, startIndex = 1) {
722
720
  for (let index = args.length - 1; index >= startIndex; index -= 1) {
@@ -726,20 +724,8 @@ function getLastPositionalToken(args, startIndex = 1) {
726
724
  }
727
725
  return undefined;
728
726
  }
729
- function getTimeoutStepArtifactPath(args) {
730
- const commandArgs = extractUpstreamCommandTokens(args);
731
- const [command] = commandArgs;
732
- if (command === "screenshot") {
733
- const index = getScreenshotPathTokenIndex(commandArgs);
734
- return index === undefined ? undefined : commandArgs[index];
735
- }
736
- if (command === "pdf")
737
- return getLastPositionalToken(commandArgs);
738
- if (command === "download")
739
- return getLastPositionalToken(commandArgs, 2);
740
- if (command === "wait")
741
- return parseWaitCommandTokens(commandArgs).downloadPath;
742
- return undefined;
727
+ function getTimeoutStepArtifactPath(commandTokens) {
728
+ return ["screenshot", "pdf", "download", "wait"].includes(commandTokens[0]) ? getExplicitArtifactDestination(commandTokens) : undefined;
743
729
  }
744
730
  async function statTimeoutArtifactPath(absolutePath) {
745
731
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -795,7 +781,7 @@ const TIMEOUT_RETRYABLE_COMMANDS = new Set([
795
781
  ]);
796
782
  function getTimeoutStepRetry(step) {
797
783
  const command = step.args[0];
798
- return command && TIMEOUT_RETRYABLE_COMMANDS.has(command) ? { args: step.args } : undefined;
784
+ return command && TIMEOUT_RETRYABLE_COMMANDS.has(command) ? { args: ["batch"], stdin: JSON.stringify([step.args]) } : undefined;
799
785
  }
800
786
  function normalizeUrlForTimeoutComparison(url) {
801
787
  if (!url)
@@ -873,7 +859,7 @@ function buildTimeoutProgressSteps(options) {
873
859
  };
874
860
  }
875
861
  export async function collectTimeoutPartialProgress(options) {
876
- const rawSteps = getTimeoutProgressSteps(options.compiledJob, options.command, options.stdin);
862
+ const rawSteps = getTimeoutProgressSteps(options.compiledJob, options.commandTokens, options.stdin);
877
863
  const artifacts = await collectTimeoutArtifactEvidence(options.cwd, rawSteps);
878
864
  const urlData = await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName });
879
865
  const recoveredUrl = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
@@ -930,7 +916,7 @@ export function formatTimeoutPartialProgressText(progress, pageTargetUnknown = f
930
916
  lines.push(`- ... ${progress.steps.length - shownSteps.length} more step${progress.steps.length - shownSteps.length === 1 ? "" : "s"} omitted`);
931
917
  }
932
918
  if (progress.retryStep?.retry?.args) {
933
- const payload = JSON.stringify({ args: redactInvocationArgs(progress.retryStep.retry.args) });
919
+ const payload = JSON.stringify({ ...progress.retryStep.retry, stdin: JSON.stringify([redactInvocationArgs(progress.retryStep.args)]) });
934
920
  lines.push(pageTargetUnknown
935
921
  ? `Retry candidate for step ${progress.retryStep.index}: ${payload}. Verify the current URL before running it.`
936
922
  : `Retry failed step: ${payload}`);
@@ -205,7 +205,7 @@ export async function prepareFinalResultRecoveryState(options) {
205
205
  return { categoryDetails, currentRefSnapshot, currentRefSnapshotInvalidation, noActivePageSnapshotFailure, richInputRecoveryDiagnostic, visibleRefFallbackDiagnostic, visibleRefFallbackSessionName };
206
206
  }
207
207
  function buildTimeoutPartialProgressNextActions(options) {
208
- const retryArgs = options.timeoutPartialProgress?.retryStep?.retry?.args;
208
+ const retry = options.timeoutPartialProgress?.retryStep?.retry;
209
209
  const stepIndex = options.timeoutPartialProgress?.retryStep?.index;
210
210
  const freshSessionAbandoned = options.sessionMode === "fresh" && options.timeoutPartialProgress?.liveUrlRecovered !== true;
211
211
  if (options.currentSessionTabTargetUnknown && !freshSessionAbandoned && options.executionPlan.sessionName) {
@@ -220,12 +220,12 @@ function buildTimeoutPartialProgressNextActions(options) {
220
220
  tool: "agent_browser",
221
221
  }];
222
222
  }
223
- if (retryArgs) {
223
+ if (retry) {
224
224
  return [{
225
225
  id: "retry-timeout-step",
226
226
  params: freshSessionAbandoned
227
- ? { args: retryArgs, sessionMode: "fresh" }
228
- : { args: withOptionalSessionArgs(options.executionPlan.sessionName, retryArgs) },
227
+ ? { ...retry, sessionMode: "fresh" }
228
+ : { ...retry, args: withOptionalSessionArgs(options.executionPlan.sessionName, retry.args) },
229
229
  reason: freshSessionAbandoned
230
230
  ? `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.`
231
231
  : `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} against the current browser session.`,
@@ -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";
@@ -73,21 +73,19 @@ async function ensureArtifactParentDirectory(commandTokens, cwd) {
73
73
  return;
74
74
  await mkdir(dirname(resolve(cwd, requestedPath)), { recursive: true });
75
75
  }
76
- async function normalizeScreenshotPathInTokens(commandTokens, cwd) {
77
- const scopedCommandTokens = extractCommandTokens(commandTokens);
78
- const projection = projectUpstreamGlobalFlags(scopedCommandTokens);
79
- const projectedPathTokenIndex = getScreenshotPathTokenIndex(projection.tokens);
80
- const scopedPathTokenIndex = projectedPathTokenIndex === undefined ? undefined : projection.indices[projectedPathTokenIndex];
81
- if (scopedPathTokenIndex === undefined) {
76
+ async function normalizeScreenshotPathInTokens(commandTokens, cwd, batchStep = false) {
77
+ // Native batch rows skip outer CLI global-flag cleanup.
78
+ const projection = batchStep ? undefined : projectUpstreamGlobalFlags(commandTokens);
79
+ const pathIndex = getScreenshotPathTokenIndex(projection?.tokens ?? commandTokens);
80
+ const screenshotPathTokenIndex = pathIndex === undefined ? undefined : projection ? projection.indices[pathIndex] : pathIndex;
81
+ if (screenshotPathTokenIndex === undefined)
82
82
  return { tokens: commandTokens };
83
- }
84
- const screenshotPathTokenIndex = commandTokens.length - scopedCommandTokens.length + scopedPathTokenIndex;
85
83
  const requestedPath = commandTokens[screenshotPathTokenIndex];
86
84
  const absolutePath = resolve(cwd, requestedPath);
87
85
  await mkdir(dirname(absolutePath), { recursive: true });
88
86
  const tokens = [...commandTokens];
89
87
  tokens[screenshotPathTokenIndex] = absolutePath;
90
- const terminatorIndex = tokens.indexOf("--");
88
+ const terminatorIndex = batchStep ? -1 : tokens.indexOf("--");
91
89
  if (terminatorIndex >= 0) {
92
90
  tokens.splice(terminatorIndex, 1);
93
91
  }
@@ -110,13 +108,12 @@ async function prepareBatchScreenshotPaths(args, stdin, cwd) {
110
108
  // prepare parent directories for the rows that will run and skip stdin
111
109
  // preparation (no directories for never-executed rows).
112
110
  for (const step of argumentSteps) {
113
- const stepTokens = extractUpstreamCommandTokens(step);
114
- await ensureArtifactParentDirectory(stepTokens, cwd);
115
- if (stepTokens[0] === "screenshot") {
111
+ await ensureArtifactParentDirectory(step, cwd);
112
+ if (step[0] === "screenshot") {
116
113
  // Reuse the screenshot path resolution for its parent-directory side
117
114
  // effect only: raw strings are never rewritten, so the normalized
118
115
  // tokens and path request are deliberately discarded.
119
- await normalizeScreenshotPathInTokens(step, cwd);
116
+ await normalizeScreenshotPathInTokens(step, cwd, true);
120
117
  }
121
118
  }
122
119
  return undefined;
@@ -134,12 +131,11 @@ async function prepareBatchScreenshotPaths(args, stdin, cwd) {
134
131
  if (!Array.isArray(step) || !step.every((item) => typeof item === "string")) {
135
132
  return step;
136
133
  }
137
- const upstreamStep = extractUpstreamCommandTokens(step);
138
- await ensureArtifactParentDirectory(upstreamStep, cwd);
139
- if (upstreamStep[0] !== "screenshot") {
134
+ await ensureArtifactParentDirectory(step, cwd);
135
+ if (step[0] !== "screenshot") {
140
136
  return step;
141
137
  }
142
- const normalized = await normalizeScreenshotPathInTokens(step, cwd);
138
+ const normalized = await normalizeScreenshotPathInTokens(step, cwd, true);
143
139
  batchScreenshotPathRequests[index] = normalized.request;
144
140
  if (normalized.request) {
145
141
  changed = true;
@@ -508,6 +504,8 @@ export async function prepareBrowserRun(options) {
508
504
  const closeCommand = isCloseCommand(executionPlan.commandInfo.command);
509
505
  const policy = await acquireOwnedManagedSessionDaemonPolicy({
510
506
  context: ownedManagedSession,
507
+ electronLaunchRecord: findElectronLaunchRecordForSession(executionPlan.sessionName, state.electronLaunchRecords, executionPlan.namespace),
508
+ electronVerificationTimeoutMs: params.timeoutMs,
511
509
  mode: closeCommand ? "close" : "reuse",
512
510
  signal,
513
511
  });
@@ -1,7 +1,7 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import { parseArgvDescriptor } from "../../argv-descriptor.js";
3
3
  import { needsManagedSession } from "../../command-policy.js";
4
- import { getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "../../argv-grammar.js";
4
+ import { deleteIdentityKeysInNamespace, getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "../../argv-grammar.js";
5
5
  import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "../../batch-lifecycle.js";
6
6
  import { isCloseAllCommand, isCloseCommand, isOpenNavigationCommand, isRecordPageTransitionCommand, isUnverifiedPageTransitionCommand, isWindowOrDiffPageTransitionCommand } from "../../command-taxonomy.js";
7
7
  import { OPEN_RESULT_TAB_CORRECTION_FLAGS } from "../../launch-scoped-flags.js";
@@ -108,12 +108,6 @@ function batchStartedManagedBrowser(data) {
108
108
  function withoutNamespaceEntries(entries, namespace) {
109
109
  return new Map([...entries].filter(([key]) => !isAgentBrowserSessionIdentityKeyInNamespace(key, namespace)));
110
110
  }
111
- function deleteNamespaceEntries(entries, namespace) {
112
- for (const key of entries.keys()) {
113
- if (isAgentBrowserSessionIdentityKeyInNamespace(key, namespace))
114
- entries.delete(key);
115
- }
116
- }
117
111
  function setNetworkRouteState(options) {
118
112
  if (!options.sessionName)
119
113
  return options.routesBySession;
@@ -223,8 +217,8 @@ export async function processBrowserOutput(input) {
223
217
  }
224
218
  if (closeAllApplied) {
225
219
  networkRoutesBySession = withoutNamespaceEntries(networkRoutesBySession, prepared.executionPlan.namespace);
226
- deleteNamespaceEntries(state.attachedSessionKeys, prepared.executionPlan.namespace);
227
- deleteNamespaceEntries(traceOwners, prepared.executionPlan.namespace);
220
+ deleteIdentityKeysInNamespace(state.attachedSessionKeys, prepared.executionPlan.namespace);
221
+ deleteIdentityKeysInNamespace(traceOwners, prepared.executionPlan.namespace);
228
222
  sessionPageState.clearNamespace(prepared.executionPlan.namespace);
229
223
  const retainedSessionKey = nestedBatchRemainsActive ? sessionStateKey : undefined;
230
224
  for (const [key, owner] of state.ownedManagedSessions) {
@@ -370,7 +364,7 @@ export async function processBrowserOutput(input) {
370
364
  let fillVerificationDiagnostic;
371
365
  let selectorTextVisibilityDiagnostics = [];
372
366
  let electronBroadGetTextScopeDiagnostics = [];
373
- const timeoutPartialProgress = processResult.timedOut ? await collectTimeoutPartialProgress({ command: prepared.executionPlan.commandInfo.command, compiledJob: prepared.compiledJob, cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, stdin: prepared.runtimeToolStdin }) : undefined;
367
+ const timeoutPartialProgress = processResult.timedOut ? await collectTimeoutPartialProgress({ commandTokens: prepared.commandTokens, compiledJob: prepared.compiledJob, cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, stdin: prepared.runtimeToolStdin }) : undefined;
374
368
  if (!currentSessionTabTarget && timeoutPartialProgress?.currentPage?.source === "live") {
375
369
  currentSessionTabTarget = normalizeSessionTabTarget(timeoutPartialProgress.currentPage);
376
370
  }
@@ -763,7 +757,7 @@ export async function processBrowserOutput(input) {
763
757
  ? presentation.batchSteps?.some((step) => isRecordPageTransitionCommand(extractUpstreamCommandTokens(step.command ?? [])))
764
758
  : isRecordPageTransitionCommand(prepared.commandTokens);
765
759
  const recordingPageWarning = processResult.agentBrowserStarted && !prepared.executionPlan.plainTextInspection && recordingTransitionReached
766
- ? "Page state: this recording command can replace or navigate the active page, even on failure; prior in-page DOM and JavaScript state may not carry over. Take a fresh snapshot before continuing with page-scoped refs."
760
+ ? "Page state: this wrapper conservatively invalidates earlier refs after recording starts and URL-bearing restarts. Take a fresh snapshot before continuing; this does not prove the page changed."
767
761
  : undefined;
768
762
  const sessionWarning = electronPostCommandHealth ? formatElectronPostCommandHealthText(electronPostCommandHealth) : electronSessionMismatch ? formatElectronSessionMismatchText(electronSessionMismatch) : aboutBlankSessionMismatch ? buildAboutBlankWarning(aboutBlankSessionMismatch) : undefined;
769
763
  const warningText = [sessionWarning, recordingPageWarning].filter(Boolean).join("\n\n") || undefined;
@@ -7,7 +7,7 @@ import { buildAgentBrowserNextActions } from "../../results/action-recommendatio
7
7
  import { parseAgentBrowserEnvelope } from "../../results/envelope.js";
8
8
  import { buildNextToolAction, withOptionalNamespaceArgs, withOptionalSessionArgs } from "../../results/next-actions.js";
9
9
  import { getSessionPageStateKey, isAboutBlankUrl, normalizeComparableUrl, targetsMatch, } from "../../session-page-state.js";
10
- import { isCloseCommand, isElectronPostCommandHealthCommand, isNavigationObservableCommandName, isOpenNavigationCommand, isRefGuardedCommand, isRefInvalidatingBatchCommand, isRecordPageTransitionCommand, isSessionTabPinningExcludedCommand, isSessionTabPostCommandCorrectionExcludedCommand, isWindowOrDiffPageTransitionCommand, } from "../../command-taxonomy.js";
10
+ import { getRecordCommandOperands, isCloseCommand, isElectronPostCommandHealthCommand, isNavigationObservableCommandName, isOpenNavigationCommand, isRefGuardedCommand, isRefInvalidatingBatchCommand, isSessionTabPinningExcludedCommand, isSessionTabPostCommandCorrectionExcludedCommand, isWindowOrDiffPageTransitionCommand, } from "../../command-taxonomy.js";
11
11
  import { chooseOpenResultTabCorrection } from "../../runtime.js";
12
12
  import { isRecord, parseRefId } from "../../parsing.js";
13
13
  import { getUpstreamEffectiveBatchSteps } from "../batch-stdin.js";
@@ -424,7 +424,7 @@ export function commandChoosesSessionTabTarget(args) {
424
424
  || isWindowOrDiffPageTransitionCommand(command, subcommand)
425
425
  || (command === "a11y" && findFirstPositionalArgument(tokens) !== undefined)
426
426
  || (["vitals", "web-vitals"].includes(command) && tokens.slice(1).some((token) => !token.startsWith("--")))
427
- || (isRecordPageTransitionCommand(tokens) && tokens[3] !== undefined);
427
+ || getRecordCommandOperands(tokens).url !== undefined;
428
428
  }
429
429
  export function shouldPinSessionTabForCommand(options) {
430
430
  if (!options.pinningRequired || !options.sessionName || !options.command)
@@ -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,
@@ -15,7 +15,7 @@ export const QUICK_START_GUIDELINES = [
15
15
  `Common advanced calls: { args: ["batch", "--bail"], stdin: "[[\"open\",\"https://example.com\"],[\"snapshot\",\"-i\"]]" }, { job: { steps: [{ action: "open", url: "https://example.com" }, { action: "assertText", text: "Example Domain" }, { action: "screenshot", path: ".dogfood/example.png" }] } }, { qa: { url: "https://example.com", expectedText: "Example Domain", screenshotPath: ".dogfood/qa-example.png" } } (example.com smoke only; elsewhere match exact visible text from snapshot -i), { electron: { action: "list", query: "code" } }, { electron: { action: "launch", appName: "Visual Studio Code", handoff: "snapshot" } }, { electron: { action: "probe" } }, { qa: { attached: true, expectedText: "Explorer" } }, { args: ["eval", "--stdin"], stdin: "document.title", outputPath: "logs/page-title.json" }, { args: ["auth", "save", "name", "--password-stdin"], stdin: "<password from user-approved secret source>" }, { args: ["--profile", "Default", "open", "https://example.com/account"], sessionMode: "fresh" }, and { args: ["open", "--enable", "react-devtools", "https://example.com"], sessionMode: "fresh" }. For app pages with a native dropdown, job steps can include { action: "select", selector: "#flavor", value: "chocolate" } before the dependent assertion; for locator-friendly pages, job click/fill steps can use semantic locator fields such as { action: "fill", locator: "role", role: "searchbox", name: "Search", text: "agent browser" }; for human-paced input, job type steps can use { action: "type", selector: "#prompt", text: "hello", delayMs: 20, press: "Enter" }; delayed typing is capped at 200 characters per step, and generated per-character rows are compacted in visible batch prose while full rows remain in details.batchSteps.`,
16
16
  "Constrained job navigation is explicit only: click (and select/submit flows that may navigate) does not prove the next page loaded; add an assertUrl that does not already match the starting page and/or assertText for new page state after navigation-prone steps before screenshot or later interactions. assertText takes only text, not selector or locator fields. Clicks can stale subsequent @refs: split the job and re-snapshot before using those refs. Keep jobs short around navigation, click, and rerender boundaries on dynamic React/product apps; avoid a whole checkout in one job. If a long job times out and details.timeoutPartialProgress shows a mutating incomplete step, inspect current page state and continue with a shorter job or single action instead of blindly retrying the mutating step. Example: { job: { steps: [{ action: \"open\", url: \"https://shop.example/checkout\" }, { action: \"fill\", selector: \"#email\", text: \"user@example.com\" }, { action: \"click\", selector: \"#continue\" }, { action: \"assertUrl\", url: \"**/shipping\" }, { action: \"assertText\", text: \"Shipping address\" }, { action: \"screenshot\", path: \".dogfood/shipping.png\" }] } }. Top-level click may add pageChangeSummary hints, but job never auto-inserts post-click asserts.",
17
17
  "High-value command reference: click <selector> --new-tab opens link-like targets in a new tab; select <selector> <value...> changes native dropdown values; wrapper-handled scroll <dir> [px|percent] and scroll to end/top target document scrolling before upstream fallback, while scroll <selector> <dir> [px|percent] targets nested scrollers; download <selector> <path> saves a file triggered by a click; read [url] returns agent-readable text (explicit URLs prefer markdown without requiring a Chrome page; omit the URL for rendered active-tab DOM); get title/url need no selector; get text/html/value/count <selector> and get attr <selector> <name> read elements/page state (use body for whole-page text/html); screenshot [selector] [path] captures a page or element image; pdf <path> saves a PDF; tab list and tab <tab-id-or-label> inspect or recover the active tab; react tree, react inspect <fiberId>, react renders start/stop, and react suspense introspect React after --enable react-devtools; vitals [url] measures Core Web Vitals; pushstate <url> performs SPA navigation; tap <selector> and swipe <direction> [distance] support iOS/provider touch flows.",
18
- "For artifact-producing commands, read the visible artifact block and details.artifactVerification before using files: check requested path, absolute path, existence, size bytes, artifact kind, optional mediaType, status, optional limitation, and verified/missing/pending/unverified counts. details.artifacts contains per-file metadata; record start rows are pending/openRecording until record stop writes the target. Upstream record start uses a fresh active page for video capture, so prior in-page DOM and JavaScript state does not carry over; the wrapper blocks prior @e… refs as stale-ref even when the start fails as already-active, and record restart with a URL navigates and invalidates the same way (plain record restart keeps the page), so take a fresh snapshot before continuing. The wrapper creates parent directories for direct artifact paths and can save simple loopback HTTP(S) anchor downloads directly to the requested path before upstream download fallback. Browser close does not delete explicit saved files; if close reports details.artifactCleanup, use host file tools to remove paths listed in explicitArtifactPaths (when non-empty) after inspection. If close fails with details.promptGuard.reason=requested-artifacts-missing-before-close, save the exact required artifact path before closing. A bare inbound image/video path is not a requested output artifact and does not block close. For annotated screenshots inside batch, put --annotate in top-level args (for example { args: [\"--annotate\", \"batch\"], stdin: \"[[\\\"screenshot\\\",\\\"/tmp/page.png\\\"]]\" }) rather than inside the screenshot step; if annotation labels crowd a dense page, use a scoped or non-annotated screenshot plus snapshot refs instead.",
18
+ "For artifact-producing commands, read the visible artifact block and details.artifactVerification before using files: check requested path, absolute path, existence, size bytes, artifact kind, optional mediaType, status, optional limitation, and verified/missing/pending/unverified counts. details.artifacts contains per-file metadata; record start rows are pending/openRecording until record stop writes the target. Current upstream records the active page unless a URL is supplied. To support older natives, the wrapper conservatively blocks prior @e… refs after every dispatched start attempt and URL-bearing restart, even on failure; this does not prove the page changed. Take a fresh snapshot before continuing. A restart with only --fps keeps the page and refs; --fps <n> selects 1–60 fps (default 30). The wrapper creates parent directories for direct artifact paths and can save simple loopback HTTP(S) anchor downloads directly to the requested path before upstream download fallback. Browser close does not delete explicit saved files; if close reports details.artifactCleanup, use host file tools to remove paths listed in explicitArtifactPaths (when non-empty) after inspection. If close fails with details.promptGuard.reason=requested-artifacts-missing-before-close, save the exact required artifact path before closing. A bare inbound image/video path is not a requested output artifact and does not block close. For annotated screenshots inside batch, put --annotate in top-level args (for example { args: [\"--annotate\", \"batch\"], stdin: \"[[\\\"screenshot\\\",\\\"/tmp/page.png\\\"]]\" }) rather than inside the screenshot step; if annotation labels crowd a dense page, use a scoped or non-annotated screenshot plus snapshot refs instead.",
19
19
  "When failure output shows Next actions, prefer those exact native agent_browser follow-up payloads over guessed commands. The same actions are available in details.nextActions to callers that expose structured details; short stdin is shown inline, while long stdin stays details-only.",
20
20
  ];
21
21
  export const WEB_SEARCH_PROMPT_GUIDELINE = "Prefer agent_browser_web_search for current or external web facts and URL discovery over public search-engine forms that can hit anti-bot/CAPTCHA-gated pages. For research before implementation, pass searchType: deep-lite unless webSearch.defaultSearchType already does; omit it for everyday lookups so config/auto wins. Provider rank is not proof of authority: when correctness or version matters, prefer the vendor or project's primary current docs, inspect page-date and version clues, and constrain one follow-up after discovering the official domain (Exa includeDomains; Brave site: in query). Do not count URL aliases as independent sources. Use agent_browser after you have a target URL that needs interaction, screenshots, or DOM inspection.";
@@ -35,7 +35,7 @@ export const SHARED_BROWSER_PLAYBOOK_GUIDELINES = [
35
35
  "After a successful `connect`, `--cdp`, or enabled `--auto-connect` call, verify with get url and keep using the resulting session without repeating the attach flag. The wrapper remembers that attachment across active-branch reload/resume and live-checks the URL before later page reads/interactions because an attached browser can drift externally; caller config, file access, launch arguments, and environment pass through unchanged. A successful close clears the marker. When several named sessions share one Chrome, pass --pin-tab once (AGENT_BROWSER_PIN_TAB) so a closed bound tab fails as tab_gone instead of acting on a neighbor; recover with tab new or tab list. --no-pin-tab turns the sticky pin off. tab list includes each tab's CDP targetId, accepted as a tab ref.",
36
36
  `If you already used the implicit session and now need launch-scoped flags (${LAUNCH_SCOPED_FLAG_LABEL}), retry with top-level sessionMode set to fresh or pass an explicit --session for the new launch; never pass --session-mode inside args. After a successful unnamed fresh launch, later auto calls follow that new session.`,
37
37
  "For WebGPU pages, use args [\"--webgpu\", \"open\", \"<url>\"] on a fresh local browser launch; use doctor --webgpu (or --headed on Linux/Windows capture paths) to prove rendering before trusting a non-black screenshot. WebGPU cannot be combined with --cdp, --auto-connect, or provider launches unless --webgpu false overrides an enabled config/environment default.",
38
- "For experimental WebMCP page tools, use webmcp list, then webmcp invoke <tool> with --params and optional --frame/--detach/--timeout; use webmcp result or cancel for detached calls. Locally managed Chrome enables WebMCP by default. --no-webmcp is launch-scoped and requires a fresh session; invoke/result/cancel can mutate or navigate, so refresh snapshot refs afterward.",
38
+ "For experimental WebMCP page tools, use webmcp list, then webmcp invoke <tool> with --params and optional --frame/--detach/--timeout; use webmcp result or cancel for detached calls. Locally managed Chrome enables WebMCP by default; a positive navigation hint means the page has tools to list. --no-webmcp is launch-scoped and requires a fresh session; invoke/result/cancel can mutate or navigate, so refresh snapshot refs afterward.",
39
39
  "For --allowed-domains, use a fresh local Chrome context. Upstream rejects CDP/auto-connect, profiles, restore/state replay, direct-page providers, iOS/Safari, and startup/profile Chrome args because they cannot guarantee containment; Chromium also disables RTCPeerConnection while the allowlist is active.",
40
40
  "For React introspection, launch the page with --enable react-devtools before first navigation, then use react tree, react inspect <fiberId>, sourceLookup candidates for local UI source hints, react renders start/stop, or react suspense; sourceLookup is experimental and reports confidence/evidence instead of guaranteed DOM-to-file mappings. For failed fetches and APIs, networkSourceLookup (experimental) correlates failed network requests with initiator metadata and bounded workspace URL literals—candidates only, not definitive blame. Use vitals [url] for Core Web Vitals and hydration timing, and pushstate <url> for client-side SPA navigation.",
41
41
  "For first-navigation setup, use open without a URL plus network route --resource-type <csv>, cookies set --curl <file>, or --init-script/--enable before navigate/opening the target page.",
@@ -57,7 +57,7 @@ export const SHARED_BROWSER_PLAYBOOK_GUIDELINES = [
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.",
60
- "Successful record stop needs ffmpeg on PATH; the wrapper may warn after record start when ffmpeg is missing.",
60
+ "Recording needs ffmpeg on PATH before start. Current upstream checks it at startup; older natives may defer failure. A pending recording is not verified output.",
61
61
  "Do not call --help or other exploratory inspection commands unless the user explicitly asks for them or debugging the browser integration is necessary.",
62
62
  ];
63
63
  export const TOOL_PROMPT_GUIDELINES_SUFFIX = [
@@ -80,7 +80,7 @@ export const RUNTIME_PROMPT_GUIDELINES = [
80
80
  "Use agent_browser with one input mode: script, args, semanticAction, job, qa, sourceLookup/networkSourceLookup, or electron. stdin: batch/eval/auth/wrapper batch only; electron rejects it; never pass --json.",
81
81
  "For agent_browser, use open → snapshot -i → @refs; re-snapshot after changes. In authenticated unattended/auto-approved employee flows, ordinary requested non-destructive submissions may proceed. Honor explicit stops; require explicit authorization for purchases, production-control, destructive/irreversible, or account/security/privacy changes.",
82
82
  "Use agent_browser sessionMode=fresh for launch flags. Use requested/configured profiles only; run profiles/doctor on failure. --allowed-domains cannot restore; macOS profile copies may omit encrypted cookies. Verify auth; use a user-approved headed login if needed. Profile content is model-visible.",
83
- "agent_browser: exact user paths; verify artifactVerification/artifacts before success claims. Save promptGuard-required files before close; record stop needs ffmpeg; close keeps files; waited:timeout proves nothing.",
83
+ "agent_browser: exact user paths; verify artifactVerification/artifacts before success claims. Save promptGuard-required files before close; ffmpeg before recording; close keeps files; waited:timeout proves nothing.",
84
84
  "When agent_browser details.nextActions exists, use them. Check Omitted high-value controls in dense snapshots. Dashboards: verify scroll via screenshot/snapshot.",
85
85
  "agent_browser: read <url> for docs/text or active DOM; get title/url; get text/html/value/count <selector>; get attr <selector> <name>. Batch 3+ getters; heed visibility warnings.",
86
86
  ];
@@ -3,7 +3,6 @@ import { extname, resolve } from "node:path";
3
3
  import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
4
4
  import { getExplicitArtifactDestination } from "../../orchestration/browser-run/artifact-paths.js";
5
5
  import { isRecord, parsePositiveInteger } from "../../parsing.js";
6
- import { extractUpstreamCommandTokens } from "../../runtime.js";
7
6
  import { formatSessionArtifactRetentionSummary, getSessionArtifactManifestEntryKey, isPendingRecordingArtifact, isPendingRecordingCommand, mergeSessionArtifactManifest, } from "../artifact-manifest.js";
8
7
  import { classifyAgentBrowserSuccessCategory } from "../categories.js";
9
8
  const PNG_HEADER = Buffer.from("89504e470d0a1a0a0000000d49484452", "hex");
@@ -192,7 +191,7 @@ async function buildFileArtifactMetadata(options) {
192
191
  namespace: options.namespace,
193
192
  path: displayPath,
194
193
  recordingState: pendingRecording ? "openRecording" : undefined,
195
- requestedPath: options.artifactRequest?.path ?? getExplicitArtifactDestination(extractUpstreamCommandTokens(options.commandInfo.commandTokens ?? [])),
194
+ requestedPath: options.artifactRequest?.path ?? getExplicitArtifactDestination(options.commandInfo.commandTokens ?? []),
196
195
  session: options.sessionName,
197
196
  sizeBytes,
198
197
  status: pendingRecording ? "pending" : exists === false ? "missing" : stale ? "stale" : options.artifactRequest?.status ?? "saved",
@@ -213,44 +212,33 @@ async function buildPreviousRestartRecordingArtifact(options) {
213
212
  if (!previousRecording)
214
213
  return undefined;
215
214
  const absolutePath = previousRecording.absolutePath ?? resolve(options.cwd, previousRecording.path);
215
+ const base = {
216
+ absolutePath,
217
+ artifactType: "video",
218
+ command: "record",
219
+ cwd: previousRecording.cwd ?? options.cwd,
220
+ extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
221
+ kind: "video",
222
+ namespace: previousRecording.namespace ?? options.namespace,
223
+ path: previousRecording.path,
224
+ requestedPath: previousRecording.requestedPath,
225
+ session: previousRecording.session ?? options.sessionName,
226
+ subcommand: "restart-previous",
227
+ };
216
228
  try {
217
229
  const fileStats = await stat(absolutePath);
218
230
  const stale = artifactMtimeIsOutsideCommandWindow(fileStats.mtimeMs, options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
219
231
  return {
220
- absolutePath,
221
- artifactType: "video",
222
- command: "record",
223
- cwd: previousRecording.cwd ?? options.cwd,
232
+ ...base,
224
233
  exists: true,
225
- extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
226
- kind: "video",
227
234
  mediaType: fileStats.isFile() ? await getFileImageMimeType(absolutePath) : undefined,
228
- namespace: previousRecording.namespace ?? options.namespace,
229
- path: previousRecording.path,
230
- requestedPath: previousRecording.requestedPath,
231
- session: previousRecording.session ?? options.sessionName,
232
235
  sizeBytes: fileStats.size,
233
236
  status: stale ? "stale" : "saved",
234
- subcommand: "restart-previous",
235
237
  updatedAtMs: fileStats.mtimeMs,
236
238
  };
237
239
  }
238
240
  catch {
239
- return {
240
- absolutePath,
241
- artifactType: "video",
242
- command: "record",
243
- cwd: previousRecording.cwd ?? options.cwd,
244
- exists: false,
245
- extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
246
- kind: "video",
247
- namespace: previousRecording.namespace ?? options.namespace,
248
- path: previousRecording.path,
249
- requestedPath: previousRecording.requestedPath,
250
- session: previousRecording.session ?? options.sessionName,
251
- status: "missing",
252
- subcommand: "restart-previous",
253
- };
241
+ return { ...base, exists: false, status: "missing" };
254
242
  }
255
243
  }
256
244
  export async function extractFileArtifacts(options) {
@@ -436,7 +424,7 @@ function formatArtifactLabel(artifact) {
436
424
  }
437
425
  if (!isPendingRecordingArtifact(artifact))
438
426
  return "Saved recording";
439
- return artifact.subcommand === "restart" ? "Recording restarted; output will be written on stop" : "Recording started in a fresh active page; output will be written on stop";
427
+ return artifact.subcommand === "restart" ? "Recording restarted; output will be written on stop" : "Recording started; output will be written on stop";
440
428
  }
441
429
  }
442
430
  export function formatArtifactSummary(artifacts) {
@@ -64,11 +64,11 @@ export function getPageSummary(data) {
64
64
  const url = typeof data.url === "string" ? data.url : undefined;
65
65
  if (title === undefined && url === undefined)
66
66
  return undefined;
67
- if (title && url)
68
- return `${title}\n${url}`;
69
- if (url)
70
- return url;
71
- return title || UNTITLED_PAGE_SUMMARY;
67
+ const summary = title && url ? `${title}\n${url}` : url || title || UNTITLED_PAGE_SUMMARY;
68
+ const webmcp = isRecord(data.webmcp) ? data.webmcp : undefined;
69
+ return webmcp?.available === true && typeof webmcp.toolCount === "number" && Number.isInteger(webmcp.toolCount) && webmcp.toolCount > 0
70
+ ? `${summary}\n\nWebMCP tools are available on this page (experimental). Run webmcp list to view them.`
71
+ : summary;
72
72
  }
73
73
  export function formatCount(count, singular, plural = `${singular}s`) {
74
74
  return `${count} ${count === 1 ? singular : plural}`;
@@ -212,7 +212,7 @@ export function buildNoActivePageRefSnapshotInvalidation() {
212
212
  export function buildPageTransitionRefSnapshotInvalidation(summary) {
213
213
  return {
214
214
  reason: "page-transition",
215
- summary: summary ?? "A recording command (record start, or record restart with a URL) replaced or navigated the active page and invalidated the prior snapshot. Run snapshot -i before using page-scoped refs.",
215
+ summary: summary ?? "Recording starts and URL-bearing restarts conservatively invalidate earlier page-scoped refs. Run snapshot -i before using refs; this is not evidence of a page change.",
216
216
  };
217
217
  }
218
218
  export function getCommandRefSnapshotInvalidation(commandTokens) {