pi-agent-browser-native 0.9.1 → 0.9.3

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 (42) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +35 -8
  3. package/dist/extensions/agent-browser/index.js +39 -16
  4. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +6 -3
  5. package/dist/extensions/agent-browser/lib/browser-transcript.js +2 -1
  6. package/dist/extensions/agent-browser/lib/launch-scoped-flags.js +4 -0
  7. package/dist/extensions/agent-browser/lib/native-output.js +2 -1
  8. package/dist/extensions/agent-browser/lib/orchestration/batch-stdin.js +2 -8
  9. package/dist/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.js +12 -2
  10. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +62 -10
  11. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +18 -12
  12. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +33 -24
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +111 -53
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/screenshot-observation.js +6 -2
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +32 -20
  16. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +37 -32
  17. package/dist/extensions/agent-browser/lib/orchestration/native-session-defaults.js +30 -11
  18. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +3 -1
  19. package/dist/extensions/agent-browser/lib/page-target-validation.js +10 -3
  20. package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +2 -1
  21. package/dist/extensions/agent-browser/lib/playbook.js +7 -6
  22. package/dist/extensions/agent-browser/lib/process-environment.js +12 -3
  23. package/dist/extensions/agent-browser/lib/process.js +6 -6
  24. package/dist/extensions/agent-browser/lib/read-confirmation.js +56 -16
  25. package/dist/extensions/agent-browser/lib/results/confirmation.js +3 -0
  26. package/dist/extensions/agent-browser/lib/results/envelope.js +5 -1
  27. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +10 -1
  28. package/dist/extensions/agent-browser/lib/results/presentation/common.js +1 -0
  29. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +34 -1
  30. package/dist/extensions/agent-browser/lib/results/presentation/registry.js +5 -1
  31. package/dist/extensions/agent-browser/lib/results/presentation.js +15 -2
  32. package/dist/extensions/agent-browser/lib/results/selector-recovery.js +5 -5
  33. package/dist/extensions/agent-browser/lib/runtime.js +5 -1
  34. package/dist/extensions/agent-browser/lib/session-page-state.js +35 -16
  35. package/docs/ARCHITECTURE.md +10 -9
  36. package/docs/COMMAND_REFERENCE.md +37 -13
  37. package/docs/RELEASE.md +6 -5
  38. package/docs/REQUIREMENTS.md +3 -1
  39. package/docs/SESSION_STORAGE.md +1 -1
  40. package/docs/SUPPORT_MATRIX.md +12 -3
  41. package/docs/TOOL_CONTRACT.md +29 -20
  42. package/package.json +4 -4
@@ -2,7 +2,7 @@ import { rm } from "node:fs/promises";
2
2
  import { observeNativeWebMcp } from "../../webmcp-observation.js";
3
3
  import { parseArgvDescriptor } from "../../argv-descriptor.js";
4
4
  import { isBrowserIndependentRead, needsManagedSession } from "../../command-policy.js";
