halo-agent 2.2.1 → 2.2.3

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.
@@ -308,7 +308,7 @@ async function detectFormErrorsInRoot(page) {
308
308
  *
309
309
  * Output: { hasEmptyRequired: boolean, emptyFields: [{label, selector?}] }
310
310
  */
311
- async function detectRequiredEmpties(page) {
311
+ async function detectRequiredEmpties(page, opts = {}) {
312
312
  const roots = [page, ...page.frames().filter((f) => {
313
313
  const u = f.url();
314
314
  return f !== page.mainFrame() && u && DFE_EMBED_HOSTS.test(u) && !/google\.com|gstatic\.com|recaptcha/i.test(u);
@@ -321,13 +321,30 @@ async function detectRequiredEmpties(page) {
321
321
  merged.emptyFields.push(...(r.emptyFields || []));
322
322
  }
323
323
  }
324
- // Dedup by label
324
+ // Exclude labels the planner deliberately skipped (conditional-not-applicable,
325
+ // honeypots, optional). The planner has dependency context the DOM walk lacks,
326
+ // so a field it intentionally left blank is NOT an unfilled required field.
327
+ const excluded = new Set((opts.excludeLabels || []).map((l) => String(l || '').toLowerCase().trim()).filter(Boolean));
328
+ // Dedup by label, drop excluded.
325
329
  const seen = new Set();
326
330
  merged.emptyFields = merged.emptyFields.filter((f) => {
327
331
  const k = (f.label || '').toLowerCase().trim();
328
332
  if (!k || seen.has(k)) return false;
329
- seen.add(k); return true;
333
+ seen.add(k);
334
+ // If smartFill confirmed a resume upload, a file/resume field reading
335
+ // "empty" is a false positive: Ashby (and similar) keep the native
336
+ // <input type=file> at files.length===0 after upload because they stash
337
+ // the file in their own state and show a filename chip elsewhere. Don't
338
+ // block on it.
339
+ if (opts.resumeUploaded && f.isFile) return false;
340
+ // Match excluded loosely — DOM label vs planner label can differ by
341
+ // trailing punctuation/truncation.
342
+ for (const ex of excluded) {
343
+ if (k === ex || k.startsWith(ex) || ex.startsWith(k) || k.includes(ex) || ex.includes(k)) return false;
344
+ }
345
+ return true;
330
346
  });
347
+ merged.hasEmptyRequired = merged.emptyFields.length > 0;
331
348
  return merged;
332
349
  }
333
350
 
@@ -396,18 +413,42 @@ async function detectRequiredEmptiesInRoot(page) {
396
413
  // A radiogroup / checkbox-set counts as "filled" if ANY member is checked.
397
414
  // We track required groups by name so we don't flag every individual radio.
398
415
  function isRequired(el) {
416
+ // Programmatic markers are authoritative and field-specific.
399
417
  if (el.required) return true;
400
418
  if (el.getAttribute('aria-required') === 'true') return true;
401
- // Asterisked label is the most common ATS convention (Ashby, Greenhouse).
402
- const lbl = nearestLabel(el);
403
- // The raw label may have had its trailing * stripped by nearestLabel, so
404
- // also look at the surrounding label text for an asterisk.
419
+
420
+ // Asterisk/"required" in label text is the common ATS convention, BUT
421
+ // it must come from THIS field's OWN label not a sibling's. The Plaid
422
+ // false-positive ("If yes, please provide further explanation below")
423
+ // was a conditional field flagged because a climb-5-ancestors walk
424
+ // scooped up a neighbouring required field's asterisk. Only trust the
425
+ // directly-associated label:
426
+ // 1. <label for=id>
427
+ // 2. el.labels[0]
428
+ // 3. the SINGLE closest wrapping label/legend that contains ONLY this
429
+ // control (not a shared group container with multiple inputs).
430
+ const labelTextOf = (lblEl) => (lblEl && (lblEl.innerText || lblEl.textContent || '')).trim();
431
+ const marks = (txt) => /\*\s*$/.test(txt) || /\(\s*required\s*\)/i.test(txt) || /\brequired\b\s*\*?$/i.test(txt);
432
+
433
+ if (el.id) {
434
+ const lbl = document.querySelector(`label[for="${el.id}"]`);
435
+ if (lbl) return marks(labelTextOf(lbl));
436
+ }
437
+ if (el.labels && el.labels[0]) return marks(labelTextOf(el.labels[0]));
438
+
439
+ // Closest wrapping label/legend, but ONLY if that container holds a
440
+ // single control (so we don't inherit a group asterisk meant for a
441
+ // different required field in the same fieldset).
405
442
  let p = el.parentElement; let hops = 0;
406
- while (p && hops < 5) {
407
- const labelEl = p.querySelector('label, legend, [class*="label"]');
443
+ while (p && hops < 4) {
444
+ const labelEl = p.querySelector(':scope > label, :scope > legend, :scope > [class*="label"]');
408
445
  if (labelEl) {
409
- const raw = (labelEl.innerText || labelEl.textContent || '');
410
- if (/\*\s*$/.test(raw.trim()) || /\brequired\b/i.test(raw)) return true;
446
+ const controlsInScope = p.querySelectorAll('input:not([type=hidden]), textarea, select, [role="combobox"], [contenteditable="true"]').length;
447
+ if (controlsInScope <= 1) return marks(labelTextOf(labelEl));
448
+ // Shared container with multiple controls — the asterisk isn't
449
+ // necessarily ours. Stop; treat as not-required unless a
450
+ // programmatic marker said otherwise (handled above).
451
+ return false;
411
452
  }
412
453
  p = p.parentElement; hops += 1;
413
454
  }
@@ -458,7 +499,9 @@ async function detectRequiredEmptiesInRoot(page) {
458
499
  const key = label.toLowerCase();
459
500
  if (seen.has(key)) continue;
460
501
  seen.add(key);
461
- emptyFields.push({ label, selector: el.id ? `#${el.id}` : (el.name ? `[name="${el.name}"]` : null) });
502
+ const isFile = type === 'file'
503
+ || /resume|cv\b|cover.?letter|attach|upload/i.test(label);
504
+ emptyFields.push({ label, isFile, selector: el.id ? `#${el.id}` : (el.name ? `[name="${el.name}"]` : null) });
462
505
  }
463
506
 
464
507
  return { hasEmptyRequired: emptyFields.length > 0, emptyFields };
package/index.js CHANGED
@@ -38,14 +38,18 @@ async function main() {
38
38
  let v = '';
39
39
  try { v = require('./package.json').version; } catch {}
40
40
  ui.banner(v);
41
+ console.log(` ${ui.bold('Usage')} ${ui.gray('halo-agent <command>')}`);
41
42
  console.log('');
42
- console.log(` ${ui.bold('Usage')}`);
43
- console.log(` ${ui.cyan('halo-agent pair')} ${ui.gray('<code>')} One-click pair with the dashboard`);
44
- console.log(` ${ui.cyan('halo-agent start')} Start the agent`);
45
- console.log(` ${ui.cyan('halo-agent install-autostart')} Auto-start at login`);
46
- console.log(` ${ui.cyan('halo-agent uninstall-autostart')} Remove login auto-start`);
47
- console.log(` ${ui.cyan('halo-agent init')} First-time setup (legacy: token paste)`);
48
- console.log(` ${ui.cyan('halo-agent token')} ${ui.gray('<value>')} Update auth token`);
43
+ ui.commandTable([
44
+ ['pair <code>', 'One-click pair with the HALO dashboard'],
45
+ ['start', 'Connect to Chrome and run the agent'],
46
+ ['install-autostart', 'Run automatically at every login'],
47
+ ['uninstall-autostart', 'Remove the login auto-start'],
48
+ ['init', 'First-time setup (legacy token paste)'],
49
+ ['token <value>', 'Update the auth token'],
50
+ ]);
51
+ console.log('');
52
+ ui.hint('Get a pairing code: HALO dashboard → Profile → Auto-apply agent → Get pairing code.');
49
53
  console.log('');
50
54
  process.exit(1);
51
55
  }
@@ -123,7 +127,8 @@ async function runPair(code) {
123
127
  spin.stop(`Paired with HALO`);
124
128
  ui.muted(` Token saved to ~/.halo-agent/config.json`);
125
129
  console.log('');
126
- console.log(` ${ui.gray('Next:')} ${ui.cyan('halo-agent start')}`);
130
+ ui.nextStep('halo-agent start', 'connect Chrome and start applying');
131
+ ui.hint('Or `halo-agent install-autostart` to run it at every login.');
127
132
  console.log('');
128
133
  }
129
134
 
@@ -308,7 +313,7 @@ async function runInit() {
308
313
  saveConfig(config);
309
314
  console.log('');
310
315
  ui.success('Config saved to ~/.halo-agent/config.json');
311
- console.log(` ${ui.gray('Next:')} ${ui.cyan('halo-agent start')}`);
316
+ ui.nextStep('halo-agent start', 'connect Chrome and start applying');
312
317
  console.log('');
313
318
  }
314
319
 
@@ -467,8 +472,8 @@ async function waitForChromeGone(timeoutMs) {
467
472
  async function runStart() {
468
473
  const config = loadConfig();
469
474
  if (!config || !config.token) {
470
- ui.fail('No config found.');
471
- console.log(` ${ui.gray('Run')} ${ui.cyan('halo-agent pair <code>')} ${ui.gray('first. Get a code from the HALO dashboard.')}`);
475
+ ui.fail('Not paired yet — no config found.');
476
+ ui.nextStep('halo-agent pair <code>', 'get a code from the HALO dashboard');
472
477
  process.exit(1);
473
478
  }
474
479
 
package/orchestrator.js CHANGED
@@ -47,6 +47,7 @@ async function fillFields(page, aep, opts) {
47
47
  askUserReasons: result.askUserReasons || [],
48
48
  plannedActions: result.planned || 0,
49
49
  fellBackToLegacy: !!result.fallback,
50
+ resumeUploaded: !!result.resumeUploaded,
50
51
  };
51
52
  }
52
53
  const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
@@ -217,8 +218,14 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
217
218
  }
218
219
  }
219
220
 
220
- // Upload resume if we have a temp file
221
- if (tempResumeFile) {
221
+ // Upload resume ONLY if smartFill didn't already place it. This blanket
222
+ // fallback predates smartFill's per-field upload_file action; running it
223
+ // unconditionally dumped the resume into the FIRST file input it found —
224
+ // which on Greenhouse (resume + cover-letter inputs) put the resume into
225
+ // the cover-letter slot too. smartFill now reports resumeUploaded, so we
226
+ // only fall back when the planner genuinely missed the resume field.
227
+ if (tempResumeFile && !fillResult.resumeUploaded) {
228
+ console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader.');
222
229
  const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
223
230
  if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
224
231
  }
@@ -444,7 +451,8 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
444
451
  // what the config/agent_config says: we route to REVIEWING so the human
445
452
  // sees exactly which fields are blank. Never silently submit incomplete.
446
453
  // ─────────────────────────────────────────────────────────────────────
447
- const requiredCheck = await detectRequiredEmpties(page).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
454
+ const skippedLabels = (ctx.skippedFields || []).map((s) => s.label);
455
+ const requiredCheck = await detectRequiredEmpties(page, { excludeLabels: skippedLabels, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
448
456
  let forceReview = false;
449
457
  if (requiredCheck.hasEmptyRequired) {
450
458
  const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
@@ -459,16 +467,21 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
459
467
 
460
468
  if (forceReview) {
461
469
  const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
462
- await reportStatus('NEEDS_ATTENTION', {
470
+ // Report REVIEWING (not NEEDS_ATTENTION): the agent is going to WAIT on
471
+ // the submit-confirmed flag, and POST /apply-queue/submit/:id only
472
+ // accepts rows in REVIEWING state. Using NEEDS_ATTENTION here meant the
473
+ // dashboard's "Resolved" click 400'd and the agent waited forever. The
474
+ // reason text still tells the user exactly which fields to fill; the
475
+ // "Submit" button then wakes the agent once they've filled them in Chrome.
476
+ await reportStatus('REVIEWING', {
463
477
  review_screenshot_r2_key: reviewKey || null,
464
- needs_attention_reason: `${requiredCheck.emptyFields.length} required field(s) could not be filled: ${list}. Fill them in Chrome, then click Submit / Resume.`,
465
- intervention_type: 'incomplete_fields',
466
478
  step: 'REVIEWING',
467
- step_detail: `Empty required: ${list}`.slice(0, 200),
479
+ step_detail: `${requiredCheck.emptyFields.length} required field(s) need you: ${list}. Fill them in Chrome, then click Submit.`.slice(0, 200),
480
+ needs_attention_reason: `${requiredCheck.emptyFields.length} required field(s) could not be auto-filled: ${list}. Fill them in your Chrome window, then click Submit.`,
468
481
  fields_filled: cumulativeFilled,
469
482
  });
470
- // Wait for the human to fill + confirm, exactly like the no-auto-submit
471
- // path. They resolve the blanks in Chrome, then click Submit/Resume.
483
+ // Wait for the human to fill + confirm. They resolve the blanks in
484
+ // Chrome, then click Submit on the dashboard.
472
485
  await waitForSubmitConfirmation(config, queueId);
473
486
  } else {
474
487
  await reportStatus('REVIEWING', {
@@ -647,7 +660,8 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
647
660
  // redirect, verifier down → it had said "trusting click").
648
661
  // If either holds, we refuse the silent DONE and route to REVIEWING.
649
662
  const stillOnForm = !/thank|confirm|success|applied|submitted/i.test(verdictUrl);
650
- const postCheck = await detectRequiredEmpties(page).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
663
+ const postSkipped = (ctx.skippedFields || []).map((s) => s.label);
664
+ const postCheck = await detectRequiredEmpties(page, { excludeLabels: postSkipped, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
651
665
 
652
666
  if (postCheck.hasEmptyRequired) {
653
667
  const list = postCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
@@ -1324,7 +1338,7 @@ async function runExtensionFill({
1324
1338
  fillResult.filled += precision.filled || 0;
1325
1339
  }
1326
1340
 
1327
- if (tempResumeFile) {
1341
+ if (tempResumeFile && !fillResult.resumeUploaded) {
1328
1342
  const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
1329
1343
  if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
1330
1344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
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/poller.js CHANGED
@@ -15,7 +15,8 @@ let sessionId = null;
15
15
 
16
16
  async function startPolling(chromeConn, config) {
17
17
  ui.success('Listening for jobs');
18
- ui.muted(` Polling every 5s. ${ui.cyan('Ctrl-C')} to stop.`);
18
+ ui.hint(`Queue one from HALO: a job's prep page → Approve & send to agent.`);
19
+ ui.muted(` Polling every 5s · ${ui.cyan('Ctrl-C')} to stop`);
19
20
  console.log('');
20
21
 
21
22
  // Register agent session with backend
package/smartFill.js CHANGED
@@ -781,6 +781,7 @@ async function smartFillPage(page, aep, options) {
781
781
  const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
782
782
  const planByMmid = new Map(plan.map((p) => [String(p.mmid), p]));
783
783
  let filled = 0, skipped = 0, failed = 0;
784
+ let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
784
785
  const askUserReasons = [];
785
786
 
786
787
  for (const f of fields) {
@@ -807,9 +808,24 @@ async function smartFillPage(page, aep, options) {
807
808
  if (item.action === 'skip') {
808
809
  console.log(`[smartFill] skip "${labelShort}" — ${result.reason}`);
809
810
  skipped += 1;
811
+ // Record the planner's deliberate skips so the orchestrator's
812
+ // required-empty guard can EXCLUDE them. The planner has full
813
+ // context (it saw "If yes…" conditional dependencies); the guard is
814
+ // a blind DOM walk. A field the planner intentionally left blank
815
+ // (conditional-not-applicable, honeypot, optional) must not be
816
+ // mistaken for an unfilled required field.
817
+ if (ctx && Array.isArray(ctx.skippedFields) && f.label) {
818
+ ctx.skippedFields.push({ label: f.label, reason: (item.reasoning || result.reason || '').slice(0, 160) });
819
+ }
810
820
  } else {
811
821
  console.log(`[smartFill] ${item.action} "${labelShort}" — ${result.reason}${item.reasoning ? ` (why: ${item.reasoning.slice(0, 80)})` : ''}`);
812
822
  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;
828
+ }
813
829
  // Track in ctx for cross-page dedup
814
830
  if (ctx && ctx.answeredFields) {
815
831
  ctx.answeredFields.set(f.label || f.selectorHint, {
@@ -827,7 +843,7 @@ async function smartFillPage(page, aep, options) {
827
843
  }
828
844
  }
829
845
 
830
- return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false };
846
+ return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded };
831
847
  }
832
848
 
833
849
  module.exports = { smartFillPage };
package/ui.js CHANGED
@@ -41,15 +41,56 @@ const warn = (msg) => console.log(`${yellow('!')} ${msg}`);
41
41
  const muted = (msg) => console.log(gray(msg));
42
42
  const step = (msg) => console.log(`${gray(sym.dot)} ${msg}`);
43
43
 
44
- // Banner shown on `start`. Three lines so it has presence on camera without
45
- // being a giant ASCII-art block that ages badly.
44
+ // Strip ANSI so box math measures the VISIBLE width, not the escape-padded
45
+ // string length. Without this, colored content overflows the box border.
46
+ const STRIP_ANSI = /\x1b\[[0-9;]*m/g;
47
+ const visLen = (s) => String(s).replace(STRIP_ANSI, '').length;
48
+
49
+ // Box-drawing glyphs (rounded). ASCII fallback on legacy Windows terminals.
50
+ const box = (process.platform === 'win32' && !process.env.WT_SESSION)
51
+ ? { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
52
+ : { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' };
53
+
54
+ // The compact boxed wordmark. Ember mark + tagline + version, in a rounded
55
+ // box — the Vercel/Bun look. Reads well on camera and in a small terminal,
56
+ // and ages better than big ASCII art. The ⬢ is HALO's hex mark.
46
57
  function banner(version) {
47
- const v = version ? gray(`v${version}`) : '';
48
- const line = gray(''.repeat(40));
58
+ const mark = `${ember('⬢')} ${ember(bold('HALO'))}`;
59
+ const ver = version ? `${gray('agent')} ${gray('v' + version)}` : gray('agent');
60
+ // Two content lines.
61
+ const line1 = `${mark} ${gray('·')} ${ver}`;
62
+ const line2 = gray('auto-apply, in your own Chrome');
63
+ const inner = Math.max(visLen(line1), visLen(line2)) + 2; // 1 space padding each side
64
+ const pad = (s) => ' ' + s + ' '.repeat(inner - visLen(s) - 1);
65
+ const top = gray(box.tl + box.h.repeat(inner) + box.tr);
66
+ const bot = gray(box.bl + box.h.repeat(inner) + box.br);
67
+ const bar = gray(box.v);
68
+ console.log('');
69
+ console.log(` ${top}`);
70
+ console.log(` ${bar}${pad(line1)}${bar}`);
71
+ console.log(` ${bar}${pad(line2)}${bar}`);
72
+ console.log(` ${bot}`);
49
73
  console.log('');
50
- console.log(` ${ember(bold('HALO'))} ${bold('Agent')} ${v}`);
51
- console.log(` ${gray('Auto-apply, in your own Chrome.')}`);
52
- console.log(` ${line}`);
74
+ }
75
+
76
+ // Affordance helper: the obvious next command, rendered consistently so the
77
+ // user always knows what to type next. Shown after pair/init/etc.
78
+ function nextStep(cmd, note) {
79
+ const tail = note ? ` ${gray(note)}` : '';
80
+ console.log(` ${gray('Next')} ${gray(sym.arrow)} ${cyan(cmd)}${tail}`);
81
+ }
82
+
83
+ // A hint line — softer than nextStep, for "you can also…" affordances.
84
+ function hint(msg) {
85
+ console.log(` ${gray(sym.dot)} ${gray(msg)}`);
86
+ }
87
+
88
+ // Aligned command table for the usage screen. rows: [[cmd, desc], ...].
89
+ function commandTable(rows) {
90
+ const w = Math.max(...rows.map(([c]) => c.length));
91
+ for (const [cmd, desc] of rows) {
92
+ console.log(` ${cyan(cmd.padEnd(w))} ${gray(desc)}`);
93
+ }
53
94
  }
54
95
 
55
96
  // Lightweight spinner for waiting states ("Connecting to Chrome..."). Frames
@@ -81,5 +122,6 @@ function spinner(message) {
81
122
 
82
123
  module.exports = {
83
124
  bold, dim, red, green, yellow, blue, magenta, cyan, gray, ember,
84
- sym, success, fail, info, warn, muted, step, banner, spinner,
125
+ sym, success, fail, info, warn, muted, step,
126
+ banner, spinner, nextStep, hint, commandTable, visLen,
85
127
  };