halo-agent 2.2.3 → 2.2.6

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/browser.js CHANGED
@@ -135,7 +135,7 @@ async function connectToChrome(retries = 10, opts = {}) {
135
135
  ctx = fresh.contexts()[0] || await fresh.newContext();
136
136
  conn.context = ctx;
137
137
  }
138
- const page = await ctx.newPage();
138
+ let page = await ctx.newPage();
139
139
  if (url) {
140
140
  try {
141
141
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -153,6 +153,39 @@ async function connectToChrome(retries = 10, opts = {}) {
153
153
  await page.waitForTimeout(8000);
154
154
  }
155
155
  }
156
+
157
+ // VERIFY-AFTER-GOTO. connectOverCDP against the user's REAL Chrome
158
+ // has a tab-handle race: ctx.newPage() may hand back a different
159
+ // tab than the one that actually navigated (e.g. Chrome already
160
+ // had a restored New Tab open). Symptom: the agent then scans
161
+ // about:blank / chrome://newtab forever while the target URL is
162
+ // loaded in a sibling tab. Self-correct: if our handle isn't on
163
+ // the target host, find the tab that IS and adopt it.
164
+ try {
165
+ const here = page.url();
166
+ const targetHost = new URL(url).hostname;
167
+ const onTarget = (u) => { try { return new URL(u).hostname === targetHost; } catch { return false; } };
168
+ if (!onTarget(here)) {
169
+ // Look across all pages in this context for the one that navigated.
170
+ const pages = ctx.pages();
171
+ const match = pages.find((p) => onTarget(p.url()));
172
+ if (match && match !== page) {
173
+ console.warn(`[halo-agent] newPage handle was on "${here.slice(0, 40)}" but target loaded in another tab — adopting it.`);
174
+ // Close the stray blank tab we opened so it doesn't litter.
175
+ if (/^about:blank$|^chrome:\/\/newtab/i.test(here)) {
176
+ try { await page.close(); } catch {}
177
+ }
178
+ page = match;
179
+ await page.bringToFront().catch(() => {});
180
+ } else if (/^about:blank$|^chrome:\/\/newtab/i.test(here)) {
181
+ // Our tab is genuinely blank and nothing else navigated —
182
+ // the goto silently no-op'd. Retry the navigation on THIS tab.
183
+ console.warn('[halo-agent] Tab still blank after goto — retrying navigation on this handle...');
184
+ await page.goto(url, { waitUntil: 'commit', timeout: 30000 }).catch(() => {});
185
+ await page.waitForTimeout(4000);
186
+ }
187
+ }
188
+ } catch { /* best-effort self-correction; fall through */ }
156
189
  }
157
190
  return page;
158
191
  },
package/index.js CHANGED
@@ -479,7 +479,7 @@ async function runStart() {
479
479
 
480
480
  let version = '';
481
481
  try { version = require('./package.json').version; } catch {}
482
- ui.banner(version);
482
+ ui.bigBanner(version); // hero frame — the one you'd screenshot
483
483
 
484
484
  // The agent runs its own isolated Chrome instance (separate --user-data-dir
485
485
  // at ~/.halo-agent/chrome-profile) so it never collides with the user's
package/orchestrator.js CHANGED
@@ -48,6 +48,7 @@ async function fillFields(page, aep, opts) {
48
48
  plannedActions: result.planned || 0,
49
49
  fellBackToLegacy: !!result.fallback,
50
50
  resumeUploaded: !!result.resumeUploaded,
51
+ coverLetterUploaded: !!result.coverLetterUploaded,
51
52
  };
52
53
  }
53
54
  const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
@@ -684,12 +685,45 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
684
685
  }
685
686
 
