pi-agent-browser-native 0.6.9 → 0.6.11

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 (54) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +62 -19
  3. package/dist/extensions/agent-browser/index.js +424 -451
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +6 -7
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +7 -1
  6. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +4 -8
  7. package/dist/extensions/agent-browser/lib/command-policy.js +41 -2
  8. package/dist/extensions/agent-browser/lib/command-taxonomy.js +15 -2
  9. package/dist/extensions/agent-browser/lib/input-modes/params.js +1 -1
  10. package/dist/extensions/agent-browser/lib/input-modes/script.js +3 -2
  11. package/dist/extensions/agent-browser/lib/managed-session-restore.js +42 -12
  12. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +3 -5
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +6 -14
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +14 -25
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +12 -6
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +1 -0
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/wait-timeouts.js +3 -2
  18. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +38 -31
  19. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +60 -20
  20. package/dist/extensions/agent-browser/lib/orchestration/browser-run/recording-recovery.js +161 -0
  21. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +5 -5
  22. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +2 -4
  23. package/dist/extensions/agent-browser/lib/orchestration/native-session-defaults.js +68 -0
  24. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +41 -6
  25. package/dist/extensions/agent-browser/lib/page-target-validation.js +9 -5
  26. package/dist/extensions/agent-browser/lib/playbook.js +13 -12
  27. package/dist/extensions/agent-browser/lib/process-environment.js +26 -8
  28. package/dist/extensions/agent-browser/lib/process.js +8 -5
  29. package/dist/extensions/agent-browser/lib/read-confirmation.js +59 -0
  30. package/dist/extensions/agent-browser/lib/recording-reservations.js +11 -1
  31. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +8 -0
  32. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +6 -5
  33. package/dist/extensions/agent-browser/lib/results/categories.js +4 -2
  34. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +76 -57
  35. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +19 -8
  36. package/dist/extensions/agent-browser/lib/results/presentation/common.js +5 -25
  37. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +40 -38
  38. package/dist/extensions/agent-browser/lib/results/presentation/errors.js +1 -0
  39. package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +3 -3
  40. package/dist/extensions/agent-browser/lib/results/presentation.js +38 -9
  41. package/dist/extensions/agent-browser/lib/results/recording.js +50 -0
  42. package/dist/extensions/agent-browser/lib/runtime.js +72 -20
  43. package/dist/extensions/agent-browser/lib/session-page-state.js +24 -8
  44. package/dist/extensions/agent-browser/lib/temp.js +4 -0
  45. package/dist/scripts/agent-browser-target.mjs +1 -1
  46. package/docs/ARCHITECTURE.md +29 -12
  47. package/docs/COMMAND_REFERENCE.md +67 -31
  48. package/docs/RELEASE.md +6 -4
  49. package/docs/SUPPORT_MATRIX.md +24 -16
  50. package/docs/TOOL_CONTRACT.md +82 -28
  51. package/package.json +1 -1
  52. package/scripts/agent-browser-capability-baseline.mjs +10 -3
  53. package/scripts/agent-browser-target.mjs +1 -1
  54. package/scripts/prepare.mjs +2 -4
@@ -1,7 +1,8 @@
1
1
  import { copyFile, mkdir } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
- import { getBooleanFlagValue, isUpstreamEnvFlagEnabled, projectUpstreamGlobalFlags } from "../../argv-grammar.js";
3
+ import { extractExplicitSessionName, getBooleanFlagValue, isUpstreamEnvFlagEnabled, projectUpstreamGlobalFlags, resolveAgentBrowserNamespace } from "../../argv-grammar.js";
4
4
  import { isCloseCommand } from "../../command-taxonomy.js";
5
+ import { isBrowserIndependentRead } from "../../command-policy.js";
5
6
  import { cleanupElectronLaunchResources } from "../../electron/cleanup.js";
6
7
  import { launchElectronApp } from "../../electron/launch.js";
7
8
  import { pathExists } from "../../fs-utils.js";
