@enricai/barnacle 1.12.3 → 1.12.5

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.
@@ -68,6 +68,7 @@ exports.parseSelectStep = parseSelectStep;
68
68
  exports.parseFillStep = parseFillStep;
69
69
  exports.parseFillValueIntent = parseFillValueIntent;
70
70
  exports.parseRadioStep = parseRadioStep;
71
+ exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
71
72
  exports.parseCheckStep = parseCheckStep;
72
73
  exports.pollEnumerate = pollEnumerate;
73
74
  exports.waitForTransitionBody = waitForTransitionBody;
@@ -388,6 +389,13 @@ const SELECT_SETTLE_MS = 400;
388
389
  * triggered it.
389
390
  */
390
391
  const PROMPT_SELECTOR_SETTLE_MS = 400;
392
+ /**
393
+ * Cap on the category->leaf drill loop in {@link commitPromptOption}: a
394
+ * two-level cascading multiselect (category click re-renders the popup to
395
+ * leaves) only ever drills one level deep in the wild, so 3 gives headroom
396
+ * for a rarer 2-level cascade without letting a genuinely broken widget spin.
397
+ */
398
+ const PROMPT_SELECTOR_MAX_DRILL_DEPTH = 3;
391
399
  /**
392
400
  * Temporary attribute {@link tryPromptSelectorPrimitive} stamps onto each
393
401
  * candidate widget during its read-only enumerate pass, so the Node-side click
@@ -3814,6 +3822,48 @@ function parseRadioStep(instruction) {
3814
3822
  const questionLabel = pickQuestionLabel(instruction, option);
3815
3823
  return { option, questionLabel };
3816
3824
  }
3825
+ /**
3826
+ * Parse a cascading-multiselect CATEGORY/LEAF widget-option click step into
3827
+ * the option to click.
3828
+ *
3829
+ * Why this exists (sibling of `parseRadioStep`): flow/replan generation
3830
+ * describes a two-level cascading multiselect's category and leaf clicks as
3831
+ * "click the top-level category 'Job Boards' to expand its sub-options" and
3832
+ * "click the leaf option 'Internet - Job Boards/Search Engines' to commit the
3833
+ * source value" — a `click` verb paired with a `category`/`option` noun that
3834
+ * `parseRadioStep` deliberately does not recognize (its `answer`/`radio` noun
3835
+ * set, and `tryRadioPrimitive`'s native `input[type=radio]` matching, are
3836
+ * scoped to actual radio groups). Left unrecognized, this phrasing matches
3837
+ * none of `tryPromptSelectorPrimitive`'s accepted shapes, so the primitive
3838
+ * returns before ever inspecting the DOM and the step falls to the observe
3839
+ * cascade's untrusted `el.click()`, which this widget family ignores.
3840
+ *
3841
+ * Deliberately excludes select/checkbox/radio-answer steps (checked first,
3842
+ * mirroring `parseRadioStep`'s own precedence) so a single step never
3843
+ * resolves through two primitives.
3844
+ */
3845
+ function parseWidgetOptionClickStep(instruction) {
3846
+ const lower = instruction.toLowerCase();
3847
+ if (/\bselect(\s+or\s+check)?\b/.test(lower))
3848
+ return null;
3849
+ if (!/\bclick\b/.test(lower))
3850
+ return null;
3851
+ if (!/\b(category|option)\b/.test(lower))
3852
+ return null;
3853
+ // Catch-all steps ("for any remaining…") have no concrete single target.
3854
+ if (/\bany\s+remaining\b/.test(lower))
3855
+ return null;
3856
+ // The OPTION is the quoted string after "click the <category|option noun>",
3857
+ // tolerant of a short qualifier ("top-level", "leaf") between the noun and
3858
+ // the quote — e.g. "click the top-level category 'Job Boards'" or "click
3859
+ // the leaf option 'Internet - Job Boards/Search Engines'".
3860
+ const optMatch = instruction.match(/\bclick\s+the\s+(?:\S+\s+)?(?:category|option)\s+'([^']+)'/i);
3861
+ if (!optMatch)
3862
+ return null;
3863
+ // biome-ignore lint/style/noNonNullAssertion: guarded by the !optMatch early-return; group 1 is required by the pattern
3864
+ const option = optMatch[1].trim();
3865
+ return { option };
3866
+ }
3817
3867
  /**
3818
3868
  * Parse a CHECKBOX flow step into the label of the checkbox it targets.
3819
3869
  *
@@ -5020,19 +5070,29 @@ async function tryPromptSelectorPrimitive(params) {
5020
5070
  // than a SELECT step, and its Yes/No variant renders no native radio inputs
5021
5071
  // at all, so flow/replan generation describes it with the same answer-verb
5022
5072
  // phrasing used for native radios ("Click the 'Yes' answer for the question
5023
- // '…'"). Accept all three shapes: parseSelectStep first, then parseFillStep
5024
- // (fieldLabel -> questionLabel, value -> option), then parseRadioStep
5025
- // (option/questionLabel already in this primitive's shape) so the widget-
5026
- // matching/open/readback phases below run unchanged regardless of which
5027
- // verb the instruction used.
5073
+ // '…'"). Also accepts a cascading-multiselect CATEGORY/LEAF click step
5074
+ // (`parseWidgetOptionClickStep`, "click the top-level category 'X'" /
5075
+ // "click the leaf option 'Y'") that widget's category/leaf clicks carry
5076
+ // no question label, matched instead by the single-unfilled-widget rule
5077
+ // below. Accept all four shapes: parseSelectStep first, then
5078
+ // parseFillStep (fieldLabel -> questionLabel, value -> option), then
5079
+ // parseRadioStep (option/questionLabel already in this primitive's
5080
+ // shape), then parseWidgetOptionClickStep (option only, no question
5081
+ // label) so the widget-matching/open/readback phases below run unchanged
5082
+ // regardless of which verb the instruction used.
5028
5083
  const parsedSelect = parseSelectStep(instruction);
5029
5084
  const parsedFill = parsedSelect ? null : parseFillStep(instruction);
5030
5085
  const parsedAnswer = parsedSelect || parsedFill ? null : parseRadioStep(instruction);
5086
+ const parsedOptionClick = parsedSelect || parsedFill || parsedAnswer ? null : parseWidgetOptionClickStep(instruction);
5031
5087
  const parsed = parsedSelect
5032
5088
  ? parsedSelect
5033
5089
  : parsedFill
5034
5090
  ? { option: parsedFill.value, questionLabel: stripQuotedLabel(parsedFill.fieldLabel) }
5035
- : parsedAnswer;
5091
+ : parsedAnswer
5092
+ ? parsedAnswer
5093
+ : parsedOptionClick
5094
+ ? { option: parsedOptionClick.option, questionLabel: null }
5095
+ : null;
5036
5096
  if (!parsed)
5037
5097
  return null;
5038
5098
  const { option, questionLabel } = parsed;
@@ -5167,33 +5227,6 @@ async function tryPromptSelectorPrimitive(params) {
5167
5227
  logger.info(`prompt-selector primitive: no unambiguous widget match for ${optLabel}; falling through to cascade`);
5168
5228
  return null;
5169
5229
  }
5170
- // Phase 2 (real gesture): open the popup. These widgets' trigger requires a
5171
- // genuine click — a synthetic dispatchEvent does not fire its handler.
5172
- // Prefer the marked widget's own interactive descendant (a real form
5173
- // control) over the marked container itself, since the container is
5174
- // frequently a non-interactive layout wrapper for widget shapes whose
5175
- // actual trigger sits a level deeper (see PROMPT_INTERACTIVE_CONTROL_SELECTORS).
5176
- const containerTriggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
5177
- const innerTriggerSel = PROMPT_INTERACTIVE_CONTROL_SELECTORS.split(",")
5178
- .map((s) => `${containerTriggerSel} ${s}`)
5179
- .join(",");
5180
- let triggerSel = containerTriggerSel;
5181
- try {
5182
- const innerCount = await target.locator(innerTriggerSel).count();
5183
- if (innerCount > 0)
5184
- triggerSel = innerTriggerSel;
5185
- }
5186
- catch {
5187
- // Fall back to the container selector.
5188
- }
5189
- try {
5190
- await target.locator(triggerSel).first().click();
5191
- }
5192
- catch (err) {
5193
- logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5194
- return null;
5195
- }
5196
- await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
5197
5230
  const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel) => {
5198
5231
  const w = document.querySelector("[" + widgetMarkAttr + '="' + wIdx + '"]');
5199
5232
  if (!w) return { optionsPresent: false };
@@ -5232,9 +5265,65 @@ async function tryPromptSelectorPrimitive(params) {
5232
5265
  const label = opts[i].getAttribute("data-automation-label") || opts[i].getAttribute("aria-label") || opts[i].textContent || "";
5233
5266
  options.push({ oIdx: i, text: label.replace(/\\s+/g, " ").trim() });
5234
5267
  }
5235
- return { optionsPresent: true, searchable: !!searchInput, options };
5268
+ // scopeIsDocument distinguishes a genuinely resolved (portal or inline)
5269
+ // popup from the document-wide last-resort fallback in
5270
+ // PROMPT_SCOPE_ROOT_EXPR — the pre-open pre-check below must NOT treat a
5271
+ // still-unopened widget as "already open" on the strength of some OTHER
5272
+ // widget's stale/leftover popup elsewhere in the document; only a scope
5273
+ // that resolved to THIS widget's own inline subtree or its own
5274
+ // aria-controls/aria-owns target counts.
5275
+ return { optionsPresent: true, searchable: !!searchInput, options, scopeIsDocument: scope === document };
5236
5276
  })(${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_OPTION_MARK_ATTR)}, ${JSON.stringify(PROMPT_OPTION_SELECTORS)}, ${JSON.stringify(PROMPT_SEARCH_SELECTORS)})`;
5237
- const optionsInitial = await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
5277
+ // Pre-check (single evaluate, no polling): a PRIOR primitive call on this
5278
+ // same unreloaded page may have already opened/drilled this exact widget's
5279
+ // popup (a two-level cascading multiselect authored as two flow steps —
5280
+ // category, then leaf — re-enters here for the leaf with the popup already
5281
+ // open). Re-clicking the trigger in that case perturbs/closes the
5282
+ // already-rendered popup instead of reading it. Skip the open-click
5283
+ // entirely when options are already present AND resolved to THIS widget's
5284
+ // OWN scope (not the document-wide fallback, which could otherwise credit
5285
+ // an unrelated widget's stale/leftover popup elsewhere on the page as
5286
+ // "already open"); a genuinely closed popup falls through to the normal
5287
+ // open-click + poll path below unchanged.
5288
+ const precheck = (await target.evaluate(enumerateOptionsExpr));
5289
+ const alreadyOpen = precheck?.optionsPresent === true &&
5290
+ (precheck.options?.length ?? 0) > 0 &&
5291
+ precheck.scopeIsDocument !== true;
5292
+ if (!alreadyOpen) {
5293
+ // Phase 2 (real gesture): open the popup. These widgets' trigger requires a
5294
+ // genuine click — a synthetic dispatchEvent does not fire its handler.
5295
+ // Prefer the marked widget's own interactive descendant (a real form
5296
+ // control) over the marked container itself, since the container is
5297
+ // frequently a non-interactive layout wrapper for widget shapes whose
5298
+ // actual trigger sits a level deeper (see PROMPT_INTERACTIVE_CONTROL_SELECTORS).
5299
+ const containerTriggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
5300
+ const innerTriggerSel = PROMPT_INTERACTIVE_CONTROL_SELECTORS.split(",")
5301
+ .map((s) => `${containerTriggerSel} ${s}`)
5302
+ .join(",");
5303
+ let triggerSel = containerTriggerSel;
5304
+ try {
5305
+ const innerCount = await target.locator(innerTriggerSel).count();
5306
+ if (innerCount > 0)
5307
+ triggerSel = innerTriggerSel;
5308
+ }
5309
+ catch {
5310
+ // Fall back to the container selector.
5311
+ }
5312
+ try {
5313
+ await target.locator(triggerSel).first().click();
5314
+ }
5315
+ catch (err) {
5316
+ logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5317
+ return null;
5318
+ }
5319
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
5320
+ }
5321
+ else {
5322
+ logger.info(`prompt-selector primitive: popup for ${optLabel} already open with rendered options; skipping trigger click`);
5323
+ }
5324
+ const optionsInitial = alreadyOpen
5325
+ ? precheck
5326
+ : await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
5238
5327
  if (!optionsInitial?.optionsPresent) {
5239
5328
  logger.info(`prompt-selector primitive: popup for ${optLabel} did not render options; falling through`);
5240
5329
  return null;
@@ -5257,6 +5346,7 @@ async function tryPromptSelectorPrimitive(params) {
5257
5346
  questionLabel,
5258
5347
  chosen,
5259
5348
  optionsResult,
5349
+ enumerateOptionsExpr,
5260
5350
  });
5261
5351
  }
5262
5352
  try {
@@ -5290,6 +5380,7 @@ async function tryPromptSelectorPrimitive(params) {
5290
5380
  questionLabel,
5291
5381
  chosen,
5292
5382
  optionsResult: optionsFiltered,
5383
+ enumerateOptionsExpr,
5293
5384
  });
5294
5385
  }
5295
5386
  catch (err) {
@@ -5306,82 +5397,115 @@ async function tryPromptSelectorPrimitive(params) {
5306
5397
  * by the value union, or its invalid marker cleared). Split out from the main
5307
5398
  * function because both the static and searchable-then-filtered branches need
5308
5399
  * the identical match/click/verify sequence.
5400
+ *
5401
+ * Bounded (see {@link PROMPT_SELECTOR_MAX_DRILL_DEPTH}) category->leaf drill:
5402
+ * when a click's readback fails, that is ambiguous between a genuine
5403
+ * non-commit and a category click that swapped the popup to a deeper level
5404
+ * (a two-level cascading multiselect authored as a SINGLE step naming only
5405
+ * the leaf). Re-enumerating after a failed readback and comparing the
5406
+ * option set disambiguates the two: an unchanged set is a real failure and
5407
+ * must still fall through to the cascade; a changed set means the drill
5408
+ * fired, so re-match/re-click continues at the new level instead of
5409
+ * abandoning the primitive.
5309
5410
  */