686
687
  if (finalState === 'REVIEWING') {
688
+ // CRITICAL: keep the Chrome tab OPEN and the agent ALIVE so the user can
689
+ // actually take over. Previously this branch reported REVIEWING then
690
+ // fell through → the `finally` closed the tab and the agent moved on,
691
+ // leaving a REVIEWING row with no open tab and nothing listening — a
692
+ // dead-end the user couldn't resolve. Now: leave the tab open, tell the
693
+ // user what's blank, and wait for them to finish in Chrome + click
694
+ // Submit (which the agent's waitForSubmitConfirmation is parked on).
695
+ leaveTabOpen = true;
696
+ // Re-check exactly which required fields are still empty so the message
697
+ // names them (the user is staring at the form; tell them what to fix).
698
+ const stillEmpty = await detectRequiredEmpties(page, {
699
+ excludeLabels: (ctx.skippedFields || []).map((s) => s.label),
700
+ resumeUploaded: !!fillResult.resumeUploaded,
701
+ }).catch(() => ({ emptyFields: [] }));
702
+ const emptyList = stillEmpty.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
703
+ const reason = emptyList
704
+ ? `${stillEmpty.emptyFields.length} field(s) need you: ${emptyList}. Fill them in the open Chrome tab, then click Submit.`
705
+ : `Couldn't auto-confirm this submit. Eyeball the open Chrome tab, then click Submit.`;
687
706
  await reportStatus('REVIEWING', {
688
707
  review_screenshot_r2_key: confirmKey || null,
689
708
  step: 'REVIEWING',
690
- step_detail: `Submit clicked at ${verdictUrl.slice(0, 100)} — verifier unavailable, please confirm`,
709
+ step_detail: reason.slice(0, 200),
710
+ needs_attention_reason: reason,
691
711
  fields_filled: cumulativeFilled,
692
712
  });
713
+ console.log(`[orchestrator] REVIEWING — tab left open. ${reason}`);
714
+ // Park here until the user clicks Submit on the dashboard. The tab stays
715
+ // open the whole time so they can fill the blanks in their own Chrome.
716
+ await waitForSubmitConfirmation(config, queueId);
717
+ // They filled the blanks and clicked Submit — click the form's submit.
718
+ const finishBtn = await findSubmitButton(page).catch(() => null);
719
+ if (finishBtn) {
720
+ console.log('[orchestrator] User confirmed — clicking submit on the finished form.');
721
+ await finishBtn.click().catch(() => {});
722
+ await page.waitForTimeout(2500);
723
+ }
724
+ await reportStatus('DONE', { confirmation_screenshot_r2_key: confirmKey || null, fields_filled: cumulativeFilled });
725
+ await clearCheckpoint(config, queueId);
726
+ console.log(`[orchestrator] Done (user-completed): ${queueItem.company} - ${queueItem.title}`);
693
727
  } else {
694
728
  leaveTabOpen = true;
695
729
  await reportStatus('DONE', {
@@ -724,8 +758,13 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
724
758
  filled_actions: filledActions,
725
759
  // Pass through which actual files were uploaded — backend writes to
726
760
  // submissions.resume_pdf_r2_key / cover_letter_pdf_r2_key for receipt.
727
- resume_pdf_r2_key: aep?.recommended_resume?.pdf_r2_key || null,
728
- cover_letter_pdf_r2_key: aep?.cover_letter_pdf?.pdf_r2_key || null,
761
+ // ONLY report a file key if the agent ACTUALLY uploaded it. Previously
762
+ // the cover-letter key was sent whenever the packet HAD one, even when
763
+ // the upload FAILED ("no cover_letter file available") — so the receipt
764
+ // claimed a cover letter that was never submitted. The receipt must
765
+ // reflect what landed on the form, not what the packet contained.
766
+ resume_pdf_r2_key: (fillResult.resumeUploaded || tempResumeFile) ? (aep?.recommended_resume?.pdf_r2_key || null) : null,
767
+ cover_letter_pdf_r2_key: fillResult.coverLetterUploaded ? (aep?.cover_letter_pdf?.pdf_r2_key || null) : null,
729
768
  }).catch(() => {}); // non-critical
730
769
 
731
770
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.2.3",
3
+ "version": "2.2.6",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
package/smartFill.js CHANGED
@@ -188,14 +188,61 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
188
188
  }
189
189
  }
190
190
  }
191
- // Strategy 2: just click the labeled option directly (custom radios).
191
+ // Strategy 2: custom radios (Ashby etc) — click the labeled option,
192
+ // but SCOPED to THIS field's own radiogroup. The old code matched
193
+ // `[role="radio"]:has-text("Yes")` across the WHOLE page via .first(),
194
+ // so on a form with several Yes/No questions it clicked the first
195
+ // "Yes" on the page (or nothing) instead of the one for this field —
196
+ // the Gen Digital bug where sponsorship + office-commitment radios
197
+ // ended up blank. Anchor on the scanned element's group container.
192
198
  const v = String(item.value);
