pi-agent-browser-native 0.2.34 → 0.2.36

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 (38) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +25 -15
  3. package/docs/ARCHITECTURE.md +19 -13
  4. package/docs/COMMAND_REFERENCE.md +274 -44
  5. package/docs/ELECTRON.md +3 -3
  6. package/docs/RELEASE.md +11 -11
  7. package/docs/REQUIREMENTS.md +5 -5
  8. package/docs/SUPPORT_MATRIX.md +43 -24
  9. package/docs/TOOL_CONTRACT.md +50 -30
  10. package/extensions/agent-browser/index.ts +518 -2402
  11. package/extensions/agent-browser/lib/argv-descriptor.ts +90 -0
  12. package/extensions/agent-browser/lib/argv-grammar.ts +128 -0
  13. package/extensions/agent-browser/lib/command-policy.ts +71 -0
  14. package/extensions/agent-browser/lib/command-taxonomy.ts +336 -0
  15. package/extensions/agent-browser/lib/electron/cleanup.ts +1 -0
  16. package/extensions/agent-browser/lib/executable-path.ts +19 -0
  17. package/extensions/agent-browser/lib/input-modes/params.ts +6 -6
  18. package/extensions/agent-browser/lib/orchestration/batch-stdin.ts +65 -0
  19. package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +154 -0
  20. package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +149 -0
  21. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +56 -30
  22. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +13 -3
  23. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +33 -27
  24. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +48 -22
  25. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +39 -10
  26. package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +93 -0
  27. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +98 -124
  28. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +40 -1
  29. package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +860 -0
  30. package/extensions/agent-browser/lib/playbook.ts +10 -10
  31. package/extensions/agent-browser/lib/prompt-policy.ts +122 -0
  32. package/extensions/agent-browser/lib/results/action-recommendations.ts +3 -23
  33. package/extensions/agent-browser/lib/results/presentation/navigation.ts +2 -34
  34. package/extensions/agent-browser/lib/runtime.ts +93 -227
  35. package/extensions/agent-browser/lib/session-page-state.ts +31 -14
  36. package/extensions/agent-browser/lib/temp.ts +148 -23
  37. package/package.json +4 -4
  38. package/scripts/agent-browser-capability-baseline.mjs +198 -1
@@ -43,6 +43,7 @@ import {
43
43
  } from "../../session-page-state.js";
44
44
  import { extractExplicitSessionName, redactInvocationArgs, redactSensitiveText, redactSensitiveValue, type OpenResultTabCorrection } from "../../runtime.js";
45
45
  import { isRecord } from "../../parsing.js";