5310
5411
  async function commitPromptOption(params) {
5311
- const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen, optionsResult, } = params;
5412
+ const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen, enumerateOptionsExpr, } = params;
5312
5413
  const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
5313
5414
  const wantOpt = norm(option);
5314
- const options = optionsResult.options ?? [];
5315
- const detMatch = options.find((o) => norm(o.text) === wantOpt) ?? null;
5316
- if (detMatch === null && (anthropic === null || options.length === 0)) {
5317
- logger.info(`prompt-selector primitive: no option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
5318
- return null;
5319
- }
5320
- const verdict = detMatch === null
5321
- ? await (0, select_option_1.judgeSelectOptionWithLLM)({
5322
- client: anthropic,
5323
- input: {
5324
- questionLabel,
5325
- desiredHint: option,
5326
- candidates: [{ label: chosen.label || null, options: options.map((o) => o.text) }],
5327
- },
5328
- captureFn,
5329
- })
5330
- : null;
5331
- if (detMatch === null &&
5332
- (!verdict || verdict.selectIndex === null || verdict.optionIndex === null)) {
5333
- logger.info(`prompt-selector primitive: LLM found no matching option for ${optLabel}${verdict ? ` (${verdict.reason})` : ""}; falling through to cascade`);
5334
- return null;
5335
- }
5336
- const chosenOption = detMatch ??
5337
- (verdict && verdict.optionIndex !== null ? (options[verdict.optionIndex] ?? null) : null);
5338
- if (!chosenOption) {
5339
- logger.info(`prompt-selector primitive: option match out of range for ${optLabel}; falling through`);
5340
- return null;
5341
- }
5342
- const matchReason = detMatch ? "deterministic" : `LLM: ${(verdict?.reason ?? "").slice(0, 60)}`;
5343
- const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${chosenOption.oIdx}"]`;
5344
- try {
5345
- await target.locator(optionSel).first().click();
5346
- }
5347
- catch (err) {
5348
- logger.info(`prompt-selector primitive: option click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5349
- return null;
5350
- }
5351
- await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
5352
- const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText) => {
5353
- const isInvalid = ${INVALID_MARKER_EL_EXPR};
5354
- const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
5355
- const buttonValue = ${BUTTON_VALUE_EXPR};
5356
- const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
5357
- const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
5358
- if (!w) return { ok: false, id: "" };
5359
- // Value via the union: aria-activedescendant, own input value, a <button>'s
5360
- // own value text (popup-pollution-safe, see BUTTON_VALUE_EXPR), or a
5361
- // selection-label node (empty-state phrase treated as no value).
5362
- const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
5363
- const adText = adid && document.getElementById(adid) ? norm(document.getElementById(adid).textContent) : "";
5364
- const own = w.tagName === "INPUT" ? norm(w.value || "") : w.tagName === "BUTTON" ? norm(buttonValue(w)) : "";
5365
- const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
5366
- const lblRaw = lbl ? (lbl.textContent || "") : "";
5367
- const lblText = lblRaw.trim() && !emptyRx.test(lblRaw.trim()) ? norm(lblRaw) : "";
5368
- const text = adText || own || lblText;
5369
- const textMatches = wantText ? text.includes(wantText) : text !== "";
5370
- let node = w;
5371
- let stillInvalid = false;
5372
- for (let d = 0; d < 6 && node; d++) {
5373
- if (node.getAttribute && isInvalid(node)) { stillInvalid = true; break; }
5374
- node = node.parentElement;
5375
- }
5376
- return { ok: textMatches || !stillInvalid, id: w.id || "" };
5377
- })(${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)}, ${JSON.stringify(norm(chosenOption.text))})`;
5378
- const readback = (await target.evaluate(readbackExpr).catch(() => ({ ok: false, id: "" })));
5379
- if (!readback?.ok) {
5380
- logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
5381
- return null;
5415
+ const optionSetKey = (opts) => opts
5416
+ .map((o) => norm(o.text))
5417
+ .sort()
5418
+ .join("");
5419
+ let optionsResult = params.optionsResult;
5420
+ for (let depth = 0; depth <= PROMPT_SELECTOR_MAX_DRILL_DEPTH; depth++) {
5421
+ const options = optionsResult.options ?? [];
5422
+ const detMatch = options.find((o) => norm(o.text) === wantOpt) ?? null;
5423
+ if (detMatch === null && (anthropic === null || options.length === 0)) {
5424
+ logger.info(`prompt-selector primitive: no option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
5425
+ return null;
5426
+ }
5427
+ const verdict = detMatch === null
5428
+ ? await (0, select_option_1.judgeSelectOptionWithLLM)({
5429
+ client: anthropic,
5430
+ input: {
5431
+ questionLabel,
5432
+ desiredHint: option,
5433
+ candidates: [{ label: chosen.label || null, options: options.map((o) => o.text) }],
5434
+ },
5435
+ captureFn,
5436
+ })
5437
+ : null;
5438
+ if (detMatch === null &&
5439
+ (!verdict || verdict.selectIndex === null || verdict.optionIndex === null)) {
5440
+ logger.info(`prompt-selector primitive: LLM found no matching option for ${optLabel}${verdict ? ` (${verdict.reason})` : ""}; falling through to cascade`);
5441
+ return null;
5442
+ }
5443
+ const chosenOption = detMatch ??
5444
+ (verdict && verdict.optionIndex !== null ? (options[verdict.optionIndex] ?? null) : null);
5445
+ if (!chosenOption) {
5446
+ logger.info(`prompt-selector primitive: option match out of range for ${optLabel}; falling through`);
5447
+ return null;
5448
+ }
5449
+ const matchReason = detMatch ? "deterministic" : `LLM: ${(verdict?.reason ?? "").slice(0, 60)}`;
5450
+ const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${chosenOption.oIdx}"]`;
5451
+ try {
5452
+ await target.locator(optionSel).first().click();
5453
+ }
5454
+ catch (err) {
5455
+ logger.info(`prompt-selector primitive: option click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5456
+ return null;
5457
+ }
5458
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
5459
+ const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText) => {
5460
+ const isInvalid = ${INVALID_MARKER_EL_EXPR};
5461
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
5462
+ const buttonValue = ${BUTTON_VALUE_EXPR};
5463
+ const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
5464
+ const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
5465
+ if (!w) return { ok: false, id: "" };
5466
+ // Value via the union: aria-activedescendant, own input value, a <button>'s
5467
+ // own value text (popup-pollution-safe, see BUTTON_VALUE_EXPR), or a
5468
+ // selection-label node (empty-state phrase treated as no value).
5469
+ const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
5470
+ const adText = adid && document.getElementById(adid) ? norm(document.getElementById(adid).textContent) : "";
5471
+ const own = w.tagName === "INPUT" ? norm(w.value || "") : w.tagName === "BUTTON" ? norm(buttonValue(w)) : "";
5472
+ const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
5473
+ const lblRaw = lbl ? (lbl.textContent || "") : "";
5474
+ const lblText = lblRaw.trim() && !emptyRx.test(lblRaw.trim()) ? norm(lblRaw) : "";
5475
+ const text = adText || own || lblText;
5476
+ const textMatches = wantText ? text.includes(wantText) : text !== "";
5477
+ let node = w;
5478
+ let stillInvalid = false;
5479
+ for (let d = 0; d < 6 && node; d++) {
5480
+ if (node.getAttribute && isInvalid(node)) { stillInvalid = true; break; }
5481
+ node = node.parentElement;
5482
+ }
5483
+ return { ok: textMatches || !stillInvalid, id: w.id || "" };
5484
+ })(${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)}, ${JSON.stringify(norm(chosenOption.text))})`;
5485
+ const readback = (await target.evaluate(readbackExpr).catch(() => ({ ok: false, id: "" })));
5486
+ if (readback?.ok) {
5487
+ logger.info(`prompt-selector primitive: selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${matchReason})`);
5488
+ return readback.id;
5489
+ }
5490
+ if (depth === PROMPT_SELECTOR_MAX_DRILL_DEPTH) {
5491
+ logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
5492
+ return null;
5493
+ }
5494
+ // Readback failed — disambiguate a genuine non-commit from a
5495
+ // category->leaf drill by re-enumerating and comparing the option set.
5496
+ const reEnumerated = (await target
5497
+ .evaluate(enumerateOptionsExpr)
5498
+ .catch(() => ({ optionsPresent: false })));
5499
+ const newOptions = reEnumerated?.optionsPresent ? (reEnumerated.options ?? []) : [];
5500
+ const drilled = newOptions.length > 0 && optionSetKey(newOptions) !== optionSetKey(options);
5501
+ if (!drilled) {
5502
+ logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
5503
+ return null;
5504
+ }
5505
+ logger.info(`prompt-selector primitive: click for ${optLabel} drilled the popup to a new option set; re-matching at the new level`);
5506
+ optionsResult = { options: newOptions };
5382
5507
  }
5383
- logger.info(`prompt-selector primitive: selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${matchReason})`);
5384
- return readback.id;
5508
+ return null;
5385
5509
  }
5386
5510
  /**
5387
5511
  * Guard for the optional-step fast-skip: is there a REQUIRED, still-empty (or
@@ -8601,6 +8725,24 @@ async function runHealingFlow(deps) {
8601
8725
  if (maxFlowMs !== undefined && Date.now() - start > maxFlowMs) {
8602
8726
  throw new errors_2.StepVerificationError(`${formatStepPrefix(i, () => steps.length)} flow exceeded its maxFlowMs budget (${maxFlowMs}ms)`, "flow-timeout");
8603
8727
  }
8728
+ // Liveness gate: a closed/crashed Stagehand session (e.g. the
8729
+ // shutdown-supervisor force-releasing mid-flow) makes `page.url()`
8730
+ // throw synchronously — the one call every Page implementation
8731
+ // answers without touching the DOM. Everything downstream (observe/
8732
+ // act probes) treats a thrown error as "page has no candidates right
8733
+ // now" and, for an optional step, quietly skips it — so without this
8734
+ // check the loop runs to completion and `runHealingFlow` resolves as
8735
+ // if the flow finished, even though the session died partway through.
8736
+ // Checking here, before any step-specific handling, means a dead
8737
+ // session is reported as a `SessionTimeoutError` distinct from a
8738
+ // legitimately-absent optional step, and the flow's own step count
8739
+ // never quietly reaches `steps.length` past the point of death.
8740
+ try {
8741
+ page.url();
8742
+ }
8743
+ catch (err) {
8744
+ throw new errors_2.SessionTimeoutError(`${formatStepPrefix(i, () => steps.length)} session appears closed/dead (page.url() threw: ${(0, errors_1.toErrorMessage)(err)}) — aborting after ${i} of ${steps.length} steps completed`);
8745
+ }
8604
8746
  lastStepIndex = i;
8605
8747
  // Resolved fresh per step (not cached across the run) so a cross-origin
8606
8748
  // iframe that attaches mid-flow (e.g. after an "Apply" click reveals a