193
- const opt = root.locator(`[role="radio"]:has-text("${v}"), label:has-text("${v}")`).first();
194
- if (await opt.isVisible({ timeout: 800 }).catch(() => false)) {
195
- await opt.click({ timeout: 1500 });
196
- return { ok: true, reason: `radio (label): ${v}` };
199
+ // Tag the field's enclosing radiogroup so we can scope option lookup.
200
+ const groupId = await locator.evaluate((el) => {
201
+ // Walk up to the nearest radiogroup / fieldset / Ashby field entry.
202
+ let p = el;
203
+ for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
204
+ const role = p.getAttribute && p.getAttribute('role');
205
+ const cls = String(p.className || '');
206
+ if (role === 'radiogroup' || p.tagName === 'FIELDSET'
207
+ || /_fieldEntry_|ashby-application-form-field-entry|radio.?group/i.test(cls)) {
208
+ const gid = 'halo-rg-' + Math.random().toString(36).slice(2, 8);
209
+ p.setAttribute('data-halo-rg', gid);
210
+ return gid;
211
+ }
212
+ }
213
+ return null;
214
+ }).catch(() => null);
215
+
216
+ const groupRoot = groupId ? root.locator(`[data-halo-rg="${groupId}"]`) : root;
217
+ // Within the group, find the option whose visible label matches value.
218
+ // Try role=radio, label, and a generic clickable row with the text.
219
+ const optInGroup = groupRoot.locator(
220
+ `[role="radio"]:has-text("${v}"), label:has-text("${v}"), ` +
221
+ `[class*="option"]:has-text("${v}"), [class*="radio"]:has-text("${v}")`
222
+ ).first();
223
+ if (await optInGroup.isVisible({ timeout: 1000 }).catch(() => false)) {
224
+ await optInGroup.click({ timeout: 1500 }).catch(async () => {
225
+ await optInGroup.click({ force: true, timeout: 1500 });
226
+ });
227
+ // Verify it registered: the matched radio should now be checked /
228
+ // aria-checked. Ashby toggles aria-checked on the role=radio element.
229
+ await page.waitForTimeout(150);
230
+ const stuck = await optInGroup.evaluate((el) => {
231
+ const r = el.querySelector('[role="radio"]') || el.closest('[role="radio"]') || el;
232
+ return r.getAttribute('aria-checked') === 'true'
233
+ || (r.querySelector && r.querySelector('input:checked') != null)
234
+ || (r.tagName === 'INPUT' && r.checked);
235
+ }).catch(() => true); // if we can't tell, assume click worked
236
+ if (stuck) return { ok: true, reason: `radio (scoped label): ${v}` };
237
+ // Didn't register — click the input inside the option as a fallback.
238
+ const innerInput = optInGroup.locator('input[type="radio"]').first();
239
+ if (await innerInput.count().catch(() => 0)) {
240
+ await innerInput.check({ force: true, timeout: 1500 }).catch(() => {});
241
+ return { ok: true, reason: `radio (scoped input): ${v}` };
242
+ }
243
+ return { ok: true, reason: `radio (clicked, unverified): ${v}` };
197
244
  }
198
- return { ok: false, reason: `radio option "${item.value}" not found` };
245
+ return { ok: false, reason: `radio option "${item.value}" not found in group` };
199
246
  } catch (e) {
200
247
  return { ok: false, reason: `radio failed: ${e.message}` };
201
248
  }
@@ -477,6 +524,29 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
477
524
  if (itlResult !== null && itlResult.ok) return itlResult;
478
525
  } catch {}
479
526
 
527
+ try {
528
+ // Ashby async typeahead. Ashby's Location field is role="combobox" with
529
+ // aria-haspopup="listbox" and an input class like `_input_v5ami_28`. It
530
+ // renders NO options until you type, then fetches matches over the
531
+ // network (~700ms) and shows them as [role="option"]. The generic
532
+ // open-and-look path opened it, saw an empty menu, and bailed — that's
533
+ // the Gen Digital "dropdown opened but no visible options found" failure.
534
+ // Detect Ashby's widget and route to type-then-wait-for-async-options.
535
+ const isAshbyCombo = await triggerLocator.evaluate((el) => {
536
+ const role = el.getAttribute('role');
537
+ const hasPopup = el.getAttribute('aria-haspopup');
538
+ const cls = String(el.className || '');
539
+ // Ashby's emotion-hashed input class, or the generic combobox+listbox shape.
540
+ const ashbyClass = /_input_/.test(cls) && !!el.closest('[class*="_fieldEntry_"], .ashby-application-form-field-entry');
541
+ return (role === 'combobox' && (hasPopup === 'listbox' || hasPopup === 'true')) || ashbyClass;
542
+ }).catch(() => false);
543
+ if (isAshbyCombo) {
544
+ const r = await typeAndPickSuggestion(root, triggerLocator, value);
545
+ if (r.ok) return r;
546
+ // If typeahead didn't land, fall through to the generic path below.
547
+ }
548
+ } catch {}
549
+
480
550
  // Second: Google Places typeahead. The Greenhouse Location field is
