halo-agent 2.4.6 → 2.5.0

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.
package/orchestrator.js CHANGED
@@ -272,6 +272,15 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
272
272
  const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
273
273
  fillResult.filled += retryResult.filled;
274
274
  fillResult.skipped = retryResult.skipped;
275
+ // Preserve sticky upload flags. If pass 1 uploaded resume / cover letter,
276
+ // pass 2's smartFill won't redo it (the file slot is already filled and
277
+ // smartFill skips), so retryResult.resumeUploaded reads false — but the
278
+ // file IS in the slot. ORing keeps the truth across passes; without this
279
+ // the orchestrator's blanket fallback uploader (below) re-uploads resume
280
+ // into whatever file input it finds next, which on Greenhouse is the
281
+ // cover-letter slot. That's the Robinhood corruption bug.
282
+ fillResult.resumeUploaded = fillResult.resumeUploaded || retryResult.resumeUploaded;
283
+ fillResult.coverLetterUploaded = fillResult.coverLetterUploaded || retryResult.coverLetterUploaded;
275
284
  console.log(`[orchestrator] Retry fill: +${retryResult.filled} fields filled`);
276
285
  }
277
286
 
@@ -292,9 +301,33 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
292
301
  // the cover-letter slot too. smartFill now reports resumeUploaded, so we
293
302
  // only fall back when the planner genuinely missed the resume field.
294
303
  if (tempResumeFile && !fillResult.resumeUploaded) {
295
- console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader.');
296
- const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
297
- if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
304
+ console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader (resume-scoped).');
305
+ // Scoped selectors. Only match file inputs that are CLEARLY resume slots:
306
+ // - aria-label / name / id mentions "resume" or "cv"
307
+ // - dedicated [data-testid*="resume"]
308
+ // - inputs inside a section/fieldset/div whose nearest heading text
309
+ // contains "Resume" or "CV" (Greenhouse pattern — uses a sibling
310
+ // <h3>Resume/CV*</h3>). We use Playwright's :has() to chain.
311
+ //
312
+ // Critically we EXCLUDE selectors that would match the cover-letter slot.
313
+ // If no resume-scoped match exists, we don't upload anywhere — better to
314
+ // surface a real failure than silently corrupt the cover-letter slot.
315
+ const resumeOnlySelectors = [
316
+ 'input[type="file"][aria-label*="resume" i]',
317
+ 'input[type="file"][aria-label*="cv" i]',
318
+ 'input[type="file"][name*="resume" i]',
319
+ 'input[type="file"][id*="resume" i]',
320
+ '[data-testid*="resume" i]',
321
+ ].join(', ');
322
+ const uploaded = await uploadFile(page, resumeOnlySelectors, tempResumeFile);
323
+ if (uploaded) {
324
+ fillResult.filled = (fillResult.filled || 0) + 1;
325
+ fillResult.resumeUploaded = true;
326
+ } else {
327
+ console.warn('[orchestrator] Fallback uploader found no resume-scoped slot. Refusing to upload to generic file inputs to avoid cover-letter corruption.');
328
+ }
329
+ } else if (tempResumeFile && fillResult.resumeUploaded) {
330
+ console.log('[orchestrator] Skipping fallback uploader — smartFill already uploaded resume.');
298
331
  }
299
332
 
