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 +34 -1
- package/index.js +1 -1
- package/orchestrator.js +42 -3
- package/package.json +1 -1
- package/smartFill.js +87 -13
- package/ui.js +21 -1
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
728
|
-
|
|
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
package/smartFill.js
CHANGED
|
@@ -188,14 +188,61 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
|
-
// Strategy 2:
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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;
|
|
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
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
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
|
};
|