@enricai/barnacle 1.6.8 → 1.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,6 +87,7 @@ const schemas_1 = require("../lib/llm/schemas");
87
87
  const logging_1 = require("../lib/logging");
88
88
  const call_capture_1 = require("../lib/telemetry/call-capture");
89
89
  const call_types_1 = require("../lib/telemetry/call-types");
90
+ const deep_locator_candidates_1 = require("../scraper/deep-locator-candidates");
90
91
  const errors_2 = require("../scraper/errors");
91
92
  const frame_target_1 = require("../scraper/frame-target");
92
93
  const phantom_click_1 = require("../scraper/phantom-click");
@@ -4408,6 +4409,28 @@ async function probeFormValidityBeforeSubmit(params) {
4408
4409
  return [];
4409
4410
  }
4410
4411
  }
4412
+ /**
4413
+ * Adapts `resolveDeepLocatorCandidates` results into `Action`-shaped
4414
+ * evidence for `rephraseWithLLM`, which only knows about Stagehand's
4415
+ * `Action` type. Never throws: `resolveDeepLocatorCandidates` itself
4416
+ * degrades to `[]` on a resolver failure, so this only feeds the rephrase
4417
+ * prompt richer evidence when available — a frame-scoped step whose
4418
+ * observe AND deepLocator both come back empty just gets the same `[]`
4419
+ * evidence it would have gotten before this fix.
4420
+ *
4421
+ * `instruction` is forwarded to `resolveDeepLocatorCandidates` so the
4422
+ * evidence list is ranked by relevance to the step, same as the act path —
4423
+ * an unranked `[]`-then-DOM-order list would feed the rephrase LLM its
4424
+ * worst evidence first instead of its best.
4425
+ */
4426
+ async function deepLocatorCandidatesAsActions(page, frameSelector, instruction) {
4427
+ const candidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, "*", instruction);
4428
+ return candidates.map((c) => ({
4429
+ selector: c.selector,
4430
+ description: c.accessibleText || "(no accessible text)",
4431
+ method: "click",
4432
+ }));
4433
+ }
4411
4434
  /**
4412
4435
  * Cheap pre-cascade reachability gate. Runs before the 5-attempt healing cascade
4413
4436
  * (and any global replan) so a step aimed at the wrong page state fails fast
@@ -4419,10 +4442,16 @@ async function probeFormValidityBeforeSubmit(params) {
4419
4442
  *
4420
4443
  * `frameTarget` scopes both observe calls to a resolved cross-origin child
4421
4444
  * frame when the flow declared `frameSelector`; omitted (main frame) is
4422
- * byte-identical to today's unscoped calls.
4445
+ * byte-identical to today's unscoped calls. When `frameTarget.frame` is a
4446
+ * resolved child frame, `observe()` is blind to it (measured against a
4447
+ * cross-origin OOPIF — see `deep-locator-candidates.ts`'s module docblock),
4448
+ * so a 0-candidate focused+unfocused observe pair additionally falls back to
4449
+ * `resolveDeepLocatorCandidates` before declaring the step "absent" — a
4450
+ * frame-scoped step must not short-circuit to replan before the cascade
4451
+ * (which itself now routes through the same resolver) ever runs.
4423
4452
  */