300
333
  await reportStatus('IN_PROGRESS', {
@@ -727,31 +760,53 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
727
760
  console.warn(`[orchestrator] verify-submit unavailable: ${e.message}`);
728
761
  }
729
762
 
730
- // Three-way verdict:
731
- // submitted: true → real success, mark DONE
732
- // submitted: false real failure, NEEDS_ATTENTION with the error
733
- // submitted: null → could not verify (Firecrawl down/missing).
734
- // Bounce to REVIEWING so the user eyeballs the
735
- // screenshot never silently mark DONE on
736
- // unverified submits (that was the Chalk bug).
763
+ // Firecrawl is ADVISORY, not authoritative. The DOM-error pass above is
764
+ // the ground truth: if `errorsAfter.hasErrors === false` (no validation
765
+ // banner, no aria-invalid, no "is required" text), the form was accepted.
766
+ //
767
+ // Firecrawl false-positives we've seen in the wild:
768
+ // - Ashby keeps the reCAPTCHA badge DOM-mounted on the thank-you page;
769
+ // Firecrawl's extract reads it as "Recaptcha requires verification."
770
+ // - Greenhouse thank-you pages include apologetic copy ("we'll be in
771
+ // touch if it didn't go through") that the LLM reads as failure.
772
+ // - URL doesn't always change on success (embed=js Ashby just swaps
773
+ // the panel to a "Thanks for applying" state).
774
+ //
775
+ // So we no longer throw NEEDS_ATTENTION on `verdict.submitted === false`
776
+ // when local signals say success. Local truth comes from:
777
+ // - errorsAfter.hasErrors === false (no DOM validation errors)
778
+ // - findSubmitButton(page) returns null (submit button replaced by
779
+ // confirmation UI), OR
780
+ // - URL matched /thank|confirm|success|applied|submitted/ during the
781
+ // wait above, OR
782
+ // - No required fields still empty.
783
+ //
784
+ // Only when local signals ALSO look suspicious do we route to REVIEWING
785
+ // (NOT NEEDS_ATTENTION-throw — REVIEWING keeps tab open + parks).
737
786
  if (verdict.submitted === false) {
738
- // The DOM-error pass above already retried + short-circuited if errors
739
- // remained. If we got here AND Firecrawl is saying false, the DOM was
740
- // clean (no aria-invalid / no banner / no "is required" text) but
741
- // Firecrawl still detected something wrong — probably a thank-you
742
- // page that includes some apologetic text the LLM misread. Surface
743
- // it gently rather than throwing; user can audit the screenshot.
744
- const reason = verdict.error_message || 'Firecrawl could not confirm submission';
745
- console.warn(`[orchestrator] Firecrawl says NOT submitted (DOM looked clean): ${reason}`);
746
- await reportStatus('NEEDS_ATTENTION', {
747
- review_screenshot_r2_key: confirmKey || null,
748
- needs_attention_reason: `Verifier flagged this submit: ${reason}. Page DOM looked clean — please eyeball.`,
749
- intervention_type: 'submit_failed',
750
- step: 'VERIFY',
751
- step_detail: reason.slice(0, 200),
752
- fields_filled: cumulativeFilled,
753
- });
754
- throw new Error(`Submission failed verification: ${reason}`);
787
+ const reason = verdict.error_message || 'verifier said not submitted';
788
+ // Local re-check: is the form gone / page in a confirmation state?
789
+ const submitStillThere = await findSubmitButton(page).then((b) => !!b).catch(() => false);
790
+ const urlLooksConfirmed = /thank|confirm|success|applied|submitted/i.test(page.url());
791
+ const postSkipped2 = (ctx.skippedFields || []).map((s) => s.label);
792
+ const reqCheck = await detectRequiredEmpties(page, { excludeLabels: postSkipped2, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
793
+
794
+ // If submit button gone OR URL changed OR no validation errors AND
795
+ // no empty required fields → trust the DOM, treat as DONE.
796
+ const domSaysClean = !errorsAfter.hasErrors && !reqCheck.hasEmptyRequired;
797
+ const looksSubmitted = !submitStillThere || urlLooksConfirmed;
798
+ if (domSaysClean && (looksSubmitted || true /* DOM clean is enough */)) {
799
+ console.log(`[orchestrator] Verifier said "${reason}" but DOM is clean (no errors, no empties, submit ${submitStillThere ? 'still present' : 'gone'}, url ${urlLooksConfirmed ? 'confirmed' : 'unchanged'}). Treating as submitted.`);
800
+ // Fall through into the success-decision block below by overriding the verdict.
801
+ verdict.submitted = true;
802
+ verdict.source = verdict.source || 'dom-clean-override';
803
+ } else {
804
+ // DOM also looks off → real ambiguity. REVIEWING, not NEEDS_ATTENTION.
805
+ // Keep tab open so the user can finish manually. No throw.
806
+ console.warn(`[orchestrator] Verifier said "${reason}" and DOM is ambiguous (errors=${errorsAfter.hasErrors}, empties=${reqCheck.hasEmptyRequired}). Parking REVIEWING.`);
807
+ verdict.submitted = null;
808
+ verdict.source = 'dom-ambiguous';
809
+ }
755
810
  }
756
811
 
757
812
  // Decide final state (DONE / REVIEWING) based on verifier verdict +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.4.6",
3
+ "version": "2.5.0",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -311,6 +311,48 @@ async function enrichFromDom(page, axFields) {
311
311
  }
312
312
  } catch {}
313
313
 
314
+ // Group-heading disambiguation for file inputs with generic labels.
315
+ // Greenhouse renders two `<input type="file">` inputs both labeled
316
+ // "Attach" — one under a `<h3>Resume/CV*</h3>` heading, one under
317
+ // `<h3>Cover Letter</h3>`. The AX name only sees "Attach" for both,
318
+ // so the planner can't route resume vs cover_letter deterministically.
319
+ // Walk up to the nearest heading and capture its text as groupLabel.
320
+ let groupLabel = '';
321
+ try {
322
+ const isFile = (el.tagName === 'INPUT' && String(el.type || '').toLowerCase() === 'file');
323
+ if (isFile) {
324
+ // Use the resolved AX/placeholder label if present; else fall back
325
+ // to the input's textContent / aria-label.
326
+ const lblForCheck = (el.getAttribute('aria-label') || el.placeholder || '').trim();
327
+ // Conservative: only fire when the immediate label is a generic
328
+ // upload verb. We don't want to clobber a real label like "Resume".
329
+ const generic = /^(attach|attach\s*file|upload|upload\s*file|choose file|select file|browse|add file|file)$/i;
330
+ // The visible button label is often on a sibling <button>; check that too.
331
+ let buttonText = '';
332
+ try {
333
+ const wrapper = el.closest('div, section, fieldset, [class*="field"], [class*="upload"]');
334
+ const btn = wrapper && wrapper.querySelector('button, [role="button"]');
335
+ if (btn) buttonText = safeText(btn.innerText || btn.textContent || '').trim();
336
+ } catch {}
337
+ const labelLooksGeneric = !lblForCheck || generic.test(lblForCheck) || generic.test(buttonText || '');
338
+ if (labelLooksGeneric) {
339
+ // Walk up to 8 ancestors and look for a heading inside that ancestor.
340
+ let p = el.parentElement, hops = 0;
341
+ while (p && hops < 8) {
342
+ const heading = p.querySelector('h1, h2, h3, h4, h5, h6, legend, [role="heading"]');
343
+ if (heading && !heading.contains(el)) {
344
+ const t = safeText(heading.textContent || '').replace(/\s*\*\s*$/, '').trim();
345
+ if (t && t.length > 0 && t.length < 150) {
346
+ groupLabel = t;
347
+ break;
348
+ }
349
+ }
350
+ p = p.parentElement; hops++;
351
+ }
352
+ }
353
+ }
354
+ } catch {}
355
+
314
356
  return {
315
357
  mmid,
316
358
  tag,
@@ -321,6 +363,7 @@ async function enrichFromDom(page, axFields) {
321
363
  ariaLabel: el.getAttribute('aria-label') || '',
322
364
  placeholder: el.placeholder || '',
323
365
  recoveredLabel,
366
+ groupLabel,
324
367
  selectorHint,
325
368
  currentValue,
326
369
  options,
package/smartFill.js CHANGED
@@ -125,6 +125,10 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
125
125
  const result = await openAndPickOption(root, locator, item.value, {
126
126
  config: ctx.config, jobId: ctx.jobId, label: field.label, field,
127
127
  });
128
+ // Always dismiss any lingering portal/option-list before the next
129
+ // field so stale overlays don't intercept the next click.
130
+ const pageRef = (root && typeof root.page === 'function') ? root.page() : root;
131
+ await pageRef.keyboard.press('Escape').catch(() => {});
128
132
  if (result.ok) return result;
129
133
  // Fall through to text-fill if no option matched at all
130
134
  // (some Greenhouse comboboxes accept free-text in addition to options).
@@ -150,9 +154,14 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
150
154
  }
151
155
 
152
156
  case 'click_option': {
153
- return await openAndPickOption(root, locator, item.value, {
157
+ const result = await openAndPickOption(root, locator, item.value, {
154
158
  config: ctx.config, jobId: ctx.jobId, label: field.label, field,
155
159
  });
160
+ // Always dismiss any lingering portal/option-list before the next
161
+ // field so stale overlays don't intercept the next click.
162
+ const pageRef = (root && typeof root.page === 'function') ? root.page() : root;
163
+ await pageRef.keyboard.press('Escape').catch(() => {});
164
+ return result;
156
165
  }
157
166
 
158
167
  case 'set_checkbox': {
@@ -656,7 +665,22 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
656
665
  // - Ashby's emotion-hashed _input_ class inside a _fieldEntry_ group
657
666
  try {
658
667
  const fieldIsTypeahead = !!(llmCtx && llmCtx.field && llmCtx.field.isTypeahead);
659
- const domIsTypeahead = await triggerLocator.evaluate((el) => {
668
+ // React-select disqualifier. Greenhouse, Lever, and many other ATSs ship
669
+ // react-select-styled dropdowns that carry `aria-autocomplete="list"` but
670
+ // are NOT async typeaheads — their options are pre-loaded and rendered as
671
+ // `.select__option` under a portal. If we type into them, react-select
672
+ // clears the input on blur because the typed text doesn't match any
673
+ // selectable option. The result is a silently-empty required field.
674
+ // Route those straight to tryReactSelect instead.
675
+ const isReactSelect = await triggerLocator.evaluate((el) => {
676
+ const matchSel = '.select__input, [class*="select__input"], .select__control, [class*="select__control"], [class*="-control"][class*="select"]';
677
+ if (el.matches?.(matchSel)) return true;
678
+ if (el.closest?.('.select__control, [class*="select__control"], .select, [class*="-container"][class*="select"]')) return true;
679
+ // Descendant check (when locator climbed to a wrapper)
680
+ if (el.querySelector?.('.select__input, [class*="select__input"], .select__control, [class*="select__control"]')) return true;
681
+ return false;
682
+ }).catch(() => false);
683
+ const domIsTypeahead = isReactSelect ? false : await triggerLocator.evaluate((el) => {
660
684
  const probe = (n) => {
661
685
  if (!n || n.nodeType !== 1) return false;
662
686
  const role = n.getAttribute('role');
@@ -685,12 +709,15 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
685
709
  return false;
686
710
  }).catch(() => false);
687
711
 
688
- if (fieldIsTypeahead || domIsTypeahead) {
712
+ if (isReactSelect) {
713
+ console.log('[smartFill] openAndPickOption: react-select detected → skipping typeahead path, routing to tryReactSelect');
714
+ }
715
+ if ((fieldIsTypeahead || domIsTypeahead) && !isReactSelect) {
689
716
  const why = [];
690
717
  if (fieldIsTypeahead) why.push('field.isTypeahead');
691
718
  if (domIsTypeahead) why.push('dom signals');
692
719
  console.log(`[smartFill] openAndPickOption: detected typeahead (${why.join(', ')}) → typeAndPickSuggestion first`);
693
- const r = await typeAndPickSuggestion(root, triggerLocator, value);
720
+ const r = await typeAndPickSuggestion(root, triggerLocator, value, { strict: true });
694
721
  if (r && r.ok) return r;
695
722
  // Fall through to specialized + generic paths on no-match.
696
723
  console.log(`[smartFill] typeahead path did not land (${r && r.reason}); falling through to generic dispatch.`);
@@ -907,8 +934,14 @@ async function reactSafeType(root, locator, value) {
907
934
 
908
935
  /**
909
936
  * Type-and-pick for typeahead inputs (Greenhouse Location etc).
937
+ *
938
+ * opts.strict: when true, "no suggestion appeared" is reported as failure
939
+ * instead of silently accepting the typed value as free text. Pass this
940
+ * for known-listbox combobox fields where accepting free text would be
941
+ * silently cleared by the framework on blur (react-select, etc.).
910
942
  */
911
- async function typeAndPickSuggestion(root, locator, value) {
943
+ async function typeAndPickSuggestion(root, locator, value, opts) {
944
+ const strict = !!(opts && opts.strict);
912
945
  // root: Page or Frame. Option lists for an in-frame field render inside
913
946
  // that frame, so they must be located via root, not the top page.
914
947
  const page = (root && typeof root.page === 'function') ? root.page() : root;
@@ -941,15 +974,15 @@ async function typeAndPickSuggestion(root, locator, value) {
941
974
  await root.locator(optionSel).first().waitFor({ state: 'visible', timeout: 1500 });
942
975
  } catch {
943
976
  const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
944
- if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
945
- return { ok: false, reason: 'typeahead opened no suggestions' };
977
+ if (got && got.trim() && !strict) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
978
+ return { ok: false, reason: strict && got && got.trim() ? 'no_match (strict: free text not allowed)' : 'typeahead opened no suggestions' };
946
979
  }
947
980
  const opts = root.locator(optionSel);
948
981
  const count = await opts.count().catch(() => 0);
949
982
  if (count === 0) {
950
983
  const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
951
- if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
952
- return { ok: false, reason: 'typeahead opened no suggestions' };
984
+ if (got && got.trim() && !strict) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
985
+ return { ok: false, reason: strict && got && got.trim() ? 'no_match (strict: free text not allowed)' : 'typeahead opened no suggestions' };
953
986
  }
954
987
  const texts = await opts.allTextContents().catch(() => []);
955
988
  const v = firstChunk.toLowerCase();