halo-agent 2.4.8 → 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', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.4.8",
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,