4424
4453
  async function probeStepBeforeAttempts(params) {
4425
- const { stagehand, step, stepIndex, totalSteps, logger, captureFn, frameTarget } = params;
4454
+ const { stagehand, page, step, stepIndex, totalSteps, logger, captureFn, frameTarget } = params;
4426
4455
  try {
4427
4456
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
4428
4457
  if (candidates.length === 0) {
@@ -4442,6 +4471,13 @@ async function probeStepBeforeAttempts(params) {
4442
4471
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
4443
4472
  return "present";
4444
4473
  }
4474
+ if (frameTarget?.frame) {
4475
+ const deepLocatorCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameTarget.frameSelector, "*");
4476
+ if (deepLocatorCandidates.length > 0) {
4477
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: observe found 0 candidates (focused and unfocused) but deepLocator found ${deepLocatorCandidates.length} — treating as present (let cascade resolve)`);
4478
+ return "present";
4479
+ }
4480
+ }
4445
4481
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found 0 candidates (focused and unfocused) — treating as absent (skip cascade, route to replan if required)`);
4446
4482
  return "absent";
4447
4483
  }
@@ -4490,7 +4526,7 @@ async function executeStepWithHealing(params) {
4490
4526
  // the existing cascade.
4491
4527
  if (await tryUploadPrimitive({
4492
4528
  page,
4493
- target: await (0, frame_target_1.resolveFrameTarget)(page),
4529
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
4494
4530
  isUploadStep: upload,
4495
4531
  fixture: resumeFixture,
4496
4532
  logger,
@@ -4508,11 +4544,10 @@ async function executeStepWithHealing(params) {
4508
4544
  // question unanswered. No-op (returns false → falls through) when the step
4509
4545
  // isn't a single-dropdown select or no option matches.
4510
4546
  //
4511
- // resolveFrameTarget(page) resolves synchronously to the main-frame target
4512
- // when no frameSelector is set, so this bridge is behavior-identical for
4513
- // every existing site until the sibling subtask threads a resolved target
4514
- // through end-to-end.
4515
- const selectFrameTarget = await (0, frame_target_1.resolveFrameTarget)(page);
4547
+ // Reuse the already-resolved ambient frameTarget rather than re-resolving:
4548
+ // falls back to the main-frame target when no frameSelector is set, so this
4549
+ // bridge stays behavior-identical for every existing site.
4550
+ const selectFrameTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
4516
4551
  if (await trySelectPrimitive({
4517
4552
  page,
4518
4553
  target: selectFrameTarget,
@@ -4588,6 +4623,7 @@ async function executeStepWithHealing(params) {
4588
4623
  // burning 4 attempts on a page that clearly isn't the right one.
4589
4624
  const probeResult = await probeStepBeforeAttempts({
4590
4625
  stagehand,
4626
+ page,
4591
4627
  step,
4592
4628
  stepIndex,
4593
4629
  totalSteps,
@@ -4651,7 +4687,15 @@ async function executeStepWithHealing(params) {
4651
4687
  .evaluate("document.body ? document.body.outerHTML : null")
4652
4688
  .catch(() => null);
4653
4689
  const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
4654
- const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
4690
+ const probeAbsentObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
4691
+ // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
4692
+ // result degrades to the deep-locator resolver for dump evidence — this
4693
+ // dump feeds replanRemainingFlow's diagnostic prompt, and an empty
4694
+ // candidate list there returns "repeat the failed step", burning the
4695
+ // replan budget on every frame-scoped probe-absent failure.
4696
+ const unfocusedObserve = probeAbsentObservedUnfocused.length === 0 && frameTarget?.frame
4697
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
4698
+ : probeAbsentObservedUnfocused;
4655
4699
  const dumpPath = onStepFailure?.({
4656
4700
  stepIndex,
4657
4701
  phase,
@@ -4689,7 +4733,7 @@ async function executeStepWithHealing(params) {
4689
4733
  : 0;
4690
4734
  if (requireSubmitEndpoint) {
4691
4735
  const invalidControls = await probeFormValidityBeforeSubmit({
4692
- target: await (0, frame_target_1.resolveFrameTarget)(page),
4736
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
4693
4737
  stepIndex,
4694
4738
  totalSteps,
4695
4739
  logger,
@@ -4974,7 +5018,58 @@ async function executeStepWithHealing(params) {
4974
5018
  ? { ignoreSelectors: [...triedSelectors], timeout: exports.STEP_WATCHDOG_MS }
4975
5019
  : { timeout: exports.STEP_WATCHDOG_MS };
4976
5020
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn, frameTarget);
4977
- if (candidates.length === 0) {
5021
+ // observe() is blind to a cross-origin OOPIF (see
5022
+ // deep-locator-candidates.ts's module docblock) — when the step is
5023
+ // frame-scoped and observe came back empty, fall back to the deep
5024
+ // locator resolver before declaring the attempt candidate-less.
5025
+ // Passing `step` ranks candidates by relevance to the instruction
5026
+ // (see resolveDeepLocatorCandidates) so the top pick below is the
5027
+ // element the step actually names, not just DOM order.
5028
+ // Attempt 4's ignoreSelectors has no deepLocator equivalent, so the
5029
+ // exclusion is applied here by filtering resolved (already-ranked)
5030
+ // candidates against triedSelectors instead — otherwise attempt 4
5031
+ // would re-pick the same failed element and burn the attempt.
5032
+ const deepLocatorCandidates = candidates.length === 0 && frameTarget?.frame
5033
+ ? (await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameTarget.frameSelector, "*", step)).filter((c) => !triedSelectors.includes(c.selector))
5034
+ : [];
5035
+ if (candidates.length === 0 && deepLocatorCandidates.length > 0) {
5036
+ const top = deepLocatorCandidates[0];
5037
+ if (top) {
5038
+ record.instruction = `deepLocator: ${top.accessibleText || "(no accessible text)"}`;
5039
+ // Deny-list guard: mirrors the observe branch's refusal below —
5040
+ // never act on a wizard-exit control regardless of which
5041
+ // candidate source (observe vs. deepLocator) surfaced it.
5042
+ if (isWizardExitAction(top.accessibleText, wizardExitButtonLabels)) {
5043
+ record.errorMessage = `refused wizard-exit control: "${top.accessibleText.slice(0, 60)}"`;
5044
+ triedSelectors.push(top.selector);
5045
+ record.triedSelectors = [top.selector];
5046
+ attempts.push(record);
5047
+ failureReasons.push(record.errorMessage);
5048
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
5049
+ continue;
5050
+ }
5051
+ triedSelectors.push(top.selector);
5052
+ record.triedSelectors = [top.selector];
5053
+ try {
5054
+ await (0, deep_locator_candidates_1.clickDeepLocatorCandidate)(page, frameTarget?.frameSelector, "*", top.index);
5055
+ record.actResultSuccess = true;
5056
+ record.actResultDescription = `deepLocator clicked "${top.accessibleText || top.selector}"`;
5057
+ // Synthesize a click action so downstream verification (network
5058
+ // / url / dom) treats this exactly like any other resolved
5059
+ // click — same idiom as deep-submit-locator/structured-click.
5060
+ resolvedAction = {
5061
+ selector: top.selector,
5062
+ description: record.actResultDescription,
5063
+ method: "click",
5064
+ };
5065
+ }
5066
+ catch (err) {
5067
+ record.actResultSuccess = false;
5068
+ record.errorMessage = `deepLocator: click threw ${(0, errors_1.toErrorMessage)(err)}`;
5069
+ }
5070
+ }
5071
+ }
5072
+ else if (candidates.length === 0) {
4978
5073
  record.errorMessage = "observe returned no candidates";
4979
5074
  // Optional-step short-circuit: when attempt 2 confirms no candidates
4980
5075
  // match AND the step was marked optional in the flow, skip cleanly.
@@ -5051,8 +5146,8 @@ async function executeStepWithHealing(params) {
5051
5146
  target.arguments.length > 0) {
5052
5147
  const fillValue = target.arguments[0];
5053
5148
  if (typeof fillValue === "string") {
5054
- const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page);
5055
- const dateFill = await fillHtml5DateTimeInput(frameTarget, target.selector, fillValue);
5149
+ const dateFillTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
5150
+ const dateFill = await fillHtml5DateTimeInput(dateFillTarget, target.selector, fillValue);
5056
5151
  if (dateFill !== null) {
5057
5152
  record.errorMessage = dateFill.filled
5058
5153
  ? `html5-date-fallback: filled ${dateFill.inputType}="${dateFill.postValue}"`
@@ -5072,7 +5167,7 @@ async function executeStepWithHealing(params) {
5072
5167
  // component rejection, masked-input library reformatting).
5073
5168
  // Generic primitive that the verifier's existing signals
5074
5169
  // (network/url/dom/htmlDelta/textChanged) miss.
5075
- const readback = await verifyFillReadback(frameTarget, target.selector, fillValue);
5170
+ const readback = await verifyFillReadback(dateFillTarget, target.selector, fillValue);
5076
5171
  if (readback !== null) {
5077
5172
  if (readback.outcome === "rejected") {
5078
5173
  record.errorMessage = `fill-value-rejected: tried "${fillValue.slice(0, 60)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
@@ -5190,7 +5285,7 @@ async function executeStepWithHealing(params) {
5190
5285
  return { resolved: true, isCheckable: true, checked: false, strategyUsed: null };
5191
5286
  })()`;
5192
5287
  try {
5193
- const structuredClickTarget = await (0, frame_target_1.resolveFrameTarget)(page);
5288
+ const structuredClickTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
5194
5289
  const result = await structuredClickTarget.evaluate(probeExpr);
5195
5290
  if (result !== null && typeof result === "object" && "resolved" in result) {
5196
5291
  const probe = result;
@@ -5241,7 +5336,15 @@ async function executeStepWithHealing(params) {
5241
5336
  "anthropic billing exhausted (FATAL_BILLING already logged); skipping rephrase";
5242
5337
  }
5243
5338
  else {
5244
- const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
5339
+ const observedCandidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5340
+ // observe() is blind to a cross-origin OOPIF, so a frame-scoped
5341
+ // empty result degrades to the deep-locator resolver for prompt
5342
+ // evidence rather than hard-failing — lower stakes than the
5343
+ // attempt-2/4 click path since this only feeds the rephrase
5344
+ // prompt, so a resolver error/empty result is fine to swallow.
5345
+ const candidates = observedCandidates.length === 0 && frameTarget?.frame
5346
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector, step)
5347
+ : observedCandidates;
5245
5348
  // Fetch live-page evidence so the rephrase prompt can reason about
5246
5349
  // form state, not just observe candidates. Mirrors the same
5247
5350
  // extraction the cascade-exhaust dump path already does.
@@ -5252,8 +5355,14 @@ async function executeStepWithHealing(params) {
5252
5355
  });
5253
5356
  // Unfocused observe so the rephrase prompt can see ambient UI
5254
5357
  // like modal Save/Close buttons that the focused candidates
5255
- // (filtered by the failed step's instruction) would hide.
5256
- const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
5358
+ // (filtered by the failed step's instruction) would hide. Scoped to
5359
+ // frameTarget: for a frame-scoped flow the ambient UI that matters
5360
+ // (the wizard's own Save/Close controls) lives inside the iframe
5361
+ // alongside the failed step, not in the top document.
5362
+ const observedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5363
+ const unfocused = observedUnfocused.length === 0 && frameTarget?.frame
5364
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
5365
+ : observedUnfocused;
5257
5366
  const submitFailureList = extractSubmitFailureEvidence(recentCaptures, ownBackendHostnames);
5258
5367
  const gaEventList = extractGaEventEvidence(recentCaptures);
5259
5368
  const priorAttemptsForPrompt = attempts.map((a, i) => ({
@@ -5322,7 +5431,7 @@ async function executeStepWithHealing(params) {
5322
5431
  // false for non-radio/non-checkbox clicks so the network/URL signal still
5323
5432
  // decides those. Radios/checkboxes are click-but-no-network just like fills.
5324
5433
  const domVerified = resolvedAction !== null && (isStateClass || isClick)
5325
- ? await verifyDomEffect(await (0, frame_target_1.resolveFrameTarget)(page), resolvedAction)
5434
+ ? await verifyDomEffect(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction)
5326
5435
  : false;
5327
5436
  // Interior-advance transition gate (opt-in). On SPAs where a page advance
5328
5437
  // and a mere field-edit share one endpoint URL (the wizard ATS's `/gq`:
@@ -5415,7 +5524,7 @@ async function executeStepWithHealing(params) {
5415
5524
  return null;
5416
5525
  })()`;
5417
5526
  try {
5418
- const submittedStateTarget = await (0, frame_target_1.resolveFrameTarget)(page);
5527
+ const submittedStateTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
5419
5528
  domSubmittedMatch = (await submittedStateTarget.evaluate(probeExpr));
5420
5529
  }
5421
5530
  catch (err) {
@@ -5424,7 +5533,7 @@ async function executeStepWithHealing(params) {
5424
5533
  }
5425
5534
  // Build the unfocused-observe evidence list. Used by the judge to
5426
5535
  // assess whether the page transitioned to a success state.
5427
- const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
5536
+ const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5428
5537
  // Quick invalid-marker count (deterministic DOM querying — counting
5429
5538
  // structural ng-invalid containers is not fuzzy matching, just
5430
5539
  // observing existence).
@@ -5540,7 +5649,7 @@ async function executeStepWithHealing(params) {
5540
5649
  // reliably trigger that default action — same gap N+42 documented
5541
5650
  // for direct checkbox/radio clicks.
5542
5651
  const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } el.click(); return { fired: true, kind: "click" }; })()`;
5543
- const n16FallbackTarget = await (0, frame_target_1.resolveFrameTarget)(page);
5652
+ const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
5544
5653
  const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
5545
5654
  const fired = probeResult.fired;
5546
5655
  // Vacuous-click guard for the n+16 fallback. Same rationale as
@@ -5860,7 +5969,15 @@ async function executeStepWithHealing(params) {
5860
5969
  }
5861
5970
  }
5862
5971
  }
5863
- const finalObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5972
+ const cascadeExhaustObservedFinal = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5973
+ // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
5974
+ // result degrades to the deep-locator resolver for dump evidence — this
5975
+ // dump feeds replanRemainingFlow's diagnostic prompt, and an empty
5976
+ // candidate list there returns "repeat the failed step", burning the
5977
+ // replan budget on every frame-scoped cascade-exhaust failure.
5978
+ const finalObserve = cascadeExhaustObservedFinal.length === 0 && frameTarget?.frame
5979
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector, step)
5980
+ : cascadeExhaustObservedFinal;
5864
5981
  const { pageTitle, pageUrl } = await resolveDumpPageIdentity(page, frameTarget);
5865
5982
  // Discriminator data for "Stagehand sees nothing" failures: capture the raw
5866
5983
  // DOM and an unfocused observe so a triager can tell empty-page from
@@ -5869,7 +5986,10 @@ async function executeStepWithHealing(params) {
5869
5986
  .evaluate("document.body ? document.body.outerHTML : null")
5870
5987
  .catch(() => null);
5871
5988
  const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
5872
- const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5989
+ const cascadeExhaustObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5990
+ const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
5991
+ ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
5992
+ : cascadeExhaustObservedUnfocused;
5873
5993
  const dumpPath = onStepFailure?.({
5874
5994
  stepIndex,
5875
5995
  phase,
@@ -5951,8 +6071,6 @@ async function runHealingFlow(deps) {
5951
6071
  let submitVerified = false;
5952
6072
  let submitStepSkipped = false;
5953
6073
  let lastStepIndex = -1;
5954
- const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page, deps.frameSelector);
5955
- await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
5956
6074
  const stopCapture = wireSignalCapture(page, {
5957
6075
  counter,
5958
6076
  signalCounter,
@@ -5974,6 +6092,14 @@ async function runHealingFlow(deps) {
5974
6092
  throw new errors_2.StepVerificationError(`${formatStepPrefix(i, () => steps.length)} flow exceeded its maxFlowMs budget (${maxFlowMs}ms)`, "flow-timeout");
5975
6093
  }
5976
6094
  lastStepIndex = i;
6095
+ // Resolved fresh per step (not cached across the run) so a cross-origin
6096
+ // iframe that attaches mid-flow (e.g. after an "Apply" click reveals a
6097
+ // wizard embedded later in the DOM) is picked up as soon as it's
6098
+ // reachable, mirroring the recon CLI's per-step resolution. `resolveFrameTarget`
6099
+ // falls back to the main-frame target when `frameSelector` is null/unresolvable,
6100
+ // so this is a no-op for every flow that doesn't declare one.
6101
+ const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page, deps.frameSelector);
6102
+ await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
5977
6103
  const outcome = await executeStepWithHealing({
5978
6104
  stagehand,
5979
6105
  page,