halo-agent 1.1.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.
@@ -0,0 +1,1122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Core state machine for auto-applying to a single job.
5
+ * States: ANALYZING -> FILLING -> PAGINATING -> REVIEWING -> SUBMITTING -> DONE | NEEDS_HUMAN
6
+ *
7
+ * The orchestrator never opens a fake browser — it always uses the user's real Chrome
8
+ * via CDP (connectOverCDP), so bot detection is not triggered.
9
+ */
10
+
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const { fillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
15
+ const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
16
+ const { visionFill, visionNavigateAndSubmit, visionFillSkipped } = require('./vision');
17
+
18
+ // ATS types that need vision fallback due to shadow DOM / canvas
19
+ const VISION_ATS = new Set(['workday', 'icims', 'taleo', 'sap', 'successfactors']);
20
+
21
+ /**
22
+ * Create a fresh FormContext for tracking state across a multi-page application.
23
+ * The context accumulates what was filled, what was skipped, and what conditional
24
+ * fields appeared as a result of previous answers.
25
+ */
26
+ function createFormContext() {
27
+ return {
28
+ pagesVisited: [], // [{pageIndex, url, fieldsFound, fieldsFilled}]
29
+ answeredFields: new Map(), // label -> {value, pageIndex, source}
30
+ skippedFields: [], // [{label, reason}]
31
+ conditionalTriggers: [], // [{triggerLabel, triggerValue, appearedLabel}]
32
+ currentPageIndex: 0,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Run the full apply flow for one queued job item.
38
+ *
39
+ * @param {object} queueItem - from GET /apply-queue/next
40
+ * @param {object} chromeConn - from browser.connectToChrome()
41
+ * @param {object} config - from ~/.halo-agent/config.json
42
+ * @param {function} reportStatus - async fn(status, extra) to call HALO backend
43
+ */
44
+ async function runJob(queueItem, chromeConn, config, reportStatus) {
45
+ const { id: queueId, job_id: jobId, apply_url, ats_type } = queueItem;
46
+ const apiKey = config.captchaApiKey || null;
47
+ const anthropicKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY || null;
48
+ const typingSpeed = config.typingSpeed || 'normal';
49
+ const useVision = config.useVisionFallback !== false;
50
+
51
+ let page = null;
52
+ let tempResumeFile = null;
53
+
54
+ // Initialize cross-page context tracker
55
+ const ctx = createFormContext();
56
+
57
+ try {
58
+ // STEP 1: ANALYZING
59
+ await reportStatus('IN_PROGRESS', { status: 'IN_PROGRESS' });
60
+ console.log(`[orchestrator] Analyzing: ${queueItem.company} - ${queueItem.title}`);
61
+
62
+ // Fetch the full AEP from HALO backend
63
+ const aep = await fetchAEP(config.apiUrl, config.token, jobId);
64
+ if (!aep) throw new Error('Could not fetch application packet from HALO backend');
65
+
66
+ // Download resume PDF to temp file if available
67
+ if (aep.recommended_resume?.pdf_presigned_url) {
68
+ tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
69
+ }
70
+
71
+ // Check for an existing checkpoint — if a previous run got past page 1
72
+ // before crashing, resume from where it stopped. The fillFields path uses
73
+ // ctx.answeredFields to skip already-completed labels, so restoring the
74
+ // map is enough to make the next page fill skip its predecessors' fields.
75
+ const checkpoint = await fetchCheckpoint(config, queueId);
76
+ const resuming = checkpoint && checkpoint.page_index > 0 && checkpoint.last_url;
77
+
78
+ // Open new tab to the application URL (or the checkpoint URL when resuming)
79
+ const url = resuming ? checkpoint.last_url : (apply_url || aep.job.apply_url || aep.job.url);
80
+ if (!url) throw new Error('No application URL for this job');
81
+ page = await chromeConn.newPage(url);
82
+ await waitForStableDOM(page, 3000); // let page fully render
83
+
84
+ if (resuming) {
85
+ const restoredCount = Object.keys(checkpoint.fields_filled || {}).length;
86
+ console.log(`[orchestrator] Resuming from checkpoint: page ${checkpoint.page_index}, ${restoredCount} fields already filled`);
87
+ for (const [label, value] of Object.entries(checkpoint.fields_filled || {})) {
88
+ ctx.answeredFields.set(label, { value, pageIndex: checkpoint.page_index, source: 'checkpoint' });
89
+ }
90
+ ctx.currentPageIndex = checkpoint.page_index + 1;
91
+ await reportStatus('IN_PROGRESS', {
92
+ step: 'FILLING',
93
+ step_detail: `Resuming page ${checkpoint.page_index + 1} (${restoredCount} fields restored)`,
94
+ fields_filled: restoredCount,
95
+ });
96
+ }
97
+
98
+ // If we landed on a job description page (not a form), click Apply.
99
+ // May return a new page object if Apply opened a new tab.
100
+ page = await clickApplyIfNeeded(page);
101
+
102
+ // STEP 2: Vision gate detection — handles modals, resume prompts, login walls, preamble screens.
103
+ // Runs before captcha check so the form is actually visible before we try to fill it.
104
+ const gateResult = await handlePageGate(page, aep, tempResumeFile, config);
105
+ if (!gateResult.handled && gateResult.gateType === 'login') {
106
+ throw new Error('Login wall encountered — cannot auto-proceed. Please log in to this ATS in Chrome and re-queue the job.');
107
+ }
108
+
109
+ // STEP 4: CAPTCHA CHECK before filling
110
+ await handleCaptchaIfPresent(page, apiKey, queueId, reportStatus, config);
111
+
112
+ // STEP 5: FILLING
113
+ console.log(`[orchestrator] Filling fields...`);
114
+ const useVisionForThis = useVision && VISION_ATS.has((ats_type || '').toLowerCase());
115
+
116
+ let fillResult;
117
+ if (useVisionForThis && anthropicKey) {
118
+ console.log(`[orchestrator] Using vision fallback for ${ats_type}`);
119
+ fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
120
+ } else {
121
+ fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
122
+ }
123
+
124
+ // Fetch AI answers for any fields that scanPage identified as needing AI
125
+ const needsAI = fillResult.needsAI || [];
126
+ if (needsAI.length > 0) {
127
+ console.log(`[orchestrator] Fetching AI answers for ${needsAI.length} custom field(s)...`);
128
+ const previouslyAnswered = Array.from(ctx.answeredFields.entries()).map(([label, data]) => ({ label, value: data.value }));
129
+ for (const f of needsAI) {
130
+ try {
131
+ const ans = await fetchFieldAnswer(config.apiUrl, config.token, {
132
+ job_id: jobId,
133
+ field_label: f.label,
134
+ field_type: f.inputType === 'textarea' || f.tag === 'textarea' ? 'textarea' : f.inputType || 'text',
135
+ previously_answered: previouslyAnswered,
136
+ });
137
+ if (ans?.value) {
138
+ aep.field_answers = aep.field_answers || [];
139
+ aep.field_answers.push({ field_id: f.label, label: f.label, value: ans.value, confidence: ans.confidence || 0.75, source: 'on-demand' });
140
+ previouslyAnswered.push({ label: f.label, value: ans.value });
141
+ console.log(`[orchestrator] Got answer for "${f.label}": ${String(ans.value).slice(0, 80)}...`);
142
+ }
143
+ } catch (e) {
144
+ console.warn(`[orchestrator] Could not fetch answer for "${f.label}": ${e.message}`);
145
+ }
146
+ }
147
+ // Second fill pass with newly fetched answers
148
+ const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
149
+ fillResult.filled += retryResult.filled;
150
+ fillResult.skipped = retryResult.skipped;
151
+ console.log(`[orchestrator] Retry fill: +${retryResult.filled} fields filled`);
152
+ }
153
+
154
+ // If DOM filling still missed fields, use vision as precision fill
155
+ if (!useVisionForThis && anthropicKey && (fillResult.skipped > 2 || fillResult.failed > 0)) {
156
+ console.log(`[orchestrator] DOM fill left ${fillResult.skipped} skipped / ${fillResult.failed} failed — running vision precision fill...`);
157
+ const precisionResult = await visionFillSkipped(page, aep, anthropicKey, ctx.answeredFields);
158
+ if (precisionResult.filled > 0) {
159
+ fillResult.filled += precisionResult.filled;
160
+ console.log(`[orchestrator] Vision precision fill recovered ${precisionResult.filled} fields`);
161
+ }
162
+ }
163
+
164
+ // Upload resume if we have a temp file
165
+ if (tempResumeFile) {
166
+ const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
167
+ if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
168
+ }
169
+
170
+ await reportStatus('IN_PROGRESS', {
171
+ fields_filled: fillResult.filled || 0,
172
+ step: 'FILLING',
173
+ step_detail: `Page 1 — ${fillResult.filled || 0} fields filled`,
174
+ });
175
+ // Persist progress so a crash before pagination doesn't lose page-1 work
176
+ await saveCheckpoint(config, queueId, buildCheckpoint(ctx, page, ats_type));
177
+
178
+ // STEP 4: PAGINATION (multi-page forms)
179
+ let paginationAttempts = 0;
180
+ let cumulativeFilled = fillResult.filled || 0;
181
+
182
+ while (paginationAttempts < 10) {
183
+ await handleCaptchaIfPresent(page, apiKey, queueId, reportStatus, config);
184
+
185
+ const submitBtn = await findSubmitButton(page);
186
+ if (submitBtn) break; // reached review/submit page
187
+
188
+ const nextBtn = await findNextButton(page);
189
+ if (!nextBtn) {
190
+ // No next and no submit — wait for dynamic forms to settle
191
+ await waitForStableDOM(page, 3000);
192
+ const retrySubmit = await findSubmitButton(page);
193
+ if (retrySubmit) break;
194
+ // Check if URL changed (some ATS redirect to confirmation)
195
+ const currentUrl = page.url();
196
+ const looksLikeConfirmation = /thank|confirm|success|applied|submitted/i.test(currentUrl);
197
+ if (looksLikeConfirmation) {
198
+ await reportStatus('DONE', { fields_filled: cumulativeFilled });
199
+ await clearCheckpoint(config, queueId);
200
+ console.log(`[orchestrator] Detected confirmation page by URL: ${currentUrl}`);
201
+ return;
202
+ }
203
+ console.log(`[orchestrator] No next/submit button found — stopping pagination at attempt ${paginationAttempts}`);
204
+ break;
205
+ }
206
+
207
+ console.log(`[orchestrator] Navigating to next page (attempt ${paginationAttempts + 1})...`);
208
+
209
+ // Snapshot visible fields BEFORE clicking next (for conditional field detection)
210
+ const fieldsBefore = await snapshotFieldLabels(page);
211
+
212
+ await nextBtn.click();
213
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
214
+ await waitForStableDOM(page, 3000); // smart wait — stops as soon as form stabilizes
215
+
216
+ // Snapshot AFTER navigation to detect newly appeared conditional fields
217
+ const fieldsAfter = await snapshotFieldLabels(page);
218
+ const newConditionalFields = [...fieldsAfter].filter(label => label && !fieldsBefore.has(label));
219
+
220
+ if (newConditionalFields.length > 0) {
221
+ console.log(`[orchestrator] Detected ${newConditionalFields.length} conditional field(s): ${newConditionalFields.slice(0, 3).join(', ')}`);
222
+
223
+ // Fetch answers for conditional fields from backend with cross-page context
224
+ const previouslyAnswered = Array.from(ctx.answeredFields.entries()).map(([label, data]) => ({
225
+ label,
226
+ value: data.value,
227
+ }));
228
+
229
+ for (const condLabel of newConditionalFields) {
230
+ // Check if it's already in the AEP answers (may have been pre-generated)
231
+ const existing = (aep.field_answers || []).find(fa =>
232
+ fa.label && fa.label.toLowerCase().trim() === condLabel.toLowerCase().trim()
233
+ );
234
+ if (existing) continue; // already have the answer in AEP, fillFields will handle it
235
+
236
+ // Fetch a fresh answer from backend with context
237
+ try {
238
+ const condAnswer = await fetchFieldAnswer(config.apiUrl, config.token, {
239
+ job_id: jobId,
240
+ field_label: condLabel,
241
+ field_type: 'input',
242
+ previously_answered: previouslyAnswered,
243
+ });
244
+
245
+ if (condAnswer?.value) {
246
+ // Inject into AEP so fillFields can pick it up
247
+ aep.field_answers = aep.field_answers || [];
248
+ aep.field_answers.push({
249
+ field_id: condLabel,
250
+ label: condLabel,
251
+ value: condAnswer.value,
252
+ confidence: condAnswer.confidence || 0.75,
253
+ source: condAnswer.source || 'conditional',
254
+ });
255
+
256
+ // Track conditional trigger
257
+ ctx.conditionalTriggers.push({
258
+ appeared_label: condLabel,
259
+ page_index: ctx.currentPageIndex,
260
+ });
261
+ }
262
+ } catch (e) {
263
+ console.warn(`[orchestrator] Could not fetch conditional answer for "${condLabel}": ${e.message}`);
264
+ }
265
+ }
266
+ }
267
+
268
+ ctx.currentPageIndex++;
269
+
270
+ // Fill any new fields that appeared on this page
271
+ const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
272
+ cumulativeFilled += pageResult.filled || 0;
273
+
274
+ // Fetch AI answers for custom fields scanPage identified on this page
275
+ const pageNeedsAI = pageResult.needsAI || [];
276
+ if (pageNeedsAI.length > 0) {
277
+ const prevAnswered = Array.from(ctx.answeredFields.entries()).map(([l, d]) => ({ label: l, value: d.value }));
278
+ for (const f of pageNeedsAI) {
279
+ try {
280
+ const ans = await fetchFieldAnswer(config.apiUrl, config.token, { job_id: jobId, field_label: f.label, field_type: f.inputType === 'textarea' || f.tag === 'textarea' ? 'textarea' : f.inputType || 'text', previously_answered: prevAnswered });
281
+ if (ans?.value) {
282
+ aep.field_answers = aep.field_answers || [];
283
+ aep.field_answers.push({ field_id: f.label, label: f.label, value: ans.value, confidence: ans.confidence || 0.75, source: 'on-demand' });
284
+ prevAnswered.push({ label: f.label, value: ans.value });
285
+ }
286
+ } catch {}
287
+ }
288
+ const retryPage = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
289
+ cumulativeFilled += retryPage.filled || 0;
290
+ }
291
+
292
+ // Precision vision fill for missed fields on this page too
293
+ if (anthropicKey && !useVisionForThis && (pageResult.skipped > 2 || pageResult.failed > 0)) {
294
+ const precisionResult = await visionFillSkipped(page, aep, anthropicKey, ctx.answeredFields);
295
+ cumulativeFilled += precisionResult.filled || 0;
296
+ }
297
+
298
+ await reportStatus('IN_PROGRESS', {
299
+ fields_filled: cumulativeFilled,
300
+ step: 'PAGINATING',
301
+ step_detail: `Page ${paginationAttempts + 2} — ${cumulativeFilled} fields filled`,
302
+ });
303
+ // Persist after each page so a crash mid-flow only loses the current page,
304
+ // not the whole application. Backend caps at 32KB; ctx.answeredFields is
305
+ // a label→value map so the payload grows linearly with form complexity.
306
+ await saveCheckpoint(config, queueId, buildCheckpoint(ctx, page, ats_type));
307
+ paginationAttempts++;
308
+ }
309
+
310
+ // STEP 5: REVIEWING
311
+ let submitBtn = await findSubmitButton(page);
312
+
313
+ if (!submitBtn && anthropicKey) {
314
+ // DOM couldn't find submit — hand off to Claude vision to navigate to submit page
315
+ console.log(`[orchestrator] Submit button not found via DOM. Trying vision fallback...`);
316
+ const visionResult = await visionNavigateAndSubmit(page, aep, anthropicKey, {
317
+ autoSubmit: config.autoSubmit || false,
318
+ alreadyFilled: ctx.answeredFields,
319
+ });
320
+
321
+ if (visionResult.submitted) {
322
+ // Vision already submitted — we're done
323
+ const confirmShot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
324
+ const confirmKey = confirmShot ? await uploadScreenshot(config, confirmShot, `confirm_${queueId}.jpg`) : null;
325
+ await reportStatus('DONE', {
326
+ confirmation_screenshot_r2_key: confirmKey || null,
327
+ fields_filled: cumulativeFilled,
328
+ });
329
+ await clearCheckpoint(config, queueId);
330
+ console.log(`[orchestrator] Done via vision: ${queueItem.company} - ${queueItem.title}`);
331
+ return;
332
+ }
333
+
334
+ // Vision navigated to review page but didn't submit — try DOM submit one more time
335
+ submitBtn = await findSubmitButton(page);
336
+ }
337
+
338
+ if (!submitBtn) {
339
+ const pageTitle = await page.title().catch(() => 'unknown');
340
+ const pageUrl = page.url();
341
+ throw new Error(`Could not find submit button. Page: "${pageTitle}" (${pageUrl})`);
342
+ }
343
+
344
+ console.log(`[orchestrator] Reached review/submit page. Waiting for confirmation...`);
345
+
346
+ // Take a screenshot of the review page
347
+ const reviewScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
348
+ const reviewKey = await uploadScreenshot(config, reviewScreenshot, `review_${queueId}.jpg`);
349
+
350
+ await reportStatus('REVIEWING', {
351
+ review_screenshot_r2_key: reviewKey || null,
352
+ step: 'REVIEWING',
353
+ step_detail: `${cumulativeFilled} fields filled · awaiting your confirm`,
354
+ });
355
+
356
+ // Wait for user to confirm submission from dashboard
357
+ // OR auto-submit if config.autoSubmit is true
358
+ if (config.autoSubmit || aep.agent_config?.auto_submit) {
359
+ const timeout = (aep.agent_config?.review_timeout_seconds || 30) * 1000;
360
+ await page.waitForTimeout(timeout);
361
+ } else {
362
+ await waitForSubmitConfirmation(config, queueId);
363
+ }
364
+
365
+ // STEP 6: SUBMITTING
366
+ console.log(`[orchestrator] Submitting application...`);
367
+ await reportStatus('IN_PROGRESS', {
368
+ step: 'SUBMITTING',
369
+ step_detail: 'Clicking submit',
370
+ fields_filled: cumulativeFilled,
371
+ });
372
+ await submitBtn.click();
373
+
374
+ // Wait for confirmation page
375
+ try {
376
+ await page.waitForURL(/thank|confirm|success|applied|submitted/i, { timeout: 15000 });
377
+ } catch {
378
+ await page.waitForTimeout(3000); // fallback wait
379
+ }
380
+
381
+ const confirmScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
382
+ const confirmKey = await uploadScreenshot(config, confirmScreenshot, `confirm_${queueId}.jpg`);
383
+
384
+ await reportStatus('DONE', {
385
+ confirmation_screenshot_r2_key: confirmKey || null,
386
+ fields_filled: cumulativeFilled,
387
+ });
388
+ await clearCheckpoint(config, queueId);
389
+
390
+ console.log(`[orchestrator] Done: ${queueItem.company} - ${queueItem.title}`);
391
+
392
+ // Post fill session data to backend for learning loop
393
+ await postFillSession(config, {
394
+ job_id: jobId,
395
+ ats_type: ats_type || 'unknown',
396
+ pages_visited: ctx.currentPageIndex + 1,
397
+ fields_filled: cumulativeFilled,
398
+ skipped_fields: ctx.skippedFields,
399
+ conditional_fields: ctx.conditionalTriggers,
400
+ }).catch(() => {}); // non-critical
401
+
402
+ } catch (err) {
403
+ console.error(`[orchestrator] Error:`, err.message);
404
+ // Classify the failure into one of the InterventionPanel types so the user
405
+ // sees the right instructions rather than a raw stack trace.
406
+ const msg = (err.message || '').toLowerCase();
407
+ let interventionType = 'generic';
408
+ if (/timeout|timed.out|deadline/.test(msg)) interventionType = 'timeout';
409
+ else if (/login|sign.?in|auth.?required|please.+log.?in/.test(msg)) interventionType = 'login_wall';
410
+ await reportStatus('NEEDS_ATTENTION', {
411
+ needs_attention_reason: err.message,
412
+ intervention_type: interventionType,
413
+ step: 'NEEDS_ATTENTION',
414
+ step_detail: err.message?.slice(0, 120),
415
+ }).catch(() => {});
416
+ } finally {
417
+ // Clean up temp resume file
418
+ if (tempResumeFile) {
419
+ try { fs.unlinkSync(tempResumeFile); } catch {}
420
+ }
421
+ // Close the tab (leave other tabs untouched)
422
+ if (page) {
423
+ try { await page.close(); } catch {}
424
+ }
425
+ }
426
+ }
427
+
428
+ async function handleCaptchaIfPresent(page, apiKey, queueId, reportStatus, config) {
429
+ const captcha = await detectCaptcha(page);
430
+ if (!captcha.detected) return;
431
+
432
+ console.log(`[orchestrator] CAPTCHA detected: ${captcha.type}`);
433
+
434
+ if (apiKey && captcha.sitekey) {
435
+ console.log(`[orchestrator] Attempting CapSolver...`);
436
+ const token = await solveCaptcha(captcha, apiKey);
437
+ if (token) {
438
+ await injectCaptchaToken(page, token, captcha.type);
439
+ await reportStatus('IN_PROGRESS', { captcha_triggered: 1, captcha_solved: 1 });
440
+ console.log(`[orchestrator] CAPTCHA solved.`);
441
+ return;
442
+ }
443
+ }
444
+
445
+ // Could not solve — pause for human
446
+ await reportStatus('NEEDS_ATTENTION', {
447
+ captcha_triggered: 1,
448
+ captcha_solved: 0,
449
+ needs_attention_reason: `CAPTCHA appeared (${captcha.type}). Please solve it in Chrome and click "Resume" in the dashboard.`,
450
+ intervention_type: 'captcha',
451
+ step: 'CAPTCHA',
452
+ step_detail: `${captcha.type} CAPTCHA — awaiting your solve`,
453
+ });
454
+
455
+ // Wait for human to resolve (poll submit-confirmed endpoint as a resume signal)
456
+ for (let i = 0; i < 150; i++) { // up to 5 minutes
457
+ await new Promise(r => setTimeout(r, 2000));
458
+ // Check if user clicked "Resume" in dashboard (sets KV flag)
459
+ try {
460
+ const flagRes = await fetch(`${config.apiUrl}/apply-queue/captcha-resolved/${queueId}`, {
461
+ headers: { Authorization: `Bearer ${config.token}` },
462
+ });
463
+ if (flagRes.ok) {
464
+ const flagData = await flagRes.json();
465
+ if (flagData.resolved) {
466
+ await reportStatus('IN_PROGRESS', { captcha_triggered: 1, captcha_solved: 1 });
467
+ return;
468
+ }
469
+ }
470
+ } catch {}
471
+ // Also re-check if CAPTCHA is gone from the page (human solved it directly)
472
+ const stillThere = await detectCaptcha(page);
473
+ if (!stillThere.detected) {
474
+ await reportStatus('IN_PROGRESS', { captcha_triggered: 1, captcha_solved: 1 });
475
+ return;
476
+ }
477
+ }
478
+
479
+ throw new Error('CAPTCHA not solved within timeout. Application paused.');
480
+ }
481
+
482
+ async function fetchAEP(apiUrl, token, jobId) {
483
+ try {
484
+ const res = await fetch(`${apiUrl}/smartfill/agent-packet/${jobId}`, {
485
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
486
+ });
487
+ if (!res.ok) return null;
488
+ return res.json();
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+
494
+ // ── Checkpoint helpers ───────────────────────────────────────────────────────
495
+ // Persist what the agent has already filled so a crashed/restarted agent can
496
+ // resume from the last completed page. Saved JSON is small (label→value map
497
+ // for the current job's pages), bounded by the backend at 32KB.
498
+ async function fetchCheckpoint(config, queueId) {
499
+ try {
500
+ const res = await fetch(`${config.apiUrl}/agent/queue/${queueId}/checkpoint`, {
501
+ headers: { Authorization: `Bearer ${config.token}` },
502
+ });
503
+ if (!res.ok) return null;
504
+ const data = await res.json();
505
+ return data.checkpoint || null;
506
+ } catch { return null; }
507
+ }
508
+
509
+ async function saveCheckpoint(config, queueId, checkpoint) {
510
+ try {
511
+ await fetch(`${config.apiUrl}/agent/queue/${queueId}/checkpoint`, {
512
+ method: 'PATCH',
513
+ headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
514
+ body: JSON.stringify(checkpoint),
515
+ });
516
+ } catch { /* fire-and-forget; non-critical */ }
517
+ }
518
+
519
+ async function clearCheckpoint(config, queueId) {
520
+ try {
521
+ await fetch(`${config.apiUrl}/agent/queue/${queueId}/checkpoint`, {
522
+ method: 'DELETE',
523
+ headers: { Authorization: `Bearer ${config.token}` },
524
+ });
525
+ } catch { /* fire-and-forget */ }
526
+ }
527
+
528
+ // Build a checkpoint snapshot from the current FormContext. Flattens
529
+ // answeredFields (a Map of label -> {value, ...}) into label -> value.
530
+ function buildCheckpoint(ctx, page, atsType) {
531
+ const fields_filled = {};
532
+ for (const [label, data] of ctx.answeredFields.entries()) {
533
+ if (data && typeof data.value === 'string') {
534
+ fields_filled[label] = data.value;
535
+ }
536
+ }
537
+ return {
538
+ page_index: ctx.currentPageIndex,
539
+ fields_filled,
540
+ pages_remaining: null,
541
+ ats_type: atsType || null,
542
+ last_url: page ? page.url() : '',
543
+ saved_at: new Date().toISOString(),
544
+ };
545
+ }
546
+
547
+ async function fetchFieldAnswer(apiUrl, token, body) {
548
+ try {
549
+ const res = await fetch(`${apiUrl}/smartfill/field-answer`, {
550
+ method: 'POST',
551
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
552
+ body: JSON.stringify(body),
553
+ });
554
+ if (!res.ok) return null;
555
+ return res.json();
556
+ } catch {
557
+ return null;
558
+ }
559
+ }
560
+
561
+ async function postFillSession(config, data) {
562
+ try {
563
+ await fetch(`${config.apiUrl}/smartfill/session`, {
564
+ method: 'POST',
565
+ headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
566
+ body: JSON.stringify(data),
567
+ });
568
+ } catch {}
569
+ }
570
+
571
+ async function downloadResume(presignedUrl) {
572
+ try {
573
+ const res = await fetch(presignedUrl);
574
+ if (!res.ok) return null;
575
+ const buffer = Buffer.from(await res.arrayBuffer());
576
+ const tmpPath = path.join(os.tmpdir(), `halo_resume_${Date.now()}.pdf`);
577
+ fs.writeFileSync(tmpPath, buffer);
578
+ return tmpPath;
579
+ } catch {
580
+ return null;
581
+ }
582
+ }
583
+
584
+ async function uploadScreenshot(config, screenshotBuffer, filename) {
585
+ // Upload to HALO backend which stores in R2
586
+ try {
587
+ const formData = new FormData();
588
+ const blob = new Blob([screenshotBuffer], { type: 'image/jpeg' });
589
+ formData.append('file', blob, filename);
590
+ const res = await fetch(`${config.apiUrl}/agent/screenshot`, {
591
+ method: 'POST',
592
+ headers: { Authorization: `Bearer ${config.token}` },
593
+ body: formData,
594
+ });
595
+ if (!res.ok) return null;
596
+ const data = await res.json();
597
+ return data.r2_key || null;
598
+ } catch {
599
+ return null; // screenshots are nice-to-have, not critical
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Vision-based intermediary step handler.
605
+ *
606
+ * After each navigation, takes a screenshot and sends it to the HALO backend
607
+ * which runs it through Workers AI (@cf/meta/llama-3.2-11b-vision-instruct) to classify
608
+ * any "gate" blocking the actual form (modal, resume upload, login wall, preamble, EEOC).
609
+ *
610
+ * The AI returns {gate_type, action, button_text, confidence} and the agent executes
611
+ * the appropriate action: click a button, upload the resume, or skip.
612
+ * Loops up to 5 times to handle stacked gates (e.g. modal then resume prompt).
613
+ */
614
+ async function handlePageGate(page, aep, resumeFile, config) {
615
+ // Re-run up to 4 times — some gates are stacked (modal then resume upload)
616
+ for (let pass = 0; pass < 5; pass++) {
617
+ await waitForStableDOM(page, 2000);
618
+
619
+ // Take screenshot and POST to HALO backend for vision-based gate classification
620
+ let gate;
621
+ try {
622
+ const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
623
+ const base64 = screenshot.toString('base64');
624
+ const pageUrl = page.url();
625
+
626
+ const res = await fetch(`${config.apiUrl}/smartfill/detect-gate`, {
627
+ method: 'POST',
628
+ headers: {
629
+ Authorization: `Bearer ${config.token}`,
630
+ 'Content-Type': 'application/json',
631
+ },
632
+ body: JSON.stringify({ screenshot_base64: base64, page_url: pageUrl }),
633
+ });
634
+
635
+ if (!res.ok) {
636
+ console.warn(`[gate] detect-gate returned ${res.status} — skipping gate check`);
637
+ break;
638
+ }
639
+ gate = await res.json();
640
+ } catch (e) {
641
+ console.warn(`[gate] Detection request failed: ${e.message} — skipping gate check`);
642
+ break;
643
+ }
644
+
645
+ console.log(`[gate] Pass ${pass + 1}: ${gate.gate_type} (confidence: ${(gate.confidence || 0).toFixed(2)}, action: ${gate.action})`);
646
+
647
+ // No gate or low confidence — we're on the actual form
648
+ if (gate.gate_type === 'none' || (gate.confidence || 0) < 0.55) break;
649
+
650
+ // Login wall — cannot auto-proceed
651
+ if (gate.gate_type === 'login') {
652
+ console.warn('[gate] Login wall detected — cannot auto-proceed');
653
+ return { handled: false, gateType: 'login' };
654
+ }
655
+
656
+ // Click a button (modal choice, preamble, EEOC acknowledge)
657
+ if (gate.action === 'click' && gate.button_text) {
658
+ console.log(`[gate] Clicking "${gate.button_text}"`);
659
+ const pattern = new RegExp(escapeRegex(gate.button_text), 'i');
660
+ const clicked =
661
+ await page.getByRole('button', { name: pattern }).first().click({ timeout: 3000 }).then(() => true).catch(() => false) ||
662
+ await page.getByRole('link', { name: pattern }).first().click({ timeout: 2000 }).then(() => true).catch(() => false) ||
663
+ await page.locator('button, a, [role="button"]').filter({ hasText: pattern }).first().click({ timeout: 2000 }).then(() => true).catch(() => false);
664
+
665
+ if (!clicked) {
666
+ console.warn(`[gate] Could not find/click "${gate.button_text}" — stopping gate loop`);
667
+ break;
668
+ }
669
+ await waitForStableDOM(page, 2500);
670
+ continue;
671
+ }
672
+
673
+ // Upload resume (Ashby autofill bar, Lever drop zone, etc.)
674
+ if (gate.action === 'upload_resume' && resumeFile) {
675
+ console.log('[gate] Uploading resume to gate prompt');
676
+ const uploaded = await uploadResumeToPage(page, resumeFile);
677
+ if (uploaded) {
678
+ console.log('[gate] Resume uploaded successfully');
679
+ await waitForStableDOM(page, 3000);
680
+ continue;
681
+ } else {
682
+ console.warn('[gate] Resume upload failed — continuing to form fill anyway');
683
+ break;
684
+ }
685
+ }
686
+
687
+ // Skip / unknown action — stop looping
688
+ break;
689
+ }
690
+
691
+ return { handled: true, gateType: null };
692
+ }
693
+
694
+ /**
695
+ * Upload a resume file to the current page.
696
+ * Tries direct setInputFiles on hidden inputs first, then file chooser intercept.
697
+ */
698
+ async function uploadResumeToPage(page, filePath) {
699
+ try {
700
+ // Unhide all file inputs and set files on the first one
701
+ const allInputs = page.locator('input[type="file"]');
702
+ const count = await allInputs.count();
703
+ for (let i = 0; i < count; i++) {
704
+ await allInputs.nth(i).evaluate(el => {
705
+ el.style.display = 'block';
706
+ el.style.opacity = '1';
707
+ el.style.visibility = 'visible';
708
+ el.style.width = '1px';
709
+ el.style.height = '1px';
710
+ el.style.position = 'fixed';
711
+ el.style.top = '0';
712
+ }).catch(() => {});
713
+ }
714
+ if (count > 0) {
715
+ await allInputs.first().setInputFiles(filePath);
716
+ return true;
717
+ }
718
+ } catch {}
719
+
720
+ // Fallback: trigger file chooser by clicking an upload button/label
721
+ try {
722
+ const uploadBtns = page.locator('button, a, label, [role="button"]').filter({ hasText: /upload|attach|browse/i });
723
+ const btnCount = await uploadBtns.count();
724
+ if (btnCount > 0) {
725
+ const [chooser] = await Promise.all([
726
+ page.waitForEvent('filechooser', { timeout: 5000 }),
727
+ uploadBtns.first().click(),
728
+ ]);
729
+ await chooser.setFiles(filePath);
730
+ return true;
731
+ }
732
+ } catch {}
733
+
734
+ return false;
735
+ }
736
+
737
+ function escapeRegex(str) {
738
+ return (str || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
739
+ }
740
+
741
+ /**
742
+ * Detect if we're on a job listing/description page instead of an application form.
743
+ * If so, find and click the Apply button to get to the actual form.
744
+ * Returns the page to use going forward (may be a new tab if Apply opens one).
745
+ */
746
+ async function clickApplyIfNeeded(page) {
747
+ // Check if there's a visible form input — if yes, we're already on an application form
748
+ const hasFormFields = await page.evaluate(() => {
749
+ const inputs = document.querySelectorAll('input[type="text"], input[type="email"], input[type="tel"], textarea');
750
+ return inputs.length > 0;
751
+ });
752
+ if (hasFormFields) return page; // already on form
753
+
754
+ // Look for an Apply button on the listing page
755
+ const applySelectors = [
756
+ '[data-automation-id="jobPostingApplyButton"]', // Workday — most specific first
757
+ '[data-testid="apply-button"]',
758
+ '.apply-button',
759
+ '#apply-button',
760
+ 'button:has-text("Apply Now")',
761
+ 'a:has-text("Apply Now")',
762
+ 'button:has-text("Apply")',
763
+ 'a:has-text("Apply")',
764
+ ];
765
+
766
+ for (const sel of applySelectors) {
767
+ try {
768
+ const el = page.locator(sel).first();
769
+ if (await el.isVisible({ timeout: 1000 })) {
770
+ console.log(`[orchestrator] On listing page — clicking Apply button (selector: ${sel})...`);
771
+
772
+ // Listen for a popup (new tab) BEFORE clicking
773
+ const popupPromise = page.context().waitForEvent('page', { timeout: 8000 }).catch(() => null);
774
+ await el.click();
775
+
776
+ const urlBefore = page.url();
777
+
778
+ // Check if a new tab opened
779
+ const newTab = await popupPromise;
780
+ if (newTab) {
781
+ console.log(`[orchestrator] Apply opened new tab: ${newTab.url()}`);
782
+ await newTab.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
783
+ await waitForStableDOM(newTab, 3000);
784
+ console.log(`[orchestrator] New tab settled at: ${newTab.url()}`);
785
+ return newTab; // caller must use this page going forward
786
+ }
787
+
788
+ // Same-tab navigation
789
+ await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
790
+ await waitForStableDOM(page, 3000);
791
+ const urlAfter = page.url();
792
+ console.log(`[orchestrator] Post-click URL: ${urlAfter} (was: ${urlBefore})`);
793
+ return page;
794
+ }
795
+ } catch {}
796
+ }
797
+
798
+ // No apply button found — log what's on the page for diagnosis
799
+ const pageUrl = page.url();
800
+ const pageTitle = await page.title().catch(() => 'unknown');
801
+ console.log(`[orchestrator] No Apply button found on "${pageTitle}" (${pageUrl}) — assuming already on form`);
802
+ return page;
803
+ }
804
+
805
+ /**
806
+ * Find visible textarea/input fields on the page that have no answer in the AEP yet.
807
+ * These are custom essay questions the AEP packet didn't pre-generate.
808
+ */
809
+ async function getUnmatchedFields(page, aep) {
810
+ const { extractPageFields } = require('./filler');
811
+ try {
812
+ const pageFields = await extractPageFields(page);
813
+ const answeredLabels = new Set((aep.field_answers || []).map(fa => (fa.label || '').toLowerCase().trim()));
814
+
815
+ // Only look at textarea fields and long-text inputs (essay questions)
816
+ const candidates = pageFields.filter(f => {
817
+ if (f.tag !== 'textarea' && !(f.tag === 'input' && f.type === 'text')) return false;
818
+ if (!f.label || f.label.length < 10) return false; // skip unlabeled / short labels
819
+ const norm = f.label.toLowerCase().trim();
820
+ // Skip if already answered
821
+ if (answeredLabels.has(norm)) return false;
822
+ // Skip common profile fields that the profile_fill map handles
823
+ const profileKeywords = ['first name', 'last name', 'email', 'phone', 'linkedin', 'github', 'address', 'city', 'zip', 'postal', 'salary', 'url', 'website'];
824
+ if (profileKeywords.some(k => norm.includes(k))) return false;
825
+ return true;
826
+ });
827
+
828
+ return candidates.map(f => f.label);
829
+ } catch {
830
+ return [];
831
+ }
832
+ }
833
+
834
+ async function waitForSubmitConfirmation(config, queueId) {
835
+ console.log(`[orchestrator] Waiting for user to confirm submission from dashboard...`);
836
+ for (let i = 0; i < 180; i++) { // up to 15 minutes
837
+ await new Promise(r => setTimeout(r, 5000));
838
+ try {
839
+ const res = await fetch(`${config.apiUrl}/apply-queue/submit-confirmed/${queueId}`, {
840
+ headers: { Authorization: `Bearer ${config.token}` },
841
+ });
842
+ if (!res.ok) continue;
843
+ const data = await res.json();
844
+ if (data.confirmed) return;
845
+ } catch {}
846
+ }
847
+ throw new Error('Submission confirmation timed out. Application paused at review stage.');
848
+ }
849
+
850
+ /**
851
+ * Extension-triggered fill (Architecture 4 handoff).
852
+ *
853
+ * Unlike runJob(), this does NOT open a new tab — the user is already on the
854
+ * application page. We attach to that tab via CDP, run the full fill pipeline,
855
+ * then wait for the extension's confirm signal before submitting.
856
+ *
857
+ * @param {object} opts
858
+ * fillId — unique ID for this fill session (used for logging)
859
+ * apply_url — URL of the page the user is on (used to find the tab)
860
+ * job_id — HALO job ID (for AEP fetch and field-answer calls)
861
+ * ats_type — detected ATS type from extension (e.g. 'greenhouse')
862
+ * packet — optional pre-generated packet from extension session storage
863
+ * extConfig — { apiUrl, token } from extension (may differ from agent config)
864
+ * chromeConn — existing CDP connection { browser, context, newPage }
865
+ * reportFillStatus — fn(state, extra) writes to in-memory session map
866
+ * waitForConfirm — Promise that resolves when user clicks Submit in extension overlay
867
+ */
868
+ async function runExtensionFill({
869
+ fillId, apply_url, job_id, ats_type, packet,
870
+ extConfig, chromeConn, reportFillStatus, waitForConfirm,
871
+ }) {
872
+ // Load agent config to get credentials + settings
873
+ const { loadConfig } = require('./config');
874
+ const config = loadConfig();
875
+
876
+ // Prefer extension-supplied credentials (user may be on a different account)
877
+ const apiUrl = (extConfig && extConfig.apiUrl) || config.apiUrl;
878
+ const token = (extConfig && extConfig.token) || config.token;
879
+ const anthropicKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY || null;
880
+ const typingSpeed = config.typingSpeed || 'normal';
881
+ const useVision = config.useVisionFallback !== false;
882
+
883
+ const log = (msg) => console.log(`[ext-fill:${fillId}] ${msg}`);
884
+
885
+ reportFillStatus('ANALYZING', { message: 'Finding active Chrome tab...' });
886
+ log('Starting extension-triggered fill');
887
+
888
+ // Find the active tab that matches apply_url — attach to it rather than opening a new one
889
+ let page = null;
890
+ try {
891
+ const ctx = chromeConn.browser.contexts()[0];
892
+ const pages = ctx.pages();
893
+ // Match by URL prefix — handles redirects and hash changes
894
+ const applyHost = new URL(apply_url).hostname;
895
+ page = pages.find(p => {
896
+ try { return new URL(p.url()).hostname === applyHost; } catch { return false; }
897
+ }) || pages[pages.length - 1]; // fallback: most-recently-opened tab
898
+ if (!page) throw new Error('No matching Chrome tab found — make sure the application page is open');
899
+ } catch (e) {
900
+ throw new Error('Could not attach to application tab: ' + e.message);
901
+ }
902
+
903
+ log(`Attached to tab: ${page.url()}`);
904
+ reportFillStatus('ANALYZING', { message: 'Fetching your application packet...' });
905
+
906
+ // Fetch AEP — prefer pre-built packet from extension, fall back to backend fetch
907
+ let aep = null;
908
+ if (job_id) {
909
+ aep = await fetchAEP(apiUrl, token, job_id);
910
+ }
911
+ if (!aep && packet) {
912
+ // Build a minimal AEP from the extension's session-stored packet
913
+ aep = {
914
+ profile_fill: packet.profile || {},
915
+ field_answers: packet._raw_answers || [],
916
+ cover_letter: packet.cover_letter || '',
917
+ sponsorship_answer: packet.sponsorship_answer || '',
918
+ relocation_answer: packet.relocation_answer || '',
919
+ salary_answer: packet.salary_answer || '',
920
+ job: { id: job_id || 'ext', title: '', company: '', apply_url },
921
+ agent_config: { auto_submit: false, review_timeout_seconds: 30, typing_speed: typingSpeed },
922
+ };
923
+ // Hydrate field_answers from short_answers_json if present
924
+ if (packet.short_answers_json) {
925
+ try {
926
+ const sa = JSON.parse(packet.short_answers_json);
927
+ aep.field_answers = sa.map((a, i) => ({
928
+ field_id: `sa_${i}`, label: a.question, value: a.answer, confidence: 0.85, source: 'packet',
929
+ }));
930
+ } catch {}
931
+ }
932
+ }
933
+ if (!aep) throw new Error('No application packet available — generate one from the HALO dashboard first');
934
+
935
+ // Download resume if available
936
+ let tempResumeFile = null;
937
+ if (aep.recommended_resume?.pdf_presigned_url) {
938
+ tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
939
+ }
940
+
941
+ const ctx = createFormContext();
942
+ const useVisionForThis = useVision && VISION_ATS.has((ats_type || '').toLowerCase());
943
+
944
+ // Wait for page to settle (user may have just navigated here)
945
+ reportFillStatus('ANALYZING', { message: 'Waiting for page to load...' });
946
+ await waitForStableDOM(page, 2000);
947
+
948
+ // Gate handling — same as runJob
949
+ reportFillStatus('ANALYZING', { message: 'Checking for page gates...' });
950
+ await handlePageGate(page, aep, tempResumeFile, { apiUrl, token, ...config });
951
+
952
+ // CAPTCHA check
953
+ const dummyQueueId = fillId;
954
+ const dummyReportStatus = async () => {};
955
+ await handleCaptchaIfPresent(page, config.captchaApiKey || null, dummyQueueId, dummyReportStatus, config);
956
+
957
+ // FILLING
958
+ reportFillStatus('FILLING', { message: 'Filling form fields...' });
959
+ log('Starting field fill');
960
+
961
+ let fillResult;
962
+ if (useVisionForThis && anthropicKey) {
963
+ log(`Vision fill for ${ats_type}`);
964
+ fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
965
+ } else {
966
+ fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
967
+ }
968
+
969
+ reportFillStatus('FILLING', { fieldsFilled: fillResult.filled || 0, message: `Filled ${fillResult.filled || 0} fields...` });
970
+
971
+ // AI answers for custom fields
972
+ const needsAI = fillResult.needsAI || [];
973
+ if (needsAI.length > 0) {
974
+ reportFillStatus('FILLING', { message: `Getting AI answers for ${needsAI.length} custom question(s)...` });
975
+ const prevAnswered = Array.from(ctx.answeredFields.entries()).map(([label, d]) => ({ label, value: d.value }));
976
+ for (const f of needsAI) {
977
+ try {
978
+ const ans = await fetchFieldAnswer(apiUrl, token, {
979
+ job_id: aep.job?.id || job_id,
980
+ field_label: f.label,
981
+ field_type: f.inputType === 'textarea' || f.tag === 'textarea' ? 'textarea' : f.inputType || 'text',
982
+ previously_answered: prevAnswered,
983
+ });
984
+ if (ans?.value) {
985
+ aep.field_answers = aep.field_answers || [];
986
+ aep.field_answers.push({ field_id: f.label, label: f.label, value: ans.value, confidence: ans.confidence || 0.75, source: 'on-demand' });
987
+ prevAnswered.push({ label: f.label, value: ans.value });
988
+ }
989
+ } catch (e) { log(`AI answer failed for "${f.label}": ${e.message}`); }
990
+ }
991
+ const retry = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
992
+ fillResult.filled += retry.filled;
993
+ fillResult.skipped = retry.skipped;
994
+ }
995
+
996
+ // Vision precision fill for anything DOM missed
997
+ if (!useVisionForThis && anthropicKey && (fillResult.skipped > 2 || fillResult.failed > 0)) {
998
+ reportFillStatus('FILLING', { message: 'Running vision precision fill for missed fields...' });
999
+ const precision = await visionFillSkipped(page, aep, anthropicKey, ctx.answeredFields);
1000
+ fillResult.filled += precision.filled || 0;
1001
+ }
1002
+
1003
+ if (tempResumeFile) {
1004
+ const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
1005
+ if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
1006
+ }
1007
+
1008
+ let cumulativeFilled = fillResult.filled || 0;
1009
+ reportFillStatus('FILLING', { fieldsFilled: cumulativeFilled, message: `Filled ${cumulativeFilled} fields. Checking for more pages...` });
1010
+
1011
+ // PAGINATION — same logic as runJob
1012
+ let paginationAttempts = 0;
1013
+ while (paginationAttempts < 10) {
1014
+ await handleCaptchaIfPresent(page, config.captchaApiKey || null, dummyQueueId, dummyReportStatus, config);
1015
+
1016
+ const submitBtn = await findSubmitButton(page);
1017
+ if (submitBtn) break;
1018
+
1019
+ const nextBtn = await findNextButton(page);
1020
+ if (!nextBtn) {
1021
+ await waitForStableDOM(page, 3000);
1022
+ if (await findSubmitButton(page)) break;
1023
+ const confUrl = /thank|confirm|success|applied|submitted/i.test(page.url());
1024
+ if (confUrl) {
1025
+ reportFillStatus('DONE', { fieldsFilled: cumulativeFilled, message: 'Application submitted.' });
1026
+ return;
1027
+ }
1028
+ break;
1029
+ }
1030
+
1031
+ reportFillStatus('FILLING', { message: `Moving to next page (${paginationAttempts + 1})...` });
1032
+ const fieldsBefore = await snapshotFieldLabels(page);
1033
+ await nextBtn.click();
1034
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
1035
+ await waitForStableDOM(page, 3000);
1036
+
1037
+ const fieldsAfter = await snapshotFieldLabels(page);
1038
+ const newFields = [...fieldsAfter].filter(l => l && !fieldsBefore.has(l));
1039
+ if (newFields.length > 0) {
1040
+ const prevAnswered = Array.from(ctx.answeredFields.entries()).map(([l, d]) => ({ label: l, value: d.value }));
1041
+ for (const condLabel of newFields) {
1042
+ const existing = (aep.field_answers || []).find(fa => fa.label?.toLowerCase().trim() === condLabel.toLowerCase().trim());
1043
+ if (existing) continue;
1044
+ try {
1045
+ const condAns = await fetchFieldAnswer(apiUrl, token, { job_id: aep.job?.id || job_id, field_label: condLabel, field_type: 'input', previously_answered: prevAnswered });
1046
+ if (condAns?.value) {
1047
+ aep.field_answers = aep.field_answers || [];
1048
+ aep.field_answers.push({ field_id: condLabel, label: condLabel, value: condAns.value, confidence: condAns.confidence || 0.75, source: 'conditional' });
1049
+ }
1050
+ } catch {}
1051
+ }
1052
+ }
1053
+
1054
+ ctx.currentPageIndex++;
1055
+ const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
1056
+ cumulativeFilled += pageResult.filled || 0;
1057
+
1058
+ if (anthropicKey && !useVisionForThis && (pageResult.skipped > 2 || pageResult.failed > 0)) {
1059
+ const pr = await visionFillSkipped(page, aep, anthropicKey, ctx.answeredFields);
1060
+ cumulativeFilled += pr.filled || 0;
1061
+ }
1062
+
1063
+ reportFillStatus('FILLING', { fieldsFilled: cumulativeFilled, message: `Filled ${cumulativeFilled} fields total...` });
1064
+ paginationAttempts++;
1065
+ }
1066
+
1067
+ // REVIEWING — take screenshot and send back to extension overlay
1068
+ reportFillStatus('REVIEWING', { fieldsFilled: cumulativeFilled, message: 'Review your application and click Submit.' });
1069
+ log('Reached review stage');
1070
+
1071
+ const reviewShot = await page.screenshot({ type: 'jpeg', quality: 65 }).catch(() => null);
1072
+ if (reviewShot) {
1073
+ // Send as base64 so extension can display it inline — no R2 needed for extension flow
1074
+ const b64 = reviewShot.toString('base64');
1075
+ reportFillStatus('REVIEWING', {
1076
+ fieldsFilled: cumulativeFilled,
1077
+ message: 'Review your application and click Submit in the HALO panel.',
1078
+ reviewScreenshot: `data:image/jpeg;base64,${b64}`,
1079
+ });
1080
+ }
1081
+
1082
+ // Wait for user to confirm from extension overlay
1083
+ log('Waiting for user confirm from extension...');
1084
+ await waitForConfirm();
1085
+
1086
+ // SUBMITTING
1087
+ reportFillStatus('SUBMITTING', { fieldsFilled: cumulativeFilled, message: 'Submitting...' });
1088
+ log('Submit confirmed — clicking submit button');
1089
+
1090
+ let submitBtn = await findSubmitButton(page);
1091
+ if (!submitBtn && anthropicKey) {
1092
+ const vr = await visionNavigateAndSubmit(page, aep, anthropicKey, { autoSubmit: true, alreadyFilled: ctx.answeredFields });
1093
+ if (vr.submitted) {
1094
+ reportFillStatus('DONE', { fieldsFilled: cumulativeFilled, message: 'Application submitted!' });
1095
+ log('Done (via vision submit)');
1096
+ if (tempResumeFile) try { require('fs').unlinkSync(tempResumeFile); } catch {}
1097
+ return;
1098
+ }
1099
+ submitBtn = await findSubmitButton(page);
1100
+ }
1101
+ if (!submitBtn) throw new Error('Could not find submit button');
1102
+
1103
+ await submitBtn.click();
1104
+ try { await page.waitForURL(/thank|confirm|success|applied|submitted/i, { timeout: 15000 }); } catch {}
1105
+
1106
+ reportFillStatus('DONE', { fieldsFilled: cumulativeFilled, message: 'Application submitted!' });
1107
+ log('Done');
1108
+
1109
+ // Learning loop session post (non-critical)
1110
+ await postFillSession({ apiUrl, token }, {
1111
+ job_id: aep.job?.id || job_id || 'ext',
1112
+ ats_type: ats_type || 'unknown',
1113
+ pages_visited: ctx.currentPageIndex + 1,
1114
+ fields_filled: cumulativeFilled,
1115
+ skipped_fields: ctx.skippedFields,
1116
+ conditional_fields: ctx.conditionalTriggers,
1117
+ }).catch(() => {});
1118
+
1119
+ if (tempResumeFile) try { require('fs').unlinkSync(tempResumeFile); } catch {}
1120
+ }
1121
+
1122
+ module.exports = { runJob, runExtensionFill };