5
- import { deleteIdentityKeysInNamespace, getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "../../argv-grammar.js";
5
+ import { deleteIdentityKeysInNamespace, getAgentBrowserSessionIdentityKey, getBooleanFlagValue, isAgentBrowserSessionIdentityKeyInNamespace } from "../../argv-grammar.js";
6
6
  import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "../../batch-lifecycle.js";
7
7
  import { isCloseAllCommand, isCloseCommand, isNavigationObservableCommandName, isOpenNavigationCommand, isPageMutationCommand, isRecordPageTransitionCommand, isUnverifiedPageTransitionCommand, isWindowOrDiffPageTransitionCommand } from "../../command-taxonomy.js";
8
8
  import { OPEN_RESULT_TAB_CORRECTION_FLAGS } from "../../launch-scoped-flags.js";
@@ -20,7 +20,8 @@ import { getClipboardWritePayloadCandidates, redactClipboardPermissionEcho, reda
20
20
  import { shouldCaptureSemanticActionNavigationSummary } from "../../results/presentation/semantic-action.js";
21
21
  import { buildPageTransitionRefSnapshotInvalidation, commandExplicitlyTargetsAboutBlank, getCommandRefSnapshotInvalidation, deriveSessionTabTarget, extractLatestRefSnapshotStateFromBatchResults, extractRefSnapshotFromData, extractSessionTabTargetFromBatchResults, extractSessionTabTargetFromCommandData, isAboutBlankSessionTabTarget, normalizeSessionTabTarget, } from "../../session-page-state.js";
22
22
  import { isRecord } from "../../parsing.js";
23
- import { buildReadConfirmationNextActions, nextReadConfirmation } from "../../read-confirmation.js";
23
+ import { getAgentBrowserProcessEnvironment } from "../../process-environment.js";
24
+ import { buildReadConfirmationNextActions, isBrowserIndependentConfirmation, isSuccessfulNativeConfirmedClose, nextReadConfirmation, parseReadConfirmation } from "../../read-confirmation.js";
24
25
  import { pruneOwnedManagedSessionRestoreSnapshots } from "../../managed-session-restore.js";
25
26
  import { isManagedSessionRestoreKey } from "../../managed-session-storage.js";
26
27
  import { createFreshSessionName, extractUpstreamCommandTokens, redactInvocationArgs, resolveManagedSessionState } from "../../runtime.js";
@@ -32,6 +33,7 @@ import { buildScrollNoopDiagnostic, collectComboboxFocusDiagnostic, collectElect
32
33
  import { repairScreenshotData } from "./prepare.js";
33
34
  import { mergeRecordingRecoveryPresentation, recoverRecordingStop } from "./recording-recovery.js";
34
35
  import { getPersistentSessionArtifactStore } from "./session-state.js";
36
+ import { nativePolicyDefinitelyUnestablished } from "./session-state.js";
35
37
  import { buildFinalAgentBrowserToolResult, buildRedactedPresentationContent, buildWrapperRecoveryHint, prepareFinalResultRecoveryState, redactExactSensitiveValue, } from "./final-result.js";
36
38
  async function repairScreenshotArtifact(options) {
37
39
  const { cwd, envelope, request } = options;
@@ -137,7 +139,7 @@ function applyBatchNetworkRouteState(options) {
137
139
  if (!isRecord(item) || !Array.isArray(item.command) || !item.command.every((token) => typeof token === "string"))
138
140
  continue;
139
141
  const commandTokens = extractUpstreamCommandTokens(item.command);
140
- const stepSucceeded = item.success !== false;
142
+ const stepSucceeded = item.success !== false && !detectConfirmationRequired(item.result);
141
143
  if (stepSucceeded && isCloseCommand(commandTokens[0]))
142
144
  routes = undefined;
143
145
  else
@@ -162,9 +164,10 @@ export async function processBrowserOutput(input) {
162
164
  let networkRoutesBySession = state.networkRoutesBySession;
163
165
  try {
164
166
  const persistentArtifactStore = getPersistentSessionArtifactStore(ctx);
165
- // Native upgrade prints text even with --json; all other command shapes keep strict JSON parsing.
167
+ // Native upgrade prints text even with --json; explicit false selects opaque native text.
166
168
  const plainTextUpgrade = !prepared.executionPlan.plainTextInspection && prepared.executionPlan.commandInfo.command === "upgrade" && !needsManagedSession(parseArgvDescriptor(prepared.runtimeToolArgs));
167
- const parsed = await parseAgentBrowserEnvelope({ stdout: processResult.stdout, stdoutPath: processResult.stdoutSpillPath, plainText: plainTextUpgrade });
169
+ const textOutput = getBooleanFlagValue(prepared.processArgs, "--json") === false;
170
+ const parsed = await parseAgentBrowserEnvelope({ stdout: processResult.stdout, stdoutPath: processResult.stdoutSpillPath, plainText: plainTextUpgrade, textOutput });
168
171
  observeNativeWebMcp(parsed.envelope?.data);
169
172
  let parseError = parsed.parseError;
170
173
  const recordingStopRecovery = await recoverRecordingStop({
@@ -194,22 +197,35 @@ export async function processBrowserOutput(input) {
194
197
  const confirmationSessionName = prepared.executionPlan.sessionName ?? "default";
195
198
  let readConfirmation = state.sessionPageState.getReadConfirmation(getAgentBrowserSessionIdentityKey(confirmationSessionName, prepared.executionPlan.namespace));
196
199
  let readConfirmationEvent;
200
+ const confirmedEffects = [];
197
201
  const confirmationRows = prepared.executionPlan.commandInfo.command === "batch" && Array.isArray(presentationEnvelope?.data)
198
- ? presentationEnvelope.data.flatMap((row, index) => isRecord(row) ? [{ tokens: batchCommandSteps[index] ?? [], data: row.result, response: row, succeeded: row.success === true }] : [])
199
- : [{ tokens: prepared.commandTokens, data: presentationEnvelope?.data, response: presentationEnvelope, succeeded: presentationEnvelope?.success === true }];
202
+ ? presentationEnvelope.data.flatMap((row, index) => isRecord(row) ? [{ tokens: Array.isArray(row.command) && row.command.every(token => typeof token === "string") ? row.command : batchCommandSteps[index] ?? [], data: row.result, index, response: row, succeeded: row.success === true }] : [])
203
+ : [{ tokens: prepared.commandTokens, data: presentationEnvelope?.data, index: 0, response: presentationEnvelope, succeeded: presentationEnvelope?.success === true }];
200
204
  for (const row of confirmationRows) {
205
+ if (readConfirmation?.state === "pending" && readConfirmation.source === "native-guarded-action" && typeof readConfirmation.command === "string"
206
+ && row.tokens.length === 2 && row.tokens[0] === "confirm" && row.tokens[1] === readConfirmation.id
207
+ && isRecord(row.data) && row.data.confirmed === true && row.data.action === readConfirmation.action
208
+ && isRecord(row.data.result) && !detectConfirmationRequired(row.data)) {
209
+ confirmedEffects.push({ command: readConfirmation.command, data: row.data.result.data, index: row.index, succeeded: row.succeeded && row.data.result.success === true });
210
+ }
201
211
  const transition = nextReadConfirmation({ commandTokens: row.tokens, current: readConfirmation, data: row.data, namespace: prepared.executionPlan.namespace, sessionName: confirmationSessionName, succeeded: row.succeeded });
202
212
  if (transition) {
203
213
  readConfirmationEvent = transition;
204
214
  readConfirmation = transition;
205
215
  }
206
- 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) {
216
+ if (row.tokens.length === 2 && row.tokens[0] === "confirm" && isRecord(row.data) && row.data.confirmed === true && isRecord(row.data.result) && row.data.result.success === false && row.response) {
207
217
  row.response.success = false;
208
218
  row.response.error = row.data.result.error;
209
219
  if (presentationEnvelope)
210
220
  presentationEnvelope.success = false;
211
221
  }
212
222
  }
223
+ const confirmedData = prepared.commandTokens[0] === "confirm" ? confirmedEffects[0]?.data : undefined;
224
+ const confirmedCommand = prepared.commandTokens[0] === "confirm" ? confirmedEffects[0]?.command : undefined;
225
+ const directClose = isCloseCommand(prepared.executionPlan.commandInfo.command)
226
+ || isSuccessfulNativeConfirmedClose(prepared.commandTokens, presentationEnvelope?.data);
227
+ if (readConfirmationEvent?.state === "pending")
228
+ state.observedBrowserEffects = { ...state.observedBrowserEffects, readConfirmation: readConfirmationEvent };
213
229
  const destinationTransition = dispatchedCommands.some((step) => {
214
230
  const [command, subcommand] = extractUpstreamCommandTokens(step);
215
231
  return isWindowOrDiffPageTransitionCommand(command, subcommand);
@@ -222,24 +238,26 @@ export async function processBrowserOutput(input) {
222
238
  const nestedBatchClosesAll = prepared.executionPlan.commandInfo.command === "batch"
223
239
  && batchHasSuccessfulCloseAll(presentationEnvelope?.data, batchCommandSteps);
224
240
  const directCloseAllRequested = isCloseAllCommand(extractUpstreamCommandTokens(prepared.commandTokens));
225
- const rawCloseStatePath = isCloseCommand(prepared.executionPlan.commandInfo.command)
226
- && isRecord(presentationEnvelope?.data)
227
- && typeof presentationEnvelope.data.statePath === "string"
228
- ? presentationEnvelope.data.statePath
241
+ const directCloseData = confirmedData ?? presentationEnvelope?.data;
242
+ const rawCloseStatePath = directClose && isRecord(directCloseData) && typeof directCloseData.statePath === "string"
243
+ ? directCloseData.statePath
229
244
  : nestedBatchClose?.statePath;
230
245
  if (presentationEnvelope && prepared.exactSensitiveValues.length > 0)
231
246
  presentationEnvelope = redactExactSensitiveValue(presentationEnvelope, prepared.exactSensitiveValues);
232
247
  const parseFailureOutput = parseError && processResult.stdoutSpillPath
233
248
  ? { fullOutputUnavailable: "Malformed upstream output was discarded because it may contain sensitive browser data." }
234
249
  : {};
235
- const browserIndependentRead = prepared.readConfirmation !== undefined || isBrowserIndependentRead(prepared.commandTokens, prepared.runtimeToolStdin);
250
+ const browserIndependentRead = isBrowserIndependentConfirmation(prepared.readConfirmation) || isBrowserIndependentRead(prepared.commandTokens, prepared.runtimeToolStdin);
251
+ const nativeCommandMayHaveExecuted = !nativePolicyDefinitelyUnestablished(processResult, parsed.envelope, prepared.commandTokens);
252
+ if (state.confirmationPolicyMayBeEstablished !== true)
253
+ state.confirmationPolicyMayBeEstablished = nativeCommandMayHaveExecuted;
236
254
  const processSucceeded = !processResult.timedOut && !processResult.aborted && !processResult.spawnError && processResult.exitCode === 0;
237
255
  const plainTextInspection = prepared.executionPlan.plainTextInspection && processSucceeded;
238
256
  const parseSucceeded = plainTextInspection || parseError === undefined;
239
257
  if (isStreamEnableAlreadyEnabledNoop({ command: prepared.executionPlan.commandInfo.command, envelope: presentationEnvelope, processSucceeded, subcommand: prepared.executionPlan.commandInfo.subcommand })) {
240
258
  presentationEnvelope = { success: true, data: { alreadyEnabled: true, enabled: true, message: getEnvelopeErrorString(presentationEnvelope) ?? "Stream already enabled" } };
241
259
  }
242
- const envelopeSuccess = plainTextInspection ? true : presentationEnvelope?.success !== false;
260
+ const envelopeSuccess = plainTextInspection ? true : presentationEnvelope?.success !== false && !detectConfirmationRequired(presentationEnvelope?.data);
243
261
  let succeeded = (processSucceeded && parseSucceeded && envelopeSuccess) || recordingStopRecovery?.recovery.healed === true;
244
262
  const inspectionText = plainTextInspection ? processResult.stdout.trim() : undefined;
245
263
  const unobservedMutation = processResult.agentBrowserStarted && !plainTextInspection
@@ -272,6 +290,11 @@ export async function processBrowserOutput(input) {
272
290
  networkRoutesBySession.delete(sessionStateKey);
273
291
  sessionPageState.clearSession(sessionStateKey);
274
292
  }
293
+ if (sessionStateKey && nestedBatchRemainsActive) {
294
+ const confirmActions = getAgentBrowserProcessEnvironment().AGENT_BROWSER_CONFIRM_ACTIONS;
295
+ if (confirmActions !== undefined)
296
+ sessionPageState.setConfirmActions(sessionStateKey, confirmActions);
297
+ }
275
298
  if (prepared.executionPlan.commandInfo.command === "batch" && Array.isArray(presentationEnvelope?.data)) {
276
299
  for (const [index, row] of presentationEnvelope.data.entries()) {
277
300
  if (!isRecord(row))
@@ -282,11 +305,11 @@ export async function processBrowserOutput(input) {
282
305
  if (!rowCommand)
283
306
  continue;
284
307
  const [command, subcommand] = extractUpstreamCommandTokens(rowCommand);
285
- updateTraceOwnerState({ command, sessionName: sessionStateKey, subcommand, succeeded: row.success === true, traceOwners });
308
+ updateTraceOwnerState({ command, sessionName: sessionStateKey, subcommand, succeeded: row.success === true && !detectConfirmationRequired(row.result), traceOwners });
286
309
  }
287
310
  }
288
311
  else {
289
- updateTraceOwnerState({ command: prepared.executionPlan.commandInfo.command, sessionName: sessionStateKey, subcommand: prepared.executionPlan.commandInfo.subcommand, succeeded, traceOwners });
312
+ updateTraceOwnerState({ command: directClose ? "close" : prepared.executionPlan.commandInfo.command, sessionName: sessionStateKey, subcommand: prepared.executionPlan.commandInfo.subcommand, succeeded, traceOwners });
290
313
  }
291
314
  let clickDispatchDiagnostic;
292
315
  if (succeeded && prepared.clickDispatchProbe) {
@@ -296,21 +319,22 @@ export async function processBrowserOutput(input) {
296
319
  presentationEnvelope = { ...(presentationEnvelope ?? {}), error: clickDispatchDiagnostic.summary, success: false };
297
320
  }
298
321
  }
299
- const tabTransition = dispatchedCommands.some((step) => {
322
+ const tabTransition = confirmedEffects.some(effect => effect.command === "tab") || dispatchedCommands.some((step) => {
300
323
  const [command, subcommand] = extractUpstreamCommandTokens(step);
301
324
  return command === "tab" && subcommand !== undefined && subcommand !== "list";
302
325
  });
303
326
  // Non-page rows (including a failed prefix) cannot retire a cold target for a navigation that never ran.
304
- const resultingPageState = sessionPageState.get(sessionStateKey).tabReopenPending === true
327
+ const resultingPageState = !nativeCommandMayHaveExecuted || detectConfirmationRequired(presentationEnvelope?.data) || sessionPageState.get(sessionStateKey).tabReopenPending === true
305
328
  ? { currentPageUrl: prepared.priorSessionTabTarget?.url, pageTargetMayHaveChanged: false, pageUrlUnknown: prepared.priorSessionTabTargetUnknown === true }
306
329
  : getResultingPageTargetState({
307
330
  args: prepared.executionPlan.effectiveArgs,
308
331
  executedBatchSteps: dispatchedCommands,
332
+ batchResults: presentationEnvelope?.data,
309
333
  currentPageUrl: prepared.priorSessionTabTarget?.url,
310
334
  pageUrlUnknown: prepared.priorSessionTabTargetUnknown === true,
311
335
  });
312
- if (succeeded && !nestedBatchClosed &&
313
- (shouldCaptureNavigationSummary(prepared.executionPlan.commandInfo.command, presentationEnvelope?.data, prepared.executionPlan.commandInfo.subcommand) ||
336
+ if ((succeeded || processSucceeded && confirmedEffects.some(effect => effect.succeeded)) && readConfirmation?.state !== "pending" && !nestedBatchClosed && !directClose &&
337
+ (confirmedEffects.some(effect => shouldCaptureNavigationSummary(effect.command, effect.data)) || shouldCaptureNavigationSummary(prepared.executionPlan.commandInfo.command, presentationEnvelope?.data, prepared.executionPlan.commandInfo.subcommand) ||
314
338
  (prepared.executionPlan.commandInfo.command === "batch" && dispatchedCommands.some(([command, subcommand]) => isNavigationObservableCommandName(command, subcommand))) ||
315
339
  shouldCaptureSemanticActionNavigationSummary(prepared.compiledSemanticAction, presentationEnvelope?.data) ||
316
340
  commandRequiresLivePageVerification(prepared.executionPlan.effectiveArgs, prepared.runtimeToolStdin) ||
@@ -318,21 +342,21 @@ export async function processBrowserOutput(input) {
318
342
  navigationSummary = await collectNavigationSummary({ cwd, namespace: prepared.executionPlan.namespace, priorTarget: prepared.priorSessionTabTarget, reusePriorTitle: !tabTransition, sessionName: prepared.executionPlan.sessionName, signal });
319
343
  }
320
344
  // Failed transitions may already have changed the page; keep only a live observed URL.
321
- if (succeeded === false &&
322
- processResult.agentBrowserStarted &&
345
+ if (succeeded === false && navigationSummary === undefined && readConfirmation?.state !== "pending" &&
346
+ processResult.agentBrowserStarted && nativeCommandMayHaveExecuted &&
323
347
  !processResult.aborted &&
324
348
  !processResult.timedOut &&
325
- !nestedBatchClosed &&
326
- dispatchedCommands.some((step) => {
349
+ !nestedBatchClosed && !directClose &&
350
+ (confirmedEffects.some(effect => isUnverifiedPageTransitionCommand(effect.command)) || dispatchedCommands.some((step) => {
327
351
  const [command, subcommand] = extractUpstreamCommandTokens(step);
328
352
  return (prepared.executionPlan.commandInfo.command === "batch" && isOpenNavigationCommand(command))
329
353
  || isUnverifiedPageTransitionCommand(command, subcommand);
330
- })) {
354
+ }))) {
331
355
  navigationSummary = await collectNavigationSummary({ cwd, namespace: prepared.executionPlan.namespace, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName, signal });
332
356
  // Re-verifying a failed transition's URL does not make its prior refs valid.
333
357
  failedTransitionReverification = navigationSummary !== undefined;
334
358
  }
335
- if (navigationSummary && presentationEnvelope && prepared.executionPlan.commandInfo.command !== "eval" && !Array.isArray(presentationEnvelope.data))
359
+ if (!textOutput && navigationSummary && presentationEnvelope && prepared.executionPlan.commandInfo.command !== "eval" && !Array.isArray(presentationEnvelope.data))
336
360
  presentationEnvelope = { ...presentationEnvelope, data: mergeNavigationSummaryIntoData(presentationEnvelope.data, navigationSummary) };
337
361
  let overlayBlockerDiagnostic;
338
362
  let openResultTabCorrection;
@@ -343,22 +367,32 @@ export async function processBrowserOutput(input) {
343
367
  if (plannedTabCorrection)
344
368
  openResultTabCorrection = await applyOpenResultTabCorrection({ correction: plannedTabCorrection, cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, signal });
345
369
  }
346
- const verifiesCurrentUrl = prepared.executionPlan.commandInfo.command === "get" && prepared.executionPlan.commandInfo.subcommand === "url";
370
+ const verifiesCurrentUrl = prepared.executionPlan.commandInfo.command === "get" && prepared.executionPlan.commandInfo.subcommand === "url"
371
+ || readConfirmationEvent?.state === "cleared" && prepared.readConfirmation?.source === "native-guarded-action" && extractSessionTabTargetFromCommandData(prepared.commandTokens, presentationEnvelope?.data) !== undefined;
347
372
  const trustsReportedPageTarget = !resultingPageState.pageUrlUnknown || verifiesCurrentUrl;
348
373
  const pendingWebMcpMutation = isPendingWebMcpMutation(prepared.executionPlan.commandInfo.command, prepared.executionPlan.commandInfo.subcommand, presentationEnvelope?.data) || (prepared.executionPlan.commandInfo.command === "batch" && batchHasPendingWebMcpMutation(presentationEnvelope?.data));
349
374
  const failedWebMcpSettlement = prepared.priorSessionTabTargetUnknown === true && ((!succeeded && isWebMcpSettlementCommand(prepared.executionPlan.commandInfo.command, prepared.executionPlan.commandInfo.subcommand))
350
375
  || (prepared.executionPlan.commandInfo.command === "batch" && batchHasFailedWebMcpSettlement(presentationEnvelope?.data)));
351
376
  const unsettledWebMcpMutation = pendingWebMcpMutation || failedWebMcpSettlement;
377
+ const helperConfirmation = parseReadConfirmation(state.observedBrowserEffects?.readConfirmation);
378
+ // Main pending installs this object above; only a helper can replace it during post-processing.
379
+ let confirmationFromHelper = state.observedBrowserEffects?.readConfirmation !== readConfirmationEvent;
380
+ if (confirmationFromHelper && helperConfirmation?.state === "pending") {
381
+ readConfirmationEvent = helperConfirmation;
382
+ readConfirmation = helperConfirmation;
383
+ succeeded = false;
384
+ }
385
+ const pageTargetData = textOutput ? undefined : presentationEnvelope?.data;
352
386
  let observedSessionTabTarget = unsettledWebMcpMutation || (unobservedMutation && !failedTransitionReverification)
353
387
  ? undefined
354
388
  : normalizeSessionTabTarget(navigationSummary)
355
- ?? (trustsReportedPageTarget ? extractSessionTabTargetFromBatchResults(presentationEnvelope?.data) : undefined)
356
- ?? (succeeded && trustsReportedPageTarget ? extractSessionTabTargetFromCommandData(prepared.commandTokens, presentationEnvelope?.data) : undefined);
357
- if (observedSessionTabTarget && !browserIndependentRead && !nestedBatchClosed && !isCloseCommand(prepared.executionPlan.commandInfo.command)) {
389
+ ?? (trustsReportedPageTarget ? extractSessionTabTargetFromBatchResults(pageTargetData) : undefined)
390
+ ?? (succeeded && trustsReportedPageTarget ? extractSessionTabTargetFromCommandData(prepared.commandTokens, pageTargetData) : undefined);
391
+ if (observedSessionTabTarget && readConfirmation?.state !== "pending" && !browserIndependentRead && !nestedBatchClosed && !directClose) {
358
392
  const reportedTarget = prepared.executionPlan.commandInfo.command === "batch"
359
- ? extractSessionTabTargetFromBatchResults(presentationEnvelope?.data)
393
+ ? extractSessionTabTargetFromBatchResults(pageTargetData)
360
394
  : prepared.commandTokens[0] === "tab" && prepared.commandTokens[1] === "close" ? undefined
361
- : extractSessionTabTargetFromCommandData(prepared.commandTokens, presentationEnvelope?.data);
395
+ : extractSessionTabTargetFromCommandData(prepared.commandTokens, pageTargetData);
362
396
  if (reportedTarget?.targetId)
363
397
  observedSessionTabTarget.targetId = reportedTarget.targetId;
364
398
  else
@@ -370,7 +404,7 @@ export async function processBrowserOutput(input) {
370
404
  // Window/diff responses do not report the final URL; URL2 is intent, not redirect evidence.
371
405
  currentSessionTabTarget = resultingPageState.pageTargetMayHaveChanged
372
406
  ? succeeded && !destinationTransition ? normalizeSessionTabTarget({ url: resultingPageState.currentPageUrl }) : undefined
373
- : deriveSessionTabTarget({ command: prepared.executionPlan.commandInfo.command, data: presentationEnvelope?.data, navigationSummary, previousTarget: prepared.priorSessionTabTarget, subcommand: prepared.executionPlan.commandInfo.subcommand });
407
+ : deriveSessionTabTarget({ command: directClose ? "close" : prepared.executionPlan.commandInfo.command, data: pageTargetData, navigationSummary, previousTarget: prepared.priorSessionTabTarget, subcommand: prepared.executionPlan.commandInfo.subcommand });
374
408
  }
375
409
  let aboutBlankSessionMismatch;
376
410
  let electronPostCommandHealth;
@@ -449,11 +483,14 @@ export async function processBrowserOutput(input) {
449
483
  const comboboxFocusDiagnostic = succeeded ? await collectComboboxFocusDiagnostic({ command: prepared.executionPlan.commandInfo.command, commandTokens: prepared.commandTokens, cwd, namespace: prepared.executionPlan.namespace, semanticAction: prepared.compiledSemanticAction, sessionName: prepared.executionPlan.sessionName, signal }) : undefined;
450
484
  const recordingDependencyWarning = await collectRecordingDependencyWarning({ command: prepared.executionPlan.commandInfo.command, commandTokens: prepared.commandTokens, succeeded });
451
485
  const scrollNoopDiagnostic = succeeded && prepared.shouldProbeScrollNoop ? buildScrollNoopDiagnostic(prepared.scrollPositionBefore, await collectScrollPositionSnapshot({ cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, signal })) : undefined;
452
- const batchRefSnapshotState = prepared.executionPlan.commandInfo.command === "batch" ? extractLatestRefSnapshotStateFromBatchResults(presentationEnvelope?.data) : undefined;
486
+ const batchRefSnapshotState = prepared.executionPlan.commandInfo.command === "batch" ? extractLatestRefSnapshotStateFromBatchResults(Array.isArray(presentationEnvelope?.data) ? presentationEnvelope.data.map((row, index) => {
487
+ const snapshot = confirmedEffects.find(effect => effect.index === index && effect.command === "snapshot");
488
+ return snapshot && isRecord(row) ? { ...row, command: ["snapshot"], result: snapshot.data, success: snapshot.succeeded } : row;
489
+ }) : presentationEnvelope?.data) : undefined;
453
490
  let currentRefSnapshot;
454
491
  let currentRefSnapshotInvalidation;
455
492
  if (sessionStateKey && !browserIndependentRead) {
456
- const sessionClosed = (isCloseCommand(prepared.executionPlan.commandInfo.command) && succeeded) || nestedBatchClosed;
493
+ const sessionClosed = (directClose && succeeded) || nestedBatchClosed;
457
494
  if (sessionClosed) {
458
495
  state.attachedSessionKeys.delete(sessionStateKey);
459
496
  networkRoutesBySession = new Map(networkRoutesBySession);
@@ -473,7 +510,7 @@ export async function processBrowserOutput(input) {
473
510
  ? buildPageTransitionRefSnapshotInvalidation("A dispatched mutation was interrupted without a final outcome. Verify the current URL and take a fresh snapshot before deciding whether to retry.")
474
511
  : unsettledWebMcpMutation
475
512
  ? buildPageTransitionRefSnapshotInvalidation("A detached WebMCP invocation is still pending or failed to settle and can mutate, rerender, or navigate the page, so prior snapshot refs remain invalid after URL verification. Run webmcp result or cancel, then take a fresh snapshot before using page-scoped refs.")
476
- : processResult.agentBrowserStarted && (directTransitionInvalidation || plannedBatchTransitionInvalidation)
513
+ : processResult.agentBrowserStarted && nativeCommandMayHaveExecuted && (directTransitionInvalidation || plannedBatchTransitionInvalidation)
477
514
  ? directTransitionInvalidation ?? plannedBatchTransitionInvalidation
478
515
  : failedTransitionReverification
479
516
  ? buildPageTransitionRefSnapshotInvalidation("A failed page transition may still have changed the page, so the prior snapshot refs were invalidated. Run snapshot -i before using page-scoped refs.")
@@ -493,13 +530,13 @@ export async function processBrowserOutput(input) {
493
530
  : prepared.executionPlan.commandInfo.command === "batch"
494
531
  ? batchRefSnapshotState?.snapshot
495
532
  : succeeded
496
- ? prepared.executionPlan.commandInfo.command === "snapshot" ? extractRefSnapshotFromData(presentationEnvelope?.data) : prepared.resolvedSemanticActionRefSnapshot ?? overlayBlockerDiagnostic?.snapshot
533
+ ? prepared.executionPlan.commandInfo.command === "snapshot" || confirmedCommand === "snapshot" ? extractRefSnapshotFromData(confirmedData ?? presentationEnvelope?.data) : prepared.resolvedSemanticActionRefSnapshot ?? overlayBlockerDiagnostic?.snapshot
497
534
  : undefined;
498
535
  const refreshArgs = batchRefSnapshotState?.refreshArgs ?? (succeeded && prepared.commandTokens[0] === "snapshot" && isRecord(presentationEnvelope?.data) && isRecord(presentationEnvelope.data.snapshot) && !refSnapshot
499
536
  ? prepared.commandTokens.filter((token) => token !== "--delta" && token !== "--full") : undefined);
500
537
  // Native owns delta baselines. Read complete refs without advancing that baseline
501
538
  // rather than maintaining a second revision/tree cache in the wrapper.
502
- if (refreshArgs && !unsettledWebMcpMutation && !unobservedMutation) {
539
+ if (refreshArgs && readConfirmation?.state !== "pending" && !unsettledWebMcpMutation && !unobservedMutation) {
503
540
  refSnapshot = extractRefSnapshotFromData(await runSessionCommandData({ args: refreshArgs, cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, signal }));
504
541
  if (!refSnapshot)
505
542
  sessionPageState.applyRefSnapshotInvalidation({ invalidation: buildPageTransitionRefSnapshotInvalidation("The native snapshot delta did not include complete refs and the full ref read failed. Run snapshot -i before using refs."), sessionName: sessionStateKey, update: sessionPageStateUpdate });
@@ -534,8 +571,8 @@ export async function processBrowserOutput(input) {
534
571
  && nestedBatchRemainsActive
535
572
  && sessionStateKey === priorManagedSessionKey;
536
573
  const closeAllClosesPriorManagedSession = closeAllTargetsPriorManagedSession && !closeAllRetainsPriorManagedSession;
537
- const commandClosesSession = isCloseCommand(prepared.executionPlan.commandInfo.command) || nestedBatchClosed || closeAllClosesPriorManagedSession;
538
- const closeCommandSucceeded = (isCloseCommand(prepared.executionPlan.commandInfo.command) && succeeded) || nestedBatchClosed || closeAllClosesPriorManagedSession;
574
+ const commandClosesSession = directClose || nestedBatchClosed || closeAllClosesPriorManagedSession;
575
+ const closeCommandSucceeded = (directClose && succeeded) || nestedBatchClosed || closeAllClosesPriorManagedSession;
539
576
  const closeTargetsPriorManagedNamespace = prepared.executionPlan.namespace === priorManagedSessionNamespace;
540
577
  const managedCloseSessionName = closeAllClosesPriorManagedSession
541
578
  ? priorManagedSessionName
@@ -613,11 +650,8 @@ export async function processBrowserOutput(input) {
613
650
  }
614
651
  if (replacedManagedSessionName) {
615
652
  const replacedSessionStateKey = getSessionContextKey(replacedManagedSessionName, priorManagedSessionNamespace);
616
- networkRoutesBySession = new Map(networkRoutesBySession);
617
- networkRoutesBySession.delete(replacedSessionStateKey ?? replacedManagedSessionName);
618
- sessionPageState.clearSession(replacedSessionStateKey ?? replacedManagedSessionName);
619
653
  const replacedSessionKey = replacedSessionStateKey ?? replacedManagedSessionName;
620
- const replacedCloseError = await closeManagedSession({ cwd: priorManagedSessionCwd, headedManagedAutosaveInterval: priorManagedSessionHeadedAutosaveInterval, namespace: priorManagedSessionNamespace, preserveAttachedBrowserSession: state.attachedSessionKeys.has(replacedSessionKey), restoreState: state.managedSessionRestoreState, sessionName: replacedManagedSessionName, socketDir: state.ownedManagedSessions.get(replacedSessionKey)?.socketDir, timeoutMs: implicitSessionCloseTimeoutMs });
654
+ const replacedCloseError = await closeManagedSession({ confirmActions: sessionPageState.get(replacedSessionKey).confirmActions ?? process.env.AGENT_BROWSER_CONFIRM_ACTIONS, cwd: priorManagedSessionCwd, headedManagedAutosaveInterval: priorManagedSessionHeadedAutosaveInterval, namespace: priorManagedSessionNamespace, preserveAttachedBrowserSession: state.attachedSessionKeys.has(replacedSessionKey), restoreState: state.managedSessionRestoreState, sessionName: replacedManagedSessionName, socketDir: state.ownedManagedSessions.get(replacedSessionKey)?.socketDir, timeoutMs: implicitSessionCloseTimeoutMs });
621
655
  if (managedSessionOutcome) {
622
656
  managedSessionOutcome = {
623
657
  ...managedSessionOutcome,
@@ -628,6 +662,9 @@ export async function processBrowserOutput(input) {
628
662
  };
629
663
  }
630
664
  if (!replacedCloseError) {
665
+ networkRoutesBySession = new Map(networkRoutesBySession);
666
+ networkRoutesBySession.delete(replacedSessionKey);
667
+ sessionPageState.clearSession(replacedSessionKey);
631
668
  state.attachedSessionKeys.delete(replacedSessionKey);
632
669
  state.closedManagedSessionNames.add(replacedSessionKey);
633
670
  }
@@ -654,7 +691,7 @@ export async function processBrowserOutput(input) {
654
691
  if (electronHandoff.error) {
655
692
  succeeded = false;
656
693
  presentationEnvelope = { error: electronHandoff.error, success: false };
657
- const closeError = await closeManagedSession({ cwd, headedManagedAutosaveInterval: prepared.ownedManagedSessionContext?.headedManagedAutosaveInterval, namespace: prepared.executionPlan.namespace, policyLock: prepared.managedSessionPolicyLock, preserveAttachedBrowserSession: input.preserveAttachedBrowserSession, restoreState: state.managedSessionRestoreState, sessionName: electronSessionName, timeoutMs: implicitSessionCloseTimeoutMs });
694
+ const closeError = await closeManagedSession({ confirmActions: sessionPageState.get(electronSessionStateKey).confirmActions ?? process.env.AGENT_BROWSER_CONFIRM_ACTIONS, cwd, headedManagedAutosaveInterval: prepared.ownedManagedSessionContext?.headedManagedAutosaveInterval, namespace: prepared.executionPlan.namespace, policyLock: prepared.managedSessionPolicyLock, preserveAttachedBrowserSession: input.preserveAttachedBrowserSession, restoreState: state.managedSessionRestoreState, sessionName: electronSessionName, timeoutMs: implicitSessionCloseTimeoutMs });
658
695
  electronFailedConnectCleanup = await cleanupElectronLaunchResources({ child: prepared.electronLaunch.child, record: electronLaunchRecord, timeoutMs: implicitSessionCloseTimeoutMs });
659
696
  electronLaunchRecord = electronFailedConnectCleanup.record;
660
697
  if (electronFailedConnectCleanup.partial) {
@@ -707,7 +744,7 @@ export async function processBrowserOutput(input) {
707
744
  if (presentationEnvelope?.error !== undefined)
708
745
  presentationEnvelope = { ...presentationEnvelope, error: redactClipboardPermissionErrorValue(prepared.executionPlan.commandInfo, presentationEnvelope.error, clipboardWritePayloadCandidates) };
709
746
  }
710
- if (plainTextUpgrade && errorText)
747
+ if ((plainTextUpgrade || textOutput) && errorText)
711
748
  presentationEnvelope = { ...presentationEnvelope, success: false, error: errorText };
712
749
  const resultRetainsPreparedManagedSession = !managedSessionOutcome || (managedSessionOutcome.activeAfter
713
750
  && managedSessionOutcome.attemptedSessionName === managedSessionOutcome.currentSessionName);
@@ -718,24 +755,33 @@ export async function processBrowserOutput(input) {
718
755
  ? prepared.ownedManagedSessionContext?.headedManagedAutosaveInterval
719
756
  : undefined;
720
757
  // Commit native lifecycle facts to the execution state before rendering/export can fail.
758
+ const finalHelperConfirmation = parseReadConfirmation(state.observedBrowserEffects?.readConfirmation);
759
+ if (finalHelperConfirmation?.state === "pending") {
760
+ confirmationFromHelper ||= state.observedBrowserEffects?.readConfirmation !== readConfirmationEvent;
761
+ readConfirmationEvent = finalHelperConfirmation;
762
+ succeeded = false;
763
+ }
721
764
  applyBrowserRunStatePatch(state, { artifactManifest, freshSessionOrdinal, managedSessionActive, managedSessionCompatibilityWorkaround, managedSessionHeadedAutosaveDisabled, managedSessionHeadedAutosaveInterval, managedSessionCwd, managedSessionName, managedSessionNamespace, networkRoutesBySession });
722
765
  state.observedBrowserEffects = {
723
- args: prepared.redactedArgs, command: prepared.executionPlan.commandInfo.command, subcommand: prepared.executionPlan.commandInfo.subcommand,
766
+ args: prepared.redactedArgs, command: directClose ? "close" : prepared.executionPlan.commandInfo.command, subcommand: directClose ? undefined : prepared.executionPlan.commandInfo.subcommand,
724
767
  sessionName: prepared.executionPlan.sessionName, namespace: prepared.executionPlan.namespace, sessionMode: prepared.sessionMode,
725
768
  usedImplicitSession: prepared.executionPlan.usedImplicitSession, agentBrowserStarted: processResult.agentBrowserStarted,
726
769
  nativeSucceeded: succeeded, managedSessionOutcome, managedSessionCwd: prepared.ownedManagedSessionContext?.cwd ?? managedSessionCwd, compatibilityWorkaround: prepared.compatibilityWorkaround,
727
770
  managedSessionHeadedAutosaveDisabled: resultHeadedManagedAutosaveDisabled, managedSessionHeadedAutosaveInterval: resultHeadedManagedAutosaveInterval,
728
771
  managedSessionRestoreDisabled: state.managedSessionRestoreState.isDisabled(prepared.executionPlan.sessionName, prepared.executionPlan.namespace),
729
- closeAllApplied, readConfirmation: readConfirmationEvent ?? prepared.readConfirmation,
772
+ closeAllApplied, readConfirmation: readConfirmationEvent ?? prepared.readConfirmation ?? state.observedBrowserEffects?.readConfirmation,
730
773
  electron: electronLaunchRecord ? { launch: electronLaunchRecord, cleanup: electronFailedConnectCleanup } : undefined,
731
774
  batchSteps: Array.isArray(presentationEnvelope?.data) ? presentationEnvelope.data.filter(isRecord).map(row => ({
732
- command: Array.isArray(row.command) && row.command.every(token => typeof token === "string") ? redactInvocationArgs(row.command) : undefined,
733
- success: row.success, lifecycle: extractAgentBrowserLifecycle(row.result),
775
+ command: Array.isArray(row.command) && row.command.every(token => typeof token === "string") ? isSuccessfulNativeConfirmedClose(row.command, row.result) ? ["close"] : redactInvocationArgs(row.command) : undefined,
776
+ success: row.success === true && !detectConfirmationRequired(row.result), lifecycle: extractAgentBrowserLifecycle(row.result),
734
777
  })) : undefined,
735
778
  };
736
- 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({ modelVisible: input.modelVisible, args: prepared.redactedProcessArgs, artifactManifest, artifactMaxUpdatedAtMs: Date.now(), artifactMinUpdatedAtMs: input.artifactRunStartedAtMs, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: prepared.executionPlan.commandInfo, compiledSemanticAction: prepared.compiledSemanticAction, cwd: operationCwd, envelope: presentationEnvelope, errorText, namespace: prepared.executionPlan.namespace, networkRouteDiagnostics, networkRoutes: activeNetworkRoutes, persistentArtifactStore, previousRecordingContactSheetPath: sessionStateKey ? state.activeRecordingReservations?.get(sessionStateKey)?.contactSheetPath : undefined, piCleanupOwnership: sessionStateKey && (state.ownedManagedSessions.has(sessionStateKey) || prepared.executionPlan.managedSessionName !== undefined) ? "wrapper-managed" : "caller-owned", sessionName: prepared.executionPlan.sessionName });
779
+ 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({ textOutput, stdin: prepared.processStdin, modelVisible: input.modelVisible, args: prepared.redactedProcessArgs, artifactManifest, artifactMaxUpdatedAtMs: Date.now(), artifactMinUpdatedAtMs: input.artifactRunStartedAtMs, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: { ...prepared.executionPlan.commandInfo, commandTokens: prepared.commandTokens }, compiledSemanticAction: prepared.compiledSemanticAction, cwd: operationCwd, envelope: presentationEnvelope, errorText, namespace: prepared.executionPlan.namespace, networkRouteDiagnostics, networkRoutes: activeNetworkRoutes, persistentArtifactStore, previousRecordingContactSheetPath: sessionStateKey ? state.activeRecordingReservations?.get(sessionStateKey)?.contactSheetPath : undefined, piCleanupOwnership: sessionStateKey && (state.ownedManagedSessions.has(sessionStateKey) || prepared.executionPlan.managedSessionName !== undefined) ? "wrapper-managed" : "caller-owned", sessionName: prepared.executionPlan.sessionName });
737
780
  if (recordingStopRecovery)
738
781
  presentation = mergeRecordingRecoveryPresentation(presentation, recordingStopRecovery);
782
+ if (parseError && processResult.exitCode !== 0 && processResult.stderr.trim() && !processResult.timedOut && !processResult.aborted && !processResult.spawnError) {
783
+ presentation.failureCategory = "upstream-error";
784
+ }
739
785
  const confirmation = readConfirmationEvent ?? prepared.readConfirmation;
740
786
  if (confirmation) {
741
787
  presentation.readConfirmation = confirmation;
@@ -746,9 +792,11 @@ export async function processBrowserOutput(input) {
746
792
  presentation.resultCategory = "failure";
747
793
  presentation.failureCategory = "confirmation-required";
748
794
  presentation.successCategory = undefined;
795
+ if (confirmationFromHelper)
796
+ presentation.content.unshift({ type: "text", text: `Native helper ${confirmation.command ?? "read"} requires confirmation (${confirmation.action ?? "read"}, ${confirmation.id}). The requested command's result, if dispatched, is preserved below.` });
749
797
  }
750
798
  }
751
- if (plainTextUpgrade && !succeeded && typeof presentationEnvelope?.data === "string") {
799
+ if (plainTextUpgrade && !textOutput && !succeeded && typeof presentationEnvelope?.data === "string") {
752
800
  presentation.data = presentationEnvelope.data;
753
801
  const errorContent = presentation.content[0];
754
802
  if (errorContent?.type === "text" && presentationEnvelope.data)
@@ -900,10 +948,20 @@ export async function processBrowserOutput(input) {
900
948
  state.managedSessionRestoreState.recordDaemonRestoreKey(prepared.executionPlan.sessionName, prepared.executionPlan.namespace, daemon.restoreKey, daemon.generation);
901
949
  }
902
950
  }
951
+ if (sessionStateKey && currentRefSnapshot && processSucceeded && parseSucceeded && !unobservedMutation && readConfirmationEvent?.state === "cleared"
952
+ && readConfirmationEvent.command === "snapshot" && readConfirmationEvent.action === "snapshot"
953
+ && confirmedEffects.some(effect => effect.command === "snapshot" && effect.succeeded
954
+ && effect.index === (Array.isArray(presentationEnvelope?.data) ? presentationEnvelope.data.length - 1 : 0) && extractRefSnapshotFromData(effect.data))) {
955
+ readConfirmationEvent = { ...readConfirmationEvent, refSnapshotFresh: true };
956
+ presentation.readConfirmation = readConfirmationEvent;
957
+ sessionPageState.applyReadConfirmation(readConfirmationEvent, sessionPageStateUpdate);
958
+ state.observedBrowserEffects = { ...state.observedBrowserEffects, readConfirmation: readConfirmationEvent };
959
+ }
903
960
  if (sessionStateKey)
904
961
  currentSessionTabTarget = authoritativePageState?.tabTarget;
905
962
  const currentSessionTabTargetUnknown = authoritativePageState?.tabTargetUnknown === true ? true : undefined;
906
- const result = buildFinalAgentBrowserToolResult({ modelVisible: input.modelVisible, 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: !prepared.ownedManagedSessionContext?.reuseOnly && resultHeadedManagedAutosaveDisabled || undefined, managedSessionHeadedAutosaveInterval: prepared.ownedManagedSessionContext?.reuseOnly ? undefined : 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 });
963
+ // ponytail: skip silent ref copies; direct failure receipts need their existing fields until final publication owns those paths.
964
+ const result = buildFinalAgentBrowserToolResult({ modelVisible: input.modelVisible, aboutBlankSessionMismatch, artifactCleanup, categoryDetails: finalRecoveryState.categoryDetails, clickDispatchDiagnostic, commandTokens: prepared.commandTokens, comboboxFocusDiagnostic, compiledNetworkSourceLookup: prepared.compiledNetworkSourceLookup, compiledSemanticAction: prepared.compiledSemanticAction, compatibilityWorkaround: prepared.compatibilityWorkaround, currentRefSnapshot: input.modelVisible === false ? undefined : 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: !prepared.ownedManagedSessionContext?.reuseOnly && resultHeadedManagedAutosaveDisabled || undefined, managedSessionHeadedAutosaveInterval: prepared.ownedManagedSessionContext?.reuseOnly ? undefined : 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 });
907
965
  const resultWithCloseAll = closeAllApplied
908
966
  ? { ...result, details: { ...(isRecord(result.details) ? result.details : {}), closeAllApplied: true } }
909
967
  : result;
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from "../../parsing.js";
2
+ import { LIGHTPANDA_IMAGE_REASON } from "../../results/presentation/common.js";
2
3
  import { getScreenshotPathTokenIndex, getScreenshotPositionalIndices } from "./artifact-paths.js";
3
4
  import { runSessionCommandData } from "./session-state.js";
4
5
  export function getScreenshotCapture(command) {
@@ -28,7 +29,8 @@ export async function collectScreenshotSample(options) {
28
29
  element = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
29
30
  }
30
31
  } catch {}
31
- return { url: location.href, frame: window === window.top ? "main" : "child", childFrameCount: window.frames.length,
32
+ const textRendered = navigator.userAgentData?.brands?.some(b => b.brand === "Lightpanda") || /\\bLightpanda\\//i.test(navigator.userAgent);
33
+ return { ...(textRendered ? { rendering: "text" } : {}), url: location.href, frame: window === window.top ? "main" : "child", childFrameCount: window.frames.length,
32
34
  viewport: { width: innerWidth, height: innerHeight },
33
35
  document: { width: Math.max(root.scrollWidth, body?.scrollWidth || 0, innerWidth), height: Math.max(root.scrollHeight, body?.scrollHeight || 0, innerHeight) },
34
36
  scroll: { x: scrollX, y: scrollY }, dpr: devicePixelRatio,
@@ -37,7 +39,7 @@ export async function collectScreenshotSample(options) {
37
39
  try {
38
40
  const data = await runSessionCommandData({ ...options, args: ["eval", "--stdin"], stdin: script, timeoutMs: 2_000 });
39
41
  const sample = isRecord(data) ? data.result : undefined;
40
- if (!isRecord(sample) || typeof sample.url !== "string" || !["main", "child"].includes(String(sample.frame))
42
+ if (!isRecord(sample) || (sample.rendering !== undefined && sample.rendering !== "text") || typeof sample.url !== "string" || !["main", "child"].includes(String(sample.frame))
41
43
  || !finiteRecord(sample.viewport, ["width", "height"]) || !finiteRecord(sample.document, ["width", "height"])
42
44
  || !finiteRecord(sample.scroll, ["x", "y"]) || !finiteRecord(sample.visualViewport, ["x", "y", "scale"])
43
45
  || typeof sample.childFrameCount !== "number" || !Number.isInteger(sample.childFrameCount) || sample.childFrameCount < 0
@@ -53,6 +55,8 @@ export async function collectScreenshotSample(options) {
53
55
  export function buildScreenshotGeometry(options) {
54
56
  const { before, after, capture, pixels } = options;
55
57
  const unknown = (reason) => ({ status: "unknown", reason, before, after });
58
+ if (options.rendering === "text" || before?.rendering === "text" || after?.rendering === "text")
59
+ return unknown(LIGHTPANDA_IMAGE_REASON);
56
60
  if (!before || !after)
57
61
  return unknown("Capture was not bracketed by browser geometry samples; coordinates are unknown.");
58
62
  if (JSON.stringify(before) !== JSON.stringify(after))
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { rm } from "node:fs/promises";
2
3
  import { getScreenshotPositionalIndices } from "./artifact-paths.js";
3
4
  import { observeNativeWebMcp } from "../../webmcp-observation.js";
@@ -7,6 +8,7 @@ import { isBrowserIndependentRead, needsManagedSession } from "../../command-pol
7
8
  import { runAgentBrowserProcess } from "../../process.js";
8
9
  import { buildAgentBrowserNextActions } from "../../results/action-recommendations.js";
9
10
  import { parseAgentBrowserEnvelope } from "../../results/envelope.js";
11
+ import { detectConfirmationRequired } from "../../results/confirmation.js";
10
12
  import { buildNextToolAction, withOptionalNamespaceArgs, withOptionalSessionArgs } from "../../results/next-actions.js";
11
13
  import { getSessionPageStateKey, isAboutBlankUrl, normalizeComparableUrl, targetsMatch, } from "../../session-page-state.js";
12
14
  import { getRecordCommandOperands, isCloseCommand, isElectronPostCommandHealthCommand, isNavigationObservableCommandName, isOpenNavigationCommand, isRefGuardedCommand, isRefInvalidatingBatchCommand, isSessionTabPinningExcludedCommand, isSessionTabPostCommandCorrectionExcludedCommand, isWindowOrDiffPageTransitionCommand, } from "../../command-taxonomy.js";
@@ -257,6 +259,10 @@ export function extractNavigationSummaryFromData(data) {
257
259
  export function shouldCaptureNavigationSummary(command, data, subcommand) {
258
260
  if (command === "eval")
259
261
  return true;
262
+ // A completed compound action may navigate without returning a page URL (e.g. recording restart).
263
+ if (command === "confirm" && isRecord(data) && data.confirmed === true && data.action === "navigate"
264
+ && isRecord(data.result) && data.result.success === true && extractStringResultField(data.result.data, "url") === undefined)
265
+ return true;
260
266
  return (isNavigationObservableCommandName(command, subcommand) &&
261
267
  (!isRecord(data) || (typeof data.title !== "string" && typeof data.url !== "string")));
262
268
  }
@@ -399,7 +405,8 @@ export function buildStaleRefPreflight(options) {
399
405
  }
400
406
  if (!options.refSnapshot)
401
407
  return undefined;
402
- if (!targetsMatch(options.refSnapshot.target, options.currentTarget)) {
408
+ if (!targetsMatch(options.refSnapshot.target, options.currentTarget)
409
+ || options.requireExactTargetUrl && options.refSnapshot.target && options.currentTarget && options.refSnapshot.target.url !== options.currentTarget.url) {
403
410
  return {
404
411
  message: `Ref ${usedRefIds.map((refId) => `@${refId}`).join(", ")} came from a snapshot for ${options.refSnapshot.target?.url ?? "a prior page"}, but the current session target is ${options.currentTarget?.url ?? "unknown"}. Run snapshot -i again before using page-scoped refs.`,
405
412
  refIds: usedRefIds,
@@ -492,47 +499,52 @@ function selectAnySessionTargetTab(options) {
492
499
  const selection = selectedTab ? getTabSelection(selectedTab) : undefined;
493
500
  return selection ? { ...selection, ...(targetTitle ? { targetTitle } : {}), targetUrl } : undefined;
494
501
  }
502
+ const sessionCommandObservation = new AsyncLocalStorage();
503
+ export function withSessionCommandObservation(observer, run) {
504
+ return sessionCommandObservation.run(observer, run);
505
+ }
506
+ export function nativePolicyDefinitelyUnestablished(result, envelope, commandTokens) {
507
+ if (commandTokens && !needsManagedSession(parseArgvDescriptor(commandTokens)) && !isBrowserIndependentRead(commandTokens))
508
+ return true;
509
+ if (!result.agentBrowserStarted)
510
+ return true;
511
+ if (result.aborted || result.timedOut || result.spawnError || result.exitCode !== 1)
512
+ return false;
513
+ // Native CLI parse/config failures precede ensure_daemon; a failed executed action does not.
514
+ return envelope?.success === false && isRecord(envelope.data) && Object.keys(envelope.data).length === 1
515
+ && ["unknown_command", "unknown_subcommand", "missing_arguments", "invalid_value", "invalid_session_name"].includes(String(envelope.data.type))
516
+ || result.stdout.trim() === "" && /config file not found:|failed to load config from /.test(result.stderr);
517
+ }
495
518
  export async function runSessionCommandData(options) {
496
519
  const { args, cwd, env, namespace, pinNamespace, sessionName, signal, stdin, throwOnFailure, timeoutMs } = options;
497
- if (!sessionName)
520
+ if (!sessionName || sessionCommandObservation.getStore()?.allow(options) === false)
498
521
  return undefined;
499
522
  const processResult = await runAgentBrowserProcess({
500
523
  args: ["--json", ...(namespace !== undefined || pinNamespace ? ["--namespace", namespace ?? ""] : []), "--session", sessionName, ...args],
501
- cwd,
502
- env,
503
- signal,
504
- stdin,
505
- timeoutMs,
524
+ cwd, env, signal, stdin, timeoutMs,
506
525
  });
507
526
  try {
508
527
  options.onProcessResult?.(processResult);
528
+ const parsed = await parseAgentBrowserEnvelope({ stdout: processResult.stdout, stdoutPath: processResult.stdoutSpillPath });
529
+ sessionCommandObservation.getStore()?.observe(options, processResult, parsed.envelope);
509
530
  if (processResult.aborted || processResult.spawnError || processResult.exitCode !== 0) {
510
531
  if (throwOnFailure) {
511
- const reason = processResult.aborted
512
- ? "command was aborted"
513
- : processResult.spawnError
514
- ? "process could not start"
515
- : `process exited with code ${processResult.exitCode}`;
532
+ const reason = processResult.aborted ? "command was aborted" : processResult.spawnError ? "process could not start" : `process exited with code ${processResult.exitCode}`;
516
533
  throw new Error(`agent-browser ${reason}`);
517
534
  }
518
535
  return undefined;
519
536
  }
520
- const parsed = await parseAgentBrowserEnvelope({
521
- stdout: processResult.stdout,
522
- stdoutPath: processResult.stdoutSpillPath,
523
- });
524
- if (parsed.parseError || parsed.envelope?.success === false) {
537
+ if (parsed.parseError || parsed.envelope?.success === false || detectConfirmationRequired(parsed.envelope?.data)) {
525
538
  if (throwOnFailure)
526
- throw new Error(parsed.parseError ? "agent-browser returned invalid structured output" : "agent-browser reported failure");
539
+ throw new Error(parsed.parseError ? "agent-browser returned invalid structured output" : "agent-browser reported failure or requires confirmation");
527
540
  return undefined;
528
541
  }
529
542
  observeNativeWebMcp(parsed.envelope?.data);
530
543
  return parsed.envelope?.data;
531
544
  }
532
545
  finally {
533
- if (processResult.stdoutSpillPath) {
546
+ if (processResult.stdoutSpillPath)
534
547
  await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
535
- }
536
548
  }
537
549
  }
538
550
  export async function collectOpenResultTabCorrection(options) {