@@ -73,21 +74,19 @@ async function ensureArtifactParentDirectory(commandTokens, cwd) {
73
74
  return;
74
75
  await mkdir(dirname(resolve(cwd, requestedPath)), { recursive: true });
75
76
  }
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) {
77
+ async function normalizeScreenshotPathInTokens(commandTokens, cwd, batchStep = false) {
78
+ // Native batch rows skip outer CLI global-flag cleanup.
79
+ const projection = batchStep ? undefined : projectUpstreamGlobalFlags(commandTokens);
80
+ const pathIndex = getScreenshotPathTokenIndex(projection?.tokens ?? commandTokens);
81
+ const screenshotPathTokenIndex = pathIndex === undefined ? undefined : projection ? projection.indices[pathIndex] : pathIndex;
82
+ if (screenshotPathTokenIndex === undefined)
82
83
  return { tokens: commandTokens };
83
- }
84
- const screenshotPathTokenIndex = commandTokens.length - scopedCommandTokens.length + scopedPathTokenIndex;
85
84
  const requestedPath = commandTokens[screenshotPathTokenIndex];
86
85
  const absolutePath = resolve(cwd, requestedPath);
87
86
  await mkdir(dirname(absolutePath), { recursive: true });
88
87
  const tokens = [...commandTokens];
89
88
  tokens[screenshotPathTokenIndex] = absolutePath;
90
- const terminatorIndex = tokens.indexOf("--");
89
+ const terminatorIndex = batchStep ? -1 : tokens.indexOf("--");
91
90
  if (terminatorIndex >= 0) {
92
91
  tokens.splice(terminatorIndex, 1);
93
92
  }
@@ -110,13 +109,12 @@ async function prepareBatchScreenshotPaths(args, stdin, cwd) {
110
109
  // prepare parent directories for the rows that will run and skip stdin
111
110
  // preparation (no directories for never-executed rows).
112
111
  for (const step of argumentSteps) {
113
- const stepTokens = extractUpstreamCommandTokens(step);
114
- await ensureArtifactParentDirectory(stepTokens, cwd);
115
- if (stepTokens[0] === "screenshot") {
112
+ await ensureArtifactParentDirectory(step, cwd);
113
+ if (step[0] === "screenshot") {
116
114
  // Reuse the screenshot path resolution for its parent-directory side
117
115
  // effect only: raw strings are never rewritten, so the normalized
118
116
  // tokens and path request are deliberately discarded.
119
- await normalizeScreenshotPathInTokens(step, cwd);
117
+ await normalizeScreenshotPathInTokens(step, cwd, true);
120
118
  }
121
119
  }
122
120
  return undefined;
@@ -134,12 +132,11 @@ async function prepareBatchScreenshotPaths(args, stdin, cwd) {
134
132
  if (!Array.isArray(step) || !step.every((item) => typeof item === "string")) {
135
133
  return step;
136
134
  }
137
- const upstreamStep = extractUpstreamCommandTokens(step);
138
- await ensureArtifactParentDirectory(upstreamStep, cwd);
139
- if (upstreamStep[0] !== "screenshot") {
135
+ await ensureArtifactParentDirectory(step, cwd);
136
+ if (step[0] !== "screenshot") {
140
137
  return step;
141
138
  }
142
- const normalized = await normalizeScreenshotPathInTokens(step, cwd);
139
+ const normalized = await normalizeScreenshotPathInTokens(step, cwd, true);
143
140
  batchScreenshotPathRequests[index] = normalized.request;
144
141
  if (normalized.request) {
145
142
  changed = true;
@@ -425,6 +422,8 @@ export async function prepareBrowserRun(options) {
425
422
  } };
426
423
  }
427
424
  const userRequestedJson = runtimeToolArgs.includes("--json");
425
+ const routedReadConfirmation = state.sessionPageState.findReadConfirmation(preparedArgs.args, resolveAgentBrowserNamespace(preparedArgs.args, agentBrowserProcessEnv.AGENT_BROWSER_NAMESPACE));
426
+ const readConfirmation = routedReadConfirmation?.capabilities?.readRequiresConfirmation === true ? routedReadConfirmation : undefined;
428
427
  let executionPlan = buildExecutionPlan(preparedArgs.args, {
429
428
  freshSessionName,
430
429
  managedSessionActive: state.managedSessionActive,
@@ -432,13 +431,14 @@ export async function prepareBrowserRun(options) {
432
431
  managedSessionName: state.managedSessionName,
433
432
  managedSessionNamespace: state.managedSessionNamespace,
434
433
  sessionMode,
434
+ stdin: runtimeToolStdin,
435
+ browserIndependentReadConfirmation: readConfirmation !== undefined,
435
436
  });
436
- const idleTimeoutMismatch = getIdleTimeoutMismatch(preparedArgs.args, options.implicitSessionIdleTimeoutMs);
437
- if (idleTimeoutMismatch)
438
- executionPlan = { ...executionPlan, recoveryHint: undefined, validationError: idleTimeoutMismatch };
437
+ const browserIndependent = readConfirmation !== undefined || isBrowserIndependentRead(extractUpstreamCommandTokens(preparedArgs.args), runtimeToolStdin)
438
+ || (executionPlan.commandInfo.command === "session" && executionPlan.commandInfo.subcommand === "info");
439
439
  const ownedSessionKey = getSessionContextKey(executionPlan.sessionName, executionPlan.namespace);
440
440
  const plannedSessionPageState = sessionPageState.get(ownedSessionKey);
441
- const pageTargetError = getPageTargetValidationError({
441
+ const pageTargetError = readConfirmation ? undefined : getPageTargetValidationError({
442
442
  args: executionPlan.effectiveArgs,
443
443
  currentPageUrl: plannedSessionPageState.tabTarget?.url,
444
444
  pageUrlUnknown: plannedSessionPageState.tabTargetUnknown === true,
@@ -450,13 +450,18 @@ export async function prepareBrowserRun(options) {
450
450
  const targetsCurrentManagedSession = state.managedSessionActive
451
451
  && ownedSessionKey === getSessionContextKey(state.managedSessionName, state.managedSessionNamespace);
452
452
  const targetsOffCurrentOwnedSession = recordedOwnedSession !== undefined && !targetsCurrentManagedSession;
453
+ const idleTimeoutMismatch = !browserIndependent && (executionPlan.managedSessionName || recordedOwnedSession || targetsCurrentManagedSession || (state.managedSessionActive && extractExplicitSessionName(preparedArgs.args) === undefined))
454
+ ? getIdleTimeoutMismatch(preparedArgs.args, options.implicitSessionIdleTimeoutMs)
455
+ : undefined;
456
+ if (idleTimeoutMismatch)
457
+ executionPlan = { ...executionPlan, recoveryHint: undefined, validationError: idleTimeoutMismatch };
453
458
  const offCurrentLaunchScopedFlags = targetsOffCurrentOwnedSession
454
459
  ? executionPlan.startupScopedFlags.filter((flag) => flag !== "--namespace")
455
460
  : [];
456
461
  const offCurrentCompatibilityUpgrade = targetsOffCurrentOwnedSession
457
462
  && executionPlan.compatibilityWorkaround !== undefined
458
463
  && recordedOwnedSession.compatibilityWorkaround === undefined;
459
- if (targetsOffCurrentOwnedSession && canUseHeadlessCompatibilityUserAgent(preparedArgs.args, agentBrowserProcessEnv)) {
464
+ if (!browserIndependent && targetsOffCurrentOwnedSession && canUseHeadlessCompatibilityUserAgent(preparedArgs.args, agentBrowserProcessEnv)) {
460
465
  const compatibilityWorkaround = executionPlan.compatibilityWorkaround ?? recordedOwnedSession.compatibilityWorkaround;
461
466
  if (compatibilityWorkaround) {
462
467
  const userAgentIndex = executionPlan.effectiveArgs.indexOf("--user-agent");
@@ -475,7 +480,7 @@ export async function prepareBrowserRun(options) {
475
480
  ?? (targetsCurrentManagedSession ? state.managedSessionHeadedAutosaveInterval : undefined);
476
481
  const explicitAutosaveInterval = resolveExplicitAutosaveInterval(agentBrowserProcessEnv.AGENT_BROWSER_AUTOSAVE_INTERVAL_MS);
477
482
  const autosavePolicyChangeError = getRunningHeadedAutosavePolicyChangeError(retainedHeadedAutosaveInterval, isCloseCommand(executionPlan.commandInfo.command));
478
- if (!executionPlan.validationError && autosavePolicyChangeError) {
483
+ if (!browserIndependent && !executionPlan.validationError && autosavePolicyChangeError) {
479
484
  executionPlan = { ...executionPlan, recoveryHint: undefined, validationError: autosavePolicyChangeError };
480
485
  }
481
486
  const headedLaunch = getBooleanFlagValue(executionPlan.effectiveArgs, "--headed") ?? isUpstreamEnvFlagEnabled(agentBrowserProcessEnv.AGENT_BROWSER_HEADED);
@@ -485,13 +490,14 @@ export async function prepareBrowserRun(options) {
485
490
  const compatibilityUserAgent = executionPlan.compatibilityWorkaround ? getDefaultHeadlessCompatUserAgent() : undefined;
486
491
  const compatibilityUserAgentApplied = compatibilityUserAgent !== undefined
487
492
  && executionPlan.effectiveArgs.some((token, index) => token === "--user-agent" && executionPlan.effectiveArgs[index + 1] === compatibilityUserAgent);
488
- const ownedManagedSession = buildOwnedManagedSessionRestoreContext({
493
+ const ownedManagedSession = browserIndependent && !recordedOwnedSession && !targetsCurrentManagedSession ? undefined : buildOwnedManagedSessionRestoreContext({
489
494
  args: executionPlan.effectiveArgs,
495
+ reuseOnly: browserIndependent,
490
496
  cwd: recordedOwnedSession?.cwd ?? cwd,
491
497
  currentManagedSessionName: state.managedSessionName,
492
498
  currentManagedSessionNamespace: state.managedSessionNamespace,
493
- headedManagedAutosaveDisabled,
494
- headedManagedAutosaveInterval,
499
+ headedManagedAutosaveDisabled: browserIndependent ? retainedHeadedAutosaveDisabled : headedManagedAutosaveDisabled,
500
+ headedManagedAutosaveInterval: browserIndependent ? retainedHeadedAutosaveInterval : headedManagedAutosaveInterval,
495
501
  managedSessionName: executionPlan.managedSessionName,
496
502
  namespace: executionPlan.namespace,
497
503
  parentEnv: agentBrowserProcessEnv,
@@ -504,7 +510,7 @@ export async function prepareBrowserRun(options) {
504
510
  });
505
511
  let managedSessionDaemonInactive = false;
506
512
  let managedSessionCleanupOnlyReason;
507
- if (!executionPlan.validationError && ownedManagedSession) {
513
+ if (!browserIndependent && !executionPlan.validationError && ownedManagedSession) {
508
514
  const closeCommand = isCloseCommand(executionPlan.commandInfo.command);
509
515
  const policy = await acquireOwnedManagedSessionDaemonPolicy({
510
516
  context: ownedManagedSession,
@@ -554,7 +560,7 @@ export async function prepareBrowserRun(options) {
554
560
  const sessionTabPinningReason = priorSessionPageState.pinningReason;
555
561
  let priorRefSnapshotState = priorSessionPageState.refSnapshot;
556
562
  let priorRefSnapshotInvalidation = priorSessionPageState.refSnapshotInvalidation;
557
- const coldManagedSession = (managedSessionDaemonInactive || priorSessionPageState.tabReopenPending === true)
563
+ const coldManagedSession = !browserIndependent && (managedSessionDaemonInactive || priorSessionPageState.tabReopenPending === true)
558
564
  && recordedOwnedSession !== undefined
559
565
  && sessionTabPinningReason === "restore"
560
566
  && ownedManagedSession?.restoreDecision === "enabled"
@@ -577,7 +583,7 @@ export async function prepareBrowserRun(options) {
577
583
  command: executionPlan.commandInfo.command,
578
584
  commandTokens: plannedCommandTokens,
579
585
  // URL QA clears diagnostics before its explicit open; those clears do not need the old tab.
580
- pinningRequired: sessionTabPinningReason !== undefined && compiledQaPreset?.checks.url === undefined,
586
+ pinningRequired: !readConfirmation && sessionTabPinningReason !== undefined && compiledQaPreset?.checks.url === undefined,
581
587
  reopenPending: coldManagedSession,
582
588
  sessionName: executionPlan.sessionName,
583
589
  stdin: runtimeToolStdin,
@@ -617,7 +623,7 @@ export async function prepareBrowserRun(options) {
617
623
  const isCallerOwnedExplicitSession = () => executionPlan.sessionName !== undefined
618
624
  && executionPlan.usedImplicitSession === false
619
625
  && ownedManagedSession === undefined;
620
- const requiresLivePageVerification = () => isCallerOwnedExplicitSession() || options.preserveAttachedBrowserSession === true;
626
+ const requiresLivePageVerification = () => !readConfirmation && (isCallerOwnedExplicitSession() || options.preserveAttachedBrowserSession === true);
621
627
  const verifyLivePage = async (request) => {
622
628
  if (!request.requirement || !executionPlan.sessionName)
623
629
  return;
@@ -1054,6 +1060,7 @@ export async function prepareBrowserRun(options) {
1054
1060
  executionPlan,
1055
1061
  ownedManagedSessionContext: ownedManagedSession,
1056
1062
  preparedArgs,
1063
+ readConfirmation,
1057
1064
  priorRefSnapshotState,
1058
1065
  priorSessionTabTarget,
1059
1066
  priorSessionTabTargetUnknown,
@@ -1,7 +1,7 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import { parseArgvDescriptor } from "../../argv-descriptor.js";
3
- import { needsManagedSession } from "../../command-policy.js";
4
- import { getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "../../argv-grammar.js";
3
+ import { isBrowserIndependentRead, needsManagedSession } from "../../command-policy.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";
@@ -13,11 +13,13 @@ import { applyNetworkRouteRecords, buildNetworkRouteDiagnostics } from "../../re
13
13
  import { buildToolPresentation } from "../../results/presentation.js";
14
14
  import { compactLargePresentationOutput } from "../../results/presentation/large-output.js";
15
15
  import { extractEnvelopeErrorText, getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
16
+ import { detectConfirmationRequired } from "../../results/confirmation.js";
16
17
  import { omitUpstreamLifecycle } from "../../results/presentation/common.js";
17
18
  import { getClipboardWritePayloadCandidates, redactClipboardPermissionEcho, redactClipboardPermissionErrorValue } from "../../results/presentation/errors.js";
18
19
  import { shouldCaptureSemanticActionNavigationSummary } from "../../results/presentation/semantic-action.js";
19
20
  import { buildPageTransitionRefSnapshotInvalidation, commandExplicitlyTargetsAboutBlank, getCommandRefSnapshotInvalidation, deriveSessionTabTarget, extractLatestRefSnapshotStateFromBatchResults, extractRefSnapshotFromData, extractSessionTabTargetFromBatchResults, extractSessionTabTargetFromCommandData, isAboutBlankSessionTabTarget, normalizeSessionTabTarget, } from "../../session-page-state.js";
20
21
  import { isRecord } from "../../parsing.js";
22
+ import { buildReadConfirmationNextActions, nextReadConfirmation } from "../../read-confirmation.js";
21
23
  import { pruneOwnedManagedSessionRestoreSnapshots } from "../../managed-session-restore.js";
22
24
  import { isManagedSessionRestoreKey } from "../../managed-session-storage.js";
23
25
  import { createFreshSessionName, extractUpstreamCommandTokens, resolveManagedSessionState } from "../../runtime.js";
@@ -27,6 +29,7 @@ import { applyOpenResultTabCorrection, buildAboutBlankRecoveryHint, buildAboutBl
27
29
  import { collectClickDispatchDiagnostic } from "./click-dispatch.js";
28
30
  import { buildScrollNoopDiagnostic, collectComboboxFocusDiagnostic, collectElectronBroadGetTextScopeDiagnostics, collectElectronHandoff, collectFillVerificationDiagnostic, collectNavigationSummary, collectOverlayBlockerDiagnostic, collectQaAttachedTarget, collectSnapshotOverlayBlockerDiagnostic, collectRecordingDependencyWarning, collectScrollPositionSnapshot, collectSelectorTextVisibilityDiagnostics, collectTimeoutPartialProgress, sleepMs, formatQaAttachedTargetText, getArtifactCleanupGuidance, getEvalResultWarning, getEvalStdinHint, getSourceLookupElectronContext, } from "./diagnostics.js";
29
31
  import { repairScreenshotData } from "./prepare.js";
32
+ import { mergeRecordingRecoveryPresentation, recoverRecordingStop } from "./recording-recovery.js";
30
33
  import { getPersistentSessionArtifactStore } from "./session-state.js";
31
34
  import { buildFinalAgentBrowserToolResult, buildRedactedPresentationContent, buildWrapperRecoveryHint, prepareFinalResultRecoveryState, redactExactSensitiveValue, } from "./final-result.js";
32
35
  async function repairScreenshotArtifact(options) {
@@ -108,12 +111,6 @@ function batchStartedManagedBrowser(data) {
108
111
  function withoutNamespaceEntries(entries, namespace) {
109
112
  return new Map([...entries].filter(([key]) => !isAgentBrowserSessionIdentityKeyInNamespace(key, namespace)));
110
113
  }
111
- function deleteNamespaceEntries(entries, namespace) {
112
- for (const key of entries.keys()) {
113
- if (isAgentBrowserSessionIdentityKeyInNamespace(key, namespace))
114
- entries.delete(key);
115
- }
116
- }
117
114
  function setNetworkRouteState(options) {
118
115
  if (!options.sessionName)
119
116
  return options.routesBySession;
@@ -167,7 +164,13 @@ export async function processBrowserOutput(input) {
167
164
  const plainTextUpgrade = !prepared.executionPlan.plainTextInspection && prepared.executionPlan.commandInfo.command === "upgrade" && !needsManagedSession(parseArgvDescriptor(prepared.runtimeToolArgs));
168
165
  const parsed = await parseAgentBrowserEnvelope({ stdout: processResult.stdout, stdoutPath: processResult.stdoutSpillPath, plainText: plainTextUpgrade });
169
166
  let parseError = parsed.parseError;
170
- let presentationEnvelope = parsed.envelope;
167
+ const recordingStopRecovery = await recoverRecordingStop({
168
+ artifactManifest, artifactRunStartedAtMs: input.artifactRunStartedAtMs, commandTokens: prepared.commandTokens, cwd,
169
+ envelope: parsed.envelope, namespace: prepared.executionPlan.namespace, parseError, processResult,
170
+ reservation: prepared.executionPlan.sessionName ? state.activeRecordingReservations?.get(getAgentBrowserSessionIdentityKey(prepared.executionPlan.sessionName, prepared.executionPlan.namespace)) : undefined,
171
+ sessionName: prepared.executionPlan.sessionName, signal, stdin: prepared.runtimeToolStdin,
172
+ });
173
+ let presentationEnvelope = recordingStopRecovery?.envelope ?? parsed.envelope;
171
174
  let navigationSummary = undefined;
172
175
  let failedTransitionReverification = false;
173
176
  const repairedScreenshot = await repairScreenshotArtifact({ cwd, envelope: presentationEnvelope, request: prepared.preparedArgs.screenshotPathRequest });
@@ -184,6 +187,25 @@ export async function processBrowserOutput(input) {
184
187
  : Array.isArray(presentationEnvelope?.data)
185
188
  ? presentationEnvelope.data.flatMap((row, index) => isRecord(row) ? [Array.isArray(row.command) && row.command.every((token) => typeof token === "string") ? row.command : batchCommandSteps[index] ?? []] : [])
186
189
  : batchCommandSteps;
190
+ const confirmationSessionName = prepared.executionPlan.sessionName ?? "default";
191
+ let readConfirmation = state.sessionPageState.getReadConfirmation(getAgentBrowserSessionIdentityKey(confirmationSessionName, prepared.executionPlan.namespace));
192
+ let readConfirmationEvent;
193
+ const confirmationRows = prepared.executionPlan.commandInfo.command === "batch" && Array.isArray(presentationEnvelope?.data)
194
+ ? presentationEnvelope.data.flatMap((row, index) => isRecord(row) ? [{ tokens: batchCommandSteps[index] ?? [], data: row.result, response: row, succeeded: row.success === true }] : [])
195
+ : [{ tokens: prepared.commandTokens, data: presentationEnvelope?.data, response: presentationEnvelope, succeeded: presentationEnvelope?.success === true }];
196
+ for (const row of confirmationRows) {
197
+ const transition = nextReadConfirmation({ commandTokens: row.tokens, current: readConfirmation, data: row.data, namespace: prepared.executionPlan.namespace, sessionName: confirmationSessionName, succeeded: row.succeeded });
198
+ if (transition) {
199
+ readConfirmationEvent = transition;
200
+ readConfirmation = transition;
201
+ }
202
+ if (row.tokens.length === 2 && row.tokens[0] === "confirm" && isRecord(row.data) && row.data.confirmed === true && row.data.action === "read" && isRecord(row.data.result) && row.data.result.success === false && row.response) {
203
+ row.response.success = false;
204
+ row.response.error = row.data.result.error;
205
+ if (presentationEnvelope)
206
+ presentationEnvelope.success = false;
207
+ }
208
+ }
187
209
  const destinationTransition = dispatchedCommands.some((step) => {
188
210
  const [command, subcommand] = extractUpstreamCommandTokens(step);
189
211
  return isWindowOrDiffPageTransitionCommand(command, subcommand);
@@ -206,6 +228,7 @@ export async function processBrowserOutput(input) {
206
228
  const parseFailureOutput = parseError && processResult.stdoutSpillPath
207
229
  ? { fullOutputUnavailable: "Malformed upstream output was discarded because it may contain sensitive browser data." }
208
230
  : {};
231
+ const browserIndependentRead = prepared.readConfirmation !== undefined || isBrowserIndependentRead(prepared.commandTokens, prepared.runtimeToolStdin);
209
232
  const processSucceeded = !processResult.timedOut && !processResult.aborted && !processResult.spawnError && processResult.exitCode === 0;
210
233
  const plainTextInspection = prepared.executionPlan.plainTextInspection && processSucceeded;
211
234
  const parseSucceeded = plainTextInspection || parseError === undefined;
@@ -213,7 +236,7 @@ export async function processBrowserOutput(input) {
213
236
  presentationEnvelope = { success: true, data: { alreadyEnabled: true, enabled: true, message: getEnvelopeErrorString(presentationEnvelope) ?? "Stream already enabled" } };
214
237
  }
215
238
  const envelopeSuccess = plainTextInspection ? true : presentationEnvelope?.success !== false;
216
- let succeeded = processSucceeded && parseSucceeded && envelopeSuccess;
239
+ let succeeded = (processSucceeded && parseSucceeded && envelopeSuccess) || recordingStopRecovery?.recovery.healed === true;
217
240
  const inspectionText = plainTextInspection ? processResult.stdout.trim() : undefined;
218
241
  const sessionStateKey = getSessionContextKey(prepared.executionPlan.sessionName, prepared.executionPlan.namespace);
219
242
  const closeAllApplied = nestedBatchClosesAll || (directCloseAllRequested && succeeded);
@@ -223,8 +246,8 @@ export async function processBrowserOutput(input) {
223
246
  }
224
247
  if (closeAllApplied) {
225
248
  networkRoutesBySession = withoutNamespaceEntries(networkRoutesBySession, prepared.executionPlan.namespace);
226
- deleteNamespaceEntries(state.attachedSessionKeys, prepared.executionPlan.namespace);
227
- deleteNamespaceEntries(traceOwners, prepared.executionPlan.namespace);
249
+ deleteIdentityKeysInNamespace(state.attachedSessionKeys, prepared.executionPlan.namespace);
250
+ deleteIdentityKeysInNamespace(traceOwners, prepared.executionPlan.namespace);
228
251
  sessionPageState.clearNamespace(prepared.executionPlan.namespace);
229
252
  const retainedSessionKey = nestedBatchRemainsActive ? sessionStateKey : undefined;
230
253
  for (const [key, owner] of state.ownedManagedSessions) {
@@ -370,7 +393,7 @@ export async function processBrowserOutput(input) {
370
393
  let fillVerificationDiagnostic;
371
394
  let selectorTextVisibilityDiagnostics = [];
372
395
  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;
396
+ const timeoutPartialProgress = processResult.timedOut && !recordingStopRecovery && !prepared.readConfirmation ? await collectTimeoutPartialProgress({ commandTokens: prepared.commandTokens, compiledJob: prepared.compiledJob, cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, stdin: prepared.runtimeToolStdin }) : undefined;
374
397
  if (!currentSessionTabTarget && timeoutPartialProgress?.currentPage?.source === "live") {
375
398
  currentSessionTabTarget = normalizeSessionTabTarget(timeoutPartialProgress.currentPage);
376
399
  }
@@ -402,7 +425,7 @@ export async function processBrowserOutput(input) {
402
425
  const batchRefSnapshotState = prepared.executionPlan.commandInfo.command === "batch" ? extractLatestRefSnapshotStateFromBatchResults(presentationEnvelope?.data) : undefined;
403
426
  let currentRefSnapshot;
404
427
  let currentRefSnapshotInvalidation;
405
- if (sessionStateKey) {
428
+ if (sessionStateKey && !browserIndependentRead) {
406
429
  const sessionClosed = (isCloseCommand(prepared.executionPlan.commandInfo.command) && succeeded) || nestedBatchClosed;
407
430
  if (sessionClosed) {
408
431
  state.attachedSessionKeys.delete(sessionStateKey);
@@ -433,7 +456,7 @@ export async function processBrowserOutput(input) {
433
456
  if (!tabUpdate.applied && succeeded)
434
457
  sessionPageState.markPinning(sessionStateKey, "drift");
435
458
  }
436
- else if (processResult.agentBrowserStarted && (resultingPageState.pageUrlUnknown || resultingPageState.pageTargetMayHaveChanged)) {
459
+ else if (processResult.agentBrowserStarted && (resultingPageState.pageUrlUnknown || resultingPageState.pageTargetMayHaveChanged) && !(prepared.commandTokens[0] === "session" && prepared.commandTokens[1] === "info")) {
437
460
  sessionPageState.markTabTargetUnknown({ sessionName: sessionStateKey, update: sessionPageStateUpdate });
438
461
  }
439
462
  const refSnapshot = unsettledWebMcpMutation
@@ -518,7 +541,7 @@ export async function processBrowserOutput(input) {
518
541
  managedSessionHeadedAutosaveDisabled = false;
519
542
  managedSessionHeadedAutosaveInterval = undefined;
520
543
  }
521
- else if (managedTransitionSucceeded && executionTargetsManagedSession) {
544
+ else if (managedTransitionSucceeded && executionTargetsManagedSession && prepared.ownedManagedSessionContext && !prepared.ownedManagedSessionContext.reuseOnly) {
522
545
  managedSessionCompatibilityWorkaround = prepared.compatibilityWorkaround;
523
546
  managedSessionHeadedAutosaveDisabled = prepared.ownedManagedSessionContext?.headedManagedAutosaveDisabled === true;
524
547
  managedSessionHeadedAutosaveInterval = prepared.ownedManagedSessionContext?.headedManagedAutosaveInterval;
@@ -636,7 +659,7 @@ export async function processBrowserOutput(input) {
636
659
  electronLaunchRecord = electronFailedConnectCleanup.record;
637
660
  }
638
661
  }
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 }) });
662
+ let errorText = recordingStopRecovery?.recovery.healed ? undefined : 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
663
  if (errorText && presentationEnvelope?.success === false && extractEnvelopeErrorText(presentationEnvelope.error) === undefined)
641
664
  presentationEnvelope = { ...presentationEnvelope, error: errorText };
642
665
  if (errorText) {
@@ -647,7 +670,21 @@ export async function processBrowserOutput(input) {
647
670
  }
648
671
  if (plainTextUpgrade && errorText)
649
672
  presentationEnvelope = { ...presentationEnvelope, success: false, error: errorText };
650
- let presentation = plainTextInspection ? { artifacts: undefined, batchFailure: undefined, batchSteps: undefined, content: [{ type: "text", text: inspectionText ?? "" }], data: undefined, fullOutputPath: undefined, fullOutputPaths: undefined, imagePath: undefined, imagePaths: undefined, savedFile: undefined, savedFilePath: undefined, summary: `${prepared.redactedArgs.join(" ")} completed` } : await buildToolPresentation({ args: prepared.redactedProcessArgs, artifactManifest, artifactMaxUpdatedAtMs: Date.now(), artifactMinUpdatedAtMs: input.artifactRunStartedAtMs, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: prepared.executionPlan.commandInfo, compiledSemanticAction: prepared.compiledSemanticAction, cwd, envelope: presentationEnvelope, errorText, namespace: prepared.executionPlan.namespace, networkRouteDiagnostics, networkRoutes: activeNetworkRoutes, persistentArtifactStore, sessionName: prepared.executionPlan.sessionName });
673
+ let presentation = plainTextInspection ? { artifacts: undefined, batchFailure: undefined, batchSteps: undefined, content: [{ type: "text", text: inspectionText ?? "" }], data: undefined, fullOutputPath: undefined, fullOutputPaths: undefined, imagePath: undefined, imagePaths: undefined, savedFile: undefined, savedFilePath: undefined, summary: `${prepared.redactedArgs.join(" ")} completed` } : recordingStopRecovery && !recordingStopRecovery.batch ? recordingStopRecovery.presentation : await buildToolPresentation({ args: prepared.redactedProcessArgs, artifactManifest, artifactMaxUpdatedAtMs: Date.now(), artifactMinUpdatedAtMs: input.artifactRunStartedAtMs, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: prepared.executionPlan.commandInfo, compiledSemanticAction: prepared.compiledSemanticAction, cwd, envelope: presentationEnvelope, errorText, namespace: prepared.executionPlan.namespace, networkRouteDiagnostics, networkRoutes: activeNetworkRoutes, persistentArtifactStore, piCleanupOwnership: sessionStateKey && (state.ownedManagedSessions.has(sessionStateKey) || prepared.executionPlan.managedSessionName !== undefined) ? "wrapper-managed" : "caller-owned", sessionName: prepared.executionPlan.sessionName });
674
+ if (recordingStopRecovery)
675
+ presentation = mergeRecordingRecoveryPresentation(presentation, recordingStopRecovery);
676
+ const confirmation = readConfirmationEvent ?? prepared.readConfirmation;
677
+ if (confirmation) {
678
+ presentation.readConfirmation = confirmation;
679
+ if (confirmation.state !== "cleared" || !detectConfirmationRequired(presentationEnvelope?.data)) {
680
+ presentation.nextActions = buildReadConfirmationNextActions(confirmation, readConfirmationEvent?.state === "pending");
681
+ }
682
+ if (readConfirmationEvent?.state === "pending") {
683
+ presentation.resultCategory = "failure";
684
+ presentation.failureCategory = "confirmation-required";
685
+ presentation.successCategory = undefined;
686
+ }
687
+ }
651
688
  if (plainTextUpgrade && !succeeded && typeof presentationEnvelope?.data === "string") {
652
689
  presentation.data = presentationEnvelope.data;
653
690
  const errorContent = presentation.content[0];
@@ -757,13 +794,15 @@ export async function processBrowserOutput(input) {
757
794
  const evalPageUrl = evalNavigationSummary?.url ?? currentSessionTabTarget?.url ?? prepared.priorSessionTabTarget?.url ?? evalSessionTabUrl;
758
795
  const evalStdinHint = getEvalStdinHint({ command: prepared.executionPlan.commandInfo.command, data: presentationEnvelope?.data, stdin: prepared.runtimeToolStdin });
759
796
  const evalResultWarning = getEvalResultWarning({ command: prepared.executionPlan.commandInfo.command, data: presentationEnvelope?.data, navigationSummary: evalNavigationSummary, pageUrl: evalPageUrl, stdin: prepared.runtimeToolStdin });
797
+ if (readConfirmationEvent)
798
+ state.sessionPageState.applyReadConfirmation(readConfirmationEvent, sessionPageStateUpdate);
760
799
  const resultArtifactManifest = presentation.artifactManifest ?? artifactManifest;
761
800
  const artifactCleanup = await getArtifactCleanupGuidance({ command: prepared.executionPlan.commandInfo.command, cwd, manifest: resultArtifactManifest, succeeded });
762
801
  const recordingTransitionReached = prepared.executionPlan.commandInfo.command === "batch"
763
802
  ? presentation.batchSteps?.some((step) => isRecordPageTransitionCommand(extractUpstreamCommandTokens(step.command ?? [])))
764
803
  : isRecordPageTransitionCommand(prepared.commandTokens);
765
804
  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."
805
+ ? "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
806
  : undefined;
768
807
  const sessionWarning = electronPostCommandHealth ? formatElectronPostCommandHealthText(electronPostCommandHealth) : electronSessionMismatch ? formatElectronSessionMismatchText(electronSessionMismatch) : aboutBlankSessionMismatch ? buildAboutBlankWarning(aboutBlankSessionMismatch) : undefined;
769
808
  const warningText = [sessionWarning, recordingPageWarning].filter(Boolean).join("\n\n") || undefined;
@@ -778,9 +817,10 @@ export async function processBrowserOutput(input) {
778
817
  const resultRetainsPreparedManagedSession = !managedSessionOutcome || (managedSessionOutcome.activeAfter
779
818
  && managedSessionOutcome.attemptedSessionName === managedSessionOutcome.currentSessionName);
780
819
  const resultHeadedManagedAutosaveDisabled = prepared.ownedManagedSessionContext?.headedManagedAutosaveDisabled === true
820
+ && !prepared.ownedManagedSessionContext.reuseOnly
781
821
  && resultRetainsPreparedManagedSession
782
822
  && !(commandClosesSession && succeeded);
783
- const resultHeadedManagedAutosaveInterval = resultRetainsPreparedManagedSession && !(commandClosesSession && succeeded)
823
+ const resultHeadedManagedAutosaveInterval = resultRetainsPreparedManagedSession && !prepared.ownedManagedSessionContext?.reuseOnly && !(commandClosesSession && succeeded)
784
824
  ? prepared.ownedManagedSessionContext?.headedManagedAutosaveInterval
785
825
  : undefined;
786
826
  const result = buildFinalAgentBrowserToolResult({ aboutBlankSessionMismatch, artifactCleanup, categoryDetails: finalRecoveryState.categoryDetails, clickDispatchDiagnostic, commandTokens: prepared.commandTokens, comboboxFocusDiagnostic, compiledNetworkSourceLookup: prepared.compiledNetworkSourceLookup, compiledSemanticAction: prepared.compiledSemanticAction, compatibilityWorkaround: prepared.compatibilityWorkaround, currentRefSnapshot, currentRefSnapshotInvalidation, currentSessionTabTarget, currentSessionTabTargetUnknown, electronBroadGetTextScopeDiagnostics, electronFailedConnectCleanup, electronHandoff, electronLaunch: prepared.electronLaunch, electronLaunchRecord, electronLaunchRecords, electronPostCommandHealth, electronProfileIsolationDetails: input.electronProfileIsolationDetails, electronRefFreshnessDiagnostic, electronSessionMismatch, errorText, evalResultWarning, evalStdinHint, exactSensitiveValues: prepared.exactSensitiveValues, executionPlan: prepared.executionPlan, fillVerificationDiagnostic, headedLaunch: prepared.headedLaunch, inspectionText, preserveAttachedBrowserSession: input.preserveAttachedBrowserSession === true, providerLaunch: prepared.providerLaunch, managedSessionHeadedAutosaveDisabled: resultHeadedManagedAutosaveDisabled || undefined, managedSessionHeadedAutosaveInterval: resultHeadedManagedAutosaveInterval, managedSessionOutcome, managedSessionRestoreDisabled: state.managedSessionRestoreState.isDisabled(prepared.executionPlan.sessionName, prepared.executionPlan.namespace), navigationSummary, networkSourceLookup, noActivePageSnapshotFailure: finalRecoveryState.noActivePageSnapshotFailure, openResultTabCorrection, overlayBlockerDiagnostic, parseError, parseFailureOutput, parseSucceeded, plainTextInspection, presentation, presentationEnvelope, priorSessionTabTarget: prepared.priorSessionTabTarget, processResult, qaAttachedTarget, qaPreset, recordingDependencyWarning, redactedArgs: prepared.redactedArgs, redactedCompiledElectron: prepared.redactedCompiledElectron, redactedCompiledJob: prepared.redactedCompiledJob, redactedCompiledNetworkSourceLookup: prepared.redactedCompiledNetworkSourceLookup, redactedCompiledQaPreset: prepared.redactedCompiledQaPreset, redactedCompiledSemanticAction: prepared.redactedCompiledSemanticAction, redactedCompiledSourceLookup: prepared.redactedCompiledSourceLookup, redactedContent, redactedProcessArgs: prepared.redactedProcessArgs, redactedRecoveryHint: prepared.redactedRecoveryHint, resultArtifactManifest, richInputRecoveryDiagnostic: finalRecoveryState.richInputRecoveryDiagnostic, scrollNoopDiagnostic, selectorTextVisibilityDiagnostics, sessionMode: prepared.sessionMode, sessionTabCorrection, sourceLookup, succeeded, timeoutPartialProgress, unsettledWebMcpMutation, userRequestedJson: prepared.userRequestedJson, visibleRefFallbackDiagnostic: finalRecoveryState.visibleRefFallbackDiagnostic, visibleRefFallbackSessionName: finalRecoveryState.visibleRefFallbackSessionName });
@@ -0,0 +1,161 @@
1
+ import { resolve } from "node:path";
2
+ import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
3
+ import { getRecordCommandOperands } from "../../command-taxonomy.js";
4
+ import { isRecord } from "../../parsing.js";
5
+ import { isPendingRecordingArtifact, mergeSessionArtifactManifest } from "../../results/artifact-manifest.js";
6
+ import { extractEnvelopeErrorText } from "../../results/envelope.js";
7
+ import { appendUniqueAgentBrowserNextActions } from "../../results/next-actions.js";
8
+ import { buildToolPresentation } from "../../results/presentation.js";
9
+ import { buildArtifactVerificationSummary, buildManifestEntriesForFileArtifacts } from "../../results/presentation/artifacts.js";
10
+ import { getRecordingReceipt } from "../../results/recording.js";
11
+ import { getUpstreamEffectiveBatchSteps } from "../batch-stdin.js";
12
+ import { runSessionCommandData } from "./session-state.js";
13
+ function isStop(tokens) {
14
+ return tokens[0] === "record" && tokens[1] === "stop";
15
+ }
16
+ function receiptMatches(receipt, expected, nowMs) {
17
+ if (resolve(expected.cwd, receipt.path) !== expected.absolutePath)
18
+ return false;
19
+ if (expected.recordingId)
20
+ return receipt.recordingId === expected.recordingId;
21
+ const startedAtMs = Date.parse(receipt.capture.startedAt ?? "");
22
+ return expected.startedAtMs !== undefined && startedAtMs >= expected.startedAtMs && startedAtMs <= nowMs;
23
+ }
24
+ export async function recoverRecordingStop(options) {
25
+ const batch = options.commandTokens[0] === "batch";
26
+ const steps = batch ? getUpstreamEffectiveBatchSteps(options.commandTokens, options.stdin) : [options.commandTokens];
27
+ const rows = batch && Array.isArray(options.envelope?.data) ? options.envelope.data : undefined;
28
+ const stopIndex = steps.reduce((last, step, index) => isStop(step) && (options.processResult.timedOut
29
+ || /no recording in progress/i.test(extractEnvelopeErrorText(rows?.[index]?.error ?? options.envelope?.error) ?? "")) ? index : last, -1);
30
+ if (stopIndex < 0 || options.processResult.aborted || options.signal?.aborted || !options.processResult.agentBrowserStarted)
31
+ return undefined;
32
+ let expected = options.reservation;
33
+ for (let index = 0; index < stopIndex; index += 1) {
34
+ const step = steps[index];
35
+ if (step[0] !== "record" || !["start", "restart"].includes(step[1]))
36
+ continue;
37
+ const row = rows?.[index];
38
+ if (isRecord(row) && row.success === false)
39
+ continue;
40
+ const started = isRecord(row) && isRecord(row.result) ? row.result : undefined;
41
+ const path = typeof started?.path === "string" ? started.path : getRecordCommandOperands(step).path;
42
+ if (path && options.sessionName)
43
+ expected = {
44
+ absolutePath: resolve(options.cwd, path), cwd: options.cwd, path, namespace: options.namespace, sessionName: options.sessionName,
45
+ recordingId: typeof started?.recordingId === "string" ? started.recordingId : undefined, startedAtMs: options.artifactRunStartedAtMs,
46
+ };
47
+ }
48
+ const attemptError = options.processResult.timedOut
49
+ ? `Record stop timed out after ${options.processResult.timeoutMs} ms.`
50
+ : extractEnvelopeErrorText(rows?.[stopIndex]?.error ?? options.envelope?.error) ?? "Record stop did not return a receipt.";
51
+ const recovery = {
52
+ attempt: { success: false, exitCode: options.processResult.exitCode, timedOut: options.processResult.timedOut, error: attemptError, parseError: options.parseError },
53
+ expected, healed: false, namespace: options.namespace, sessionName: options.sessionName, source: "session-info", status: "unavailable",
54
+ reason: "Native session info did not provide a matching recording receipt. File presence alone cannot prove successful recording finalization.",
55
+ };
56
+ let info;
57
+ try {
58
+ info = await runSessionCommandData({ args: ["session", "info"], cwd: options.cwd, namespace: options.namespace, pinNamespace: true, sessionName: options.sessionName, signal: options.signal, timeoutMs: 2_000 });
59
+ }
60
+ catch (error) {
61
+ recovery.reason = `Native session info was unavailable: ${error instanceof Error ? error.message : String(error)}. File presence alone cannot prove successful recording finalization.`;
62
+ }
63
+ const runtime = isRecord(info) && isRecord(info.runtime) ? info.runtime : undefined;
64
+ const recordings = isRecord(runtime?.recording) ? runtime.recording : undefined;
65
+ const sameSession = isRecord(info) && typeof info.session === "string" && options.sessionName !== undefined
66
+ && getAgentBrowserSessionIdentityKey(info.session, typeof info.namespace === "string" ? info.namespace : undefined)
67
+ === getAgentBrowserSessionIdentityKey(options.sessionName, options.namespace);
68
+ const current = getRecordingReceipt(recordings?.current);
69
+ let matchedData;
70
+ if (recordings && sameSession && expected) {
71
+ for (const candidate of [recordings.current, recordings.last]) {
72
+ const receipt = getRecordingReceipt(candidate);
73
+ if (!receipt || !receiptMatches(receipt, expected, Date.now()))
74
+ continue;
75
+ recovery.receipt = receipt;
76
+ matchedData = candidate;
77
+ break;
78
+ }
79
+ if (!recovery.receipt) {
80
+ recovery.status = "mismatch";
81
+ recovery.reason = "Native recording IDs, paths or capture windows did not match this attempt; unrelated receipt measurements were not used.";
82
+ }
83
+ }
84
+ else if (recordings && !sameSession) {
85
+ recovery.status = "mismatch";
86
+ recovery.reason = "Native session info reported a different namespace/session; its recording receipt was not used.";
87
+ }
88
+ const receipt = recovery.receipt;
89
+ const currentUsesSamePath = current && receipt && current.recordingId !== receipt.recordingId && expected
90
+ && resolve(expected.cwd, current.path) === expected.absolutePath;
91
+ const terminalMeasurements = receipt?.success === true && receipt.output.encoderSucceeded === true
92
+ && receipt.output.encodedFrames !== null && receipt.output.encodedFrames > 0
93
+ && receipt.file.exists === true && receipt.file.sizeBytes !== null && receipt.file.sizeBytes > 0
94
+ && Number.isFinite(Date.parse(receipt.capture.startedAt ?? "")) && Number.isFinite(Date.parse(receipt.capture.endedAt ?? ""))
95
+ && !currentUsesSamePath;
96
+ const data = matchedData ?? (expected ? { path: expected.absolutePath, recordingId: expected.recordingId, success: null } : undefined);
97
+ const presentation = await buildToolPresentation({
98
+ artifactManifest: options.artifactManifest, artifactMinUpdatedAtMs: expected?.startedAtMs ?? options.artifactRunStartedAtMs, artifactMaxUpdatedAtMs: Date.now(),
99
+ artifactRequest: expected ? { path: expected.path, absolutePath: expected.absolutePath, status: terminalMeasurements ? undefined : "unverified" } : undefined,
100
+ commandInfo: { command: "record", subcommand: "stop" }, cwd: expected?.cwd ?? options.cwd,
101
+ envelope: { success: receipt?.success === true, data }, namespace: options.namespace, sessionName: options.sessionName,
102
+ recordingPending: receipt ? receipt.success === null : options.processResult.timedOut,
103
+ });
104
+ const file = presentation.artifacts?.[0];
105
+ const stopVerified = terminalMeasurements && presentation.artifactVerification?.verified === true && file?.sizeBytes === receipt?.file.sizeBytes;
106
+ if (receipt) {
107
+ recovery.status = stopVerified ? "recovered" : receipt.success === null ? "pending" : receipt.success === false ? "failed" : "unverified";
108
+ recovery.reason = stopVerified ? "Native terminal success and encoder measurements match the expected recording and the verified file."
109
+ : receipt.success === null ? "The matching native take is still pending; no terminal success was reported."
110
+ : receipt.success === false ? `The matching native receipt reports failure: ${receipt.error ?? "unknown encoder/capture failure"}.`
111
+ : currentUsesSamePath ? "Another active recording uses this path. The previous receipt cannot verify the new take's file."
112
+ : "A matching receipt was found, but successful encoding and a matching fresh file could not both be verified.";
113
+ }
114
+ let envelope = options.envelope;
115
+ if (rows) {
116
+ const updatedRows = rows.map((row, index) => index === stopIndex && isRecord(row) ? { ...row, result: data, success: stopVerified === true, error: stopVerified ? undefined : row.error } : row);
117
+ recovery.healed = stopVerified === true && !options.processResult.timedOut && updatedRows.every(row => isRecord(row) && row.success === true);
118
+ envelope = { success: recovery.healed, data: updatedRows, error: recovery.healed ? undefined : options.envelope?.error };
119
+ }
120
+ else if (!batch) {
121
+ recovery.healed = stopVerified === true;
122
+ envelope = { success: recovery.healed, data, error: recovery.healed ? undefined : attemptError };
123
+ }
124
+ if (!recovery.healed) {
125
+ presentation.resultCategory = "failure";
126
+ presentation.failureCategory = options.processResult.timedOut ? "timeout" : "upstream-error";
127
+ presentation.successCategory = undefined;
128
+ presentation.summary = `${attemptError} ${recovery.reason}`;
129
+ }
130
+ const followups = options.sessionName && !recovery.healed ? [{
131
+ id: "inspect-recording-receipt", tool: "agent_browser", params: { args: ["--namespace", options.namespace ?? "", "--session", options.sessionName, "session", "info"] },
132
+ reason: "Inspect the native current/last recording receipts for this exact session.", safety: "Read-only status; does not launch or retarget the browser. Do not infer encoding success from an existing file.",
133
+ }] : [];
134
+ if (current && receipt && current.recordingId === receipt.recordingId && recovery.status === "pending" && options.sessionName)
135
+ followups.push({
136
+ id: "stop-pending-recording", tool: "agent_browser", params: { args: ["--namespace", options.namespace ?? "", "--session", options.sessionName, "record", "stop"] },
137
+ reason: `Finalize the still-current native recording ${receipt.recordingId}; it has not reported a terminal outcome.`, safety: "Run only while this same recording remains current. No repeated stop was dispatched during recovery.",
138
+ });
139
+ presentation.nextActions = followups;
140
+ presentation.recordingRecovery = recovery;
141
+ const text = `Recording receipt recovery (${recovery.status}): ${recovery.reason}\nOriginal attempt: ${attemptError}`;
142
+ presentation.content.unshift({ type: "text", text });
143
+ return { batch, envelope, partialBatch: batch && !rows, stopIndex, presentation, recovery };
144
+ }
145
+ export function mergeRecordingRecoveryPresentation(base, result) {
146
+ if (!result.batch)
147
+ return result.presentation;
148
+ const artifacts = result.partialBatch ? [...(base.artifacts ?? []), ...(result.presentation.artifacts ?? [])] : base.artifacts;
149
+ const laterRecording = base.batchSteps?.slice(result.stopIndex + 1).some(step => step.artifacts?.some(isPendingRecordingArtifact));
150
+ const unrelatedFailure = base.batchFailure && !isStop(base.batchFailure.failedStep.command ?? []);
151
+ const nextActions = (base.nextActions ?? []).filter(action => action.id === "stop-pending-recording" ? laterRecording : unrelatedFailure);
152
+ appendUniqueAgentBrowserNextActions(nextActions, result.presentation.nextActions);
153
+ return {
154
+ ...base, artifacts, artifactVerification: buildArtifactVerificationSummary(artifacts ?? []),
155
+ batchSteps: base.batchSteps?.map((step) => step.index === result.stopIndex ? { ...step, artifacts: result.presentation.artifacts, artifactVerification: result.presentation.artifactVerification } : step),
156
+ artifactManifest: result.partialBatch ? mergeSessionArtifactManifest({ base: base.artifactManifest, entries: buildManifestEntriesForFileArtifacts(result.presentation.artifacts ?? []) }) : base.artifactManifest,
157
+ content: [...base.content, ...(result.partialBatch ? result.presentation.content : result.presentation.content.slice(0, 1))],
158
+ nextActions,
159
+ recordingRecovery: result.recovery,
160
+ };
161
+ }
@@ -1,13 +1,13 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
3
3
  import { parseArgvDescriptor } from "../../argv-descriptor.js";
4
- import { needsManagedSession } from "../../command-policy.js";
4
+ import { isBrowserIndependentRead, needsManagedSession } from "../../command-policy.js";
5
5
  import { runAgentBrowserProcess } from "../../process.js";
6
6
  import { buildAgentBrowserNextActions } from "../../results/action-recommendations.js";
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,10 +424,10 @@ 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
- if (!options.pinningRequired || !options.sessionName || !options.command)
430
+ if (!options.pinningRequired || !options.sessionName || !options.command || isBrowserIndependentRead(options.commandTokens, options.stdin))
431
431
  return false;
432
432
  const steps = options.command === "batch" ? getUpstreamEffectiveBatchSteps(options.commandTokens, options.stdin) : [options.commandTokens];
433
433
  for (const step of steps) {
@@ -441,7 +441,7 @@ export function shouldPinSessionTabForCommand(options) {
441
441
  continue;
442
442
  if (command === "get" && subcommand === "url" && !options.reopenPending)
443
443
  continue;
444
- if (command === "read" && findFirstPositionalArgument(tokens) !== undefined)
444
+ if (command === "record" && subcommand === "stop")
445
445
  continue;
446
446
  if (["console", "errors"].includes(command))
447
447
  continue;