46
+ import { buildClickDispatchNextActions, formatClickDispatchDiagnosticText } from "./click-dispatch.js";
46
47
  import {
47
48
  buildComboboxFocusNextActions,
48
49
  buildElectronBroadGetTextScopeNextActions,
@@ -55,6 +56,7 @@ import {
55
56
  formatArtifactCleanupGuidanceText,
56
57
  formatComboboxFocusDiagnosticText,
57
58
  formatElectronBroadGetTextScopeText,
59
+ formatEvalResultWarningText,
58
60
  formatEvalStdinHintText,
59
61
  formatFillVerificationText,
60
62
  formatOverlayBlockerText,
@@ -68,6 +70,7 @@ import {
68
70
  buildElectronLifecycleNextActions,
69
71
  buildElectronMismatchNextActions,
70
72
  buildElectronRefFreshnessNextActions,
73
+ buildManagedSessionFreshFailureNextActions,
71
74
  buildManagedSessionOutcome,
72
75
  buildSessionDetailFields,
73
76
  formatElectronPostCommandHealthText,
@@ -212,7 +215,7 @@ export function buildElectronHostFailureResult(options: {
212
215
  return { content: [{ type: "text", text: redactSensitiveText(text) }], details: redactToolDetails(details, []), isError: true };
213
216
  }
214
217
 
215
- function formatElectronTargetLines(targets: ElectronCdpTarget[], limit = 8): string[] {
218
+ export function formatElectronTargetLines(targets: ElectronCdpTarget[], limit = 8): string[] {
216
219
  const shownTargets = targets.slice(0, limit);
217
220
  const lines = shownTargets.map((target) => {
218
221
  const label = [target.type, target.title].filter(Boolean).join(" ") || target.id || "target";
@@ -319,8 +322,10 @@ function buildResultNextActions(options: FinalResultInput): AgentBrowserNextActi
319
322
  if (options.selectorTextVisibilityDiagnostics.length > 0) nextActionCollector.append(buildSelectorTextVisibilityNextActions({ diagnostics: options.selectorTextVisibilityDiagnostics, sessionName: options.executionPlan.sessionName }));
320
323
  if (options.electronBroadGetTextScopeDiagnostics.length > 0) nextActionCollector.append(buildElectronBroadGetTextScopeNextActions({ diagnostics: options.electronBroadGetTextScopeDiagnostics, sessionName: options.executionPlan.sessionName }));
321
324
  if (options.sourceLookup?.electronContext) nextActionCollector.appendUnique(buildSourceLookupElectronNextActions(options.sourceLookup));
325
+ if (options.clickDispatchDiagnostic) nextActionCollector.append(buildClickDispatchNextActions({ commandTokens: options.commandTokens, sessionName: options.executionPlan.sessionName }));
322
326
  if (options.scrollNoopDiagnostic) nextActionCollector.append(buildScrollNoopNextActions(options.executionPlan.sessionName));
323
327
  if (options.comboboxFocusDiagnostic) nextActionCollector.append(buildComboboxFocusNextActions(options.executionPlan.sessionName));
328
+ if (options.managedSessionOutcome) nextActionCollector.appendUnique(buildManagedSessionFreshFailureNextActions(options.managedSessionOutcome));
324
329
  if (options.categoryDetails.failureCategory === "stale-ref" && options.redactedCompiledSemanticAction && isCompiledSemanticActionFindCommand(options.compiledSemanticAction)) nextActionCollector.append([{ id: "retry-semantic-action-after-stale-ref", params: { args: options.redactedCompiledSemanticAction.args }, reason: "Retry the same semantic target via its compiled find command after the upstream stale-ref failure proves the prior action did not execute.", safety: "Use only for the same intended target; direct stale @refs still require a fresh snapshot or stable locator before retrying.", tool: "agent_browser" as const }]);
325
330
  if (options.electronLaunchRecord) nextActionCollector.append(buildAgentBrowserNextActions({ electron: { launchId: options.electronLaunchRecord.launchId, sessionName: options.electronLaunchRecord.sessionName, status: options.electronLaunchRecord.cleanupState }, failureCategory: options.categoryDetails.failureCategory, resultCategory: options.categoryDetails.resultCategory, successCategory: options.categoryDetails.successCategory }));
326
331
  return nextActionCollector.toArray();
@@ -369,6 +374,7 @@ function buildAgentBrowserResultDetails(options: FinalResultInput, nextActions:
369
374
  imagePaths: options.presentation.imagePaths,
370
375
  nextActions,
371
376
  pageChangeSummary,
377
+ clickDispatch: options.clickDispatchDiagnostic,
372
378
  overlayBlockers: options.overlayBlockerDiagnostic,
373
379
  fillVerification: options.fillVerificationDiagnostic,
374
380
  visibleRefFallback: publicVisibleRefFallbackDiagnostic,
@@ -383,6 +389,7 @@ function buildAgentBrowserResultDetails(options: FinalResultInput, nextActions:
383
389
  selectorTextVisibility: options.selectorTextVisibilityDiagnostics[0],
384
390
  selectorTextVisibilityAll: options.selectorTextVisibilityDiagnostics.length > 1 ? options.selectorTextVisibilityDiagnostics : undefined,
385
391
  evalStdinHint: options.evalStdinHint,
392
+ evalResultWarning: options.evalResultWarning,
386
393
  timeoutPartialProgress: options.timeoutPartialProgress,
387
394
  parseError: options.plainTextInspection ? undefined : options.parseError,
388
395
  savedFile: options.presentation.savedFile,
@@ -411,6 +418,7 @@ export function buildFinalAgentBrowserToolResult(options: FinalResultInput): Age
411
418
  const visibleRefFallbackText = formatVisibleRefFallbackText(options.visibleRefFallbackDiagnostic);
412
419
  const richInputRecoveryText = formatRichInputRecoveryText(options.richInputRecoveryDiagnostic);
413
420
  const semanticActionCandidateText = nextActions ? formatSemanticActionCandidateText(nextActions) : undefined;
421
+ const clickDispatchText = options.clickDispatchDiagnostic ? formatClickDispatchDiagnosticText(options.clickDispatchDiagnostic) : undefined;
414
422
  const overlayBlockerText = options.overlayBlockerDiagnostic ? formatOverlayBlockerText(options.overlayBlockerDiagnostic) : undefined;
415
423
  const fillVerificationText = formatFillVerificationText(options.fillVerificationDiagnostic);
416
424
  const electronRefFreshnessText = formatElectronRefFreshnessText(options.electronRefFreshnessDiagnostic);
@@ -420,10 +428,11 @@ export function buildFinalAgentBrowserToolResult(options: FinalResultInput): Age
420
428
  const comboboxFocusDiagnosticText = formatComboboxFocusDiagnosticText(options.comboboxFocusDiagnostic);
421
429
  const recordingDependencyWarningText = formatRecordingDependencyWarningText(options.recordingDependencyWarning);
422
430
  const evalStdinHintText = formatEvalStdinHintText(options.evalStdinHint);
431
+ const evalResultWarningText = formatEvalResultWarningText(options.evalResultWarning);
423
432
  const artifactCleanupText = formatArtifactCleanupGuidanceText(options.artifactCleanup);
424
433
  const timeoutPartialProgressText = options.timeoutPartialProgress ? formatTimeoutPartialProgressText(options.timeoutPartialProgress) : undefined;
425
434
  const managedSessionOutcomeText = formatManagedSessionOutcomeText(options.managedSessionOutcome);
426
- const rawAppendedDiagnosticText = [visibleRefFallbackText, richInputRecoveryText, semanticActionCandidateText, overlayBlockerText, fillVerificationText, electronRefFreshnessText, selectorTextVisibilityText, electronBroadGetTextScopeText, scrollNoopDiagnosticText, comboboxFocusDiagnosticText, recordingDependencyWarningText, evalStdinHintText, artifactCleanupText, timeoutPartialProgressText, managedSessionOutcomeText].filter((item): item is string => item !== undefined).join("\n\n");
435
+ const rawAppendedDiagnosticText = [visibleRefFallbackText, richInputRecoveryText, semanticActionCandidateText, clickDispatchText, overlayBlockerText, fillVerificationText, electronRefFreshnessText, selectorTextVisibilityText, electronBroadGetTextScopeText, scrollNoopDiagnosticText, comboboxFocusDiagnosticText, recordingDependencyWarningText, evalStdinHintText, evalResultWarningText, artifactCleanupText, timeoutPartialProgressText, managedSessionOutcomeText].filter((item): item is string => item !== undefined).join("\n\n");
427
436
  const appendedDiagnosticText = redactSensitiveText(redactExactSensitiveText(rawAppendedDiagnosticText, options.exactSensitiveValues));
428
437
  const shouldAppendDiagnosticText = appendedDiagnosticText.length > 0 && (!options.userRequestedJson || options.plainTextInspection);
429
438
  let content = shouldAppendDiagnosticText && options.redactedContent[0]?.type === "text" ? [{ ...options.redactedContent[0], text: `${options.redactedContent[0].text}\n\n${appendedDiagnosticText}` }, ...options.redactedContent.slice(1)] : options.redactedContent;
@@ -439,6 +448,7 @@ export async function buildMissingBinaryFailureResult(options: { compatibilityWo
439
448
  const errorText = buildMissingBinaryMessage();
440
449
  const managedSessionOutcome = buildManagedSessionOutcome({ activeAfter: options.managedSessionActive, activeBefore: options.managedSessionActive, attemptedSessionName: options.executionPlan.managedSessionName, command: options.executionPlan.commandInfo.command, currentSessionName: options.managedSessionName, previousSessionName: options.managedSessionName, sessionMode: options.sessionMode, succeeded: false });
441
450
  const managedSessionOutcomeText = formatManagedSessionOutcomeText(managedSessionOutcome);
451
+ const managedSessionRecoveryNextActions = buildManagedSessionFreshFailureNextActions(managedSessionOutcome);
442
452
  let missingBinaryElectronCleanup: ElectronCleanupResult | undefined;
443
453
  let missingBinaryElectronRecord: ElectronLaunchRecord | undefined;
444
454
  if (options.electronLaunch) {
@@ -446,5 +456,5 @@ export async function buildMissingBinaryFailureResult(options: { compatibilityWo
446
456
  missingBinaryElectronRecord = missingBinaryElectronCleanup.record;
447
457
  }
448
458
  const textParts = [errorText, managedSessionOutcomeText, missingBinaryElectronCleanup ? `Electron cleanup after failed attach: ${missingBinaryElectronCleanup.summary}` : undefined].filter((part): part is string => part !== undefined && part.length > 0);
449
- return { content: [{ type: "text", text: textParts.join("\n\n") }], details: { args: options.redactedArgs, compatibilityWorkaround: options.compatibilityWorkaround, effectiveArgs: options.redactedProcessArgs, electron: missingBinaryElectronRecord ? { action: "launch" as const, cleanup: missingBinaryElectronCleanup, launch: missingBinaryElectronRecord, status: "failed" as const, targets: options.electronLaunch?.targets, version: options.electronLaunch?.version } : undefined, managedSessionOutcome, sessionMode: options.sessionMode, sessionTabCorrection: options.sessionTabCorrection, ...buildAgentBrowserResultCategoryDetails({ args: options.redactedProcessArgs, command: options.executionPlan.commandInfo.command, errorText, failureCategory: "missing-binary", spawnError: options.processResult.spawnError.message, succeeded: false }), spawnError: options.processResult.spawnError.message }, isError: true };
459
+ return { content: [{ type: "text", text: textParts.join("\n\n") }], details: { args: options.redactedArgs, compatibilityWorkaround: options.compatibilityWorkaround, effectiveArgs: options.redactedProcessArgs, electron: missingBinaryElectronRecord ? { action: "launch" as const, cleanup: missingBinaryElectronCleanup, launch: missingBinaryElectronRecord, status: "failed" as const, targets: options.electronLaunch?.targets, version: options.electronLaunch?.version } : undefined, managedSessionOutcome, nextActions: managedSessionRecoveryNextActions.length > 0 ? managedSessionRecoveryNextActions : undefined, sessionMode: options.sessionMode, sessionTabCorrection: options.sessionTabCorrection, ...buildAgentBrowserResultCategoryDetails({ args: options.redactedProcessArgs, command: options.executionPlan.commandInfo.command, errorText, failureCategory: "missing-binary", spawnError: options.processResult.spawnError.message, succeeded: false }), spawnError: options.processResult.spawnError.message }, isError: true };
450
460
  }
@@ -1,11 +1,13 @@
1
1
  import { runAgentBrowserProcess } from "../../process.js";
2
+ import { cleanupClickDispatchProbe } from "./click-dispatch.js";
2
3
  import { applyBrowserRunStatePatch } from "./session-state.js";
3
4
  import { buildMissingBinaryFailureResult } from "./final-result.js";
4
5
  import { prepareBrowserRun } from "./prepare.js";
5
6
  import { processBrowserOutput } from "./process-output.js";
6
7
  import type { AgentBrowserToolResult, BrowserRunOptions } from "./types.js";
7
8
 
8
- export type { BrowserRunOptions, BrowserRunState } from "./types.js";
9
+ export { closeManagedSession } from "./session-state.js";
10
+ export type { AgentBrowserToolResult, BrowserRunOptions, BrowserRunState, TraceOwner } from "./types.js";
9
11
 
10
12
  export async function runAgentBrowserTool(options: BrowserRunOptions): Promise<AgentBrowserToolResult> {
11
13
  const preparedResult = await prepareBrowserRun(options);
@@ -15,32 +17,36 @@ export async function runAgentBrowserTool(options: BrowserRunOptions): Promise<A
15
17
  }
16
18
 
17
19
  const { prepared } = preparedResult;
18
- const processResult = await runAgentBrowserProcess({
19
- args: prepared.processArgs,
20
- cwd: options.cwd,
21
- env: prepared.executionPlan.managedSessionName ? { AGENT_BROWSER_IDLE_TIMEOUT_MS: options.implicitSessionIdleTimeoutMs } : undefined,
22
- signal: options.signal,
23
- stdin: prepared.processStdin,
24
- });
20
+ try {
21
+ const processResult = await runAgentBrowserProcess({
22
+ args: prepared.processArgs,
23
+ cwd: options.cwd,
24
+ env: prepared.executionPlan.managedSessionName ? { AGENT_BROWSER_IDLE_TIMEOUT_MS: options.implicitSessionIdleTimeoutMs } : undefined,
25
+ signal: options.signal,
26
+ stdin: prepared.processStdin,
27
+ });
25
28
 
26
- const missingBinaryResult = await buildMissingBinaryFailureResult({
27
- compatibilityWorkaround: prepared.compatibilityWorkaround,
28
- electronLaunch: prepared.electronLaunch,
29
- executionPlan: prepared.executionPlan,
30
- implicitSessionCloseTimeoutMs: options.implicitSessionCloseTimeoutMs,
31
- managedSessionActive: options.state.managedSessionActive,
32
- managedSessionName: options.state.managedSessionName,
33
- processResult,
34
- redactedArgs: prepared.redactedArgs,
35
- redactedProcessArgs: prepared.redactedProcessArgs,
36
- sessionMode: prepared.sessionMode,
37
- sessionTabCorrection: prepared.sessionTabCorrection,
38
- });
39
- if (missingBinaryResult) {
40
- return missingBinaryResult;
41
- }
29
+ const missingBinaryResult = await buildMissingBinaryFailureResult({
30
+ compatibilityWorkaround: prepared.compatibilityWorkaround,
31
+ electronLaunch: prepared.electronLaunch,
32
+ executionPlan: prepared.executionPlan,
33
+ implicitSessionCloseTimeoutMs: options.implicitSessionCloseTimeoutMs,
34
+ managedSessionActive: options.state.managedSessionActive,
35
+ managedSessionName: options.state.managedSessionName,
36
+ processResult,
37
+ redactedArgs: prepared.redactedArgs,
38
+ redactedProcessArgs: prepared.redactedProcessArgs,
39
+ sessionMode: prepared.sessionMode,
40
+ sessionTabCorrection: prepared.sessionTabCorrection,
41
+ });
42
+ if (missingBinaryResult) {
43
+ return missingBinaryResult;
44
+ }
42
45
 
43
- const output = await processBrowserOutput({ ...options, prepared, processResult });
44
- applyBrowserRunStatePatch(options.state, output.statePatch);
45
- return output.result;
46
+ const output = await processBrowserOutput({ ...options, prepared, processResult });
47
+ applyBrowserRunStatePatch(options.state, output.statePatch);
48
+ return output.result;
49
+ } finally {
50
+ await cleanupClickDispatchProbe({ cwd: options.cwd, probe: prepared.clickDispatchProbe, sessionName: prepared.executionPlan.sessionName });
51
+ }
46
52
  }
@@ -26,8 +26,11 @@ import {
26
26
  runSessionCommandData,
27
27
  shouldPinSessionTabForCommand,
28
28
  } from "./session-state.js";
29
+ import { parseBatchStdinJsonArray, parseValidBatchStepEntries } from "../batch-stdin.js";
29
30
  import { buildElectronHostFailureResult, getElectronLaunchFailureCategory, redactRecoveryHint } from "./final-result.js";
31
+ import { prepareClickDispatchProbe } from "./click-dispatch.js";
30
32
  import { collectScrollPositionSnapshot, validateQaAttachedPrecondition } from "./diagnostics.js";
33
+ import { findRequestedArtifactCloseViolation, findStopBoundaryViolation } from "./prompt-guards.js";
31
34
  import type {
32
35
  BrowserRunInputFields,
33
36
  BrowserRunOptions,
@@ -141,19 +144,14 @@ async function prepareBatchScreenshotPaths(args: string[], stdin: string | undef
141
144
  if (commandTokens[0] !== "batch" || stdin === undefined) {
142
145
  return undefined;
143
146
  }
144
- let steps: unknown;
145
- try {
146
- steps = JSON.parse(stdin);
147
- } catch {
148
- return undefined;
149
- }
150
- if (!Array.isArray(steps)) {
147
+ const parsed = parseBatchStdinJsonArray(stdin);
148
+ if (parsed.error || parsed.steps === undefined) {
151
149
  return undefined;
152
150
  }
153
151
 
154
152
  let changed = false;
155
153
  const batchScreenshotPathRequests: Array<ScreenshotPathRequest | undefined> = [];
156
- const preparedSteps = await Promise.all(steps.map(async (step, index) => {
154
+ const preparedSteps = await Promise.all(parsed.steps.map(async (step, index) => {
157
155
  if (!Array.isArray(step) || !step.every((item) => typeof item === "string") || step[0] !== "screenshot") {
158
156
  return step;
159
157
  }
@@ -282,20 +280,7 @@ export function validateWaitIpcTimeoutContract(commandTokens: string[], stdin: s
282
280
  if (commandTokens[0] !== "batch" || stdin === undefined) {
283
281
  return undefined;
284
282
  }
285
- let steps: unknown;
286
- try {
287
- steps = JSON.parse(stdin);
288
- } catch {
289
- return undefined;
290
- }
291
- if (!Array.isArray(steps)) {
292
- return undefined;
293
- }
294
- for (let index = 0; index < steps.length; index += 1) {
295
- const step = steps[index];
296
- if (!Array.isArray(step) || !step.every((item) => typeof item === "string")) {
297
- continue;
298
- }
283
+ for (const { index, step } of parseValidBatchStepEntries(stdin)) {
299
284
  const waitTimeout = findWaitTimeoutMs(step);
300
285
  if (waitTimeout && waitTimeout.timeoutMs > SAFE_AGENT_BROWSER_OPERATION_TIMEOUT_MS) {
301
286
  return buildIpcUnsafeWaitError(waitTimeout.source, waitTimeout.timeoutMs, index);
@@ -528,6 +513,43 @@ export async function prepareBrowserRun(options: BrowserRunOptions): Promise<Pre
528
513
  const resolvedSemanticActionRefSnapshot: SessionRefSnapshot | undefined = semanticActionVisibleRefResolution?.snapshot
529
514
  ? { ...semanticActionVisibleRefResolution.snapshot, target: semanticActionVisibleRefResolution.snapshot.target ?? priorSessionTabTarget }
530
515
  : undefined;
516
+ const promptRefSnapshot = resolvedSemanticActionRefSnapshot ?? priorRefSnapshotState;
517
+ const stopBoundaryViolation = findStopBoundaryViolation({ commandTokens, promptPolicy: options.promptPolicy, refSnapshot: promptRefSnapshot, stdin: runtimeToolStdin });
518
+ if (stopBoundaryViolation) {
519
+ return { kind: "early-result", statePatch, result: {
520
+ content: [{ type: "text", text: stopBoundaryViolation.message }],
521
+ details: {
522
+ args: redactedArgs,
523
+ command: executionPlan.commandInfo.command,
524
+ compatibilityWorkaround,
525
+ effectiveArgs: redactedEffectiveArgs,
526
+ promptGuard: stopBoundaryViolation,
527
+ sessionMode,
528
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: stopBoundaryViolation.message, failureCategory: "policy-blocked", succeeded: false, validationError: stopBoundaryViolation.message }),
529
+ validationError: stopBoundaryViolation.message,
530
+ ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
531
+ },
532
+ isError: true,
533
+ } };
534
+ }
535
+ const requestedArtifactCloseViolation = await findRequestedArtifactCloseViolation({ artifactManifest: state.artifactManifest, command: executionPlan.commandInfo.command, cwd, promptPolicy: options.promptPolicy });
536
+ if (requestedArtifactCloseViolation) {
537
+ return { kind: "early-result", statePatch, result: {
538
+ content: [{ type: "text", text: requestedArtifactCloseViolation.message }],
539
+ details: {
540
+ args: redactedArgs,
541
+ command: executionPlan.commandInfo.command,
542
+ compatibilityWorkaround,
543
+ effectiveArgs: redactedEffectiveArgs,
544
+ promptGuard: requestedArtifactCloseViolation,
545
+ sessionMode,
546
+ ...buildAgentBrowserResultCategoryDetails({ args: redactedEffectiveArgs, command: executionPlan.commandInfo.command, errorText: requestedArtifactCloseViolation.message, failureCategory: "policy-blocked", succeeded: false, validationError: requestedArtifactCloseViolation.message }),
547
+ validationError: requestedArtifactCloseViolation.message,
548
+ ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
549
+ },
550
+ isError: true,
551
+ } };
552
+ }
531
553
  const staleRefPreflight = buildStaleRefPreflight({
532
554
  commandTokens,
533
555
  currentTarget: priorSessionTabTarget,
@@ -676,6 +698,9 @@ export async function prepareBrowserRun(options: BrowserRunOptions): Promise<Pre
676
698
  }
677
699
  }
678
700
  }
701
+ const clickDispatchProbe = pinnedBatchUnwrapMode === undefined && compiledElectron === undefined
702
+ ? await prepareClickDispatchProbe({ commandTokens, cwd, sessionName: executionPlan.sessionName, signal })
703
+ : undefined;
679
704
  const redactedProcessArgs = redactInvocationArgs(processArgs);
680
705
  const shouldProbeScrollNoop = executionPlan.commandInfo.command === "scroll" && executionPlan.startupScopedFlags.length === 0;
681
706
  const scrollPositionBefore = shouldProbeScrollNoop
@@ -702,6 +727,7 @@ export async function prepareBrowserRun(options: BrowserRunOptions): Promise<Pre
702
727
  compiledSemanticAction,
703
728
  compiledSourceLookup,
704
729
  compatibilityWorkaround,
730
+ clickDispatchProbe,
705
731
  electronLaunch,
706
732
  exactSensitiveValues,
707
733
  executionPlan,
@@ -1,5 +1,6 @@
1
1
  import { readFile, rm } from "node:fs/promises";
2
2
 
3
+ import { isCloseCommand, isOpenNavigationCommand } from "../../command-taxonomy.js";
3
4
  import { cleanupElectronLaunchResources, inspectElectronLaunchStatus, type ElectronCleanupResult } from "../../electron/cleanup.js";
4
5
  import type { ElectronLaunchRecord } from "../../electron/launch.js";
5
6
  import {
@@ -38,7 +39,7 @@ import {
38
39
  import type { PersistentSessionArtifactEviction, PersistentSessionArtifactStore } from "../../temp.js";
39
40
  import { writePersistentSessionArtifactFile, writeSecureTempFile } from "../../temp.js";
40
41
  import { isRecord } from "../../parsing.js";
41
- import { hasLaunchScopedTabCorrectionFlag, resolveManagedSessionState } from "../../runtime.js";
42
+ import { createFreshSessionName, hasLaunchScopedTabCorrectionFlag, resolveManagedSessionState } from "../../runtime.js";
42
43
  import {
43
44
  applyOpenResultTabCorrection,
44
45
  buildAboutBlankRecoveryHint,
@@ -50,6 +51,7 @@ import {
50
51
  closeManagedSession,
51
52
  collectOpenResultTabCorrection,
52
53
  collectSessionTabSelection,
54
+ extractNavigationSummaryFromData,
53
55
  extractStringResultField,
54
56
  findElectronLaunchRecordForSession,
55
57
  formatElectronPostCommandHealthText,
@@ -62,6 +64,7 @@ import {
62
64
  unwrapPinnedSessionBatchEnvelope,
63
65
  updateTraceOwnerState,
64
66
  } from "./session-state.js";
67
+ import { collectClickDispatchDiagnostic } from "./click-dispatch.js";
65
68
  import {
66
69
  buildScrollNoopDiagnostic,
67
70
  collectComboboxFocusDiagnostic,
@@ -77,6 +80,7 @@ import {
77
80
  collectTimeoutPartialProgress,
78
81
  formatQaAttachedTargetText,
79
82
  getArtifactCleanupGuidance,
83
+ getEvalResultWarning,
80
84
  getEvalStdinHint,
81
85
  getSourceLookupElectronContext,
82
86
  sleepMs,
@@ -177,6 +181,7 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
177
181
  const { prepared, processResult } = input;
178
182
  const { electronChildProcesses, electronLaunchRecords, sessionPageState, traceOwners } = state;
179
183
  let artifactManifest = state.artifactManifest;
184
+ let freshSessionOrdinal = state.freshSessionOrdinal;
180
185
  let managedSessionActive = state.managedSessionActive;
181
186
  let managedSessionCwd = state.managedSessionCwd;
182
187
  let managedSessionName = state.managedSessionName;
@@ -208,6 +213,15 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
208
213
  const inspectionText = plainTextInspection ? processResult.stdout.trim() : undefined;
209
214
  updateTraceOwnerState({ command: prepared.executionPlan.commandInfo.command, sessionName: prepared.executionPlan.sessionName, subcommand: prepared.executionPlan.commandInfo.subcommand, succeeded, traceOwners });
210
215
 
216
+ let clickDispatchDiagnostic: Awaited<ReturnType<typeof collectClickDispatchDiagnostic>>;
217
+ if (succeeded && prepared.clickDispatchProbe) {
218
+ clickDispatchDiagnostic = await collectClickDispatchDiagnostic({ cwd, probe: prepared.clickDispatchProbe, sessionName: prepared.executionPlan.sessionName, signal });
219
+ if (clickDispatchDiagnostic) {
220
+ succeeded = false;
221
+ presentationEnvelope = { ...(presentationEnvelope ?? {}), error: clickDispatchDiagnostic.summary, success: false };
222
+ }
223
+ }
224
+
211
225
  if (
212
226
  succeeded &&
213
227
  !navigationSummary &&
@@ -220,7 +234,7 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
220
234
  let overlayBlockerDiagnostic: Awaited<ReturnType<typeof collectOverlayBlockerDiagnostic>>;
221
235
 
222
236
  let openResultTabCorrection: Awaited<ReturnType<typeof collectOpenResultTabCorrection>>;
223
- if (succeeded && prepared.executionPlan.sessionName && hasLaunchScopedTabCorrectionFlag(prepared.runtimeToolArgs) && ["goto", "navigate", "open"].includes(prepared.executionPlan.commandInfo.command ?? "")) {
237
+ if (succeeded && prepared.executionPlan.sessionName && hasLaunchScopedTabCorrectionFlag(prepared.runtimeToolArgs) && isOpenNavigationCommand(prepared.executionPlan.commandInfo.command)) {
224
238
  const targetTitle = extractStringResultField(presentationEnvelope?.data, "title");
225
239
  const targetUrl = extractStringResultField(presentationEnvelope?.data, "url");
226
240
  const plannedTabCorrection = await collectOpenResultTabCorrection({ cwd, sessionName: prepared.executionPlan.sessionName, signal, targetTitle, targetUrl });
@@ -275,7 +289,7 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
275
289
  fillVerificationDiagnostic = await collectFillVerificationDiagnostic({ commandTokens: prepared.commandTokens, cwd, sessionName: prepared.executionPlan.sessionName, signal });
276
290
  electronRefFreshnessDiagnostic = buildElectronRefFreshnessDiagnostic({ command: prepared.executionPlan.commandInfo.command, commandTokens: prepared.commandTokens, record: electronRecordForCommand, sessionName: prepared.executionPlan.sessionName, stdin: prepared.runtimeToolStdin });
277
291
  }
278
- if (succeeded && !sessionTabCorrection && !aboutBlankSessionMismatch && !electronRecordForCommand) overlayBlockerDiagnostic = await collectOverlayBlockerDiagnostic({ command: prepared.executionPlan.commandInfo.command, cwd, data: presentationEnvelope?.data, navigationSummary, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName, signal });
292
+ if (succeeded && !sessionTabCorrection && !aboutBlankSessionMismatch && !electronRecordForCommand && !clickDispatchDiagnostic) overlayBlockerDiagnostic = await collectOverlayBlockerDiagnostic({ command: prepared.executionPlan.commandInfo.command, cwd, data: presentationEnvelope?.data, navigationSummary, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName, signal });
279
293
  if (succeeded) {
280
294
  selectorTextVisibilityDiagnostics = await collectSelectorTextVisibilityDiagnostics({ commandInfo: prepared.executionPlan.commandInfo, commandTokens: prepared.commandTokens, cwd, data: presentationEnvelope?.data, sessionName: prepared.executionPlan.sessionName, signal });
281
295
  electronBroadGetTextScopeDiagnostics = collectElectronBroadGetTextScopeDiagnostics({ commandInfo: prepared.executionPlan.commandInfo, commandTokens: prepared.commandTokens, currentTarget: currentSessionTabTarget, data: presentationEnvelope?.data, electronLaunchRecords, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName });
@@ -287,8 +301,10 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
287
301
  let currentRefSnapshotInvalidation: SessionRefSnapshotInvalidation | undefined;
288
302
  const batchRefSnapshotState = prepared.executionPlan.commandInfo.command === "batch" ? extractLatestRefSnapshotStateFromBatchResults(presentationEnvelope?.data) : undefined;
289
303
  if (prepared.executionPlan.sessionName) {
290
- if (prepared.executionPlan.commandInfo.command === "close" && succeeded) sessionPageState.clearSession(prepared.executionPlan.sessionName);
291
- else if (currentSessionTabTarget) {
304
+ if (isCloseCommand(prepared.executionPlan.commandInfo.command) && succeeded) {
305
+ sessionPageState.clearSession(prepared.executionPlan.sessionName);
306
+ state.closedManagedSessionNames.add(prepared.executionPlan.sessionName);
307
+ } else if (currentSessionTabTarget) {
292
308
  const tabUpdate = sessionPageState.applyTabTarget({ sessionName: prepared.executionPlan.sessionName, target: currentSessionTabTarget, update: sessionPageStateUpdate });
293
309
  if (!tabUpdate.applied && succeeded) sessionPageState.markPinning(prepared.executionPlan.sessionName, "drift");
294
310
  }
@@ -307,11 +323,19 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
307
323
  const priorManagedSessionActive = managedSessionActive;
308
324
  const priorManagedSessionCwd = managedSessionCwd;
309
325
  const priorManagedSessionName = managedSessionName;
310
- const managedSessionState = resolveManagedSessionState({ command: prepared.executionPlan.commandInfo.command, managedSessionName: prepared.executionPlan.managedSessionName, priorActive: priorManagedSessionActive, priorSessionName: priorManagedSessionName, succeeded });
326
+ const commandClosesSession = isCloseCommand(prepared.executionPlan.commandInfo.command);
327
+ const managedCloseSessionName = commandClosesSession && succeeded && prepared.executionPlan.sessionName === priorManagedSessionName
328
+ ? prepared.executionPlan.sessionName
329
+ : prepared.executionPlan.managedSessionName;
330
+ const managedSessionState = resolveManagedSessionState({ command: prepared.executionPlan.commandInfo.command, managedSessionName: managedCloseSessionName, priorActive: priorManagedSessionActive, priorSessionName: priorManagedSessionName, succeeded });
311
331
  const replacedManagedSessionName = managedSessionState.replacedSessionName;
312
332
  managedSessionActive = managedSessionState.active;
313
333
  managedSessionName = managedSessionState.sessionName;
314
- let managedSessionOutcome = buildManagedSessionOutcome({ activeAfter: managedSessionActive, activeBefore: priorManagedSessionActive, attemptedSessionName: prepared.executionPlan.managedSessionName, command: prepared.executionPlan.commandInfo.command, currentSessionName: managedSessionName, previousSessionName: priorManagedSessionName, replacedSessionName: replacedManagedSessionName, sessionMode: prepared.sessionMode, succeeded });
334
+ if (commandClosesSession && succeeded && managedCloseSessionName === priorManagedSessionName && !managedSessionActive) {
335
+ freshSessionOrdinal += 1;
336
+ managedSessionName = createFreshSessionName(state.managedSessionBaseName, state.ephemeralSessionSeed, freshSessionOrdinal);
337
+ }
338
+ let managedSessionOutcome = buildManagedSessionOutcome({ activeAfter: managedSessionActive, activeBefore: priorManagedSessionActive, attemptedSessionName: managedCloseSessionName, command: prepared.executionPlan.commandInfo.command, currentSessionName: managedSessionName, previousSessionName: priorManagedSessionName, replacedSessionName: replacedManagedSessionName, sessionMode: prepared.sessionMode, succeeded });
315
339
  if (prepared.executionPlan.managedSessionName && succeeded) managedSessionCwd = cwd;
316
340
  if (prepared.executionPlan.sessionName && succeeded) {
317
341
  if (openResultTabCorrection || sessionTabCorrection || aboutBlankSessionMismatch?.recoveryApplied) sessionPageState.markPinning(prepared.executionPlan.sessionName, "drift");
@@ -319,7 +343,8 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
319
343
  }
320
344
  if (replacedManagedSessionName) {
321
345
  sessionPageState.clearSession(replacedManagedSessionName);
322
- await closeManagedSession({ cwd: priorManagedSessionCwd, sessionName: replacedManagedSessionName, timeoutMs: implicitSessionCloseTimeoutMs });
346
+ const replacedCloseError = await closeManagedSession({ cwd: priorManagedSessionCwd, sessionName: replacedManagedSessionName, timeoutMs: implicitSessionCloseTimeoutMs });
347
+ if (!replacedCloseError) state.closedManagedSessionNames.add(replacedManagedSessionName);
323
348
  }
324
349
 
325
350
  let electronLaunchRecord: ElectronLaunchRecord | undefined;
@@ -396,7 +421,11 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
396
421
  if (!skipAttachedTargetBanner && qaAttachedTargetText && presentation.content[0]?.type === "text") presentation.content[0] = { ...presentation.content[0], text: `${qaAttachedTargetText}\n\n${presentation.content[0].text}` };
397
422
  else if (!skipAttachedTargetBanner && qaAttachedTargetText) presentation.content.unshift({ type: "text", text: qaAttachedTargetText });
398
423
  if (managedSessionOutcome && managedSessionOutcome.succeeded !== succeeded) managedSessionOutcome = { ...managedSessionOutcome, succeeded };
424
+ const evalNavigationSummary = navigationSummary ?? extractNavigationSummaryFromData(presentationEnvelope?.data);
425
+ const evalSessionTabUrl = prepared.executionPlan.sessionName ? sessionPageState.get(prepared.executionPlan.sessionName).tabTarget?.url : undefined;
426
+ const evalPageUrl = evalNavigationSummary?.url ?? currentSessionTabTarget?.url ?? prepared.priorSessionTabTarget?.url ?? evalSessionTabUrl;
399
427
  const evalStdinHint = getEvalStdinHint({ command: prepared.executionPlan.commandInfo.command, data: presentationEnvelope?.data, stdin: prepared.runtimeToolStdin });
428
+ const evalResultWarning = getEvalResultWarning({ command: prepared.executionPlan.commandInfo.command, data: presentationEnvelope?.data, navigationSummary: evalNavigationSummary, pageUrl: evalPageUrl, stdin: prepared.runtimeToolStdin });
400
429
  const resultArtifactManifest = presentation.artifactManifest ?? artifactManifest;
401
430
  const artifactCleanup = await getArtifactCleanupGuidance({ command: prepared.executionPlan.commandInfo.command, cwd, manifest: resultArtifactManifest, succeeded });
402
431
  const warningText = electronPostCommandHealth ? formatElectronPostCommandHealthText(electronPostCommandHealth) : electronSessionMismatch ? formatElectronSessionMismatchText(electronSessionMismatch) : aboutBlankSessionMismatch ? buildAboutBlankWarning(aboutBlankSessionMismatch) : undefined;
@@ -404,8 +433,8 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
404
433
  const finalRecoveryState = await prepareFinalResultRecoveryState({ aboutBlankSessionMismatch, batchRefSnapshotState, commandTokens: prepared.commandTokens, compiledSemanticAction: prepared.compiledSemanticAction, currentRefSnapshot, currentRefSnapshotInvalidation, currentSessionTabTarget, cwd, electronPostCommandHealth, errorText, executionPlan: prepared.executionPlan, parseError, plainTextInspection, presentation, processResult, redactedProcessArgs: prepared.redactedProcessArgs, runtimeToolArgs: prepared.runtimeToolArgs, sessionPageState, sessionPageStateUpdate, sessionTabCorrection, signal, succeeded });
405
434
  currentRefSnapshot = finalRecoveryState.currentRefSnapshot;
406
435
  currentRefSnapshotInvalidation = finalRecoveryState.currentRefSnapshotInvalidation;
407
- const result = buildFinalAgentBrowserToolResult({ aboutBlankSessionMismatch, artifactCleanup, categoryDetails: finalRecoveryState.categoryDetails, comboboxFocusDiagnostic, compiledNetworkSourceLookup: prepared.compiledNetworkSourceLookup, compiledSemanticAction: prepared.compiledSemanticAction, compatibilityWorkaround: prepared.compatibilityWorkaround, currentRefSnapshot, currentRefSnapshotInvalidation, currentSessionTabTarget, electronBroadGetTextScopeDiagnostics, electronFailedConnectCleanup, electronHandoff, electronLaunch: prepared.electronLaunch, electronLaunchRecord, electronLaunchRecords, electronPostCommandHealth, electronProfileIsolationDetails: input.electronProfileIsolationDetails, electronRefFreshnessDiagnostic, electronSessionMismatch, errorText, evalStdinHint, exactSensitiveValues: prepared.exactSensitiveValues, executionPlan: prepared.executionPlan, fillVerificationDiagnostic, inspectionText, managedSessionOutcome, 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, userRequestedJson: prepared.userRequestedJson, visibleRefFallbackDiagnostic: finalRecoveryState.visibleRefFallbackDiagnostic, visibleRefFallbackSessionName: finalRecoveryState.visibleRefFallbackSessionName });
408
- const statePatch: BrowserRunStatePatch = { artifactManifest, managedSessionActive, managedSessionCwd, managedSessionName };
436
+ 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, electronBroadGetTextScopeDiagnostics, electronFailedConnectCleanup, electronHandoff, electronLaunch: prepared.electronLaunch, electronLaunchRecord, electronLaunchRecords, electronPostCommandHealth, electronProfileIsolationDetails: input.electronProfileIsolationDetails, electronRefFreshnessDiagnostic, electronSessionMismatch, errorText, evalResultWarning, evalStdinHint, exactSensitiveValues: prepared.exactSensitiveValues, executionPlan: prepared.executionPlan, fillVerificationDiagnostic, inspectionText, managedSessionOutcome, 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, userRequestedJson: prepared.userRequestedJson, visibleRefFallbackDiagnostic: finalRecoveryState.visibleRefFallbackDiagnostic, visibleRefFallbackSessionName: finalRecoveryState.visibleRefFallbackSessionName });
437
+ const statePatch: BrowserRunStatePatch = { artifactManifest, freshSessionOrdinal, managedSessionActive, managedSessionCwd, managedSessionName };
409
438
  return { result, statePatch };
410
439
  } finally {
411
440
  if (processResult.stdoutSpillPath) await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
@@ -0,0 +1,93 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+
3
+ import { isCloseCommand } from "../../command-taxonomy.js";
4
+ import { executableExistsOnPath } from "../../executable-path.js";
5
+ import type { SessionArtifactManifest } from "../../results/contracts.js";
6
+ import type { PromptPolicy, PromptRequestedArtifact } from "../../prompt-policy.js";
7
+ import type { SessionRefSnapshot } from "../../session-page-state.js";
8
+ import { findBlockedFinalizingAction, STOP_BOUNDARY_GUARD_SCOPE, type BrowserFinalizingAction } from "./browser-action-model.js";
9
+
10
+ export interface StopBoundaryViolation {
11
+ action: BrowserFinalizingAction;
12
+ command: string[];
13
+ message: string;
14
+ reason: "explicit-user-stop-boundary";
15
+ stepIndex?: number;
16
+ target?: string;
17
+ }
18
+
19
+ export interface RequestedArtifactCloseViolation {
20
+ message: string;
21
+ missingArtifacts: PromptRequestedArtifact[];
22
+ reason: "requested-artifacts-missing-before-close";
23
+ }
24
+
25
+ function formatStopBoundaryActionPhrase(action: BrowserFinalizingAction): string {
26
+ if (action.kind === "keyboard-submit") return "keyboard submit (Enter/Return)";
27
+ return "click-like action";
28
+ }
29
+
30
+ export function findStopBoundaryViolation(options: { commandTokens: string[]; promptPolicy: PromptPolicy; refSnapshot?: SessionRefSnapshot; stdin?: string }): StopBoundaryViolation | undefined {
31
+ if (!options.promptPolicy.stopBoundary) return undefined;
32
+ const blocked = findBlockedFinalizingAction({
33
+ commandTokens: options.commandTokens,
34
+ refSnapshot: options.refSnapshot,
35
+ stdin: options.stdin,
36
+ });
37
+ if (!blocked) return undefined;
38
+ const target = blocked.targetLabel;
39
+ const actionPhrase = formatStopBoundaryActionPhrase(blocked);
40
+ const scopeNote = `Best-effort guard scope covers ${STOP_BOUNDARY_GUARD_SCOPE.covered.join(", ")}; it does not block ${STOP_BOUNDARY_GUARD_SCOPE.excluded.join(", ")}.`;
41
+ if (blocked.stepIndex === undefined) {
42
+ return {
43
+ action: blocked,
44
+ command: blocked.command,
45
+ message: `Blocked likely final submit/order ${actionPhrase} (${target}) because the latest user prompt set an explicit stop boundary. Gather evidence on the current page instead of activating the final action. ${scopeNote}`,
46
+ reason: "explicit-user-stop-boundary",
47
+ target,
48
+ };
49
+ }
50
+ return {
51
+ action: blocked,
52
+ command: blocked.command,
53
+ message: `Blocked likely final submit/order ${actionPhrase} in batch step ${blocked.stepIndex + 1} (${target}) because the latest user prompt set an explicit stop boundary. Gather evidence on the current page instead of activating the final action. ${scopeNote}`,
54
+ reason: "explicit-user-stop-boundary",
55
+ stepIndex: blocked.stepIndex,
56
+ target,
57
+ };
58
+ }
59
+
60
+ function resolveArtifactPath(cwd: string, path: string): string {
61
+ return isAbsolute(path) ? path : resolve(cwd, path);
62
+ }
63
+
64
+ function manifestContainsArtifact(manifest: SessionArtifactManifest | undefined, cwd: string, artifact: PromptRequestedArtifact): boolean {
65
+ if (!manifest) return false;
66
+ const requestedAbsolutePath = resolveArtifactPath(cwd, artifact.path);
67
+ const expectedKind = artifact.kind === "screenshot" ? "image" : "video";
68
+ return manifest.entries.some((entry) => {
69
+ const entryAbsolutePath = entry.absolutePath ?? resolveArtifactPath(cwd, entry.path);
70
+ return entry.storageScope === "explicit-path" && entry.kind === expectedKind && entryAbsolutePath === requestedAbsolutePath && entry.retentionState === "live" && entry.exists === true;
71
+ });
72
+ }
73
+
74
+ async function isArtifactRequired(artifact: PromptRequestedArtifact): Promise<boolean> {
75
+ if (artifact.required) return true;
76
+ return artifact.kind === "recording" && await executableExistsOnPath("ffmpeg");
77
+ }
78
+
79
+ export async function findRequestedArtifactCloseViolation(options: { artifactManifest?: SessionArtifactManifest; command: string | undefined; cwd: string; promptPolicy: PromptPolicy }): Promise<RequestedArtifactCloseViolation | undefined> {
80
+ if (!isCloseCommand(options.command)) return undefined;
81
+ const missingArtifacts: PromptRequestedArtifact[] = [];
82
+ for (const artifact of options.promptPolicy.requestedArtifacts) {
83
+ if (!await isArtifactRequired(artifact)) continue;
84
+ if (!manifestContainsArtifact(options.artifactManifest, options.cwd, artifact)) missingArtifacts.push(artifact);
85
+ }
86
+ if (missingArtifacts.length === 0) return undefined;
87
+ const missingList = missingArtifacts.map((artifact) => `${artifact.kind}: ${artifact.path}`).join(", ");
88
+ return {
89
+ message: `Blocked browser close because requested artifact path${missingArtifacts.length === 1 ? " is" : "s are"} missing or unverified: ${missingList}. Save the requested artifact path first, or report why an optional artifact is unavailable before closing.`,
90
+ missingArtifacts,
91
+ reason: "requested-artifacts-missing-before-close",
92
+ };
93
+ }