481
551
  // marked role=combobox in AX, so the planner sends click_option — but
482
552
  // it's really a type-then-pick autocomplete that opens a .pac-container
@@ -781,7 +851,8 @@ async function smartFillPage(page, aep, options) {
781
851
  const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
782
852
  const planByMmid = new Map(plan.map((p) => [String(p.mmid), p]));
783
853
  let filled = 0, skipped = 0, failed = 0;
784
- let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
854
+ let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
855
+ let coverLetterUploaded = false; // so the receipt only claims a cover letter if one actually went in
785
856
  const askUserReasons = [];
786
857
 
787
858
  for (const f of fields) {
@@ -820,11 +891,14 @@ async function smartFillPage(page, aep, options) {
820
891
  } else {
821
892
  console.log(`[smartFill] ${item.action} "${labelShort}" — ${result.reason}${item.reasoning ? ` (why: ${item.reasoning.slice(0, 80)})` : ''}`);
822
893
  filled += 1;
823
- // Note a successful resume upload so the orchestrator doesn't run its
824
- // legacy blanket "upload resume to the first file input" fallback,
825
- // which was dumping the resume into the COVER-LETTER slot too.
826
- if (item.action === 'upload_file' && String(item.value).toLowerCase().trim() !== 'cover_letter') {
827
- resumeUploaded = true;
894
+ // Track which files actually went in. resumeUploaded gates the
895
+ // orchestrator's blanket fallback; coverLetterUploaded gates whether
896
+ // the RECEIPT is allowed to claim a cover letter was submitted (it was
897
+ // lying before packet had a cover-letter key, upload FAILED, receipt
898
+ // still showed it).
899
+ if (item.action === 'upload_file') {
900
+ if (String(item.value).toLowerCase().trim() === 'cover_letter') coverLetterUploaded = true;
901
+ else resumeUploaded = true;
828
902
  }
829
903
  // Track in ctx for cross-page dedup
830
904
  if (ctx && ctx.answeredFields) {
@@ -843,7 +917,7 @@ async function smartFillPage(page, aep, options) {
843
917
  }
844
918
  }
845
919
 
846
- return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded };
920
+ return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded, coverLetterUploaded };
847
921
  }
848
922
 
849
923
  module.exports = { smartFillPage };
package/ui.js CHANGED
@@ -73,6 +73,26 @@ function banner(version) {
73
73
  console.log('');
74
74
  }
75
75
 
76
+ // Big block-letter wordmark for the hero moment (`start`) — the frame you'd
77
+ // screenshot for a LinkedIn post. Five-row ASCII HALO in ember, tagline +
78
+ // version underneath. Falls back to the compact boxed banner when color is
79
+ // off (logs/CI) so the auto-start service log doesn't get a wall of blocks.
80
+ const HALO_ART = [
81
+ '██ ██ █████ ██ ██████ ',
82
+ '██ ██ ██ ██ ██ ██ ██ ',
83
+ '███████ ███████ ██ ██ ██ ',
84
+ '██ ██ ██ ██ ██ ██ ██ ',
85
+ '██ ██ ██ ██ ███████ ██████ ',
86
+ ];
87
+ function bigBanner(version) {
88
+ if (!useColor) { banner(version); return; }
89
+ const ver = version ? gray('· agent v' + version) : gray('· agent');
90
+ console.log('');
91
+ for (const row of HALO_ART) console.log(' ' + ember(row));
92
+ console.log(' ' + gray('auto-apply, in your own Chrome') + ' ' + ver);
93
+ console.log('');
94
+ }
95
+
76
96
  // Affordance helper: the obvious next command, rendered consistently so the
77
97
  // user always knows what to type next. Shown after pair/init/etc.
78
98
  function nextStep(cmd, note) {
@@ -123,5 +143,5 @@ function spinner(message) {
123
143
  module.exports = {
124
144
  bold, dim, red, green, yellow, blue, magenta, cyan, gray, ember,
125
145
  sym, success, fail, info, warn, muted, step,
126
- banner, spinner, nextStep, hint, commandTable, visLen,
146
+ banner, bigBanner, spinner, nextStep, hint, commandTable, visLen,
127
147
  };